From c78b15baaf2bd3c8d936d883a8173bb348a9edfc Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:25:58 +0000 Subject: [PATCH] fix(auth): use writable auth paths on ubuntu --- packages/lib/src/usecases/auth-codex.ts | 2 +- packages/lib/src/usecases/auth-github.ts | 3 +- .../lib/src/usecases/github-auth-image.ts | 2 +- packages/lib/src/usecases/path-helpers.ts | 8 +- .../usecases/auth-container-paths.test.ts | 248 ++++++++++++++++++ .../lib/tests/usecases/path-helpers.test.ts | 120 +++++++++ 6 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 packages/lib/tests/usecases/auth-container-paths.test.ts create mode 100644 packages/lib/tests/usecases/path-helpers.test.ts diff --git a/packages/lib/src/usecases/auth-codex.ts b/packages/lib/src/usecases/auth-codex.ts index 041f1cf5..2d232cd1 100644 --- a/packages/lib/src/usecases/auth-codex.ts +++ b/packages/lib/src/usecases/auth-codex.ts @@ -25,7 +25,7 @@ type CodexAccountContext = { const codexImageName = "docker-git-auth-codex:latest" const codexImageDir = ".docker-git/.orch/auth/codex/.image" -const codexHome = "/root/.codex" +const codexHome = "/codex-home" const ensureCodexOrchLayout = ( cwd: string, diff --git a/packages/lib/src/usecases/auth-github.ts b/packages/lib/src/usecases/auth-github.ts index 82305d82..a276df34 100644 --- a/packages/lib/src/usecases/auth-github.ts +++ b/packages/lib/src/usecases/auth-github.ts @@ -125,6 +125,7 @@ const resolveGithubTokenFromGh = ( image: ghImageName, hostPath: accountPath, containerPath: ghAuthDir, + env: `GH_CONFIG_DIR=${ghAuthDir}`, args: ["auth", "token"], interactive: false }), @@ -149,7 +150,7 @@ const runGithubLogin = ( image: ghImageName, hostPath: accountPath, containerPath: ghAuthDir, - env: "BROWSER=echo", + env: ["BROWSER=echo", `GH_CONFIG_DIR=${ghAuthDir}`], args: [ "auth", "login", diff --git a/packages/lib/src/usecases/github-auth-image.ts b/packages/lib/src/usecases/github-auth-image.ts index 67202968..6f376a5e 100644 --- a/packages/lib/src/usecases/github-auth-image.ts +++ b/packages/lib/src/usecases/github-auth-image.ts @@ -8,7 +8,7 @@ import type { CommandFailedError } from "../shell/errors.js" import { ensureDockerImage } from "./docker-image.js" export const ghAuthRoot = ".docker-git/.orch/auth/gh" -export const ghAuthDir = "/root/.config/gh" +export const ghAuthDir = "/gh-auth" export const ghImageName = "docker-git-auth-gh:latest" export const ghImageDir = ".docker-git/.orch/auth/gh/.image" diff --git a/packages/lib/src/usecases/path-helpers.ts b/packages/lib/src/usecases/path-helpers.ts index 283ec4bd..a4e33496 100644 --- a/packages/lib/src/usecases/path-helpers.ts +++ b/packages/lib/src/usecases/path-helpers.ts @@ -169,7 +169,13 @@ export const findKeyByPriority = ( } } - const home = resolveEnvPath("HOME") + const dockerGitHomeKey = path.join(defaultProjectsRoot(cwd), spec.devKeyName) + const dockerGitHomeExisting = yield* _(findExistingPath(fs, dockerGitHomeKey)) + if (dockerGitHomeExisting !== null) { + return dockerGitHomeExisting + } + + const home = resolveHomeDir() if (home === null) { return null } diff --git a/packages/lib/tests/usecases/auth-container-paths.test.ts b/packages/lib/tests/usecases/auth-container-paths.test.ts new file mode 100644 index 00000000..e1ea61f8 --- /dev/null +++ b/packages/lib/tests/usecases/auth-container-paths.test.ts @@ -0,0 +1,248 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import { authCodexLogin } from "../../src/usecases/auth-codex.js" +import { authGithubLogin } from "../../src/usecases/auth-github.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-auth-paths-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +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 withWorkingDirectory = ( + cwd: string, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.cwd() + process.chdir(cwd) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + process.chdir(previous) + }) + ) + +const includesArgsInOrder = ( + args: ReadonlyArray, + expectedSequence: ReadonlyArray +): boolean => { + let searchFrom = 0 + for (const expected of expectedSequence) { + const foundAt = args.indexOf(expected, searchFrom) + if (foundAt === -1) { + return false + } + searchFrom = foundAt + 1 + } + return true +} + +const isDockerRunFor = ( + entry: RecordedCommand, + image: string, + args: ReadonlyArray +): boolean => + entry.command === "docker" && + includesArgsInOrder(entry.args, ["run", "--rm"]) && + includesArgsInOrder(entry.args, [image, ...args]) + +const makeFakeExecutor = ( + recorded: Array +): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const last = flattened[flattened.length - 1]! + const invocation: RecordedCommand = { command: last.command, args: last.args } + const stdoutText = isDockerRunFor(invocation, "docker-git-auth-gh:latest", ["auth", "token"]) + ? "test-gh-token\n" + : "" + const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText)) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "AuthContainerPathsTestProcess", command: invocation.command, args: invocation.args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "AuthContainerPathsTestProcess", + command: invocation.command, + args: invocation.args + }), + toString: () => `[AuthContainerPathsTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +describe("auth container paths", () => { + it.effect("pins gh auth login and token reads to the same writable config dir", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const envPath = `${root}/.docker-git/.orch/env/global.env` + const accountPath = `${root}/.docker-git/.orch/auth/gh/default` + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + + yield* _( + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: null, + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) + ) + + const loginCommand = recorded.find((entry) => + isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "login"]) + ) + const tokenCommand = recorded.find((entry) => + isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "token"]) + ) + + expect(loginCommand).toBeDefined() + expect(tokenCommand).toBeDefined() + expect( + includesArgsInOrder(loginCommand?.args ?? [], [ + "-v", + `${accountPath}:/gh-auth`, + "-e", + "BROWSER=echo", + "-e", + "GH_CONFIG_DIR=/gh-auth", + "docker-git-auth-gh:latest", + "auth", + "login" + ]) + ).toBe(true) + expect( + includesArgsInOrder(tokenCommand?.args ?? [], [ + "-v", + `${accountPath}:/gh-auth`, + "-e", + "GH_CONFIG_DIR=/gh-auth", + "docker-git-auth-gh:latest", + "auth", + "token" + ]) + ).toBe(true) + + const envText = yield* _(fs.readFileString(envPath)) + expect(envText).toContain("GITHUB_TOKEN=test-gh-token") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("runs codex auth against a non-root CODEX_HOME mount", () => + withTempDir((root) => + Effect.gen(function*(_) { + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + + yield* _( + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authCodexLogin({ + _tag: "AuthCodexLogin", + label: null, + codexAuthPath: ".docker-git/.orch/auth/codex" + }).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) + ) + + const loginCommand = recorded.find((entry) => + isDockerRunFor(entry, "docker-git-auth-codex:latest", ["codex", "login", "--device-auth"]) + ) + + expect(loginCommand).toBeDefined() + expect(loginCommand?.args.some((arg) => arg.endsWith(":/codex-home")) ?? false).toBe(true) + expect(loginCommand?.args.includes("CODEX_HOME=/codex-home") ?? false).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/lib/tests/usecases/path-helpers.test.ts b/packages/lib/tests/usecases/path-helpers.test.ts new file mode 100644 index 00000000..157f8223 --- /dev/null +++ b/packages/lib/tests/usecases/path-helpers.test.ts @@ -0,0 +1,120 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { findAuthorizedKeysSource, findSshPrivateKey } from "../../src/usecases/path-helpers.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-path-helpers-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +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 + } + } + }) + ) + +describe("path helpers", () => { + it.effect("prefers the docker-git projects root public key over generic ~/.ssh keys", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, "shared-projects") + const homeDir = path.join(root, "home") + const workspaceDir = path.join(root, "workspace", "a", "b", "c", "d", "e", "f", "repo") + const dockerGitKey = path.join(projectsRoot, "dev_ssh_key.pub") + const sshFallback = path.join(homeDir, ".ssh", "id_ed25519.pub") + + yield* _(fs.makeDirectory(path.dirname(dockerGitKey), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(sshFallback), { recursive: true })) + yield* _(fs.makeDirectory(workspaceDir, { recursive: true })) + yield* _(fs.writeFileString(dockerGitKey, "docker-git-public-key\n")) + yield* _(fs.writeFileString(sshFallback, "generic-public-key\n")) + + const found = yield* _( + withPatchedEnv( + { + HOME: homeDir, + DOCKER_GIT_PROJECTS_ROOT: projectsRoot, + DOCKER_GIT_AUTHORIZED_KEYS: undefined, + DOCKER_GIT_SSH_KEY: undefined + }, + findAuthorizedKeysSource(fs, path, workspaceDir) + ) + ) + + expect(found).toBe(dockerGitKey) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("prefers the docker-git projects root private key over generic ~/.ssh keys", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, "shared-projects") + const homeDir = path.join(root, "home") + const workspaceDir = path.join(root, "workspace", "repo") + const dockerGitKey = path.join(projectsRoot, "dev_ssh_key") + const sshFallback = path.join(homeDir, ".ssh", "id_ed25519") + + yield* _(fs.makeDirectory(path.dirname(dockerGitKey), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(sshFallback), { recursive: true })) + yield* _(fs.makeDirectory(workspaceDir, { recursive: true })) + yield* _(fs.writeFileString(dockerGitKey, "docker-git-private-key\n")) + yield* _(fs.writeFileString(sshFallback, "generic-private-key\n")) + + const found = yield* _( + withPatchedEnv( + { + HOME: homeDir, + DOCKER_GIT_PROJECTS_ROOT: projectsRoot, + DOCKER_GIT_AUTHORIZED_KEYS: undefined, + DOCKER_GIT_SSH_KEY: undefined + }, + findSshPrivateKey(fs, path, workspaceDir) + ) + ) + + expect(found).toBe(dockerGitKey) + }) + ).pipe(Effect.provide(NodeContext.layer))) +})