From ace0ce46687f921f3013804f84181b075b5a88d5 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 18 Mar 2026 09:48:35 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/155 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..24e097a --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-03-18T09:48:35.233Z for PR creation at branch issue-155-065209a7c9ad for issue https://github.com/ProverCoderAI/docker-git/issues/155 \ No newline at end of file From 2ad27a48e0c5bae4139bf642f14f5cc73adfdeb5 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 18 Mar 2026 09:52:26 +0000 Subject: [PATCH 2/4] 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 --- .../lib/src/usecases/actions/prepare-files.ts | 53 +++++++++++-- .../lib/tests/usecases/prepare-files.test.ts | 76 +++++++++++++++++++ 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 51514fc..c2edf0b 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -12,7 +12,13 @@ import { migrateLegacyOrchLayout, syncAuthArtifacts } from "../auth-sync.js" -import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js" +import { + defaultProjectsRoot, + findAuthorizedKeysSource, + findExistingPath, + findSshPrivateKey, + resolveAuthorizedKeysPath +} from "../path-helpers.js" import { withFsPathContext } from "../runtime.js" import { resolvePathFromBase } from "./paths.js" @@ -47,6 +53,7 @@ const ensureAuthorizedKeys = ( withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { const resolved = resolveAuthorizedKeysPath(path, baseDir, authorizedKeysPath) + const managedDefaultAuthorizedKeys = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys") const state = yield* _( ensureFileReady( fs, @@ -55,11 +62,15 @@ const ensureAuthorizedKeys = ( `Authorized keys was a directory, moved to ${backupPath}. Creating a file at ${resolvedPath}.` ) ) - if (state === "exists") { - return - } - const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) + const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const matchingPublicKey = + sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`)) + const source = yield* _( + matchingPublicKey === null + ? findAuthorizedKeysSource(fs, path, process.cwd()) + : Effect.succeed(matchingPublicKey) + ) if (source === null) { yield* _( Effect.logError( @@ -69,6 +80,38 @@ const ensureAuthorizedKeys = ( return } + const desiredContents = (yield* _(fs.readFileString(source))).trim() + if (desiredContents.length === 0) { + yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`)) + return + } + + if (state === "exists") { + if (resolved !== managedDefaultAuthorizedKeys) { + return + } + + const currentContents = yield* _(fs.readFileString(resolved)) + const currentLines = currentContents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + if (currentLines.includes(desiredContents)) { + return + } + + const normalizedCurrent = currentContents.trimEnd() + const nextContents = + normalizedCurrent.length === 0 + ? `${desiredContents}\n` + : `${normalizedCurrent}\n${desiredContents}\n` + + yield* _(fs.writeFileString(resolved, nextContents)) + yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`)) + return + } + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) yield* _(fs.copyFile(source, resolved)) yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 1c8df2a..916beed 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -23,6 +23,36 @@ const withTempDir = ( }) ) +const withPatchedEnv = ( + patch: Readonly>, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = new Map() + for (const [key, value] of Object.entries(patch)) { + previous.set(key, process.env[key]) + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + }) + ) + const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({ containerName: "dg-test", serviceName: "dg-test", @@ -209,4 +239,50 @@ describe("prepareProjectFiles", () => { expect(compose).not.toContain("external: true") }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("appends the active public key to the managed authorized_keys file", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const homeDir = path.join(root, "home") + const projectsRoot = path.join(homeDir, ".docker-git") + const outDir = path.join(projectsRoot, "org", "repo") + const authorizedKeysPath = path.join(projectsRoot, "authorized_keys") + const sshPrivateKeyPath = path.join(homeDir, ".ssh", "id_ed25519") + const sshPublicKeyPath = `${sshPrivateKeyPath}.pub` + const staleKey = "ssh-ed25519 AAAA-stale stale@example\n" + const currentKey = "ssh-ed25519 AAAA-current current@example\n" + const globalConfig = makeGlobalConfig(projectsRoot, path) + const projectConfig = { + ...makeProjectConfig(outDir, false, path), + authorizedKeysPath: "../../authorized_keys" + } + + yield* _(fs.makeDirectory(path.dirname(authorizedKeysPath), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(sshPrivateKeyPath), { recursive: true })) + yield* _(fs.writeFileString(authorizedKeysPath, staleKey)) + yield* _(fs.writeFileString(sshPrivateKeyPath, "PRIVATE\n")) + yield* _(fs.writeFileString(sshPublicKeyPath, currentKey)) + + yield* _( + withPatchedEnv( + { + HOME: homeDir, + DOCKER_GIT_PROJECTS_ROOT: projectsRoot, + DOCKER_GIT_AUTHORIZED_KEYS: undefined, + DOCKER_GIT_SSH_KEY: undefined + }, + prepareProjectFiles(outDir, projectsRoot, globalConfig, projectConfig, { + force: false, + forceEnv: false + }) + ) + ) + + const synchronizedAuthorizedKeys = yield* _(fs.readFileString(authorizedKeysPath)) + expect(synchronizedAuthorizedKeys).toContain(staleKey.trim()) + expect(synchronizedAuthorizedKeys).toContain(currentKey.trim()) + }) + ).pipe(Effect.provide(NodeContext.layer))) }) From 99469c48b79752fc29b8aceb42fc5f5a6807bcfa Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 18 Mar 2026 09:59:39 +0000 Subject: [PATCH 3/4] refactor(shell): extract helpers to satisfy max-lines-per-function lint rule Extract appendKeyIfMissing and resolveAuthorizedKeysSource into separate functions so that ensureAuthorizedKeys stays within the 50-line limit. Co-Authored-By: Claude Opus 4.6 --- .../lib/src/usecases/actions/prepare-files.ts | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index c2edf0b..7d7062f 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -46,6 +46,45 @@ const ensureFileReady = ( return "exists" }) +const appendKeyIfMissing = ( + fs: FileSystem.FileSystem, + resolved: string, + source: string, + desiredContents: string +): Effect.Effect => + Effect.gen(function*(_) { + const currentContents = yield* _(fs.readFileString(resolved)) + const currentLines = currentContents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + if (currentLines.includes(desiredContents)) { + return + } + + const normalizedCurrent = currentContents.trimEnd() + const nextContents = normalizedCurrent.length === 0 + ? `${desiredContents}\n` + : `${normalizedCurrent}\n${desiredContents}\n` + + yield* _(fs.writeFileString(resolved, nextContents)) + yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`)) + }) + +const resolveAuthorizedKeysSource = ( + fs: FileSystem.FileSystem, + path: Path.Path, + cwd: string +): Effect.Effect => + Effect.gen(function*(_) { + const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, cwd)) + const matchingPublicKey = sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`)) + return matchingPublicKey === null + ? yield* _(findAuthorizedKeysSource(fs, path, cwd)) + : matchingPublicKey + }) + const ensureAuthorizedKeys = ( baseDir: string, authorizedKeysPath: string @@ -63,14 +102,7 @@ const ensureAuthorizedKeys = ( ) ) - const sshPrivateKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) - const matchingPublicKey = - sshPrivateKey === null ? null : yield* _(findExistingPath(fs, `${sshPrivateKey}.pub`)) - const source = yield* _( - matchingPublicKey === null - ? findAuthorizedKeysSource(fs, path, process.cwd()) - : Effect.succeed(matchingPublicKey) - ) + const source = yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd())) if (source === null) { yield* _( Effect.logError( @@ -87,28 +119,9 @@ const ensureAuthorizedKeys = ( } if (state === "exists") { - if (resolved !== managedDefaultAuthorizedKeys) { - return + if (resolved === managedDefaultAuthorizedKeys) { + yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents)) } - - const currentContents = yield* _(fs.readFileString(resolved)) - const currentLines = currentContents - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - - if (currentLines.includes(desiredContents)) { - return - } - - const normalizedCurrent = currentContents.trimEnd() - const nextContents = - normalizedCurrent.length === 0 - ? `${desiredContents}\n` - : `${normalizedCurrent}\n${desiredContents}\n` - - yield* _(fs.writeFileString(resolved, nextContents)) - yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`)) return } From b9f447234d00b6b0cbb86d60f9c8fbf59e675e81 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 18 Mar 2026 10:06:11 +0000 Subject: [PATCH 4/4] Revert "Initial commit with task details" This reverts commit ace0ce46687f921f3013804f84181b075b5a88d5. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 24e097a..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-03-18T09:48:35.233Z for PR creation at branch issue-155-065209a7c9ad for issue https://github.com/ProverCoderAI/docker-git/issues/155 \ No newline at end of file