Skip to content

Commit 2ad27a4

Browse files
konardclaude
andcommitted
fix(shell): sync authorized_keys with active SSH public key to prevent Permission denied
When the managed authorized_keys file already exists but contains a stale key, SSH connections fail with "Permission denied (publickey)". This change detects the active SSH private key, finds its matching .pub file, and appends the current public key to authorized_keys if it is missing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ace0ce4 commit 2ad27a4

2 files changed

Lines changed: 124 additions & 5 deletions

File tree

packages/lib/src/usecases/actions/prepare-files.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
migrateLegacyOrchLayout,
1313
syncAuthArtifacts
1414
} from "../auth-sync.js"
15-
import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js"
15+
import {
16+
defaultProjectsRoot,
17+
findAuthorizedKeysSource,
18+
findExistingPath,
19+
findSshPrivateKey,
20+
resolveAuthorizedKeysPath
21+
} from "../path-helpers.js"
1622
import { withFsPathContext } from "../runtime.js"
1723
import { resolvePathFromBase } from "./paths.js"
1824

@@ -47,6 +53,7 @@ const ensureAuthorizedKeys = (
4753
withFsPathContext(({ fs, path }) =>
4854
Effect.gen(function*(_) {
4955
const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath)
56+
const managedDefaultAuthorizedKeys = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys")
5057
const state = yield* _(
5158
ensureFileReady(
5259
fs,
@@ -55,11 +62,15 @@ const ensureAuthorizedKeys = (
5562
`Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.`
5663
)
5764
)
58-
if (state === "exists") {
59-
return
60-
}
6165

62-
const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd()))
66+
const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
67+
const matchingPublicKey =
68+
sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`))
69+
const source = yield* _(
70+
matchingPublicKey === null
71+
? findAuthorizedKeysSource(fs, path, process.cwd())
72+
: Effect.succeed(matchingPublicKey)
73+
)
6374
if (source === null) {
6475
yield* _(
6576
Effect.logError(
@@ -69,6 +80,38 @@ const ensureAuthorizedKeys = (
6980
return
7081
}
7182

83+
const desiredContents = (yield* _(fs.readFileString(source))).trim()
84+
if (desiredContents.length === 0) {
85+
yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`))
86+
return
87+
}
88+
89+
if (state === "exists") {
90+
if (resolved !== managedDefaultAuthorizedKeys) {
91+
return
92+
}
93+
94+
const currentContents = yield* _(fs.readFileString(resolved))
95+
const currentLines = currentContents
96+
.split(/\r?\n/)
97+
.map((line) => line.trim())
98+
.filter((line) => line.length > 0)
99+
100+
if (currentLines.includes(desiredContents)) {
101+
return
102+
}
103+
104+
const normalizedCurrent = currentContents.trimEnd()
105+
const nextContents =
106+
normalizedCurrent.length === 0
107+
? `${desiredContents}\n`
108+
: `${normalizedCurrent}\n${desiredContents}\n`
109+
110+
yield* _(fs.writeFileString(resolved, nextContents))
111+
yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`))
112+
return
113+
}
114+
72115
yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true }))
73116
yield* _(fs.copyFile(source, resolved))
74117
yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`))

packages/lib/tests/usecases/prepare-files.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ const withTempDir = <A, E, R>(
2323
})
2424
)
2525

26+
const withPatchedEnv = <A, E, R>(
27+
patch: Readonly<Record<string, string | undefined>>,
28+
effect: Effect.Effect<A, E, R>
29+
): Effect.Effect<A, E, R> =>
30+
Effect.acquireUseRelease(
31+
Effect.sync(() => {
32+
const previous = new Map<string, string | undefined>()
33+
for (const [key, value] of Object.entries(patch)) {
34+
previous.set(key, process.env[key])
35+
if (value === undefined) {
36+
delete process.env[key]
37+
} else {
38+
process.env[key] = value
39+
}
40+
}
41+
return previous
42+
}),
43+
() => effect,
44+
(previous) =>
45+
Effect.sync(() => {
46+
for (const [key, value] of previous.entries()) {
47+
if (value === undefined) {
48+
delete process.env[key]
49+
} else {
50+
process.env[key] = value
51+
}
52+
}
53+
})
54+
)
55+
2656
const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({
2757
containerName: "dg-test",
2858
serviceName: "dg-test",
@@ -209,4 +239,50 @@ describe("prepareProjectFiles", () => {
209239
expect(compose).not.toContain("external: true")
210240
})
211241
).pipe(Effect.provide(NodeContext.layer)))
242+
243+
it.effect("appends the active public key to the managed authorized_keys file", () =>
244+
withTempDir((root) =>
245+
Effect.gen(function*(_) {
246+
const fs = yield* _(FileSystem.FileSystem)
247+
const path = yield* _(Path.Path)
248+
const homeDir = path.join(root, "home")
249+
const projectsRoot = path.join(homeDir, ".docker-git")
250+
const outDir = path.join(projectsRoot, "org", "repo")
251+
const authorizedKeysPath = path.join(projectsRoot, "authorized_keys")
252+
const sshPrivateKeyPath = path.join(homeDir, ".ssh", "id_ed25519")
253+
const sshPublicKeyPath = `${sshPrivateKeyPath}.pub`
254+
const staleKey = "ssh-ed25519 AAAA-stale stale@example\n"
255+
const currentKey = "ssh-ed25519 AAAA-current current@example\n"
256+
const globalConfig = makeGlobalConfig(projectsRoot, path)
257+
const projectConfig = {
258+
...makeProjectConfig(outDir, false, path),
259+
authorizedKeysPath: "../../authorized_keys"
260+
}
261+
262+
yield* _(fs.makeDirectory(path.dirname(authorizedKeysPath), { recursive: true }))
263+
yield* _(fs.makeDirectory(path.dirname(sshPrivateKeyPath), { recursive: true }))
264+
yield* _(fs.writeFileString(authorizedKeysPath, staleKey))
265+
yield* _(fs.writeFileString(sshPrivateKeyPath, "PRIVATE\n"))
266+
yield* _(fs.writeFileString(sshPublicKeyPath, currentKey))
267+
268+
yield* _(
269+
withPatchedEnv(
270+
{
271+
HOME: homeDir,
272+
DOCKER_GIT_PROJECTS_ROOT: projectsRoot,
273+
DOCKER_GIT_AUTHORIZED_KEYS: undefined,
274+
DOCKER_GIT_SSH_KEY: undefined
275+
},
276+
prepareProjectFiles(outDir, projectsRoot, globalConfig, projectConfig, {
277+
force: false,
278+
forceEnv: false
279+
})
280+
)
281+
)
282+
283+
const synchronizedAuthorizedKeys = yield* _(fs.readFileString(authorizedKeysPath))
284+
expect(synchronizedAuthorizedKeys).toContain(staleKey.trim())
285+
expect(synchronizedAuthorizedKeys).toContain(currentKey.trim())
286+
})
287+
).pipe(Effect.provide(NodeContext.layer)))
212288
})

0 commit comments

Comments
 (0)