From 2738a0cadfc68b47838d6e694b6268c86d1267a3 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 26 Jun 2026 08:24:20 +0000 Subject: [PATCH 01/19] 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/439 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..ee4d936c --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-26T08:24:20.247Z for PR creation at branch issue-439-c9a9c01e8b9b for issue https://github.com/ProverCoderAI/docker-git/issues/439 \ No newline at end of file From 7c58988d148df9b2177e01824f1004b812358588 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:33:07 +0000 Subject: [PATCH 02/19] chore(release): version packages --- packages/app/CHANGELOG.md | 9 +++++++++ packages/app/package.json | 2 +- packages/docker-git-session-sync/CHANGELOG.md | 6 ++++++ packages/docker-git-session-sync/package.json | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 2cc86b25..d13ddbb3 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.14 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.70 + ## 1.3.13 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2caac76a..07ed7322 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.13", + "version": "1.3.14", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 424da984..0b49da7b 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.70 + +### Patch Changes + +- chore: automated version bump + ## 1.0.69 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 44aebcee..f8caac85 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.69", + "version": "1.0.70", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { From 72bf8eb51db6747358832d032b5e7e05ae2509ec Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 26 Jun 2026 08:34:10 +0000 Subject: [PATCH 03/19] fix(claude): keep OAuth token when post-login API probe fails docker-git auth claude login created and persisted the OAuth token, then ran a 'claude -p ping' probe and hard-failed (exit 1) on any non-zero probe exit, discarding an otherwise successful login. Transient probe failures (network, rate limit, token propagation delay) must not invalidate a saved token. The probe failure is now logged as a warning, mirroring authClaudeStatus. Adds a regression test asserting the token is persisted even when the probe returns non-zero. Fixes #439 --- .changeset/fix-claude-auth-login-probe.md | 15 ++ .gitkeep | 1 - packages/lib/src/usecases/auth-claude.ts | 15 +- .../tests/usecases/auth-claude-login.test.ts | 162 ++++++++++++++++++ 4 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-claude-auth-login-probe.md delete mode 100644 .gitkeep create mode 100644 packages/lib/tests/usecases/auth-claude-login.test.ts diff --git a/.changeset/fix-claude-auth-login-probe.md b/.changeset/fix-claude-auth-login-probe.md new file mode 100644 index 00000000..5ba46e72 --- /dev/null +++ b/.changeset/fix-claude-auth-login-probe.md @@ -0,0 +1,15 @@ +--- +"@prover-coder-ai/docker-git": patch +--- + +Fix `docker-git auth claude login` failing after a successful OAuth login. + +After `claude setup-token` created and persisted the OAuth token, the login +command ran a verification probe (`claude -p ping`) and treated any non-zero +exit as a hard failure, exiting with code 1 even though the token was already +saved. A transient probe failure (network hiccup, rate limit, or token +propagation delay) would therefore discard an otherwise successful login. + +The probe failure is now reported as a warning instead of an error, mirroring +`docker-git auth claude status`. The token is kept, and the user is advised to +re-check connectivity later with `docker-git auth claude status`. diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index ee4d936c..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-26T08:24:20.247Z for PR creation at branch issue-439-c9a9c01e8b9b for issue https://github.com/ProverCoderAI/docker-git/issues/439 \ No newline at end of file diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index a9907c0e..e143b7d9 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -268,14 +268,19 @@ export const authClaudeLogin = ( yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}\n`)) yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 0o600), Effect.orElseSucceed(() => void 0)) yield* _(resolveClaudeAuthMethod(fs, path, accountPath)) + // CHANGE: treat a failing post-login API probe as a warning instead of a hard error + // WHY: the OAuth token is already created and persisted; a transient probe failure + // (network hiccup, rate limit, token propagation delay) must not discard a + // successful login. Mirrors authClaudeStatus, which only warns on probe failure. + // REF: issue-439 + // SOURCE: n/a const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token)) if (probeExitCode !== 0) { yield* _( - Effect.fail( - new CommandFailedError({ - command: "claude setup-token", - exitCode: probeExitCode - }) + Effect.logWarning( + `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${probeExitCode}). ` + + `The token may need a moment to activate, or there was a transient network issue. ` + + `Verify later with 'docker-git auth claude status'.` ) ) } diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts new file mode 100644 index 00000000..8d3e31d2 --- /dev/null +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -0,0 +1,162 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +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 * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import { authClaudeLogin } from "../../src/usecases/auth-claude.js" + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const oauthToken = "sk-ant-oat01-EXAMPLE0123456789abcdef" + +// Mirrors the real `claude setup-token` output that the OAuth parser scans for. +const setupTokenOutput = (token: string): string => + [ + "Welcome to Claude Code", + "", + " ✓ Long-lived authentication token created successfully!", + "", + " Your OAuth token (valid for 1 year):", + "", + ` ${token}`, + "", + " Store this token securely. You won't be able to see it again." + ].join("\n") + +const isSetupToken = (args: ReadonlyArray): boolean => args.includes("setup-token") +const isPingProbe = (args: ReadonlyArray): boolean => args.includes("-p") && args.includes("ping") + +// CHANGE: fake docker executor that captures a setup-token and lets the ping probe fail +// WHY: reproduce issue-439 where a successful OAuth login was discarded by a failing probe +// REF: issue-439 +const makeFakeExecutor = ( + token: string, + pingExitCode: number +): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.sync(() => { + const flattened = Command.flatten(command) + const invocation = flattened[flattened.length - 1]! + const args = invocation.args + + const stdoutText = isSetupToken(args) ? setupTokenOutput(token) : "" + const exitCode = isPingProbe(args) ? pingExitCode : 0 + 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(exitCode)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "ClaudeLoginTestProcess", command: invocation.command, args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "ClaudeLoginTestProcess", + command: invocation.command, + args + }), + toString: () => `[ClaudeLoginTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +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-claude-" })) + 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 runLoginAndReadToken = ( + root: string, + pingExitCode: number +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + + yield* _( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, pingExitCode)) + ) + ) + + return yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) + }) + +describe("authClaudeLogin", () => { + // Regression for issue-439: a non-zero probe exit must not discard a created token. + it.effect("persists the OAuth token even when the post-login API probe fails", () => + withTempDir((root) => + withPatchedEnv( + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + Effect.gen(function*(_) { + const persisted = yield* _(runLoginAndReadToken(root, 7)) + expect(persisted.trim()).toBe(oauthToken) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("persists the OAuth token when the post-login API probe succeeds", () => + withTempDir((root) => + withPatchedEnv( + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + Effect.gen(function*(_) { + const persisted = yield* _(runLoginAndReadToken(root, 0)) + expect(persisted.trim()).toBe(oauthToken) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) +}) From 09dc4073802ca0b3cc27aaaa49399d8612b6cd13 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 29 Jun 2026 06:05:48 +0000 Subject: [PATCH 04/19] test(claude): verify docker-backed auth login --- .github/workflows/check.yml | 20 +++++ docker-compose.yml | 1 + packages/lib/src/usecases/auth-claude.ts | 1 + .../tests/usecases/auth-claude-login.test.ts | 54 ++++++++++++- scripts/e2e/_lib.sh | 1 + scripts/e2e/auth-claude-login.sh | 78 +++++++++++++++++++ scripts/e2e/run-all.sh | 2 +- 7 files changed, 154 insertions(+), 3 deletions(-) create mode 100755 scripts/e2e/auth-claude-login.sh diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 35247746..65b157ad 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -249,6 +249,26 @@ jobs: - name: Login context notice run: bash scripts/e2e/login-context.sh + e2e-auth-claude-login: + name: E2E (Claude auth login) + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" + steps: + - uses: actions/checkout@v6 + with: + submodules: true + - name: Install dependencies + uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk + - name: Docker info + run: docker version && docker compose version + - name: Claude auth login warning path + run: bash scripts/e2e/auth-claude-login.sh + e2e-runtime-volumes-ssh: name: E2E (Runtime volumes + SSH) runs-on: ubuntu-latest diff --git a/docker-compose.yml b/docker-compose.yml index 910129f8..a431e69a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-} DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000} DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000} + DOCKER_GIT_CLAUDE_OAUTH_TOKEN: ${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-} ports: - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" dns: diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index e143b7d9..c4037022 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -279,6 +279,7 @@ export const authClaudeLogin = ( yield* _( Effect.logWarning( `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${probeExitCode}). ` + + `Login is complete because the token was captured and persisted; live Claude API access is not yet verified. ` + `The token may need a moment to activate, or there was a transient network issue. ` + `Verify later with 'docker-git auth claude status'.` ) diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index 8d3e31d2..fec04fcf 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -29,6 +29,14 @@ const setupTokenOutput = (token: string): string => " Store this token securely. You won't be able to see it again." ].join("\n") +const setupTokenOutputWithoutToken = (): string => + [ + "Welcome to Claude Code", + "", + " OAuth flow finished without printing a long-lived token.", + "" + ].join("\n") + const isSetupToken = (args: ReadonlyArray): boolean => args.includes("setup-token") const isPingProbe = (args: ReadonlyArray): boolean => args.includes("-p") && args.includes("ping") @@ -36,7 +44,7 @@ const isPingProbe = (args: ReadonlyArray): boolean => args.includes("-p" // WHY: reproduce issue-439 where a successful OAuth login was discarded by a failing probe // REF: issue-439 const makeFakeExecutor = ( - token: string, + token: string | null, pingExitCode: number ): CommandExecutor.CommandExecutor => { const start = (command: Command.Command): Effect.Effect => @@ -45,7 +53,11 @@ const makeFakeExecutor = ( const invocation = flattened[flattened.length - 1]! const args = invocation.args - const stdoutText = isSetupToken(args) ? setupTokenOutput(token) : "" + const stdoutText = isSetupToken(args) + ? token === null + ? setupTokenOutputWithoutToken() + : setupTokenOutput(token) + : "" const exitCode = isPingProbe(args) ? pingExitCode : 0 const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText)) @@ -136,6 +148,34 @@ const runLoginAndReadToken = ( return yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) }) +const runLoginWithoutCapturedToken = ( + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + const tokenPath = path.join(claudeAuthPath, "default", ".oauth-token") + + const error = yield* _( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(null, 0)), + Effect.flip + ) + ) + + expect(error._tag).toBe("AuthError") + if (error._tag === "AuthError") { + expect(error.message).toContain("without a captured token") + } + const hasTokenFile = yield* _(fs.exists(tokenPath)) + expect(hasTokenFile).toBe(false) + }) + describe("authClaudeLogin", () => { // Regression for issue-439: a non-zero probe exit must not discard a created token. it.effect("persists the OAuth token even when the post-login API probe fails", () => @@ -159,4 +199,14 @@ describe("authClaudeLogin", () => { }) ) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("fails when setup-token completes without a captured OAuth token", () => + withTempDir((root) => + withPatchedEnv( + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + Effect.gen(function*(_) { + yield* _(runLoginWithoutCapturedToken(root)) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 6bd3a7ba..f47a36fc 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -42,6 +42,7 @@ exec sudo -n env \ "DOCKER_GIT_PROJECTS_ROOT_VOLUME=${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-}" \ "DOCKER_GIT_PROJECT_DOCKER_HOST=${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" \ "DOCKER_GIT_PROJECT_SSH_BIND_HOST=${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-}" \ + "DOCKER_GIT_CLAUDE_OAUTH_TOKEN=${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-}" \ "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-}" \ docker "$@" EOF diff --git a/scripts/e2e/auth-claude-login.sh b/scripts/e2e/auth-claude-login.sh new file mode 100755 index 00000000..140620de --- /dev/null +++ b/scripts/e2e/auth-claude-login.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_ID="$(date +%s)-$RANDOM" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +source "$REPO_ROOT/scripts/e2e/_lib.sh" + +ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-/tmp/docker-git-e2e-root}" +mkdir -p "$ROOT_BASE" +ROOT="$(mktemp -d "$ROOT_BASE/auth-claude-login.XXXXXX")" +chmod 0777 "$ROOT" +KEEP="${KEEP:-0}" + +export DOCKER_GIT_PROJECTS_ROOT="$ROOT" +export DOCKER_GIT_STATE_AUTO_SYNC=0 +export DOCKER_GIT_API_CONTAINER_NAME="docker-git-e2e-auth-claude-$RUN_ID-api" +export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-auth-claude-$RUN_ID-projects" +export COMPOSE_PROJECT_NAME="docker-git-e2e-auth-claude-$RUN_ID" +export DOCKER_GIT_CLAUDE_OAUTH_TOKEN="${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-sk-ant-oat01-DOCKER-GIT-E2E-FAKE-TOKEN-000000000000}" + +LOG_FILE="/tmp/docker-git-auth-claude-login-$RUN_ID.log" + +fail() { + echo "e2e/auth-claude-login: $*" >&2 + exit 1 +} + +on_error() { + local line="$1" + echo "e2e/auth-claude-login: failed at line $line" >&2 + docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true + docker logs "$DOCKER_GIT_API_CONTAINER_NAME" --tail 200 || true + (cd "$REPO_ROOT" && docker compose ps) || true + (cd "$REPO_ROOT" && docker compose logs --no-color --tail 200) || true +} + +cleanup() { + (cd "$REPO_ROOT" && docker compose down -v --remove-orphans) >/dev/null 2>&1 || true + if [[ "$KEEP" == "1" ]]; then + echo "e2e/auth-claude-login: KEEP=1 set; preserving temp dir: $ROOT" >&2 + echo "e2e/auth-claude-login: log file: $LOG_FILE" >&2 + return + fi + rm -rf "$ROOT" >/dev/null 2>&1 || true + rm -f "$LOG_FILE" >/dev/null 2>&1 || true +} + +trap 'on_error $LINENO' ERR +trap cleanup EXIT + +command -v timeout >/dev/null 2>&1 || fail "missing 'timeout' command" + +dg_ensure_docker "$ROOT/.e2e-bin" +dg_prepare_docker_git_cli "$REPO_ROOT" "$ROOT/.e2e-bin" + +set +e +timeout 180s bash -lc 'cd "$1" && bun packages/app/dist/src/docker-git/main.js auth claude login' bash "$REPO_ROOT" \ + >"$LOG_FILE" 2>&1 +login_exit=$? +set -e + +if [[ "$login_exit" -ne 0 ]]; then + cat "$LOG_FILE" >&2 || true + fail "docker-git auth claude login failed (exit: $login_exit)" +fi + +grep -Fq -- "Claude OAuth token saved" "$LOG_FILE" \ + || fail "expected saved-token warning in auth claude login output" + +grep -Fq -- "live Claude API access is not yet verified" "$LOG_FILE" \ + || fail "expected diagnostic API probe warning in auth claude login output" + +docker exec "$DOCKER_GIT_API_CONTAINER_NAME" \ + test -s "$ROOT/.orch/auth/claude/default/.oauth-token" \ + || fail "expected persisted Claude OAuth token in controller state" + +echo "e2e/auth-claude-login: docker-backed Claude login warning path verified" >&2 diff --git a/scripts/e2e/run-all.sh b/scripts/e2e/run-all.sh index 238d0bd2..b9d10341 100755 --- a/scripts/e2e/run-all.sh +++ b/scripts/e2e/run-all.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cases=("$@") if [[ "${#cases[@]}" -eq 0 ]]; then - cases=("local-package-cli" "browser-command" "clone-cache" "login-context" "runtime-volumes-ssh" "clone-auto-open-ssh" "opencode-autoconnect") + cases=("local-package-cli" "browser-command" "clone-cache" "login-context" "auth-claude-login" "runtime-volumes-ssh" "clone-auto-open-ssh" "opencode-autoconnect") fi for case_name in "${cases[@]}"; do From a02936b90f1f421139a99b61b7d77993fa62946a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:26:16 +0000 Subject: [PATCH 05/19] refactor(claude): extract docker oauth package --- bun.lock | 16 +- package.json | 9 +- packages/api/Dockerfile | 6 +- packages/auth-oauth/package.json | 57 +++ .../auth-oauth/src/claude-docker-oauth.ts | 396 ++++++++++++++++++ packages/auth-oauth/src/claude-local-smoke.ts | 287 +++++++++++++ packages/auth-oauth/src/claude-oauth-token.ts | 152 +++++++ packages/auth-oauth/src/index.ts | 3 + .../tests/claude-docker-oauth.test.ts | 90 ++++ .../tests/claude-local-smoke.test.ts | 111 +++++ .../tests/claude-oauth-token.test.ts | 91 ++++ packages/auth-oauth/tsconfig.json | 12 + packages/lib/package.json | 9 +- .../lib/src/usecases/auth-claude-local.ts | 82 ++++ .../src/usecases/auth-claude-login-flow.ts | 77 ++++ .../lib/src/usecases/auth-claude-oauth.ts | 255 ++++++----- packages/lib/src/usecases/auth-claude.ts | 72 ++-- packages/lib/src/usecases/auth.ts | 2 + .../tests/usecases/auth-claude-local.test.ts | 114 +++++ .../usecases/auth-claude-login-flow.test.ts | 96 +++++ pnpm-workspace.yaml | 1 + 21 files changed, 1757 insertions(+), 181 deletions(-) create mode 100644 packages/auth-oauth/package.json create mode 100644 packages/auth-oauth/src/claude-docker-oauth.ts create mode 100644 packages/auth-oauth/src/claude-local-smoke.ts create mode 100644 packages/auth-oauth/src/claude-oauth-token.ts create mode 100644 packages/auth-oauth/src/index.ts create mode 100644 packages/auth-oauth/tests/claude-docker-oauth.test.ts create mode 100644 packages/auth-oauth/tests/claude-local-smoke.test.ts create mode 100644 packages/auth-oauth/tests/claude-oauth-token.test.ts create mode 100644 packages/auth-oauth/tsconfig.json create mode 100644 packages/lib/src/usecases/auth-claude-local.ts create mode 100644 packages/lib/src/usecases/auth-claude-login-flow.ts create mode 100644 packages/lib/tests/usecases/auth-claude-local.test.ts create mode 100644 packages/lib/tests/usecases/auth-claude-login-flow.test.ts diff --git a/bun.lock b/bun.lock index ac3d6c76..d3787c7d 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.3.12", + "version": "1.3.14", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -110,6 +110,15 @@ "ws": "^8.21.0", }, }, + "packages/auth-oauth": { + "name": "@prover-coder-ai/docker-git-auth-oauth", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^6.0.3", + "vitest": "^4.1.9", + }, + }, "packages/container": { "name": "@prover-coder-ai/docker-git-container", "version": "0.0.0", @@ -154,7 +163,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.68", + "version": "1.0.70", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -194,6 +203,7 @@ "@effect/sql": "^0.51.1", "@effect/typeclass": "^0.40.0", "@effect/workflow": "^0.18.2", + "@prover-coder-ai/docker-git-auth-oauth": "workspace:*", "@prover-coder-ai/docker-git-container": "workspace:*", "effect": "^3.21.3", "ts-morph": "^28.0.0", @@ -664,6 +674,8 @@ "@prover-coder-ai/docker-git": ["@prover-coder-ai/docker-git@workspace:packages/app"], + "@prover-coder-ai/docker-git-auth-oauth": ["@prover-coder-ai/docker-git-auth-oauth@workspace:packages/auth-oauth"], + "@prover-coder-ai/docker-git-container": ["@prover-coder-ai/docker-git-container@workspace:packages/container"], "@prover-coder-ai/docker-git-openapi": ["@prover-coder-ai/docker-git-openapi@workspace:packages/openapi"], diff --git a/package.json b/package.json index b35463f1..fe7d4725 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "workspaces": [ "packages/api", "packages/app", + "packages/auth-oauth", "packages/container", "packages/docker-git-session-sync", "packages/lib", @@ -15,13 +16,13 @@ ], "scripts": { "setup:pre-commit-hook": "bun scripts/setup-pre-commit-hook.js", - "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git-terminal build && bun run --filter @prover-coder-ai/docker-git build", + "build": "bun run --filter @prover-coder-ai/docker-git-auth-oauth build && bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git-terminal build && bun run --filter @prover-coder-ai/docker-git build", "api:build": "bun run --filter @effect-template/api build", "api:start": "bun run --filter @effect-template/api start", "api:dev": "bun run --filter @effect-template/api dev", "api:test": "bun run --filter @effect-template/api test", "api:typecheck": "bun run --filter @effect-template/api typecheck", - "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git-terminal check && bun run --filter @prover-coder-ai/docker-git-openapi check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", + "check": "bun run --filter @prover-coder-ai/docker-git-auth-oauth check && bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git-terminal check && bun run --filter @prover-coder-ai/docker-git-openapi check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", "check:dist-deps-prune": "bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js scan --package ./packages/app/package.json --prune-dev true --silent", "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", @@ -52,8 +53,8 @@ "lint:effect": "bun run --filter @prover-coder-ai/docker-git-session-sync lint:effect && bun run --filter @prover-coder-ai/docker-git-terminal lint:effect && bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @prover-coder-ai/docker-git-container lint:effect && bun run --filter @effect-template/lib lint:effect && bun run --filter @effect-template/api lint:effect", "effect:skill:init": "git submodule update --init --checkout third_party/effect-ts-skills", "effect:skill:check": "bun run effect:skill:init && bash .codex/skills/effect-ts-guide/scripts/run-effect-ts-check.sh packages/app/src/web/api-create-project.ts packages/app/src/web/api-database.ts packages/app/src/web/api-http.ts packages/app/src/web/api-prompts.ts packages/app/src/web/api-skills.ts packages/app/src/web/api-tasks.ts packages/openapi/src --profile strict", - "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git-terminal test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", - "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git-terminal typecheck && bun run --filter @prover-coder-ai/docker-git-openapi typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", + "test": "bun run --filter @prover-coder-ai/docker-git-auth-oauth test && bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git-terminal test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", + "typecheck": "bun run --filter @prover-coder-ai/docker-git-auth-oauth typecheck && bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git-terminal typecheck && bun run --filter @prover-coder-ai/docker-git-openapi typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", "start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js" }, "devDependencies": { diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 593094f0..d4fc30e8 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -76,9 +76,10 @@ RUN set -eu; \ FROM controller-base AS workspace-deps COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./ -RUN mkdir -p packages/api packages/app packages/container packages/docker-git-session-sync packages/lib packages/openapi packages/terminal +RUN mkdir -p packages/api packages/app packages/auth-oauth packages/container packages/docker-git-session-sync packages/lib packages/openapi packages/terminal COPY packages/api/package.json ./packages/api/package.json COPY packages/app/package.json ./packages/app/package.json +COPY packages/auth-oauth/package.json ./packages/auth-oauth/package.json COPY packages/container/package.json ./packages/container/package.json COPY packages/docker-git-session-sync/package.json ./packages/docker-git-session-sync/package.json COPY packages/lib/package.json ./packages/lib/package.json @@ -92,6 +93,7 @@ RUN set -eu; \ --silent \ --filter @effect-template/api \ --filter @effect-template/lib \ + --filter @prover-coder-ai/docker-git-auth-oauth \ --filter @prover-coder-ai/docker-git-container \ --filter @prover-coder-ai/docker-git-terminal \ --filter @prover-coder-ai/docker-git-session-sync; then \ @@ -109,12 +111,14 @@ FROM workspace-deps AS workspace-static COPY patches ./patches COPY scripts ./scripts COPY packages/container ./packages/container +COPY packages/auth-oauth ./packages/auth-oauth COPY packages/docker-git-session-sync ./packages/docker-git-session-sync COPY packages/lib ./packages/lib COPY packages/terminal ./packages/terminal RUN bun run --cwd packages/docker-git-session-sync build RUN bun run --cwd packages/terminal build +RUN bun run --cwd packages/auth-oauth build RUN bun run --cwd packages/container build RUN bun run --cwd packages/lib build diff --git a/packages/auth-oauth/package.json b/packages/auth-oauth/package.json new file mode 100644 index 00000000..e082ae00 --- /dev/null +++ b/packages/auth-oauth/package.json @@ -0,0 +1,57 @@ +{ + "name": "@prover-coder-ai/docker-git-auth-oauth", + "version": "0.0.0", + "private": true, + "description": "Pure OAuth token helpers for docker-git auth flows", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "packageManager": "bun@1.3.11", + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "bun run typecheck", + "auth:claude:docker": "bun src/claude-docker-oauth.ts", + "smoke:claude": "bun src/claude-local-smoke.ts --mode=env-token", + "smoke:claude:setup-token": "bun src/claude-local-smoke.ts --mode=setup-token", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ProverCoderAI/docker-git.git" + }, + "keywords": [ + "docker-git", + "auth", + "oauth" + ], + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/ProverCoderAI/docker-git/issues" + }, + "homepage": "https://github.com/ProverCoderAI/docker-git#readme", + "devDependencies": { + "@types/node": "^25.9.3", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./claude-docker-oauth": { + "types": "./dist/claude-docker-oauth.d.ts", + "import": "./dist/claude-docker-oauth.js" + }, + "./claude-local-smoke": { + "types": "./dist/claude-local-smoke.d.ts", + "import": "./dist/claude-local-smoke.js" + }, + "./claude-oauth-token": { + "types": "./dist/claude-oauth-token.d.ts", + "import": "./dist/claude-oauth-token.js" + } + } +} diff --git a/packages/auth-oauth/src/claude-docker-oauth.ts b/packages/auth-oauth/src/claude-docker-oauth.ts new file mode 100644 index 00000000..1aa1cde2 --- /dev/null +++ b/packages/auth-oauth/src/claude-docker-oauth.ts @@ -0,0 +1,396 @@ +import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn } from "node:child_process" + +import { + claudeOauthTokenPath, + classifyClaudeSetupTokenResult, + extractClaudeOauthToken, + formatClaudeOauthTokenFile +} from "./claude-oauth-token.js" + +export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest" +export const defaultClaudeDockerOauthContainerHome = "/claude-home" + +export type ClaudeDockerOauthOptions = { + readonly cwd?: string + readonly accountPath?: string + readonly dockerHostPath?: string + readonly image?: string + readonly containerPath?: string + readonly dockerCommand?: string + readonly skipBuild?: boolean + readonly keepAccountPath?: boolean + readonly printToken?: boolean + readonly redactLiveOutput?: boolean + readonly runBuild?: (spec: ClaudeDockerBuildSpec) => Promise + readonly runSetupToken?: (spec: ClaudeDockerSetupTokenSpec) => Promise + readonly runProbe?: (spec: ClaudeDockerProbeSpec) => Promise +} + +export type ClaudeDockerBuildSpec = { + readonly dockerCommand: string + readonly args: ReadonlyArray + readonly cwd: string +} + +export type ClaudeDockerSetupTokenSpec = { + readonly dockerCommand: string + readonly args: ReadonlyArray + readonly cwd: string + readonly redactLiveOutput: boolean +} + +export type ClaudeDockerProbeSpec = { + readonly dockerCommand: string + readonly args: ReadonlyArray + readonly cwd: string +} + +export type ClaudeDockerSetupTokenRunResult = { + readonly exitCode: number + readonly token: string | null +} + +export type ClaudeDockerOauthResult = + | { + readonly _tag: "ClaudeDockerOauthTokenCaptured" + readonly token: string + readonly accountPath: string + readonly image: string + readonly exitCode: number + readonly probeStatus: ClaudeDockerProbeStatus + } + | { + readonly _tag: "ClaudeDockerOauthCommandFailed" + readonly accountPath: string + readonly image: string + readonly exitCode: number + } + | { + readonly _tag: "ClaudeDockerOauthTokenMissing" + readonly accountPath: string + readonly image: string + } + +export type ClaudeDockerProbeStatus = + | { readonly _tag: "ClaudeDockerProbeSucceeded"; readonly exitCode: 0 } + | { readonly _tag: "ClaudeDockerProbeFailed"; readonly exitCode: number } + +const outputWindowSize = 262_144 + +const claudeDockerfile = String.raw`FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl bsdutils \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && node -v \ + && npm -v \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g @anthropic-ai/claude-code@latest +ENTRYPOINT ["claude"] +` + +const redactedOauthTokenText = (text: string): string => + text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") + +const appendOutputWindow = (outputWindow: string, chunk: string): string => { + const next = `${outputWindow}${chunk}` + return next.length > outputWindowSize ? next.slice(-outputWindowSize) : next +} + +const resolveDefaultDockerUser = (): string | null => { + const getUid = Reflect.get(process, "getuid") + const getGid = Reflect.get(process, "getgid") + if (typeof getUid !== "function" || typeof getGid !== "function") { + return null + } + const uid = getUid.call(process) + const gid = getGid.call(process) + return typeof uid === "number" && typeof gid === "number" ? `${uid}:${gid}` : null +} + +const buildDockerBindMountArg = (hostPath: string, containerPath: string): string => + `type=bind,source=${hostPath},target=${containerPath}` + +const runDockerBuildInherited = (spec: ClaudeDockerBuildSpec): Promise => + new Promise((resolveExitCode, reject) => { + const child = spawn(spec.dockerCommand, [...spec.args], { cwd: spec.cwd, stdio: "inherit" }) + child.on("error", reject) + child.on("close", (code) => { + resolveExitCode(code ?? 1) + }) + }) + +const ensureClaudeDockerImage = async ( + dockerCommand: string, + image: string, + cwd: string, + skipBuild: boolean, + runBuild: (spec: ClaudeDockerBuildSpec) => Promise +): Promise => { + if (skipBuild) { + return + } + const contextPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-image-")) + try { + await writeFile(join(contextPath, "Dockerfile"), claudeDockerfile, "utf8") + const exitCode = await runBuild({ + dockerCommand, + args: ["build", "-t", image, contextPath], + cwd + }) + if (exitCode !== 0) { + throw new Error(`docker build failed with exit=${exitCode}`) + } + } finally { + await rm(contextPath, { recursive: true, force: true }) + } +} + +const buildDockerSetupTokenArgs = ( + image: string, + hostPath: string, + containerPath: string +): ReadonlyArray => { + const args: Array = [ + "run", + "--rm", + "-i", + "-t", + "--mount", + buildDockerBindMountArg(hostPath, containerPath) + ] + const dockerUser = resolveDefaultDockerUser() + if (dockerUser !== null) { + args.push("--user", dockerUser) + } + args.push( + "-e", + `CLAUDE_CONFIG_DIR=${containerPath}`, + "-e", + `HOME=${containerPath}`, + "-e", + "BROWSER=echo", + image, + "setup-token" + ) + return args +} + +const buildDockerProbeArgs = ( + image: string, + hostPath: string, + containerPath: string +): ReadonlyArray => { + const args: Array = [ + "run", + "--rm", + "-i", + "--mount", + buildDockerBindMountArg(hostPath, containerPath) + ] + const dockerUser = resolveDefaultDockerUser() + if (dockerUser !== null) { + args.push("--user", dockerUser) + } + args.push( + "-e", + `CLAUDE_CONFIG_DIR=${containerPath}`, + "-e", + `HOME=${containerPath}`, + image, + "-p", + "ping" + ) + return args +} + +const runDockerSetupToken = (spec: ClaudeDockerSetupTokenSpec): Promise => + new Promise((resolveResult, reject) => { + const child = spawn(spec.dockerCommand, [...spec.args], { + cwd: spec.cwd, + stdio: ["inherit", "pipe", "pipe"] + }) + const decoder = new TextDecoder("utf-8") + let outputWindow = "" + let token: string | null = null + + const capture = (chunk: Uint8Array, fd: 1 | 2): void => { + const text = decoder.decode(chunk) + outputWindow = appendOutputWindow(outputWindow, text) + token = token ?? extractClaudeOauthToken(outputWindow) + const output = spec.redactLiveOutput ? redactedOauthTokenText(text) : text + if (fd === 2) { + process.stderr.write(output) + return + } + process.stdout.write(output) + } + + child.stdout?.on("data", (chunk: Uint8Array) => { + capture(chunk, 1) + }) + child.stderr?.on("data", (chunk: Uint8Array) => { + capture(chunk, 2) + }) + child.on("error", reject) + child.on("close", (code) => { + resolveResult({ exitCode: code ?? 1, token }) + }) + }) + +const runDockerProbe = (spec: ClaudeDockerProbeSpec): Promise => + new Promise((resolveExitCode, reject) => { + const child = spawn(spec.dockerCommand, [...spec.args], { + cwd: spec.cwd, + stdio: "inherit" + }) + child.on("error", reject) + child.on("close", (code) => { + resolveExitCode(code ?? 1) + }) + }) + +const writeCapturedToken = async (accountPath: string, token: string): Promise => { + const tokenPath = claudeOauthTokenPath(accountPath) + await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") + await chmod(tokenPath, 0o600).catch(() => undefined) +} + +const dockerProbeStatusFromExitCode = (exitCode: number): ClaudeDockerProbeStatus => + exitCode === 0 + ? { _tag: "ClaudeDockerProbeSucceeded", exitCode } + : { _tag: "ClaudeDockerProbeFailed", exitCode } + +export const runClaudeDockerOauth = async ( + options: ClaudeDockerOauthOptions = {} +): Promise => { + const cwd = options.cwd ?? process.cwd() + const image = options.image ?? defaultClaudeDockerOauthImage + const containerPath = options.containerPath ?? defaultClaudeDockerOauthContainerHome + const dockerCommand = options.dockerCommand ?? "docker" + const accountPath = resolve(options.accountPath ?? await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-account-"))) + const dockerHostPath = resolve(options.dockerHostPath ?? accountPath) + const keepAccountPath = options.keepAccountPath ?? options.accountPath !== undefined + + try { + await mkdir(accountPath, { recursive: true }) + await ensureClaudeDockerImage( + dockerCommand, + image, + cwd, + options.skipBuild ?? false, + options.runBuild ?? runDockerBuildInherited + ) + const setup = await (options.runSetupToken ?? runDockerSetupToken)({ + dockerCommand, + args: buildDockerSetupTokenArgs(image, dockerHostPath, containerPath), + cwd, + redactLiveOutput: options.redactLiveOutput ?? true + } + ) + const result = classifyClaudeSetupTokenResult(setup.token, setup.exitCode) + if (result._tag === "ClaudeSetupTokenCaptured") { + await writeCapturedToken(accountPath, result.token) + const probeExitCode = await (options.runProbe ?? runDockerProbe)({ + dockerCommand, + args: buildDockerProbeArgs(image, dockerHostPath, containerPath), + cwd + }) + return { + _tag: "ClaudeDockerOauthTokenCaptured", + token: result.token, + accountPath, + image, + exitCode: result.exitCode, + probeStatus: dockerProbeStatusFromExitCode(probeExitCode) + } + } + if (result._tag === "ClaudeSetupTokenCommandFailed") { + return { + _tag: "ClaudeDockerOauthCommandFailed", + accountPath, + image, + exitCode: result.exitCode + } + } + return { + _tag: "ClaudeDockerOauthTokenMissing", + accountPath, + image + } + } finally { + if (!keepAccountPath) { + await rm(accountPath, { recursive: true, force: true }) + } + } +} + +export const renderClaudeDockerOauthResult = ( + result: ClaudeDockerOauthResult, + printToken: boolean +): string => { + if (result._tag === "ClaudeDockerOauthTokenCaptured") { + const probe = result.probeStatus._tag === "ClaudeDockerProbeSucceeded" + ? "probe=ok" + : `probe=failed exit=${result.probeStatus.exitCode}` + return printToken + ? `status=ClaudeDockerOauthTokenCaptured ${probe} token=${result.token}` + : `status=ClaudeDockerOauthTokenCaptured ${probe}` + } + if (result._tag === "ClaudeDockerOauthCommandFailed") { + return `status=ClaudeDockerOauthCommandFailed exit=${result.exitCode}` + } + return "status=ClaudeDockerOauthTokenMissing" +} + +const readFlagValue = (argv: ReadonlyArray, flag: string): string | null => { + const prefix = `${flag}=` + const match = argv.find((arg) => arg.startsWith(prefix)) + return match === undefined ? null : match.slice(prefix.length) +} + +const isDirectExecution = (): boolean => { + const entry = process.argv[1] + return entry !== undefined && resolve(entry) === fileURLToPath(import.meta.url) +} + +if (isDirectExecution()) { + const printToken = !process.argv.includes("--no-print-token") + const accountPath = readFlagValue(process.argv, "--account-path") + const dockerHostPath = readFlagValue(process.argv, "--docker-host-path") + const image = readFlagValue(process.argv, "--image") + const containerPath = readFlagValue(process.argv, "--container-path") + const options: ClaudeDockerOauthOptions = { + skipBuild: process.argv.includes("--skip-build"), + keepAccountPath: process.argv.includes("--keep-account-path") || accountPath !== null, + printToken, + redactLiveOutput: !process.argv.includes("--no-redact-live-output") + } + if (accountPath !== null) { + Object.assign(options, { accountPath }) + } + if (dockerHostPath !== null) { + Object.assign(options, { dockerHostPath }) + } + if (image !== null) { + Object.assign(options, { image }) + } + if (containerPath !== null) { + Object.assign(options, { containerPath }) + } + runClaudeDockerOauth(options) + .then((result) => { + console.log(renderClaudeDockerOauthResult(result, printToken)) + process.exitCode = result._tag === "ClaudeDockerOauthTokenCaptured" ? 0 : 1 + }) + .catch((error: Error) => { + console.error(`status=ClaudeDockerOauthError message=${error.message}`) + process.exitCode = 1 + }) +} diff --git a/packages/auth-oauth/src/claude-local-smoke.ts b/packages/auth-oauth/src/claude-local-smoke.ts new file mode 100644 index 00000000..4cacd6fa --- /dev/null +++ b/packages/auth-oauth/src/claude-local-smoke.ts @@ -0,0 +1,287 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawn } from "node:child_process" + +import { + claudeCodeOauthTokenEnvKey, + claudeOauthTokenFileMode, + claudeOauthTokenPath, + classifyClaudeSetupTokenResult, + dockerGitClaudeOauthTokenEnvKey, + extractClaudeOauthToken, + formatClaudeOauthTokenFile, + type OAuthEnvironment, + readClaudeOauthTokenFromEnv +} from "./claude-oauth-token.js" + +export type ClaudeLocalOauthSmokeMode = "env-token" | "setup-token" + +export type ClaudeLocalOauthProbeSpec = { + readonly cwd: string + readonly command: string + readonly args: ReadonlyArray + readonly env: NodeJS.ProcessEnv +} + +export type ClaudeLocalOauthSetupTokenResult = { + readonly exitCode: number + readonly token: string | null +} + +export type ClaudeLocalOauthSmokeResult = + | { + readonly _tag: "ClaudeLocalOauthSmokeMissingToken" + readonly envKeys: ReadonlyArray + } + | { + readonly _tag: "ClaudeLocalOauthSmokeSucceeded" + readonly accountPath: string + } + | { + readonly _tag: "ClaudeLocalOauthSmokeProbeFailed" + readonly accountPath: string + readonly exitCode: number + } + | { + readonly _tag: "ClaudeLocalOauthSmokeSetupTokenFailed" + readonly accountPath: string + readonly exitCode: number + } + | { + readonly _tag: "ClaudeLocalOauthSmokeSetupTokenMissingToken" + readonly accountPath: string + readonly exitCode: 0 + } + +export type ClaudeLocalOauthSmokeOptions = { + readonly mode?: ClaudeLocalOauthSmokeMode + readonly env?: OAuthEnvironment & NodeJS.ProcessEnv + readonly cwd?: string + readonly command?: string + readonly args?: ReadonlyArray + readonly keepTemp?: boolean + readonly runProbe?: (spec: ClaudeLocalOauthProbeSpec) => Promise + readonly runSetupToken?: (spec: ClaudeLocalOauthProbeSpec) => Promise +} + +export const claudeLocalOauthSmokeEnvKeys = [ + dockerGitClaudeOauthTokenEnvKey, + claudeCodeOauthTokenEnvKey +] as const + +export const buildClaudeLocalOauthEnv = ( + baseEnv: NodeJS.ProcessEnv, + accountPath: string, + oauthToken: string +): NodeJS.ProcessEnv => ({ + ...baseEnv, + CLAUDE_CONFIG_DIR: accountPath, + CLAUDE_CODE_OAUTH_TOKEN: oauthToken, + HOME: accountPath +}) + +export const persistClaudeLocalOauthToken = async ( + accountPath: string, + token: string +): Promise => { + const tokenPath = claudeOauthTokenPath(accountPath) + await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") + await chmod(tokenPath, claudeOauthTokenFileMode).catch(() => undefined) +} + +const redactedOauthTokenText = (text: string): string => + text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") + +const defaultClaudeLocalOauthProbe = (spec: ClaudeLocalOauthProbeSpec): Promise => + new Promise((resolveExitCode, reject) => { + const child = spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: spec.env, + stdio: "inherit" + }) + + child.on("error", reject) + child.on("close", (code) => { + resolveExitCode(code ?? 1) + }) + }) + +const appendOutputWindow = (outputWindow: string, chunk: string): string => { + const next = `${outputWindow}${chunk}` + return next.length > 262_144 ? next.slice(-262_144) : next +} + +const defaultClaudeSetupToken = ( + spec: ClaudeLocalOauthProbeSpec +): Promise => + new Promise((resolveResult, reject) => { + const child = spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: spec.env, + stdio: ["inherit", "pipe", "pipe"] + }) + const decoder = new TextDecoder("utf-8") + let outputWindow = "" + let token: string | null = null + + const capture = (chunk: Uint8Array, fd: 1 | 2): void => { + const text = decoder.decode(chunk) + outputWindow = appendOutputWindow(outputWindow, text) + token = token ?? extractClaudeOauthToken(outputWindow) + const redacted = redactedOauthTokenText(text) + if (fd === 2) { + process.stderr.write(redacted) + return + } + process.stdout.write(redacted) + } + + child.stdout?.on("data", (chunk: Uint8Array) => { + capture(chunk, 1) + }) + child.stderr?.on("data", (chunk: Uint8Array) => { + capture(chunk, 2) + }) + child.on("error", reject) + child.on("close", (code) => { + resolveResult({ exitCode: code ?? 1, token }) + }) + }) + +const removeTempRoot = (root: string, keepTemp: boolean): Promise => + keepTemp ? Promise.resolve() : rm(root, { recursive: true, force: true }) + +const buildClaudeSetupTokenEnv = ( + baseEnv: NodeJS.ProcessEnv, + accountPath: string +): NodeJS.ProcessEnv => ({ + ...baseEnv, + CLAUDE_CONFIG_DIR: accountPath, + HOME: accountPath +}) + +const readTokenFromEnv = (env: OAuthEnvironment): ClaudeLocalOauthSmokeResult | string => { + const token = readClaudeOauthTokenFromEnv(env, claudeLocalOauthSmokeEnvKeys) + return token === null + ? { + _tag: "ClaudeLocalOauthSmokeMissingToken", + envKeys: claudeLocalOauthSmokeEnvKeys + } + : token +} + +const readTokenFromSetupToken = async ( + accountPath: string, + spec: ClaudeLocalOauthProbeSpec, + runSetupToken: (spec: ClaudeLocalOauthProbeSpec) => Promise +): Promise => { + const setup = await runSetupToken(spec) + const result = classifyClaudeSetupTokenResult(setup.token, setup.exitCode) + if (result._tag === "ClaudeSetupTokenCaptured") { + return result.token + } + if (result._tag === "ClaudeSetupTokenCommandFailed") { + return { + _tag: "ClaudeLocalOauthSmokeSetupTokenFailed", + accountPath, + exitCode: result.exitCode + } + } + return { + _tag: "ClaudeLocalOauthSmokeSetupTokenMissingToken", + accountPath, + exitCode: result.exitCode + } +} + +const isSmokeResult = (value: ClaudeLocalOauthSmokeResult | string): value is ClaudeLocalOauthSmokeResult => + typeof value !== "string" + +export const runClaudeLocalOauthSmoke = async ( + options: ClaudeLocalOauthSmokeOptions = {} +): Promise => { + const env = options.env ?? process.env + const mode = options.mode ?? "env-token" + + if (mode === "env-token") { + const envToken = readTokenFromEnv(env) + if (isSmokeResult(envToken)) { + return envToken + } + } + + const root = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-smoke-")) + const accountPath = join(root, "default") + const keepTemp = options.keepTemp ?? false + try { + await mkdir(accountPath, { recursive: true }) + const command = options.command ?? "claude" + const cwd = options.cwd ?? process.cwd() + const setupEnv = buildClaudeSetupTokenEnv(env, accountPath) + const token = mode === "setup-token" + ? await readTokenFromSetupToken(accountPath, { + cwd, + command, + args: ["setup-token"], + env: setupEnv + }, options.runSetupToken ?? defaultClaudeSetupToken) + : readTokenFromEnv(env) + if (isSmokeResult(token)) { + return token + } + await persistClaudeLocalOauthToken(accountPath, token) + const exitCode = await (options.runProbe ?? defaultClaudeLocalOauthProbe)({ + cwd, + command, + args: options.args ?? ["-p", "ping"], + env: buildClaudeLocalOauthEnv(env, accountPath, token) + }) + + return exitCode === 0 + ? { _tag: "ClaudeLocalOauthSmokeSucceeded", accountPath } + : { _tag: "ClaudeLocalOauthSmokeProbeFailed", accountPath, exitCode } + } finally { + await removeTempRoot(root, keepTemp) + } +} + +export const renderClaudeLocalOauthSmokeResult = (result: ClaudeLocalOauthSmokeResult): string => { + if (result._tag === "ClaudeLocalOauthSmokeSucceeded") { + return "smoke=ClaudeLocalOauthSmokeSucceeded" + } + if (result._tag === "ClaudeLocalOauthSmokeProbeFailed") { + return `smoke=ClaudeLocalOauthSmokeProbeFailed exit=${result.exitCode}` + } + if (result._tag === "ClaudeLocalOauthSmokeSetupTokenFailed") { + return `smoke=ClaudeLocalOauthSmokeSetupTokenFailed exit=${result.exitCode}` + } + if (result._tag === "ClaudeLocalOauthSmokeSetupTokenMissingToken") { + return "smoke=ClaudeLocalOauthSmokeSetupTokenMissingToken" + } + return `smoke=ClaudeLocalOauthSmokeMissingToken env=${result.envKeys.join("|")}` +} + +const modeFromArgv = (argv: ReadonlyArray): ClaudeLocalOauthSmokeMode => { + const modeArg = argv.find((arg) => arg.startsWith("--mode=")) + return modeArg === "--mode=setup-token" ? "setup-token" : "env-token" +} + +const isDirectExecution = (): boolean => { + const entry = process.argv[1] + return entry !== undefined && resolve(entry) === fileURLToPath(import.meta.url) +} + +if (isDirectExecution()) { + const keepTemp = process.argv.includes("--keep-temp") + runClaudeLocalOauthSmoke({ keepTemp, mode: modeFromArgv(process.argv) }) + .then((result) => { + console.log(renderClaudeLocalOauthSmokeResult(result)) + process.exitCode = result._tag === "ClaudeLocalOauthSmokeSucceeded" ? 0 : 1 + }) + .catch((error: Error) => { + console.error(`smoke=ClaudeLocalOauthSmokeError message=${error.message}`) + process.exitCode = 1 + }) +} diff --git a/packages/auth-oauth/src/claude-oauth-token.ts b/packages/auth-oauth/src/claude-oauth-token.ts new file mode 100644 index 00000000..7687ae0f --- /dev/null +++ b/packages/auth-oauth/src/claude-oauth-token.ts @@ -0,0 +1,152 @@ +export const claudeCodeOauthTokenEnvKey = "CLAUDE_CODE_OAUTH_TOKEN" +export const dockerGitClaudeOauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN" +export const claudeOauthTokenFileName = ".oauth-token" +export const claudeOauthTokenFileMode = 0o600 + +export type OAuthEnvironment = Readonly> + +export type ClaudeSetupTokenResult = + | { + readonly _tag: "ClaudeSetupTokenCaptured" + readonly token: string + readonly exitCode: number + readonly exitedNonZero: boolean + } + | { + readonly _tag: "ClaudeSetupTokenMissing" + readonly exitCode: 0 + } + | { + readonly _tag: "ClaudeSetupTokenCommandFailed" + readonly exitCode: number + } + +const ansiEscape = "\u{1B}" +const ansiBell = "\u{7}" +const tokenMarker = "Your OAuth token (valid for 1 year):" +const tokenFooterMarker = "Store this token securely." +const oauthTokenRegex = /([A-Za-z0-9][A-Za-z0-9._-]{20,})/u + +const isAnsiFinalByte = (codePoint: number | undefined): boolean => + codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7E + +const skipCsiSequence = (raw: string, start: number): number => { + const length = raw.length + let index = start + 2 + while (index < length) { + const codePoint = raw.codePointAt(index) + if (isAnsiFinalByte(codePoint)) { + return index + 1 + } + index += 1 + } + return index +} + +const skipOscSequence = (raw: string, start: number): number => { + const length = raw.length + let index = start + 2 + while (index < length) { + const char = raw[index] ?? "" + if (char === ansiBell) { + return index + 1 + } + if (char === ansiEscape && raw[index + 1] === "\\") { + return index + 2 + } + index += 1 + } + return index +} + +const skipEscapeSequence = (raw: string, start: number): number => { + const next = raw[start + 1] ?? "" + if (next === "[") { + return skipCsiSequence(raw, start) + } + if (next === "]") { + return skipOscSequence(raw, start) + } + return Math.min(raw.length, start + 2) +} + +const stripAnsi = (raw: string): string => { + const cleaned: Array = [] + let index = 0 + while (index < raw.length) { + const current = raw[index] ?? "" + if (current !== ansiEscape) { + cleaned.push(current) + index += 1 + continue + } + index = skipEscapeSequence(raw, index) + } + return cleaned.join("") +} + +export const normalizeClaudeOauthToken = (rawToken: string): string | null => { + const token = rawToken.trim() + return token.length > 0 ? token : null +} + +export const claudeOauthTokenPath = (accountPath: string): string => + `${accountPath}/${claudeOauthTokenFileName}` + +export const formatClaudeOauthTokenFile = (token: string): string => `${token}\n` + +export const extractClaudeOauthToken = (rawOutput: string): string | null => { + const normalized = stripAnsi(rawOutput).replaceAll("\r", "\n") + const markerIndex = normalized.lastIndexOf(tokenMarker) + if (markerIndex === -1) { + return null + } + + const tail = normalized.slice(markerIndex + tokenMarker.length) + const footerIndex = tail.indexOf(tokenFooterMarker) + const tokenSection = footerIndex === -1 ? tail : tail.slice(0, footerIndex) + const compactSection = tokenSection.replaceAll(/\s+/gu, "") + const compactMatch = oauthTokenRegex.exec(compactSection) + if (compactMatch?.[1] !== undefined) { + return compactMatch[1] + } + + const directMatch = oauthTokenRegex.exec(tokenSection) + return directMatch?.[1] ?? null +} + +export const readClaudeOauthTokenFromEnv = ( + env: OAuthEnvironment, + keys: ReadonlyArray +): string | null => { + for (const key of keys) { + const value = env[key] + if (value === undefined) { + continue + } + const token = normalizeClaudeOauthToken(value) + if (token !== null) { + return token + } + } + return null +} + +export const classifyClaudeSetupTokenResult = ( + rawToken: string | null, + exitCode: number +): ClaudeSetupTokenResult => { + const token = rawToken === null ? null : normalizeClaudeOauthToken(rawToken) + if (token !== null) { + return { + _tag: "ClaudeSetupTokenCaptured", + token, + exitCode, + exitedNonZero: exitCode !== 0 + } + } + if (exitCode !== 0) { + return { _tag: "ClaudeSetupTokenCommandFailed", exitCode } + } + return { _tag: "ClaudeSetupTokenMissing", exitCode } +} diff --git a/packages/auth-oauth/src/index.ts b/packages/auth-oauth/src/index.ts new file mode 100644 index 00000000..5f9e237b --- /dev/null +++ b/packages/auth-oauth/src/index.ts @@ -0,0 +1,3 @@ +export * from "./claude-docker-oauth.js" +export * from "./claude-local-smoke.js" +export * from "./claude-oauth-token.js" diff --git a/packages/auth-oauth/tests/claude-docker-oauth.test.ts b/packages/auth-oauth/tests/claude-docker-oauth.test.ts new file mode 100644 index 00000000..5a160dae --- /dev/null +++ b/packages/auth-oauth/tests/claude-docker-oauth.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { describe, expect, it } from "vitest" + +import { + renderClaudeDockerOauthResult, + runClaudeDockerOauth, + type ClaudeDockerBuildSpec, + type ClaudeDockerProbeSpec, + type ClaudeDockerSetupTokenSpec +} from "../src/claude-docker-oauth.js" +import { claudeOauthTokenPath } from "../src/claude-oauth-token.js" + +const oauthToken = "sk-ant-oat01-DOCKER0123456789abcdef" + +describe("Claude Docker OAuth runner", () => { + it("runs Docker setup-token, persists token, then probes through the mounted token file", async () => { + const accountPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-test-")) + const builds: Array = [] + const setupRuns: Array = [] + const probeRuns: Array = [] + + const result = await runClaudeDockerOauth({ + cwd: "/workspace", + accountPath, + image: "claude-test:latest", + runBuild: (spec) => { + builds.push(spec) + return Promise.resolve(0) + }, + runSetupToken: (spec) => { + setupRuns.push(spec) + return Promise.resolve({ exitCode: 1, token: oauthToken }) + }, + runProbe: async (spec) => { + probeRuns.push(spec) + await expect(readFile(claudeOauthTokenPath(accountPath), "utf8")).resolves.toBe(`${oauthToken}\n`) + return 0 + } + }) + + expect(result).toEqual({ + _tag: "ClaudeDockerOauthTokenCaptured", + token: oauthToken, + accountPath, + image: "claude-test:latest", + exitCode: 1, + probeStatus: { _tag: "ClaudeDockerProbeSucceeded", exitCode: 0 } + }) + expect(builds).toHaveLength(1) + expect(builds[0]?.args.slice(0, 3)).toEqual(["build", "-t", "claude-test:latest"]) + expect(setupRuns).toHaveLength(1) + expect(setupRuns[0]?.args).toContain("setup-token") + expect(setupRuns[0]?.args.join(" ")).toContain(accountPath) + expect(probeRuns).toHaveLength(1) + expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) + expect((await stat(claudeOauthTokenPath(accountPath))).mode & 0o777).toBe(0o600) + }) + + it("keeps the captured token when Docker probe fails", async () => { + const accountPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-probe-test-")) + const result = await runClaudeDockerOauth({ + accountPath, + skipBuild: true, + runSetupToken: () => Promise.resolve({ exitCode: 0, token: oauthToken }), + runProbe: () => Promise.resolve(7) + }) + + expect(renderClaudeDockerOauthResult(result, false)).toBe( + "status=ClaudeDockerOauthTokenCaptured probe=failed exit=7" + ) + expect(renderClaudeDockerOauthResult(result, true)).toBe( + `status=ClaudeDockerOauthTokenCaptured probe=failed exit=7 token=${oauthToken}` + ) + }) + + it("returns command failure when setup-token exits non-zero without token", async () => { + const result = await runClaudeDockerOauth({ + skipBuild: true, + runSetupToken: () => Promise.resolve({ exitCode: 23, token: null }), + runProbe: () => { + throw new Error("probe must not run") + } + }) + + expect(renderClaudeDockerOauthResult(result, true)).toBe("status=ClaudeDockerOauthCommandFailed exit=23") + }) +}) diff --git a/packages/auth-oauth/tests/claude-local-smoke.test.ts b/packages/auth-oauth/tests/claude-local-smoke.test.ts new file mode 100644 index 00000000..221bc99f --- /dev/null +++ b/packages/auth-oauth/tests/claude-local-smoke.test.ts @@ -0,0 +1,111 @@ +import { readFile } from "node:fs/promises" + +import { describe, expect, it } from "vitest" + +import { + buildClaudeLocalOauthEnv, + claudeLocalOauthSmokeEnvKeys, + persistClaudeLocalOauthToken, + renderClaudeLocalOauthSmokeResult, + runClaudeLocalOauthSmoke +} from "../src/claude-local-smoke.js" +import { + claudeCodeOauthTokenEnvKey, + claudeOauthTokenPath, + dockerGitClaudeOauthTokenEnvKey +} from "../src/claude-oauth-token.js" + +const oauthToken = "sk-ant-oat01-SMOKE0123456789abcdef" + +describe("Claude local OAuth smoke runner", () => { + it("builds an isolated Claude env for the local probe", () => { + expect(buildClaudeLocalOauthEnv({ PATH: "/bin" }, "/tmp/claude", oauthToken)).toEqual({ + PATH: "/bin", + CLAUDE_CONFIG_DIR: "/tmp/claude", + CLAUDE_CODE_OAUTH_TOKEN: oauthToken, + HOME: "/tmp/claude" + }) + }) + + it("persists the OAuth token in Claude's expected file", async () => { + const root = await import("node:fs/promises").then((fs) => + fs.mkdtemp(`${process.env.TMPDIR ?? "/tmp"}/docker-git-auth-oauth-test-`) + ) + await persistClaudeLocalOauthToken(root, oauthToken) + await expect(readFile(claudeOauthTokenPath(root), "utf8")).resolves.toBe(`${oauthToken}\n`) + }) + + it("returns a missing-token result without invoking the probe", async () => { + const result = await runClaudeLocalOauthSmoke({ + env: {}, + runProbe: () => { + throw new Error("probe must not run") + } + }) + + expect(result).toEqual({ + _tag: "ClaudeLocalOauthSmokeMissingToken", + envKeys: claudeLocalOauthSmokeEnvKeys + }) + }) + + it("persists the token before running the probe", async () => { + const seen = await runClaudeLocalOauthSmoke({ + mode: "env-token", + env: { [dockerGitClaudeOauthTokenEnvKey]: oauthToken }, + runProbe: async (spec) => { + await expect(readFile(claudeOauthTokenPath(spec.env.CLAUDE_CONFIG_DIR!), "utf8")).resolves.toBe( + `${oauthToken}\n` + ) + expect(spec.env.CLAUDE_CODE_OAUTH_TOKEN).toBe(oauthToken) + return 0 + } + }) + + expect(seen._tag).toBe("ClaudeLocalOauthSmokeSucceeded") + }) + + it("captures setup-token output before running the probe", async () => { + const events: Array = [] + const result = await runClaudeLocalOauthSmoke({ + mode: "setup-token", + env: {}, + runSetupToken: async (spec) => { + events.push(`setup:${spec.args.join(" ")}`) + return { exitCode: 0, token: ` ${oauthToken} ` } + }, + runProbe: async (spec) => { + events.push("probe") + await expect(readFile(claudeOauthTokenPath(spec.env.CLAUDE_CONFIG_DIR!), "utf8")).resolves.toBe( + `${oauthToken}\n` + ) + return 0 + } + }) + + expect(result._tag).toBe("ClaudeLocalOauthSmokeSucceeded") + expect(events).toEqual(["setup:setup-token", "probe"]) + }) + + it("reports setup-token failures before probing", async () => { + const result = await runClaudeLocalOauthSmoke({ + mode: "setup-token", + env: {}, + runSetupToken: () => Promise.resolve({ exitCode: 23, token: null }), + runProbe: () => { + throw new Error("probe must not run") + } + }) + + expect(renderClaudeLocalOauthSmokeResult(result)).toBe("smoke=ClaudeLocalOauthSmokeSetupTokenFailed exit=23") + }) + + it("reports failed local probes with the exit code", async () => { + const result = await runClaudeLocalOauthSmoke({ + env: { [claudeCodeOauthTokenEnvKey]: oauthToken }, + runProbe: () => Promise.resolve(7) + }) + + expect(renderClaudeLocalOauthSmokeResult(result)).toBe("smoke=ClaudeLocalOauthSmokeProbeFailed exit=7") + }) +}) diff --git a/packages/auth-oauth/tests/claude-oauth-token.test.ts b/packages/auth-oauth/tests/claude-oauth-token.test.ts new file mode 100644 index 00000000..bac877d4 --- /dev/null +++ b/packages/auth-oauth/tests/claude-oauth-token.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest" + +import { + claudeCodeOauthTokenEnvKey, + claudeOauthTokenFileMode, + claudeOauthTokenFileName, + claudeOauthTokenPath, + classifyClaudeSetupTokenResult, + dockerGitClaudeOauthTokenEnvKey, + extractClaudeOauthToken, + formatClaudeOauthTokenFile, + normalizeClaudeOauthToken, + readClaudeOauthTokenFromEnv +} from "../src/claude-oauth-token.js" + +const oauthToken = "sk-ant-oat01-OAUTH0123456789abcdef" + +const setupTokenOutput = (token: string): string => + [ + "Welcome to Claude Code", + "", + " ✓ Long-lived authentication token created successfully!", + "", + " Your OAuth token (valid for 1 year):", + "", + ` ${token}`, + "", + " Store this token securely. You won't be able to see it again." + ].join("\n") + +describe("Claude OAuth token helpers", () => { + it("extracts the OAuth token from setup-token output", () => { + expect(extractClaudeOauthToken(setupTokenOutput(oauthToken))).toBe(oauthToken) + }) + + it("extracts hard-wrapped OAuth tokens from setup-token output", () => { + const wrapped = `${oauthToken.slice(0, 18)}\n${oauthToken.slice(18)}` + expect(extractClaudeOauthToken(setupTokenOutput(wrapped))).toBe(oauthToken) + }) + + it("strips ANSI before extracting the token", () => { + expect(extractClaudeOauthToken(`\u001B[32m${setupTokenOutput(oauthToken)}\u001B[0m`)).toBe(oauthToken) + }) + + it("returns null when setup-token output does not contain the OAuth marker", () => { + expect(extractClaudeOauthToken("Long-lived authentication token created successfully")).toBeNull() + }) + + it("normalizes token whitespace", () => { + expect(normalizeClaudeOauthToken(`\n${oauthToken}\n`)).toBe(oauthToken) + expect(normalizeClaudeOauthToken(" \n ")).toBeNull() + }) + + it("reads env tokens by explicit key priority", () => { + const env = { + [claudeCodeOauthTokenEnvKey]: "sk-ant-oat01-LOWERPRIORITY0123456789", + [dockerGitClaudeOauthTokenEnvKey]: ` ${oauthToken} ` + } + + expect(readClaudeOauthTokenFromEnv(env, [dockerGitClaudeOauthTokenEnvKey, claudeCodeOauthTokenEnvKey])).toBe( + oauthToken + ) + expect(readClaudeOauthTokenFromEnv(env, [claudeCodeOauthTokenEnvKey, dockerGitClaudeOauthTokenEnvKey])).toBe( + "sk-ant-oat01-LOWERPRIORITY0123456789" + ) + }) + + it("classifies setup-token outcomes without throwing package-specific errors", () => { + expect(classifyClaudeSetupTokenResult(oauthToken, 1)).toEqual({ + _tag: "ClaudeSetupTokenCaptured", + token: oauthToken, + exitCode: 1, + exitedNonZero: true + }) + expect(classifyClaudeSetupTokenResult(null, 1)).toEqual({ + _tag: "ClaudeSetupTokenCommandFailed", + exitCode: 1 + }) + expect(classifyClaudeSetupTokenResult(null, 0)).toEqual({ + _tag: "ClaudeSetupTokenMissing", + exitCode: 0 + }) + }) + + it("describes Claude OAuth token storage", () => { + expect(claudeOauthTokenFileName).toBe(".oauth-token") + expect(claudeOauthTokenFileMode).toBe(0o600) + expect(claudeOauthTokenPath("/tmp/account")).toBe("/tmp/account/.oauth-token") + expect(formatClaudeOauthTokenFile(oauthToken)).toBe(`${oauthToken}\n`) + }) +}) diff --git a/packages/auth-oauth/tsconfig.json b/packages/auth-oauth/tsconfig.json new file mode 100644 index 00000000..e81b8207 --- /dev/null +++ b/packages/auth-oauth/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "types": ["vitest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/lib/package.json b/packages/lib/package.json index fe0c282c..bf210b9e 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -8,15 +8,15 @@ "doc": "doc" }, "scripts": { - "prebuild": "bun run --cwd ../container build", + "prebuild": "bun run --cwd ../auth-oauth build && bun run --cwd ../container build", "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", - "prelint": "bun run --cwd ../container build", + "prelint": "bun run --cwd ../auth-oauth build && bun run --cwd ../container build", "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", - "pretypecheck": "bun run --cwd ../container build", + "pretypecheck": "bun run --cwd ../auth-oauth build && bun run --cwd ../container build", "typecheck": "tsc --noEmit -p tsconfig.json", - "pretest": "bun run --cwd ../container build", + "pretest": "bun run --cwd ../auth-oauth build && bun run --cwd ../container build && bun run --cwd ../docker-git-session-sync build", "test": "vitest run --passWithNoTests" }, "repository": { @@ -38,6 +38,7 @@ "homepage": "https://github.com/ProverCoderAI/docker-git#readme", "packageManager": "bun@1.3.11", "dependencies": { + "@prover-coder-ai/docker-git-auth-oauth": "workspace:*", "@prover-coder-ai/docker-git-container": "workspace:*", "@effect/cli": "^0.75.2", "@effect/cluster": "^0.59.0", diff --git a/packages/lib/src/usecases/auth-claude-local.ts b/packages/lib/src/usecases/auth-claude-local.ts new file mode 100644 index 00000000..ed7d45b1 --- /dev/null +++ b/packages/lib/src/usecases/auth-claude-local.ts @@ -0,0 +1,82 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { + claudeCodeOauthTokenEnvKey, + dockerGitClaudeOauthTokenEnvKey, + readClaudeOauthTokenFromEnv +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { Effect } from "effect" + +import { runCommandExitCode } from "../shell/command-runner.js" +import { AuthError } from "../shell/errors.js" +import { type ClaudeLoginFlowResult, runClaudeLoginFlow } from "./auth-claude-login-flow.js" + +export type ClaudeLocalLoginFlowSpec = { + readonly cwd: string + readonly accountLabel: string + readonly accountPath: string + readonly env?: NodeJS.ProcessEnv + readonly persistToken: (token: string) => Effect.Effect + readonly normalizeStoredCredentials: Effect.Effect + readonly syncState: Effect.Effect +} + +export const readClaudeLocalOauthTokenFromEnv = ( + env: NodeJS.ProcessEnv = process.env +): Effect.Effect => { + const token = readClaudeOauthTokenFromEnv(env, [dockerGitClaudeOauthTokenEnvKey, claudeCodeOauthTokenEnvKey]) + return token === null + ? Effect.fail( + new AuthError({ + message: + `Set ${dockerGitClaudeOauthTokenEnvKey} or ${claudeCodeOauthTokenEnvKey} to run the local Claude auth smoke.` + }) + ) + : Effect.succeed(token) +} + +export const buildClaudeLocalEnv = ( + accountPath: string, + oauthToken: string +): Readonly> => ({ + CLAUDE_CONFIG_DIR: accountPath, + CLAUDE_CODE_OAUTH_TOKEN: oauthToken, + HOME: accountPath +}) + +export const runClaudeLocalPingProbeExitCode = ( + cwd: string, + accountPath: string, + oauthToken: string +): Effect.Effect => + runCommandExitCode({ + cwd, + command: "claude", + args: ["-p", "ping"], + env: buildClaudeLocalEnv(accountPath, oauthToken) + }) + +// CHANGE: provide a no-Docker Claude auth smoke runner +// WHY: local environments may have Claude CLI and token access even when nested Docker is unavailable +// REF: issue-439 +// SOURCE: n/a +// FORMAT THEOREM: forall env: token(env) -> same login policy as docker runner +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: local smoke uses a caller-provided accountPath and never logs token material +// COMPLEXITY: O(probe) +export const runClaudeLocalEnvTokenLoginFlow = ( + spec: ClaudeLocalLoginFlowSpec +): Effect.Effect< + ClaudeLoginFlowResult, + AuthError | PlatformError | EStore | ESync, + CommandExecutor.CommandExecutor | RStore | RSync +> => + runClaudeLoginFlow({ + accountLabel: spec.accountLabel, + captureToken: readClaudeLocalOauthTokenFromEnv(spec.env), + persistToken: spec.persistToken, + normalizeStoredCredentials: spec.normalizeStoredCredentials, + probeToken: (token) => runClaudeLocalPingProbeExitCode(spec.cwd, spec.accountPath, token), + syncState: spec.syncState + }) diff --git a/packages/lib/src/usecases/auth-claude-login-flow.ts b/packages/lib/src/usecases/auth-claude-login-flow.ts new file mode 100644 index 00000000..ba8ecf49 --- /dev/null +++ b/packages/lib/src/usecases/auth-claude-login-flow.ts @@ -0,0 +1,77 @@ +import { normalizeClaudeOauthToken } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { Effect } from "effect" + +import { AuthError } from "../shell/errors.js" + +export type ClaudeLoginProbeStatus = + | { readonly _tag: "ClaudeLoginProbeSucceeded"; readonly exitCode: 0 } + | { readonly _tag: "ClaudeLoginProbeFailed"; readonly exitCode: number } + +export type ClaudeLoginFlowResult = { + readonly accountLabel: string + readonly probeStatus: ClaudeLoginProbeStatus +} + +export type ClaudeLoginFlowSpec = { + readonly accountLabel: string + readonly captureToken: Effect.Effect + readonly persistToken: (token: string) => Effect.Effect + readonly normalizeStoredCredentials: Effect.Effect + readonly probeToken: (token: string) => Effect.Effect + readonly syncState: Effect.Effect +} + +const probeStatusFromExitCode = (exitCode: number): ClaudeLoginProbeStatus => + exitCode === 0 + ? { _tag: "ClaudeLoginProbeSucceeded", exitCode } + : { _tag: "ClaudeLoginProbeFailed", exitCode } + +const warnOnProbeFailure = ( + accountLabel: string, + status: ClaudeLoginProbeStatus +): Effect.Effect => + status._tag === "ClaudeLoginProbeSucceeded" + ? Effect.void + : Effect.logWarning( + `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${status.exitCode}). ` + + `Login is complete because the token was captured and persisted; live Claude API access is not yet verified. ` + + `The token may need a moment to activate, or there was a transient network issue. ` + + `Verify later with 'docker-git auth claude status'.` + ) + +const ensureClaudeOauthToken = (rawToken: string): Effect.Effect => { + const token = normalizeClaudeOauthToken(rawToken) + return token === null + ? Effect.fail(new AuthError({ message: "Claude OAuth token is empty." })) + : Effect.succeed(token) +} + +// CHANGE: isolate Claude login policy from the Docker-specific runner +// WHY: issue-439 is a policy invariant: captured token persistence must not depend on the live API probe result +// REF: issue-439 +// SOURCE: n/a +// FORMAT THEOREM: forall token, probe: non_empty(token) -> persisted(token) before probe_result(probe) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: a non-empty captured token is persisted before the post-login probe is interpreted +// COMPLEXITY: O(login + persist + probe + sync) +export const runClaudeLoginFlow = ( + spec: ClaudeLoginFlowSpec +): Effect.Effect< + ClaudeLoginFlowResult, + AuthError | ELogin | EStore | EProbe | ESync, + RLogin | RStore | RProbe | RSync +> => + Effect.gen(function*(_) { + const token = yield* _(spec.captureToken.pipe(Effect.flatMap(ensureClaudeOauthToken))) + yield* _(spec.persistToken(token)) + yield* _(spec.normalizeStoredCredentials) + const probeExitCode = yield* _(spec.probeToken(token)) + const probeStatus = probeStatusFromExitCode(probeExitCode) + yield* _(warnOnProbeFailure(spec.accountLabel, probeStatus)) + yield* _(spec.syncState) + return { + accountLabel: spec.accountLabel, + probeStatus + } satisfies ClaudeLoginFlowResult + }) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index c1502770..3ca7daff 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -1,116 +1,38 @@ import * as Command from "@effect/platform/Command" import * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" +import { + type ClaudeDockerOauthResult, + type ClaudeDockerProbeSpec, + type ClaudeDockerSetupTokenSpec, + runClaudeDockerOauth +} from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" +import { + dockerGitClaudeOauthTokenEnvKey, + extractClaudeOauthToken, + readClaudeOauthTokenFromEnv +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { Effect, pipe } from "effect" import * as Fiber from "effect/Fiber" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" -import { stripAnsi, writeChunkToFd } from "../shell/ansi-strip.js" -import { buildDockerBindMountArg, resolveDefaultDockerUser, resolveDockerVolumeHostPath } from "../shell/docker-auth.js" +import { writeChunkToFd } from "../shell/ansi-strip.js" +import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN" -const tokenMarker = "Your OAuth token (valid for 1 year):" -const tokenFooterMarker = "Store this token securely." const outputWindowSize = 262_144 -const oauthTokenRegex = /([A-Za-z0-9][A-Za-z0-9._-]{20,})/u - -const extractOauthToken = (rawOutput: string): string | null => { - const normalized = stripAnsi(rawOutput).replaceAll("\r", "\n") - const markerIndex = normalized.lastIndexOf(tokenMarker) - if (markerIndex === -1) { - return null - } - - const tail = normalized.slice(markerIndex + tokenMarker.length) - const footerIndex = tail.indexOf(tokenFooterMarker) - const tokenSection = footerIndex === -1 ? tail : tail.slice(0, footerIndex) - - // CHANGE: join wrapped lines in token section before parsing - // WHY: some terminals hard-wrap long OAuth tokens with newline characters - // REF: issue-377 - // SOURCE: n/a - // PURITY: CORE - // INVARIANT: only whitespace is removed; token alphabet remains intact - const compactSection = tokenSection.replaceAll(/\s+/gu, "") - const compactMatch = oauthTokenRegex.exec(compactSection) - if (compactMatch?.[1] !== undefined) { - return compactMatch[1] - } - - const directMatch = oauthTokenRegex.exec(tokenSection) - return directMatch?.[1] ?? null -} - -const oauthTokenFromEnv = (): string | null => { - const value = (process.env[oauthTokenEnvKey] ?? "").trim() - return value.length > 0 ? value : null -} - -const ensureOauthToken = (rawToken: string): Effect.Effect => { - const token = rawToken.trim() - return token.length > 0 - ? Effect.succeed(token) - : Effect.fail(new AuthError({ message: "Claude OAuth token is empty." })) -} - -type DockerSetupTokenSpec = { - readonly cwd: string - readonly image: string - readonly hostPath: string - readonly containerPath: string - readonly env: ReadonlyArray - readonly args: ReadonlyArray -} - -const buildDockerSetupTokenSpec = ( - cwd: string, - accountPath: string, - image: string, - containerPath: string -): DockerSetupTokenSpec => ({ - cwd, - image, - hostPath: accountPath, - containerPath, - env: [`CLAUDE_CONFIG_DIR=${containerPath}`, `HOME=${containerPath}`, "BROWSER=echo"], - args: ["setup-token"] -}) - -const buildDockerSetupTokenArgs = (spec: DockerSetupTokenSpec): ReadonlyArray => { - const base: Array = [ - "run", - "--rm", - "-i", - "-t", - "--mount", - buildDockerBindMountArg({ hostPath: spec.hostPath, containerPath: spec.containerPath }) - ] - const dockerUser = resolveDefaultDockerUser() - if (dockerUser !== null) { - base.push("--user", dockerUser) - } - for (const entry of spec.env) { - const trimmed = entry.trim() - if (trimmed.length === 0) { - continue - } - base.push("-e", trimmed) - } - return [...base, spec.image, ...spec.args] -} - const startDockerProcess = ( executor: CommandExecutor.CommandExecutor, - spec: DockerSetupTokenSpec + cwd: string, + dockerCommand: string, + args: ReadonlyArray ): Effect.Effect => { - const dockerArgs = buildDockerSetupTokenArgs(spec) return executor.start( pipe( - Command.make("docker", ...dockerArgs), - Command.workingDirectory(spec.cwd), + Command.make(dockerCommand, ...args), + Command.workingDirectory(cwd), Command.stdin("inherit"), Command.stdout("pipe"), Command.stderr("pipe") @@ -118,6 +40,9 @@ const startDockerProcess = ( ) } +const redactedOauthTokenText = (text: string): string => + text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") + const pumpDockerOutput = ( source: Stream.Stream, fd: number, @@ -130,15 +55,16 @@ const pumpDockerOutput = ( source, Stream.runForEach((chunk) => Effect.sync(() => { - writeChunkToFd(fd, chunk) - outputWindow += decoder.decode(chunk) + const text = decoder.decode(chunk) + writeChunkToFd(fd, new TextEncoder().encode(redactedOauthTokenText(text))) + outputWindow += text if (outputWindow.length > outputWindowSize) { outputWindow = outputWindow.slice(-outputWindowSize) } if (tokenBox.value !== null) { return } - const parsed = extractOauthToken(outputWindow) + const parsed = extractClaudeOauthToken(outputWindow) if (parsed !== null) { tokenBox.value = parsed } @@ -147,37 +73,113 @@ const pumpDockerOutput = ( ).pipe(Effect.asVoid) } -const resolveCapturedToken = (token: string | null): Effect.Effect => - token === null - ? Effect.fail( - new AuthError({ - message: - "Claude OAuth completed without a captured token. Retry login and ensure the flow reaches 'Long-lived authentication token created successfully'." +const pipeDockerOutputToFd = ( + source: Stream.Stream, + fd: 1 | 2 +): Effect.Effect => + pipe( + source, + Stream.runForEach((chunk) => + Effect.sync(() => { + writeChunkToFd(fd, chunk) + }) + ) + ).pipe(Effect.asVoid) + +const runDockerSetupTokenWithExecutor = ( + executor: CommandExecutor.CommandExecutor, + spec: ClaudeDockerSetupTokenSpec +) => + Effect.runPromise( + Effect.scoped( + Effect.gen(function*(_) { + const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) + const tokenBox: { value: string | null } = { value: null } + const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, tokenBox))) + const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, tokenBox))) + const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) + yield* _(Fiber.join(stdoutFiber)) + yield* _(Fiber.join(stderrFiber)) + return { exitCode, token: tokenBox.value } }) ) - : ensureOauthToken(token) + ) + +const runDockerProbeWithExecutor = ( + executor: CommandExecutor.CommandExecutor, + spec: ClaudeDockerProbeSpec +) => + Effect.runPromise( + Effect.scoped( + Effect.gen(function*(_) { + const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) + const stdoutFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stdout, 1))) + const stderrFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stderr, 2))) + const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) + yield* _(Fiber.join(stdoutFiber)) + yield* _(Fiber.join(stderrFiber)) + return exitCode + }) + ) + ) -const resolveLoginResult = ( - token: string | null, - exitCode: number +const runClaudeDockerOauthEffect = ( + cwd: string, + accountPath: string, + hostPath: string, + options: { + readonly image: string + readonly containerPath: string + }, + executor: CommandExecutor.CommandExecutor +): Effect.Effect => + Effect.tryPromise({ + try: () => + runClaudeDockerOauth({ + cwd, + accountPath, + dockerHostPath: hostPath, + image: options.image, + containerPath: options.containerPath, + skipBuild: true, + keepAccountPath: true, + printToken: false, + runSetupToken: (spec) => runDockerSetupTokenWithExecutor(executor, spec), + runProbe: (spec) => runDockerProbeWithExecutor(executor, spec) + }), + catch: (error) => + new AuthError({ + message: error instanceof Error ? error.message : "Claude Docker OAuth failed." + }) + }) + +const resolveClaudeDockerOauthTokenResult = ( + result: ClaudeDockerOauthResult ): Effect.Effect => Effect.gen(function*(_) { - if (token !== null) { - if (exitCode !== 0) { + if (result._tag === "ClaudeDockerOauthTokenCaptured") { + if (result.exitCode !== 0) { yield* _( Effect.logWarning( - `claude setup-token returned exit=${exitCode}, but OAuth token was captured; continuing.` + `claude setup-token returned exit=${result.exitCode}, but OAuth token was captured; continuing.` ) ) } - return yield* _(ensureOauthToken(token)) + return result.token } - - if (exitCode !== 0) { - yield* _(Effect.fail(new CommandFailedError({ command: "claude setup-token", exitCode }))) + if (result._tag === "ClaudeDockerOauthCommandFailed") { + return yield* _( + Effect.fail(new CommandFailedError({ command: "claude setup-token", exitCode: result.exitCode })) + ) } - - return yield* _(resolveCapturedToken(token)) + return yield* _( + Effect.fail( + new AuthError({ + message: + "Claude OAuth completed without a captured token. Retry login and ensure the flow reaches 'Long-lived authentication token created successfully'." + }) + ) + ) }) export const runClaudeOauthLoginWithPrompt = ( @@ -188,26 +190,17 @@ export const runClaudeOauthLoginWithPrompt = ( readonly containerPath: string } ): Effect.Effect => { - const envToken = oauthTokenFromEnv() + const envToken = readClaudeOauthTokenFromEnv(process.env, [dockerGitClaudeOauthTokenEnvKey]) if (envToken !== null) { - return ensureOauthToken(envToken) + return Effect.succeed(envToken) } return Effect.scoped( Effect.gen(function*(_) { const executor = yield* _(CommandExecutor.CommandExecutor) const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) - const spec = buildDockerSetupTokenSpec(cwd, hostPath, options.image, options.containerPath) - const proc = yield* _(startDockerProcess(executor, spec)) - - const tokenBox: { value: string | null } = { value: null } - const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, tokenBox))) - const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, tokenBox))) - - const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) - yield* _(Fiber.join(stdoutFiber)) - yield* _(Fiber.join(stderrFiber)) - return yield* _(resolveLoginResult(tokenBox.value, exitCode)) + const result = yield* _(runClaudeDockerOauthEffect(cwd, accountPath, hostPath, options, executor)) + return yield* _(resolveClaudeDockerOauthTokenResult(result)) }) ) } diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index c4037022..2bd41be6 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -2,6 +2,11 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" +import { + claudeOauthTokenFileMode, + claudeOauthTokenPath, + formatClaudeOauthTokenFile +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { Effect } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" @@ -9,6 +14,7 @@ import { defaultTemplateConfig } from "../core/domain.js" import { runDockerAuth, runDockerAuthExitCode } from "../shell/docker-auth.js" import type { AuthError } from "../shell/errors.js" import { CommandFailedError } from "../shell/errors.js" +import { runClaudeLoginFlow } from "./auth-claude-login-flow.js" import { runClaudeOauthLoginWithPrompt } from "./auth-claude-oauth.js" import { buildDockerAuthSpec, isRegularFile, normalizeAccountLabel } from "./auth-helpers.js" import { migrateLegacyOrchLayout } from "./auth-sync.js" @@ -34,17 +40,26 @@ export const claudeAuthRoot = ".docker-git/.orch/auth/claude" const claudeImageName = "docker-git-auth-claude:latest" const claudeImageDir = ".docker-git/.orch/auth/claude/.image" const claudeContainerHomeDir = "/claude-home" -const claudeOauthTokenFileName = ".oauth-token" const claudeConfigFileName = ".claude.json" const claudeCredentialsFileName = ".credentials.json" const claudeCredentialsDirName = ".claude" -const claudeOauthTokenPath = (accountPath: string): string => `${accountPath}/${claudeOauthTokenFileName}` const claudeConfigPath = (accountPath: string): string => `${accountPath}/${claudeConfigFileName}` const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsFileName}` const claudeNestedCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` +const persistClaudeOauthToken = ( + fs: FileSystem.FileSystem, + accountPath: string, + token: string +): Effect.Effect => + Effect.gen(function*(_) { + const tokenPath = claudeOauthTokenPath(accountPath) + yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token))) + yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode), Effect.orElseSucceed(() => void 0)) + }) + const syncClaudeCredentialsFile = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -175,12 +190,12 @@ const resolveClaudeAccountPath = (path: Path.Path, rootPath: string, label: stri return { accountLabel, accountPath } } -const withClaudeAuth = ( +const withClaudeAuth = ( command: AuthClaudeLoginCommand | AuthClaudeLogoutCommand | AuthClaudeStatusCommand, run: ( context: ClaudeAccountContext - ) => Effect.Effect -): Effect.Effect => + ) => Effect.Effect +): Effect.Effect => withFsPathContext(({ cwd, fs, path }) => Effect.gen(function*(_) { yield* _(ensureClaudeOrchLayout(cwd)) @@ -255,40 +270,19 @@ const runClaudePingProbeExitCode = ( // COMPLEXITY: O(command) export const authClaudeLogin = ( command: AuthClaudeLoginCommand -): Effect.Effect => { - const accountLabel = normalizeAccountLabel(command.label, "default") - return withClaudeAuth(command, ({ accountPath, cwd, fs, path }) => - Effect.gen(function*(_) { - const token = yield* _( - runClaudeOauthLoginWithPrompt(cwd, accountPath, { - image: claudeImageName, - containerPath: claudeContainerHomeDir - }) - ) - yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}\n`)) - yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 0o600), Effect.orElseSucceed(() => void 0)) - yield* _(resolveClaudeAuthMethod(fs, path, accountPath)) - // CHANGE: treat a failing post-login API probe as a warning instead of a hard error - // WHY: the OAuth token is already created and persisted; a transient probe failure - // (network hiccup, rate limit, token propagation delay) must not discard a - // successful login. Mirrors authClaudeStatus, which only warns on probe failure. - // REF: issue-439 - // SOURCE: n/a - const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token)) - if (probeExitCode !== 0) { - yield* _( - Effect.logWarning( - `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${probeExitCode}). ` + - `Login is complete because the token was captured and persisted; live Claude API access is not yet verified. ` + - `The token may need a moment to activate, or there was a transient network issue. ` + - `Verify later with 'docker-git auth claude status'.` - ) - ) - } - })).pipe( - Effect.zipRight(autoSyncState(`chore(state): auth claude ${accountLabel}`)) - ) -} +): Effect.Effect => + withClaudeAuth(command, ({ accountLabel, accountPath, cwd, fs, path }) => + runClaudeLoginFlow({ + accountLabel, + captureToken: runClaudeOauthLoginWithPrompt(cwd, accountPath, { + image: claudeImageName, + containerPath: claudeContainerHomeDir + }), + persistToken: (token) => persistClaudeOauthToken(fs, accountPath, token), + normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid), + probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, token), + syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`) + }).pipe(Effect.asVoid)) // CHANGE: show Claude Code auth status for a given label // WHY: allow verifying OAuth cache presence without exposing credentials diff --git a/packages/lib/src/usecases/auth.ts b/packages/lib/src/usecases/auth.ts index 97a410ab..f7a6d078 100644 --- a/packages/lib/src/usecases/auth.ts +++ b/packages/lib/src/usecases/auth.ts @@ -1,3 +1,5 @@ +export * from "./auth-claude-local.js" +export * from "./auth-claude-login-flow.js" export * from "./auth-claude.js" export * from "./auth-codex.js" export * from "./auth-gemini.js" diff --git a/packages/lib/tests/usecases/auth-claude-local.test.ts b/packages/lib/tests/usecases/auth-claude-local.test.ts new file mode 100644 index 00000000..4a5147d9 --- /dev/null +++ b/packages/lib/tests/usecases/auth-claude-local.test.ts @@ -0,0 +1,114 @@ +import { + claudeCodeOauthTokenEnvKey, + dockerGitClaudeOauthTokenEnvKey +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +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 { + buildClaudeLocalEnv, + readClaudeLocalOauthTokenFromEnv, + runClaudeLocalEnvTokenLoginFlow +} from "../../src/usecases/auth-claude-local.js" + +const oauthToken = "sk-ant-oat01-LOCAL0123456789abcdef" + +const makeExitCodeExecutor = ( + exitCode: number, + invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> +): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.sync(() => { + const flattened = Command.flatten(command) + const invocation = flattened[flattened.length - 1]! + invocations.push({ command: invocation.command, args: invocation.args }) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout: Stream.empty, + toJSON: () => ({ _tag: "ClaudeLocalTestProcess", command: invocation.command, args: invocation.args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "ClaudeLocalTestProcess", + command: invocation.command, + args: invocation.args + }), + toString: () => `[ClaudeLocalTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +describe("Claude local auth runner", () => { + it.effect("reads a Claude OAuth token from the local smoke env keys", () => + Effect.gen(function*(_) { + const fromClaudeEnv = yield* _( + readClaudeLocalOauthTokenFromEnv({ + [claudeCodeOauthTokenEnvKey]: ` ${oauthToken} ` + }) + ) + const fromDockerGitEnv = yield* _( + readClaudeLocalOauthTokenFromEnv({ + [claudeCodeOauthTokenEnvKey]: "sk-ant-oat01-LOWERPRIORITY0123456789", + [dockerGitClaudeOauthTokenEnvKey]: oauthToken + }) + ) + + expect(fromClaudeEnv).toBe(oauthToken) + expect(fromDockerGitEnv).toBe(oauthToken) + })) + + it.effect("fails without a local smoke token", () => + Effect.gen(function*(_) { + const error = yield* _(readClaudeLocalOauthTokenFromEnv({}).pipe(Effect.flip)) + expect(error._tag).toBe("AuthError") + expect(error.message).toContain(dockerGitClaudeOauthTokenEnvKey) + expect(error.message).toContain(claudeCodeOauthTokenEnvKey) + })) + + it("builds an isolated local Claude CLI environment without exposing unrelated env", () => { + expect(buildClaudeLocalEnv("/tmp/claude-account", oauthToken)).toEqual({ + CLAUDE_CONFIG_DIR: "/tmp/claude-account", + CLAUDE_CODE_OAUTH_TOKEN: oauthToken, + HOME: "/tmp/claude-account" + }) + }) + + it.effect("runs the shared login flow through the local Claude probe runner", () => + Effect.gen(function*(_) { + const invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [] + let persisted: string | null = null + const result = yield* _( + runClaudeLocalEnvTokenLoginFlow({ + cwd: "/workspace", + accountLabel: "default", + accountPath: "/tmp/claude-account", + env: { [claudeCodeOauthTokenEnvKey]: oauthToken }, + persistToken: (token) => Effect.sync(() => { + persisted = token + }), + normalizeStoredCredentials: Effect.void, + syncState: Effect.void + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeExitCodeExecutor(7, invocations)) + ) + ) + + expect(persisted).toBe(oauthToken) + expect(result.probeStatus).toEqual({ _tag: "ClaudeLoginProbeFailed", exitCode: 7 }) + expect(invocations).toEqual([{ command: "claude", args: ["-p", "ping"] }]) + })) +}) diff --git a/packages/lib/tests/usecases/auth-claude-login-flow.test.ts b/packages/lib/tests/usecases/auth-claude-login-flow.test.ts new file mode 100644 index 00000000..0d6dafa1 --- /dev/null +++ b/packages/lib/tests/usecases/auth-claude-login-flow.test.ts @@ -0,0 +1,96 @@ +import { normalizeClaudeOauthToken } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { runClaudeLoginFlow } from "../../src/usecases/auth-claude-login-flow.js" + +const oauthToken = "sk-ant-oat01-FLOW0123456789abcdef" + +describe("runClaudeLoginFlow", () => { + it.effect("persists and normalizes a captured token before interpreting a failed probe", () => + Effect.gen(function*(_) { + const events: Array = [] + const result = yield* _( + runClaudeLoginFlow({ + accountLabel: "work", + captureToken: Effect.succeed(oauthToken), + persistToken: (token) => Effect.sync(() => { + events.push(`persist:${token}`) + }), + normalizeStoredCredentials: Effect.sync(() => { + events.push("normalize") + }), + probeToken: (token) => Effect.sync(() => { + events.push(`probe:${token}`) + return 7 + }), + syncState: Effect.sync(() => { + events.push("sync") + }) + }) + ) + + expect(result).toEqual({ + accountLabel: "work", + probeStatus: { _tag: "ClaudeLoginProbeFailed", exitCode: 7 } + }) + expect(events).toEqual([ + `persist:${oauthToken}`, + "normalize", + `probe:${oauthToken}`, + "sync" + ]) + })) + + it.effect("reports a successful probe without changing the persistence invariant", () => + Effect.gen(function*(_) { + let persisted: string | null = null + const result = yield* _( + runClaudeLoginFlow({ + accountLabel: "default", + captureToken: Effect.succeed(oauthToken), + persistToken: (token) => Effect.sync(() => { + persisted = token + }), + normalizeStoredCredentials: Effect.void, + probeToken: () => Effect.succeed(0), + syncState: Effect.void + }) + ) + + expect(persisted).toBe(oauthToken) + expect(result.probeStatus).toEqual({ _tag: "ClaudeLoginProbeSucceeded", exitCode: 0 }) + })) + + it.effect("does not persist, normalize, probe, or sync an empty token", () => + Effect.gen(function*(_) { + const events: Array = [] + const error = yield* _( + runClaudeLoginFlow({ + accountLabel: "default", + captureToken: Effect.succeed(" \n "), + persistToken: () => Effect.sync(() => { + events.push("persist") + }), + normalizeStoredCredentials: Effect.sync(() => { + events.push("normalize") + }), + probeToken: () => Effect.sync(() => { + events.push("probe") + return 0 + }), + syncState: Effect.sync(() => { + events.push("sync") + }) + }).pipe(Effect.flip) + ) + + expect(error._tag).toBe("AuthError") + expect(events).toEqual([]) + })) + + it.effect("normalizes token whitespace at the flow boundary", () => + Effect.sync(() => { + expect(normalizeClaudeOauthToken(`\n${oauthToken}\n`)).toBe(oauthToken) + })) +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b3a3f5c7..4ff61459 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/api - packages/app + - packages/auth-oauth - packages/container - packages/docker-git-session-sync - packages/lib From bd54c544c4ebff3c30881d63b2715d316d2aac48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:55:52 +0000 Subject: [PATCH 06/19] chore(release): version packages --- packages/app/CHANGELOG.md | 9 +++++++++ packages/app/package.json | 2 +- packages/docker-git-session-sync/CHANGELOG.md | 6 ++++++ packages/docker-git-session-sync/package.json | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 2cc86b25..d13ddbb3 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.14 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.70 + ## 1.3.13 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2caac76a..07ed7322 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.13", + "version": "1.3.14", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 424da984..0b49da7b 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.70 + +### Patch Changes + +- chore: automated version bump + ## 1.0.69 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 44aebcee..f8caac85 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.69", + "version": "1.0.70", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { From 025b92543f033eb9252a7f48f2654ee138d8e0cb Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 13:43:11 +0000 Subject: [PATCH 07/19] fix(auth): harden claude oauth login probe path --- .github/workflows/check.yml | 3 +- bun.lock | 3 + docker-compose.yml | 1 - .../app/src/docker-git/controller-compose.ts | 54 ++++- .../docker-git/controller-compose.test.ts | 43 ++++ packages/auth-oauth/package.json | 3 + .../auth-oauth/src/claude-docker-oauth.ts | 63 +++-- packages/auth-oauth/src/claude-local-smoke.ts | 34 ++- packages/auth-oauth/src/claude-oauth-token.ts | 95 ++++++++ .../tests/claude-docker-oauth.test.ts | 215 ++++++++++++------ .../tests/claude-local-smoke.test.ts | 70 +++++- .../tests/claude-oauth-token.test.ts | 145 +++++++++++- .../lib/src/usecases/auth-claude-local.ts | 4 +- .../src/usecases/auth-claude-login-flow.ts | 21 +- .../lib/src/usecases/auth-claude-oauth.ts | 78 ++++--- packages/lib/src/usecases/auth-claude.ts | 20 +- .../tests/usecases/auth-claude-local.test.ts | 6 +- .../usecases/auth-claude-login-flow.test.ts | 3 +- .../tests/usecases/auth-claude-login.test.ts | 3 +- scripts/e2e/_lib.sh | 2 +- scripts/e2e/auth-claude-login.sh | 17 +- 21 files changed, 706 insertions(+), 177 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 65b157ad..fd31f756 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -257,8 +257,9 @@ jobs: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: + persist-credentials: false submodules: true - name: Install dependencies uses: ./.github/actions/setup diff --git a/bun.lock b/bun.lock index d3787c7d..47e9efbe 100644 --- a/bun.lock +++ b/bun.lock @@ -114,7 +114,10 @@ "name": "@prover-coder-ai/docker-git-auth-oauth", "version": "0.0.0", "devDependencies": { + "@effect/vitest": "^0.29.0", "@types/node": "^25.9.3", + "effect": "^3.21.3", + "fast-check": "^4.8.0", "typescript": "^6.0.3", "vitest": "^4.1.9", }, diff --git a/docker-compose.yml b/docker-compose.yml index a431e69a..910129f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,6 @@ services: DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-} DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000} DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000} - DOCKER_GIT_CLAUDE_OAUTH_TOKEN: ${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-} ports: - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" dns: diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index d2bc191d..25fb44ee 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -13,12 +13,14 @@ import type { ControllerBootstrapError } from "./host-errors.js" export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU" export const controllerBuildSkillerEnvKey = "DOCKER_GIT_CONTROLLER_BUILD_SKILLER" +export const controllerComposeExtraFileEnvKey = "DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE" export type ControllerGpuMode = "none" | "all" export type ControllerBuildSkillerMode = "0" | "1" export type ControllerComposeFiles = { readonly composePath: string + readonly extraOverlayPath: string | null readonly gpuOverlayPath: string | null readonly runtimeOverlayPath: string | null } @@ -114,6 +116,42 @@ const mapSkillerPathError = (error: PlatformError): ControllerBootstrapError => const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError => controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`) +// CHANGE: add a verified controller compose overlay boundary for E2E/runtime callers +// WHY: temporary compose overrides must be part of the explicit docker compose argument vector +// QUOTE(ТЗ): n/a +// REF: issue-440-review-compose-overlay +// SOURCE: n/a +// FORMAT THEOREM: forall p: env(extra)=p and exists(resolve(p)) -> resolve(extra)=Some(resolve(p)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: non-empty extra compose env values either resolve to an existing file or fail before docker compose +// COMPLEXITY: O(1) +const loadControllerComposeExtraPath = (): Effect.Effect< + string | null, + ControllerBootstrapError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function*(_) { + const raw = process.env[controllerComposeExtraFileEnvKey]?.trim() ?? "" + if (raw.length === 0) { + return null + } + + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const extraOverlayPath = path.resolve(raw) + const isExists = yield* _(fs.exists(extraOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return isExists + ? extraOverlayPath + : yield* _( + Effect.fail( + controllerBootstrapError( + `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.` + ) + ) + ) + }) + const skillerSubmoduleCommand = [ "submodule", "update", @@ -209,19 +247,22 @@ export const ensureSkillerSubmoduleInitialized = ( export const composeFilesForMode = ( composePath: string, gpuOverlayPath: string | null, - runtimeOverlayPath: string | null = null + runtimeOverlayPath: string | null = null, + extraOverlayPath: string | null = null ): ReadonlyArray => [ "-f", composePath, ...(runtimeOverlayPath === null ? [] : ["-f", runtimeOverlayPath]), - ...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]) + ...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]), + ...(extraOverlayPath === null ? [] : ["-f", extraOverlayPath]) ] export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): ReadonlyArray => composeFilesForMode( composeFiles.composePath, composeFiles.gpuOverlayPath, - composeFiles.runtimeOverlayPath + composeFiles.runtimeOverlayPath, + composeFiles.extraOverlayPath ) const requireGpuOverlayPath = ( @@ -246,9 +287,9 @@ const composeFilesForGpuMode = ( gpuMode: ControllerGpuMode ): Effect.Effect => gpuMode === "none" - ? Effect.succeed({ composePath, gpuOverlayPath: null, runtimeOverlayPath: null }) + ? Effect.succeed({ composePath, extraOverlayPath: null, gpuOverlayPath: null, runtimeOverlayPath: null }) : requireGpuOverlayPath(composePath).pipe( - Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath, runtimeOverlayPath: null })) + Effect.map((gpuOverlayPath) => ({ composePath, extraOverlayPath: null, gpuOverlayPath, runtimeOverlayPath: null })) ) type ComposePathAndGpuMode = { @@ -286,8 +327,9 @@ export const resolveControllerComposeFiles = (): Effect.Effect< withComposePathAndGpuMode(({ composePath, dockerRuntime, gpuMode }) => Effect.gen(function*(_) { const composeFiles = yield* _(composeFilesForGpuMode(composePath, gpuMode)) + const extraOverlayPath = yield* _(loadControllerComposeExtraPath()) const runtimeOverlayPath = yield* _(resolveControllerRuntimeOverlayPath(composePath, dockerRuntime)) - return { ...composeFiles, runtimeOverlayPath } + return { ...composeFiles, extraOverlayPath, runtimeOverlayPath } }) ) diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index c59ce285..818a3e4c 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -8,6 +8,7 @@ import * as fc from "fast-check" import { resolveControllerRuntimeOverlayPath } from "../../src/docker-git/controller-compose-runtime.js" import { controllerBuildSkillerEnvKey, + controllerComposeExtraFileEnvKey, controllerComposeProjectName, controllerGpuModeEnvKey, ensureSkillerSubmoduleInitialized, @@ -61,6 +62,9 @@ const writeMinimalCompose = (rootDir: string) => const writeMinimalIsolatedCompose = (rootDir: string) => writeRootFile(rootDir, "docker-compose.isolated.yml", "services:\n api:\n volumes: !override []\n") +const writeMinimalExtraCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.auth-claude-login.yml", "services:\n api:\n environment: {}\n") + const writeSkillerPackage = (rootDir: string) => writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n") @@ -159,6 +163,7 @@ const prepareRevisionInTemporaryRoot = ({ yield* _( withControllerEnv([ [controllerBuildSkillerEnvKey, buildSkillerMode], + [controllerComposeExtraFileEnvKey, undefined], [controllerDockerRuntimeEnvKey, undefined], [controllerGpuModeEnvKey, undefined], [controllerRevisionEnvKey, undefined] @@ -192,6 +197,7 @@ const resolveComposeFilesInTemporaryRoot = ( yield* _( withControllerEnv([ [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, undefined], [controllerDockerRuntimeEnvKey, dockerRuntimeMode], [controllerGpuModeEnvKey, undefined] ]) @@ -215,6 +221,7 @@ describe("controller compose preparation", () => { yield* _( withControllerEnv([ [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, undefined], [controllerDockerRuntimeEnvKey, undefined], [controllerGpuModeEnvKey, undefined] ]) @@ -235,6 +242,42 @@ describe("controller compose preparation", () => { ).pipe(Effect.provide(NodeContext.layer)) }) + it.effect("passes the verified extra compose overlay into controller compose commands", () => { + const startedCommands: Array = [] + + return withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + yield* _(writeMinimalExtraCompose(rootDir)) + const extraComposePath = path.join(rootDir, "docker-compose.auth-claude-login.yml") + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, extraComposePath], + [controllerDockerRuntimeEnvKey, undefined], + [controllerGpuModeEnvKey, undefined] + ]) + ) + + const composeFiles = yield* _(resolveControllerComposeFiles()) + expect(composeFiles.extraOverlayPath).toBe(extraComposePath) + + const recordedExecutorLayer = recordedCommandExecutorLayer(startedCommands, emptyCommandResult) + yield* _( + runCompose(["up", "-d"]).pipe( + Effect.provide(recordedExecutorLayer) + ) + ) + + const composeCommand = startedCommands.find((command) => + command.startsWith(`docker compose --project-name ${controllerComposeProjectName} -f `) + ) + expect(composeCommand).toBeDefined() + expect(composeCommand).toContain(` -f ${extraComposePath} up -d`) + }) + ).pipe(Effect.provide(NodeContext.layer)) + }) + it.effect("does not initialize the Skiller submodule when package metadata already exists", () => { const startedCommands: Array = [] diff --git a/packages/auth-oauth/package.json b/packages/auth-oauth/package.json index e082ae00..6775130b 100644 --- a/packages/auth-oauth/package.json +++ b/packages/auth-oauth/package.json @@ -32,7 +32,10 @@ }, "homepage": "https://github.com/ProverCoderAI/docker-git#readme", "devDependencies": { + "@effect/vitest": "^0.29.0", "@types/node": "^25.9.3", + "effect": "^3.21.3", + "fast-check": "^4.8.0", "typescript": "^6.0.3", "vitest": "^4.1.9" }, diff --git a/packages/auth-oauth/src/claude-docker-oauth.ts b/packages/auth-oauth/src/claude-docker-oauth.ts index 1aa1cde2..68440272 100644 --- a/packages/auth-oauth/src/claude-docker-oauth.ts +++ b/packages/auth-oauth/src/claude-docker-oauth.ts @@ -5,14 +5,22 @@ import { fileURLToPath } from "node:url" import { spawn } from "node:child_process" import { + claudeOauthTokenFileMode, claudeOauthTokenPath, classifyClaudeSetupTokenResult, extractClaudeOauthToken, - formatClaudeOauthTokenFile + flushClaudeOauthTokenRedactionState, + formatClaudeOauthTokenFile, + initialClaudeOauthTokenRedactionState, + redactClaudeOauthTokenChunk, + type ClaudeOauthTokenRedactionState } from "./claude-oauth-token.js" export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest" export const defaultClaudeDockerOauthContainerHome = "/claude-home" +export const claudeDockerOauthBaseImage = + "node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc" +export const claudeDockerOauthClaudeCodeVersion = "2.1.195" export type ClaudeDockerOauthOptions = { readonly cwd?: string @@ -81,23 +89,19 @@ export type ClaudeDockerProbeStatus = const outputWindowSize = 262_144 -const claudeDockerfile = String.raw`FROM ubuntu:24.04 +export const renderClaudeDockerOauthDockerfile = (): string => + String.raw`FROM ${claudeDockerOauthBaseImage} ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl bsdutils \ + && apt-get install -y --no-install-recommends ca-certificates bsdutils \ && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && node -v \ +RUN node -v \ && npm -v \ - && rm -rf /var/lib/apt/lists/* -RUN npm install -g @anthropic-ai/claude-code@latest + && npm install -g --no-audit --no-fund @anthropic-ai/claude-code@${claudeDockerOauthClaudeCodeVersion} \ + && claude --version ENTRYPOINT ["claude"] ` -const redactedOauthTokenText = (text: string): string => - text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") - const appendOutputWindow = (outputWindow: string, chunk: string): string => { const next = `${outputWindow}${chunk}` return next.length > outputWindowSize ? next.slice(-outputWindowSize) : next @@ -138,7 +142,7 @@ const ensureClaudeDockerImage = async ( } const contextPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-image-")) try { - await writeFile(join(contextPath, "Dockerfile"), claudeDockerfile, "utf8") + await writeFile(join(contextPath, "Dockerfile"), renderClaudeDockerOauthDockerfile(), "utf8") const exitCode = await runBuild({ dockerCommand, args: ["build", "-t", image, contextPath], @@ -219,17 +223,36 @@ const runDockerSetupToken = (spec: ClaudeDockerSetupTokenSpec): Promise { + if (output.length === 0) { + return + } + if (fd === 2) { + process.stderr.write(output) + return + } + process.stdout.write(output) + } const capture = (chunk: Uint8Array, fd: 1 | 2): void => { const text = decoder.decode(chunk) outputWindow = appendOutputWindow(outputWindow, text) token = token ?? extractClaudeOauthToken(outputWindow) - const output = spec.redactLiveOutput ? redactedOauthTokenText(text) : text - if (fd === 2) { - process.stderr.write(output) + if (!spec.redactLiveOutput) { + writeOutput(fd, text) return } - process.stdout.write(output) + const state = fd === 2 ? stderrRedactionState : stdoutRedactionState + const redacted = redactClaudeOauthTokenChunk(state, text) + if (fd === 2) { + stderrRedactionState = redacted.state + } else { + stdoutRedactionState = redacted.state + } + writeOutput(fd, redacted.output) } child.stdout?.on("data", (chunk: Uint8Array) => { @@ -240,6 +263,10 @@ const runDockerSetupToken = (spec: ClaudeDockerSetupTokenSpec): Promise { + if (spec.redactLiveOutput) { + writeOutput(1, flushClaudeOauthTokenRedactionState(stdoutRedactionState)) + writeOutput(2, flushClaudeOauthTokenRedactionState(stderrRedactionState)) + } resolveResult({ exitCode: code ?? 1, token }) }) }) @@ -259,7 +286,7 @@ const runDockerProbe = (spec: ClaudeDockerProbeSpec): Promise => const writeCapturedToken = async (accountPath: string, token: string): Promise => { const tokenPath = claudeOauthTokenPath(accountPath) await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") - await chmod(tokenPath, 0o600).catch(() => undefined) + await chmod(tokenPath, claudeOauthTokenFileMode) } const dockerProbeStatusFromExitCode = (exitCode: number): ClaudeDockerProbeStatus => @@ -361,7 +388,7 @@ const isDirectExecution = (): boolean => { } if (isDirectExecution()) { - const printToken = !process.argv.includes("--no-print-token") + const printToken = process.argv.includes("--print-token") const accountPath = readFlagValue(process.argv, "--account-path") const dockerHostPath = readFlagValue(process.argv, "--docker-host-path") const image = readFlagValue(process.argv, "--image") diff --git a/packages/auth-oauth/src/claude-local-smoke.ts b/packages/auth-oauth/src/claude-local-smoke.ts index 4cacd6fa..da6037ad 100644 --- a/packages/auth-oauth/src/claude-local-smoke.ts +++ b/packages/auth-oauth/src/claude-local-smoke.ts @@ -11,7 +11,11 @@ import { classifyClaudeSetupTokenResult, dockerGitClaudeOauthTokenEnvKey, extractClaudeOauthToken, + flushClaudeOauthTokenRedactionState, formatClaudeOauthTokenFile, + initialClaudeOauthTokenRedactionState, + redactClaudeOauthTokenChunk, + type ClaudeOauthTokenRedactionState, type OAuthEnvironment, readClaudeOauthTokenFromEnv } from "./claude-oauth-token.js" @@ -88,12 +92,9 @@ export const persistClaudeLocalOauthToken = async ( ): Promise => { const tokenPath = claudeOauthTokenPath(accountPath) await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") - await chmod(tokenPath, claudeOauthTokenFileMode).catch(() => undefined) + await chmod(tokenPath, claudeOauthTokenFileMode) } -const redactedOauthTokenText = (text: string): string => - text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") - const defaultClaudeLocalOauthProbe = (spec: ClaudeLocalOauthProbeSpec): Promise => new Promise((resolveExitCode, reject) => { const child = spawn(spec.command, [...spec.args], { @@ -125,17 +126,32 @@ const defaultClaudeSetupToken = ( const decoder = new TextDecoder("utf-8") let outputWindow = "" let token: string | null = null + let stdoutRedactionState: ClaudeOauthTokenRedactionState = initialClaudeOauthTokenRedactionState + let stderrRedactionState: ClaudeOauthTokenRedactionState = initialClaudeOauthTokenRedactionState + + const writeOutput = (fd: 1 | 2, output: string): void => { + if (output.length === 0) { + return + } + if (fd === 2) { + process.stderr.write(output) + return + } + process.stdout.write(output) + } const capture = (chunk: Uint8Array, fd: 1 | 2): void => { const text = decoder.decode(chunk) outputWindow = appendOutputWindow(outputWindow, text) token = token ?? extractClaudeOauthToken(outputWindow) - const redacted = redactedOauthTokenText(text) + const state = fd === 2 ? stderrRedactionState : stdoutRedactionState + const redacted = redactClaudeOauthTokenChunk(state, text) if (fd === 2) { - process.stderr.write(redacted) - return + stderrRedactionState = redacted.state + } else { + stdoutRedactionState = redacted.state } - process.stdout.write(redacted) + writeOutput(fd, redacted.output) } child.stdout?.on("data", (chunk: Uint8Array) => { @@ -146,6 +162,8 @@ const defaultClaudeSetupToken = ( }) child.on("error", reject) child.on("close", (code) => { + writeOutput(1, flushClaudeOauthTokenRedactionState(stdoutRedactionState)) + writeOutput(2, flushClaudeOauthTokenRedactionState(stderrRedactionState)) resolveResult({ exitCode: code ?? 1, token }) }) }) diff --git a/packages/auth-oauth/src/claude-oauth-token.ts b/packages/auth-oauth/src/claude-oauth-token.ts index 7687ae0f..c769340a 100644 --- a/packages/auth-oauth/src/claude-oauth-token.ts +++ b/packages/auth-oauth/src/claude-oauth-token.ts @@ -2,8 +2,17 @@ export const claudeCodeOauthTokenEnvKey = "CLAUDE_CODE_OAUTH_TOKEN" export const dockerGitClaudeOauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN" export const claudeOauthTokenFileName = ".oauth-token" export const claudeOauthTokenFileMode = 0o600 +export const claudeOauthTokenRedactionText = "" export type OAuthEnvironment = Readonly> +export type ClaudeOauthTokenRedactionState = { + readonly pending: string + readonly redacting: boolean +} +export type ClaudeOauthTokenRedactionStep = { + readonly state: ClaudeOauthTokenRedactionState + readonly output: string +} export type ClaudeSetupTokenResult = | { @@ -26,6 +35,15 @@ const ansiBell = "\u{7}" const tokenMarker = "Your OAuth token (valid for 1 year):" const tokenFooterMarker = "Store this token securely." const oauthTokenRegex = /([A-Za-z0-9][A-Za-z0-9._-]{20,})/u +const oauthLiveOutputTokenPrefix = ["sk", "ant", ""].join("-") +const oauthLiveOutputTokenCharacters = new Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-".split("") +) + +export const initialClaudeOauthTokenRedactionState: ClaudeOauthTokenRedactionState = { + pending: "", + redacting: false +} const isAnsiFinalByte = (codePoint: number | undefined): boolean => codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7E @@ -95,6 +113,83 @@ export const claudeOauthTokenPath = (accountPath: string): string => export const formatClaudeOauthTokenFile = (token: string): string => `${token}\n` +const isOauthLiveOutputTokenCharacter = (char: string): boolean => + oauthLiveOutputTokenCharacters.has(char) + +const longestOauthTokenPrefixSuffixLength = (text: string): number => { + const maxLength = Math.min(oauthLiveOutputTokenPrefix.length - 1, text.length) + let length = maxLength + while (length > 0) { + if (oauthLiveOutputTokenPrefix.startsWith(text.slice(-length))) { + return length + } + length -= 1 + } + return 0 +} + +const splitOauthRedactionPending = (pending: string): { + readonly output: string + readonly pending: string +} => { + const keepLength = longestOauthTokenPrefixSuffixLength(pending) + return { + output: pending.slice(0, pending.length - keepLength), + pending: pending.slice(pending.length - keepLength) + } +} + +export const redactClaudeOauthTokenChunk = ( + state: ClaudeOauthTokenRedactionState, + chunk: string +): ClaudeOauthTokenRedactionStep => { + let pending = state.pending + let redacting = state.redacting + const output: Array = [] + + const acceptPlainChar = (char: string): void => { + pending = `${pending}${char}` + if (pending === oauthLiveOutputTokenPrefix) { + pending = "" + redacting = true + return + } + if (oauthLiveOutputTokenPrefix.startsWith(pending)) { + return + } + const split = splitOauthRedactionPending(pending) + output.push(split.output) + pending = split.pending + } + + for (const char of chunk) { + if (redacting) { + if (isOauthLiveOutputTokenCharacter(char)) { + continue + } + output.push(claudeOauthTokenRedactionText) + redacting = false + acceptPlainChar(char) + continue + } + acceptPlainChar(char) + } + + return { + state: { pending, redacting }, + output: output.join("") + } +} + +export const flushClaudeOauthTokenRedactionState = ( + state: ClaudeOauthTokenRedactionState +): string => state.redacting ? claudeOauthTokenRedactionText : state.pending + +export const redactClaudeOauthTokenText = (text: string): string => { + const step = redactClaudeOauthTokenChunk(initialClaudeOauthTokenRedactionState, text) + return `${step.output}${flushClaudeOauthTokenRedactionState(step.state)}` +} + export const extractClaudeOauthToken = (rawOutput: string): string | null => { const normalized = stripAnsi(rawOutput).replaceAll("\r", "\n") const markerIndex = normalized.lastIndexOf(tokenMarker) diff --git a/packages/auth-oauth/tests/claude-docker-oauth.test.ts b/packages/auth-oauth/tests/claude-docker-oauth.test.ts index 5a160dae..a2ada969 100644 --- a/packages/auth-oauth/tests/claude-docker-oauth.test.ts +++ b/packages/auth-oauth/tests/claude-docker-oauth.test.ts @@ -2,89 +2,172 @@ import { mkdtemp, readFile, stat } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import fc from "fast-check" import { + renderClaudeDockerOauthDockerfile, renderClaudeDockerOauthResult, runClaudeDockerOauth, type ClaudeDockerBuildSpec, type ClaudeDockerProbeSpec, type ClaudeDockerSetupTokenSpec } from "../src/claude-docker-oauth.js" -import { claudeOauthTokenPath } from "../src/claude-oauth-token.js" +import { claudeOauthTokenFileMode, claudeOauthTokenPath } from "../src/claude-oauth-token.js" -const oauthToken = "sk-ant-oat01-DOCKER0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const makeOauthToken = (suffix: string): string => `${oauthTokenPrefix}oat01-${suffix}` +const oauthToken = makeOauthToken("DOCKER0123456789abcdef") +const oauthTokenArbitrary = fc.array(fc.constantFrom( + "A", + "B", + "C", + "D", + "E", + "F", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" +), { + minLength: 24, + maxLength: 64 +}).map((chars) => `${oauthTokenPrefix}${chars.join("")}`) describe("Claude Docker OAuth runner", () => { - it("runs Docker setup-token, persists token, then probes through the mounted token file", async () => { - const accountPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-test-")) - const builds: Array = [] - const setupRuns: Array = [] - const probeRuns: Array = [] + it.effect("runs Docker setup-token, persists token, then probes through the mounted token file", () => + Effect.gen(function*(_) { + const accountPath = yield* _( + Effect.tryPromise(() => mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-test-"))) + ) + const builds: Array = [] + const setupRuns: Array = [] + const probeRuns: Array = [] - const result = await runClaudeDockerOauth({ - cwd: "/workspace", - accountPath, - image: "claude-test:latest", - runBuild: (spec) => { - builds.push(spec) - return Promise.resolve(0) - }, - runSetupToken: (spec) => { - setupRuns.push(spec) - return Promise.resolve({ exitCode: 1, token: oauthToken }) - }, - runProbe: async (spec) => { - probeRuns.push(spec) - await expect(readFile(claudeOauthTokenPath(accountPath), "utf8")).resolves.toBe(`${oauthToken}\n`) - return 0 - } - }) + const result = yield* _( + Effect.tryPromise(() => + runClaudeDockerOauth({ + cwd: "/workspace", + accountPath, + image: "claude-test:latest", + runBuild: (spec) => { + builds.push(spec) + return Effect.runPromise(Effect.succeed(0)) + }, + runSetupToken: (spec) => { + setupRuns.push(spec) + return Effect.runPromise(Effect.succeed({ exitCode: 1, token: oauthToken })) + }, + runProbe: (spec) => { + probeRuns.push(spec) + return Effect.runPromise( + Effect.gen(function*(_) { + const tokenFile = yield* _(Effect.tryPromise(() => readFile(claudeOauthTokenPath(accountPath), "utf8"))) + expect(tokenFile).toBe(`${oauthToken}\n`) + return 0 + }) + ) + } + }) + ) + ) - expect(result).toEqual({ - _tag: "ClaudeDockerOauthTokenCaptured", - token: oauthToken, - accountPath, - image: "claude-test:latest", - exitCode: 1, - probeStatus: { _tag: "ClaudeDockerProbeSucceeded", exitCode: 0 } - }) - expect(builds).toHaveLength(1) - expect(builds[0]?.args.slice(0, 3)).toEqual(["build", "-t", "claude-test:latest"]) - expect(setupRuns).toHaveLength(1) - expect(setupRuns[0]?.args).toContain("setup-token") - expect(setupRuns[0]?.args.join(" ")).toContain(accountPath) - expect(probeRuns).toHaveLength(1) - expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) - expect((await stat(claudeOauthTokenPath(accountPath))).mode & 0o777).toBe(0o600) - }) + expect(result).toEqual({ + _tag: "ClaudeDockerOauthTokenCaptured", + token: oauthToken, + accountPath, + image: "claude-test:latest", + exitCode: 1, + probeStatus: { _tag: "ClaudeDockerProbeSucceeded", exitCode: 0 } + }) + expect(builds).toHaveLength(1) + expect(builds[0]?.args.slice(0, 3)).toEqual(["build", "-t", "claude-test:latest"]) + expect(setupRuns).toHaveLength(1) + expect(setupRuns[0]?.args).toContain("setup-token") + expect(setupRuns[0]?.args.join(" ")).toContain(accountPath) + expect(probeRuns).toHaveLength(1) + expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) + const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath)))) + expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode) + })) - it("keeps the captured token when Docker probe fails", async () => { - const accountPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-probe-test-")) - const result = await runClaudeDockerOauth({ - accountPath, - skipBuild: true, - runSetupToken: () => Promise.resolve({ exitCode: 0, token: oauthToken }), - runProbe: () => Promise.resolve(7) - }) + it.effect("keeps the captured token and file mode when Docker probe fails", () => + Effect.gen(function*(_) { + const accountPath = yield* _( + Effect.tryPromise(() => mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-probe-test-"))) + ) + const result = yield* _( + Effect.tryPromise(() => + runClaudeDockerOauth({ + accountPath, + skipBuild: true, + runSetupToken: () => Effect.runPromise(Effect.succeed({ exitCode: 0, token: oauthToken })), + runProbe: () => Effect.runPromise(Effect.succeed(7)) + }) + ) + ) - expect(renderClaudeDockerOauthResult(result, false)).toBe( - "status=ClaudeDockerOauthTokenCaptured probe=failed exit=7" - ) - expect(renderClaudeDockerOauthResult(result, true)).toBe( - `status=ClaudeDockerOauthTokenCaptured probe=failed exit=7 token=${oauthToken}` - ) + expect(renderClaudeDockerOauthResult(result, false)).toBe( + "status=ClaudeDockerOauthTokenCaptured probe=failed exit=7" + ) + expect(renderClaudeDockerOauthResult(result, true)).toBe( + `status=ClaudeDockerOauthTokenCaptured probe=failed exit=7 token=${oauthToken}` + ) + const tokenFile = yield* _(Effect.tryPromise(() => readFile(claudeOauthTokenPath(accountPath), "utf8"))) + const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath)))) + expect(tokenFile).toBe(`${oauthToken}\n`) + expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode) + })) + + it.effect("returns command failure when setup-token exits non-zero without token", () => + Effect.gen(function*(_) { + const result = yield* _( + Effect.tryPromise(() => + runClaudeDockerOauth({ + skipBuild: true, + runSetupToken: () => Effect.runPromise(Effect.succeed({ exitCode: 23, token: null })), + runProbe: () => Effect.runPromise(Effect.dieMessage("probe must not run")) + }) + ) + ) + + expect(renderClaudeDockerOauthResult(result, true)).toBe("status=ClaudeDockerOauthCommandFailed exit=23") + })) + + it("renders the Claude OAuth Dockerfile from pinned inputs", () => { + const dockerfile = renderClaudeDockerOauthDockerfile() + expect(dockerfile).toContain("FROM node:24-bookworm-slim@sha256:") + expect(dockerfile).toContain("@anthropic-ai/claude-code@2.1.195") + expect(dockerfile).not.toContain("@latest") + expect(dockerfile).not.toContain("curl -fsSL https://deb.nodesource.com") }) - it("returns command failure when setup-token exits non-zero without token", async () => { - const result = await runClaudeDockerOauth({ - skipBuild: true, - runSetupToken: () => Promise.resolve({ exitCode: 23, token: null }), - runProbe: () => { - throw new Error("probe must not run") - } - }) + it("renders tagged results without exposing tokens unless explicitly requested", () => { + fc.assert( + fc.property(oauthTokenArbitrary, fc.integer({ min: 1, max: 255 }), (token, exitCode) => { + const result = { + _tag: "ClaudeDockerOauthTokenCaptured", + token, + accountPath: "/tmp/claude", + image: "claude-test:latest", + exitCode: 0, + probeStatus: { _tag: "ClaudeDockerProbeFailed", exitCode } + } satisfies Awaited> - expect(renderClaudeDockerOauthResult(result, true)).toBe("status=ClaudeDockerOauthCommandFailed exit=23") + expect(renderClaudeDockerOauthResult(result, false)).toBe( + `status=ClaudeDockerOauthTokenCaptured probe=failed exit=${exitCode}` + ) + expect(renderClaudeDockerOauthResult(result, true)).toBe( + `status=ClaudeDockerOauthTokenCaptured probe=failed exit=${exitCode} token=${token}` + ) + }) + ) }) }) diff --git a/packages/auth-oauth/tests/claude-local-smoke.test.ts b/packages/auth-oauth/tests/claude-local-smoke.test.ts index 221bc99f..4d83ecc4 100644 --- a/packages/auth-oauth/tests/claude-local-smoke.test.ts +++ b/packages/auth-oauth/tests/claude-local-smoke.test.ts @@ -1,13 +1,15 @@ import { readFile } from "node:fs/promises" -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "@effect/vitest" +import fc from "fast-check" import { buildClaudeLocalOauthEnv, claudeLocalOauthSmokeEnvKeys, persistClaudeLocalOauthToken, renderClaudeLocalOauthSmokeResult, - runClaudeLocalOauthSmoke + runClaudeLocalOauthSmoke, + type ClaudeLocalOauthSmokeResult } from "../src/claude-local-smoke.js" import { claudeCodeOauthTokenEnvKey, @@ -15,9 +17,71 @@ import { dockerGitClaudeOauthTokenEnvKey } from "../src/claude-oauth-token.js" -const oauthToken = "sk-ant-oat01-SMOKE0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const makeOauthToken = (suffix: string): string => `${oauthTokenPrefix}oat01-${suffix}` +const oauthToken = makeOauthToken("SMOKE0123456789abcdef") +const oauthTokenArbitrary = fc.array(fc.constantFrom("A", "B", "C", "D", "E", "F", "0", "1", "2", "3"), { + minLength: 24, + maxLength: 64 +}).map((chars) => `${oauthTokenPrefix}${chars.join("")}`) +const envArbitrary = fc.dictionary( + fc.constantFrom("PATH", "LANG", "SHELL", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "HOME"), + fc.string({ maxLength: 40 }) +) +const accountPathArbitrary = fc.array(fc.constantFrom("a", "b", "c", "d", "e", "f", "0", "1", "2", "/", "-", "_"), { + minLength: 1, + maxLength: 40 +}).map((chars) => chars.join("")) +const smokeResultArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + _tag: fc.constant("ClaudeLocalOauthSmokeMissingToken"), + envKeys: fc.constant(claudeLocalOauthSmokeEnvKeys) + }), + fc.record({ + _tag: fc.constant("ClaudeLocalOauthSmokeSucceeded"), + accountPath: accountPathArbitrary + }), + fc.record({ + _tag: fc.constant("ClaudeLocalOauthSmokeProbeFailed"), + accountPath: accountPathArbitrary, + exitCode: fc.integer({ min: 1, max: 255 }) + }), + fc.record({ + _tag: fc.constant("ClaudeLocalOauthSmokeSetupTokenFailed"), + accountPath: accountPathArbitrary, + exitCode: fc.integer({ min: 1, max: 255 }) + }), + fc.record({ + _tag: fc.constant("ClaudeLocalOauthSmokeSetupTokenMissingToken"), + accountPath: accountPathArbitrary, + exitCode: fc.constant(0) + }) +) describe("Claude local OAuth smoke runner", () => { + it("builds isolated Claude env overrides for arbitrary base envs", () => { + fc.assert( + fc.property(envArbitrary, fc.string({ minLength: 1, maxLength: 40 }), oauthTokenArbitrary, (base, accountPath, token) => { + expect(buildClaudeLocalOauthEnv(base, accountPath, token)).toEqual({ + ...base, + CLAUDE_CONFIG_DIR: accountPath, + CLAUDE_CODE_OAUTH_TOKEN: token, + HOME: accountPath + }) + }) + ) + }) + + it("renders every local smoke result as a stable tagged summary", () => { + fc.assert( + fc.property(smokeResultArbitrary, (result) => { + const rendered = renderClaudeLocalOauthSmokeResult(result) + expect(rendered).toContain(result._tag) + expect(rendered).not.toContain(oauthTokenPrefix) + }) + ) + }) + it("builds an isolated Claude env for the local probe", () => { expect(buildClaudeLocalOauthEnv({ PATH: "/bin" }, "/tmp/claude", oauthToken)).toEqual({ PATH: "/bin", diff --git a/packages/auth-oauth/tests/claude-oauth-token.test.ts b/packages/auth-oauth/tests/claude-oauth-token.test.ts index bac877d4..5a67689e 100644 --- a/packages/auth-oauth/tests/claude-oauth-token.test.ts +++ b/packages/auth-oauth/tests/claude-oauth-token.test.ts @@ -1,19 +1,73 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it } from "@effect/vitest" +import fc from "fast-check" import { claudeCodeOauthTokenEnvKey, claudeOauthTokenFileMode, claudeOauthTokenFileName, claudeOauthTokenPath, + claudeOauthTokenRedactionText, classifyClaudeSetupTokenResult, dockerGitClaudeOauthTokenEnvKey, extractClaudeOauthToken, + flushClaudeOauthTokenRedactionState, formatClaudeOauthTokenFile, + initialClaudeOauthTokenRedactionState, normalizeClaudeOauthToken, + redactClaudeOauthTokenChunk, readClaudeOauthTokenFromEnv } from "../src/claude-oauth-token.js" -const oauthToken = "sk-ant-oat01-OAUTH0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const oauthTokenChars = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "_", + "-" +] as const + +const makeOauthToken = (suffix: string): string => `${oauthTokenPrefix}oat01-${suffix}` +const oauthToken = makeOauthToken("OAUTH0123456789abcdef") +const lowerPriorityToken = makeOauthToken("LOWERPRIORITY0123456789") +const oauthTokenArbitrary = fc.array(fc.constantFrom(...oauthTokenChars), { + minLength: 24, + maxLength: 72 +}).map((chars) => `${oauthTokenPrefix}${chars.join("")}`) +const nonBlankStringArbitrary = fc.string({ maxLength: 80 }).filter((value) => value.trim().length > 0) const setupTokenOutput = (token: string): string => [ @@ -28,7 +82,60 @@ const setupTokenOutput = (token: string): string => " Store this token securely. You won't be able to see it again." ].join("\n") +const chunkText = (text: string, size: number): ReadonlyArray => { + const chunks: Array = [] + let offset = 0 + while (offset < text.length) { + chunks.push(text.slice(offset, offset + size)) + offset += size + } + return chunks +} + +const redactChunks = (chunks: ReadonlyArray): string => { + let state = initialClaudeOauthTokenRedactionState + const output: Array = [] + for (const chunk of chunks) { + const step = redactClaudeOauthTokenChunk(state, chunk) + state = step.state + output.push(step.output) + } + output.push(flushClaudeOauthTokenRedactionState(state)) + return output.join("") +} + describe("Claude OAuth token helpers", () => { + it("normalizes non-blank token text as trim(raw)", () => { + fc.assert( + fc.property(nonBlankStringArbitrary, (raw) => { + expect(normalizeClaudeOauthToken(`\n ${raw}\t `)).toBe(raw.trim()) + }) + ) + }) + + it("extracts arbitrary OAuth tokens from setup-token output", () => { + fc.assert( + fc.property(oauthTokenArbitrary, (token) => { + expect(extractClaudeOauthToken(setupTokenOutput(token))).toBe(token) + }) + ) + }) + + it("redacts OAuth tokens split across live-output chunks", () => { + fc.assert( + fc.property( + oauthTokenArbitrary, + fc.integer({ min: 1, max: 9 }), + (token, chunkSize) => { + const output = redactChunks(["prefix:", ...chunkText(`${token}\n`, chunkSize), "suffix"]) + expect(output).toBe(`prefix:${claudeOauthTokenRedactionText}\nsuffix`) + expect(output).not.toContain(token) + expect(output).not.toContain(oauthTokenPrefix) + } + ) + ) + }) + it("extracts the OAuth token from setup-token output", () => { expect(extractClaudeOauthToken(setupTokenOutput(oauthToken))).toBe(oauthToken) }) @@ -53,7 +160,7 @@ describe("Claude OAuth token helpers", () => { it("reads env tokens by explicit key priority", () => { const env = { - [claudeCodeOauthTokenEnvKey]: "sk-ant-oat01-LOWERPRIORITY0123456789", + [claudeCodeOauthTokenEnvKey]: lowerPriorityToken, [dockerGitClaudeOauthTokenEnvKey]: ` ${oauthToken} ` } @@ -61,7 +168,37 @@ describe("Claude OAuth token helpers", () => { oauthToken ) expect(readClaudeOauthTokenFromEnv(env, [claudeCodeOauthTokenEnvKey, dockerGitClaudeOauthTokenEnvKey])).toBe( - "sk-ant-oat01-LOWERPRIORITY0123456789" + lowerPriorityToken + ) + }) + + it("reads env tokens by priority for arbitrary token pairs", () => { + fc.assert( + fc.property(oauthTokenArbitrary, oauthTokenArbitrary, (first, second) => { + const env = { + [dockerGitClaudeOauthTokenEnvKey]: ` ${first} `, + [claudeCodeOauthTokenEnvKey]: ` ${second} ` + } + expect(readClaudeOauthTokenFromEnv(env, [dockerGitClaudeOauthTokenEnvKey, claudeCodeOauthTokenEnvKey])).toBe( + first + ) + expect(readClaudeOauthTokenFromEnv(env, [claudeCodeOauthTokenEnvKey, dockerGitClaudeOauthTokenEnvKey])).toBe( + second + ) + }) + ) + }) + + it("classifies setup-token results from normalized token presence and exit code", () => { + fc.assert( + fc.property(oauthTokenArbitrary, fc.integer({ min: 0, max: 255 }), (token, exitCode) => { + expect(classifyClaudeSetupTokenResult(` ${token} `, exitCode)).toEqual({ + _tag: "ClaudeSetupTokenCaptured", + token, + exitCode, + exitedNonZero: exitCode !== 0 + }) + }) ) }) diff --git a/packages/lib/src/usecases/auth-claude-local.ts b/packages/lib/src/usecases/auth-claude-local.ts index ed7d45b1..373a216e 100644 --- a/packages/lib/src/usecases/auth-claude-local.ts +++ b/packages/lib/src/usecases/auth-claude-local.ts @@ -15,14 +15,14 @@ export type ClaudeLocalLoginFlowSpec = { readonly cwd: string readonly accountLabel: string readonly accountPath: string - readonly env?: NodeJS.ProcessEnv + readonly env: NodeJS.ProcessEnv readonly persistToken: (token: string) => Effect.Effect readonly normalizeStoredCredentials: Effect.Effect readonly syncState: Effect.Effect } export const readClaudeLocalOauthTokenFromEnv = ( - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv ): Effect.Effect => { const token = readClaudeOauthTokenFromEnv(env, [dockerGitClaudeOauthTokenEnvKey, claudeCodeOauthTokenEnvKey]) return token === null diff --git a/packages/lib/src/usecases/auth-claude-login-flow.ts b/packages/lib/src/usecases/auth-claude-login-flow.ts index ba8ecf49..7156291c 100644 --- a/packages/lib/src/usecases/auth-claude-login-flow.ts +++ b/packages/lib/src/usecases/auth-claude-login-flow.ts @@ -1,5 +1,5 @@ import { normalizeClaudeOauthToken } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" -import { Effect } from "effect" +import { Effect, Match } from "effect" import { AuthError } from "../shell/errors.js" @@ -30,14 +30,17 @@ const warnOnProbeFailure = ( accountLabel: string, status: ClaudeLoginProbeStatus ): Effect.Effect => - status._tag === "ClaudeLoginProbeSucceeded" - ? Effect.void - : Effect.logWarning( - `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${status.exitCode}). ` + - `Login is complete because the token was captured and persisted; live Claude API access is not yet verified. ` + - `The token may need a moment to activate, or there was a transient network issue. ` + - `Verify later with 'docker-git auth claude status'.` - ) + Match.value(status).pipe( + Match.when({ _tag: "ClaudeLoginProbeSucceeded" }, () => Effect.void), + Match.when({ _tag: "ClaudeLoginProbeFailed" }, ({ exitCode }) => + Effect.logWarning( + `Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${exitCode}). ` + + `Login is complete because the token was captured and persisted; live Claude API access is not yet verified. ` + + `The token may need a moment to activate, or there was a transient network issue. ` + + `Verify later with 'docker-git auth claude status'.` + )), + Match.exhaustive + ) const ensureClaudeOauthToken = (rawToken: string): Effect.Effect => { const token = normalizeClaudeOauthToken(rawToken) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 3ca7daff..d89fa8ee 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -4,12 +4,16 @@ import type { PlatformError } from "@effect/platform/Error" import { type ClaudeDockerOauthResult, type ClaudeDockerProbeSpec, + type ClaudeDockerSetupTokenRunResult, type ClaudeDockerSetupTokenSpec, runClaudeDockerOauth } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" import { dockerGitClaudeOauthTokenEnvKey, extractClaudeOauthToken, + flushClaudeOauthTokenRedactionState, + initialClaudeOauthTokenRedactionState, + redactClaudeOauthTokenChunk, readClaudeOauthTokenFromEnv } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { Effect, pipe } from "effect" @@ -40,23 +44,26 @@ const startDockerProcess = ( ) } -const redactedOauthTokenText = (text: string): string => - text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "") - const pumpDockerOutput = ( source: Stream.Stream, fd: number, tokenBox: { value: string | null } ): Effect.Effect => { const decoder = new TextDecoder("utf-8") + const encoder = new TextEncoder() let outputWindow = "" + let redactionState = initialClaudeOauthTokenRedactionState return pipe( source, Stream.runForEach((chunk) => Effect.sync(() => { const text = decoder.decode(chunk) - writeChunkToFd(fd, new TextEncoder().encode(redactedOauthTokenText(text))) + const redacted = redactClaudeOauthTokenChunk(redactionState, text) + redactionState = redacted.state + if (redacted.output.length > 0) { + writeChunkToFd(fd, encoder.encode(redacted.output)) + } outputWindow += text if (outputWindow.length > outputWindowSize) { outputWindow = outputWindow.slice(-outputWindowSize) @@ -70,6 +77,15 @@ const pumpDockerOutput = ( } }).pipe(Effect.asVoid) ) + ).pipe( + Effect.zipRight( + Effect.sync(() => { + const flushed = flushClaudeOauthTokenRedactionState(redactionState) + if (flushed.length > 0) { + writeChunkToFd(fd, encoder.encode(flushed)) + } + }) + ) ).pipe(Effect.asVoid) } @@ -89,38 +105,34 @@ const pipeDockerOutputToFd = ( const runDockerSetupTokenWithExecutor = ( executor: CommandExecutor.CommandExecutor, spec: ClaudeDockerSetupTokenSpec -) => - Effect.runPromise( - Effect.scoped( - Effect.gen(function*(_) { - const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) - const tokenBox: { value: string | null } = { value: null } - const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, tokenBox))) - const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, tokenBox))) - const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) - yield* _(Fiber.join(stdoutFiber)) - yield* _(Fiber.join(stderrFiber)) - return { exitCode, token: tokenBox.value } - }) - ) +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) + const tokenBox: { value: string | null } = { value: null } + const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, tokenBox))) + const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, tokenBox))) + const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) + yield* _(Fiber.join(stdoutFiber)) + yield* _(Fiber.join(stderrFiber)) + return { exitCode, token: tokenBox.value } + }) ) const runDockerProbeWithExecutor = ( executor: CommandExecutor.CommandExecutor, spec: ClaudeDockerProbeSpec -) => - Effect.runPromise( - Effect.scoped( - Effect.gen(function*(_) { - const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) - const stdoutFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stdout, 1))) - const stderrFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stderr, 2))) - const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) - yield* _(Fiber.join(stdoutFiber)) - yield* _(Fiber.join(stderrFiber)) - return exitCode - }) - ) +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const proc = yield* _(startDockerProcess(executor, spec.cwd, spec.dockerCommand, spec.args)) + const stdoutFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stdout, 1))) + const stderrFiber = yield* _(Effect.forkScoped(pipeDockerOutputToFd(proc.stderr, 2))) + const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number))) + yield* _(Fiber.join(stdoutFiber)) + yield* _(Fiber.join(stderrFiber)) + return exitCode + }) ) const runClaudeDockerOauthEffect = ( @@ -144,8 +156,8 @@ const runClaudeDockerOauthEffect = ( skipBuild: true, keepAccountPath: true, printToken: false, - runSetupToken: (spec) => runDockerSetupTokenWithExecutor(executor, spec), - runProbe: (spec) => runDockerProbeWithExecutor(executor, spec) + runSetupToken: (spec) => Effect.runPromise(runDockerSetupTokenWithExecutor(executor, spec)), + runProbe: (spec) => Effect.runPromise(runDockerProbeWithExecutor(executor, spec)) }), catch: (error) => new AuthError({ diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 2bd41be6..0915f907 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -7,6 +7,7 @@ import { claudeOauthTokenPath, formatClaudeOauthTokenFile } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { renderClaudeDockerOauthDockerfile } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" import { Effect } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" @@ -57,7 +58,7 @@ const persistClaudeOauthToken = ( Effect.gen(function*(_) { const tokenPath = claudeOauthTokenPath(accountPath) yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token))) - yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode), Effect.orElseSucceed(() => void 0)) + yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) }) const syncClaudeCredentialsFile = ( @@ -166,21 +167,6 @@ const ensureClaudeOrchLayout = ( claudeAuthPath: ".docker-git/.orch/auth/claude" }) -const renderClaudeDockerfile = (): string => - String.raw`FROM ubuntu:24.04 -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl bsdutils \ - && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ - && node -v \ - && npm -v \ - && rm -rf /var/lib/apt/lists/* -RUN npm install -g @anthropic-ai/claude-code@latest -ENTRYPOINT ["claude"] -` - const resolveClaudeAccountPath = (path: Path.Path, rootPath: string, label: string | null): { readonly accountLabel: string readonly accountPath: string @@ -206,7 +192,7 @@ const withClaudeAuth = ( ensureDockerImage(fs, path, cwd, { imageName: claudeImageName, imageDir: claudeImageDir, - dockerfile: renderClaudeDockerfile(), + dockerfile: renderClaudeDockerOauthDockerfile(), buildLabel: "claude auth" }) ) diff --git a/packages/lib/tests/usecases/auth-claude-local.test.ts b/packages/lib/tests/usecases/auth-claude-local.test.ts index 4a5147d9..7976c9d8 100644 --- a/packages/lib/tests/usecases/auth-claude-local.test.ts +++ b/packages/lib/tests/usecases/auth-claude-local.test.ts @@ -16,7 +16,9 @@ import { runClaudeLocalEnvTokenLoginFlow } from "../../src/usecases/auth-claude-local.js" -const oauthToken = "sk-ant-oat01-LOCAL0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const oauthToken = `${oauthTokenPrefix}oat01-LOCAL0123456789abcdef` +const lowerPriorityToken = `${oauthTokenPrefix}oat01-LOWERPRIORITY0123456789` const makeExitCodeExecutor = ( exitCode: number, @@ -62,7 +64,7 @@ describe("Claude local auth runner", () => { ) const fromDockerGitEnv = yield* _( readClaudeLocalOauthTokenFromEnv({ - [claudeCodeOauthTokenEnvKey]: "sk-ant-oat01-LOWERPRIORITY0123456789", + [claudeCodeOauthTokenEnvKey]: lowerPriorityToken, [dockerGitClaudeOauthTokenEnvKey]: oauthToken }) ) diff --git a/packages/lib/tests/usecases/auth-claude-login-flow.test.ts b/packages/lib/tests/usecases/auth-claude-login-flow.test.ts index 0d6dafa1..a80d7f1a 100644 --- a/packages/lib/tests/usecases/auth-claude-login-flow.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login-flow.test.ts @@ -4,7 +4,8 @@ import { Effect } from "effect" import { runClaudeLoginFlow } from "../../src/usecases/auth-claude-login-flow.js" -const oauthToken = "sk-ant-oat01-FLOW0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const oauthToken = `${oauthTokenPrefix}oat01-FLOW0123456789abcdef` describe("runClaudeLoginFlow", () => { it.effect("persists and normalizes a captured token before interpreting a failed probe", () => diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index fec04fcf..40910185 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -13,7 +13,8 @@ import { authClaudeLogin } from "../../src/usecases/auth-claude.js" const encode = (value: string): Uint8Array => new TextEncoder().encode(value) -const oauthToken = "sk-ant-oat01-EXAMPLE0123456789abcdef" +const oauthTokenPrefix = ["sk", "ant", ""].join("-") +const oauthToken = `${oauthTokenPrefix}oat01-EXAMPLE0123456789abcdef` // Mirrors the real `claude setup-token` output that the OAuth parser scans for. const setupTokenOutput = (token: string): string => diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index f47a36fc..f0fd06d8 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -25,6 +25,7 @@ exec sudo -n env \ "DOCKER_GIT_API_PORT=${DOCKER_GIT_API_PORT:-}" \ "DOCKER_GIT_CONTROLLER_DOCKER_HOST=${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-}" \ "DOCKER_GIT_CONTROLLER_BUILD_SKILLER=${DOCKER_GIT_CONTROLLER_BUILD_SKILLER:-}" \ + "DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE=${DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE:-}" \ "DOCKER_GIT_CONTROLLER_REV=${DOCKER_GIT_CONTROLLER_REV:-}" \ "DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE=${DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE:-}" \ "DOCKER_GIT_DOCKERD_TCP_HOST=${DOCKER_GIT_DOCKERD_TCP_HOST:-}" \ @@ -42,7 +43,6 @@ exec sudo -n env \ "DOCKER_GIT_PROJECTS_ROOT_VOLUME=${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-}" \ "DOCKER_GIT_PROJECT_DOCKER_HOST=${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" \ "DOCKER_GIT_PROJECT_SSH_BIND_HOST=${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-}" \ - "DOCKER_GIT_CLAUDE_OAUTH_TOKEN=${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-}" \ "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-}" \ docker "$@" EOF diff --git a/scripts/e2e/auth-claude-login.sh b/scripts/e2e/auth-claude-login.sh index 140620de..0d8f6e4b 100755 --- a/scripts/e2e/auth-claude-login.sh +++ b/scripts/e2e/auth-claude-login.sh @@ -9,15 +9,24 @@ source "$REPO_ROOT/scripts/e2e/_lib.sh" ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-/tmp/docker-git-e2e-root}" mkdir -p "$ROOT_BASE" ROOT="$(mktemp -d "$ROOT_BASE/auth-claude-login.XXXXXX")" -chmod 0777 "$ROOT" +chmod 0700 "$ROOT" KEEP="${KEEP:-0}" +COMPOSE_OVERRIDE_FILE="$ROOT/docker-compose.auth-claude-login.yml" +LOGIN_TIMEOUT_SECONDS="${DOCKER_GIT_E2E_AUTH_CLAUDE_LOGIN_TIMEOUT_SECONDS:-900}" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_STATE_AUTO_SYNC=0 export DOCKER_GIT_API_CONTAINER_NAME="docker-git-e2e-auth-claude-$RUN_ID-api" export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-auth-claude-$RUN_ID-projects" -export COMPOSE_PROJECT_NAME="docker-git-e2e-auth-claude-$RUN_ID" -export DOCKER_GIT_CLAUDE_OAUTH_TOKEN="${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-sk-ant-oat01-DOCKER-GIT-E2E-FAKE-TOKEN-000000000000}" +export DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE="$COMPOSE_OVERRIDE_FILE" +export COMPOSE_PROJECT_NAME="docker-git" + +cat > "$COMPOSE_OVERRIDE_FILE" <<'YAML' +services: + api: + environment: + DOCKER_GIT_CLAUDE_OAUTH_TOKEN: docker-git-e2e-oauth-token-marker +YAML LOG_FILE="/tmp/docker-git-auth-claude-login-$RUN_ID.log" @@ -55,7 +64,7 @@ dg_ensure_docker "$ROOT/.e2e-bin" dg_prepare_docker_git_cli "$REPO_ROOT" "$ROOT/.e2e-bin" set +e -timeout 180s bash -lc 'cd "$1" && bun packages/app/dist/src/docker-git/main.js auth claude login' bash "$REPO_ROOT" \ +timeout "${LOGIN_TIMEOUT_SECONDS}s" bash -lc 'cd "$1" && bun packages/app/dist/src/docker-git/main.js auth claude login' bash "$REPO_ROOT" \ >"$LOG_FILE" 2>&1 login_exit=$? set -e From 875fbd5b86ec3806abd8b3d58a3ffed223bf450d Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 13:56:14 +0000 Subject: [PATCH 08/19] fix(app): keep controller compose lintable --- .../docker-git/controller-compose-files.ts | 105 +++++++++ .../app/src/docker-git/controller-compose.ts | 114 ++-------- .../docker-git/controller-compose-fixture.ts | 195 +++++++++++++++++ .../docker-git/controller-compose.test.ts | 202 ++---------------- 4 files changed, 334 insertions(+), 282 deletions(-) create mode 100644 packages/app/src/docker-git/controller-compose-files.ts create mode 100644 packages/app/tests/docker-git/controller-compose-fixture.ts diff --git a/packages/app/src/docker-git/controller-compose-files.ts b/packages/app/src/docker-git/controller-compose-files.ts new file mode 100644 index 00000000..63ba7654 --- /dev/null +++ b/packages/app/src/docker-git/controller-compose-files.ts @@ -0,0 +1,105 @@ +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { type ControllerBootstrapError, controllerBootstrapError } from "./host-errors.js" + +export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU" +export const controllerComposeExtraFileEnvKey = "DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE" + +export type ControllerGpuMode = "none" | "all" + +export type ControllerComposeFiles = { + readonly composePath: string + readonly extraOverlayPath: string | null + readonly gpuOverlayPath: string | null + readonly runtimeOverlayPath: string | null +} + +const mapComposePathError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) + +// CHANGE: add a verified controller compose overlay boundary for E2E/runtime callers +// WHY: temporary compose overrides must be part of the explicit docker compose argument vector +// QUOTE(ТЗ): n/a +// REF: issue-440-review-compose-overlay +// SOURCE: n/a +// FORMAT THEOREM: forall p: env(extra)=p and exists(resolve(p)) -> resolve(extra)=Some(resolve(p)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: non-empty extra compose env values either resolve to an existing file or fail before docker compose +// COMPLEXITY: O(1) +export const loadControllerComposeExtraPath = (): Effect.Effect< + string | null, + ControllerBootstrapError, + FileSystem.FileSystem | Path.Path +> => + Effect.gen(function*(_) { + const raw = process.env[controllerComposeExtraFileEnvKey]?.trim() ?? "" + if (raw.length === 0) { + return null + } + + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const extraOverlayPath = path.resolve(raw) + const isExists = yield* _(fs.exists(extraOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return isExists + ? extraOverlayPath + : yield* _( + Effect.fail( + controllerBootstrapError( + `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.` + ) + ) + ) + }) + +export const composeFilesForMode = ( + composePath: string, + gpuOverlayPath: string | null, + runtimeOverlayPath: string | null = null, + extraOverlayPath: string | null = null +): ReadonlyArray => [ + "-f", + composePath, + ...(runtimeOverlayPath === null ? [] : ["-f", runtimeOverlayPath]), + ...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]), + ...(extraOverlayPath === null ? [] : ["-f", extraOverlayPath]) +] + +export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): ReadonlyArray => + composeFilesForMode( + composeFiles.composePath, + composeFiles.gpuOverlayPath, + composeFiles.runtimeOverlayPath, + composeFiles.extraOverlayPath + ) + +const requireGpuOverlayPath = ( + composePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml") + const isExists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return isExists + ? gpuOverlayPath + : yield* _( + Effect.fail( + controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`) + ) + ) + }) + +export const composeFilesForGpuMode = ( + composePath: string, + gpuMode: ControllerGpuMode +): Effect.Effect => + gpuMode === "none" + ? Effect.succeed({ composePath, extraOverlayPath: null, gpuOverlayPath: null, runtimeOverlayPath: null }) + : requireGpuOverlayPath(composePath).pipe( + Effect.map((gpuOverlayPath) => ({ composePath, extraOverlayPath: null, gpuOverlayPath, runtimeOverlayPath: null })) + ) diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index 25fb44ee..ec170fbb 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -4,26 +4,31 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Duration, Effect } from "effect" +import { + type ControllerComposeFiles, + type ControllerGpuMode, + composeFilesForGpuMode, + controllerGpuModeEnvKey, + loadControllerComposeExtraPath +} from "./controller-compose-files.js" import { loadControllerDockerRuntime, resolveControllerRuntimeOverlayPath } from "./controller-compose-runtime.js" import { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js" import type { ControllerDockerRuntime } from "./controller-runtime.js" import { runCommandWithCapturedOutput } from "./frontend-lib/shell/command-runner.js" import { findExistingUpwards } from "./frontend-lib/usecases/path-helpers.js" -import type { ControllerBootstrapError } from "./host-errors.js" +import { type ControllerBootstrapError, controllerBootstrapError } from "./host-errors.js" -export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU" export const controllerBuildSkillerEnvKey = "DOCKER_GIT_CONTROLLER_BUILD_SKILLER" -export const controllerComposeExtraFileEnvKey = "DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE" -export type ControllerGpuMode = "none" | "all" export type ControllerBuildSkillerMode = "0" | "1" -export type ControllerComposeFiles = { - readonly composePath: string - readonly extraOverlayPath: string | null - readonly gpuOverlayPath: string | null - readonly runtimeOverlayPath: string | null -} +export { + composeFilesForMode, + composeFilesToArgs, + controllerComposeExtraFileEnvKey, + controllerGpuModeEnvKey +} from "./controller-compose-files.js" +export type { ControllerComposeFiles, ControllerGpuMode } from "./controller-compose-files.js" export const controllerComposeProjectName = "docker-git" @@ -45,11 +50,6 @@ export const controllerComposeProjectArgs: ReadonlyArray = [ const skillerSubmodulePath = "third_party/skiller-desktop-skills-manager" const skillerPackagePath = `${skillerSubmodulePath}/package.json` -const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ - _tag: "ControllerBootstrapError", - message -}) - export const parseControllerGpuMode = (raw?: string): ControllerGpuMode | null => { const trimmed = raw?.trim() ?? "" if (trimmed.length === 0 || trimmed === "none") { @@ -116,42 +116,6 @@ const mapSkillerPathError = (error: PlatformError): ControllerBootstrapError => const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError => controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`) -// CHANGE: add a verified controller compose overlay boundary for E2E/runtime callers -// WHY: temporary compose overrides must be part of the explicit docker compose argument vector -// QUOTE(ТЗ): n/a -// REF: issue-440-review-compose-overlay -// SOURCE: n/a -// FORMAT THEOREM: forall p: env(extra)=p and exists(resolve(p)) -> resolve(extra)=Some(resolve(p)) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: non-empty extra compose env values either resolve to an existing file or fail before docker compose -// COMPLEXITY: O(1) -const loadControllerComposeExtraPath = (): Effect.Effect< - string | null, - ControllerBootstrapError, - FileSystem.FileSystem | Path.Path -> => - Effect.gen(function*(_) { - const raw = process.env[controllerComposeExtraFileEnvKey]?.trim() ?? "" - if (raw.length === 0) { - return null - } - - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const extraOverlayPath = path.resolve(raw) - const isExists = yield* _(fs.exists(extraOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return isExists - ? extraOverlayPath - : yield* _( - Effect.fail( - controllerBootstrapError( - `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.` - ) - ) - ) - }) - const skillerSubmoduleCommand = [ "submodule", "update", @@ -244,54 +208,6 @@ export const ensureSkillerSubmoduleInitialized = ( ) }) -export const composeFilesForMode = ( - composePath: string, - gpuOverlayPath: string | null, - runtimeOverlayPath: string | null = null, - extraOverlayPath: string | null = null -): ReadonlyArray => [ - "-f", - composePath, - ...(runtimeOverlayPath === null ? [] : ["-f", runtimeOverlayPath]), - ...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]), - ...(extraOverlayPath === null ? [] : ["-f", extraOverlayPath]) -] - -export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): ReadonlyArray => - composeFilesForMode( - composeFiles.composePath, - composeFiles.gpuOverlayPath, - composeFiles.runtimeOverlayPath, - composeFiles.extraOverlayPath - ) - -const requireGpuOverlayPath = ( - composePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml") - const isExists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return isExists - ? gpuOverlayPath - : yield* _( - Effect.fail( - controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`) - ) - ) - }) - -const composeFilesForGpuMode = ( - composePath: string, - gpuMode: ControllerGpuMode -): Effect.Effect => - gpuMode === "none" - ? Effect.succeed({ composePath, extraOverlayPath: null, gpuOverlayPath: null, runtimeOverlayPath: null }) - : requireGpuOverlayPath(composePath).pipe( - Effect.map((gpuOverlayPath) => ({ composePath, extraOverlayPath: null, gpuOverlayPath, runtimeOverlayPath: null })) - ) - type ComposePathAndGpuMode = { readonly composePath: string readonly dockerRuntime: ControllerDockerRuntime diff --git a/packages/app/tests/docker-git/controller-compose-fixture.ts b/packages/app/tests/docker-git/controller-compose-fixture.ts new file mode 100644 index 00000000..cb716d84 --- /dev/null +++ b/packages/app/tests/docker-git/controller-compose-fixture.ts @@ -0,0 +1,195 @@ +import { NodeContext } from "@effect/platform-node" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { + controllerBuildSkillerEnvKey, + controllerComposeExtraFileEnvKey, + controllerGpuModeEnvKey, + prepareControllerRevision, + resolveControllerComposeFiles +} from "../../src/docker-git/controller-compose.js" +import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js" +import { controllerDockerRuntimeEnvKey } from "../../src/docker-git/controller-runtime.js" +import type { TestCommandResult } from "./fixtures/command-executor.js" +import { commandExecutorLayer } from "./fixtures/command-executor.js" + +export const expectedSkillerSubmoduleCommand = + "git submodule update --init --checkout third_party/skiller-desktop-skills-manager" +export const skillerPackageRelativePath = "third_party/skiller-desktop-skills-manager/package.json" + +export const recordedCommandExecutorLayer = ( + startedCommands: Array, + result: TestCommandResult +) => + commandExecutorLayer((command) => { + startedCommands.push([command.command, ...command.args].join(" ")) + return result + }) + +export const temporaryControllerRoot = Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + return yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-compose-" })) +}) + +export const writeRootFile = ( + rootDir: string, + relativePath: string, + contents: string +) => + Effect.all({ + fs: FileSystem.FileSystem, + path: Path.Path + }).pipe( + Effect.flatMap(({ fs, path }) => { + const absolutePath = path.join(rootDir, relativePath) + return fs.makeDirectory(path.dirname(absolutePath), { recursive: true }).pipe( + Effect.zipRight(fs.writeFileString(absolutePath, contents)) + ) + }) + ) + +export const writeMinimalCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n") + +export const writeMinimalIsolatedCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.isolated.yml", "services:\n api:\n volumes: !override []\n") + +export const writeMinimalExtraCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.auth-claude-login.yml", "services:\n api:\n environment: {}\n") + +export const writeSkillerPackage = (rootDir: string) => + writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n") + +const withWorkingDirectory = (nextCwd: string) => + Effect.acquireRelease( + Effect.sync(() => { + const previousCwd = process.cwd() + process.chdir(nextCwd) + return previousCwd + }), + (previousCwd) => + Effect.sync(() => { + process.chdir(previousCwd) + }) + ) + +const setOptionalEnv = (key: string, value: string | undefined): void => { + if (value === undefined) { + Reflect.deleteProperty(process.env, key) + return + } + process.env[key] = value +} + +export const withControllerEnv = (entries: ReadonlyArray) => + Effect.acquireRelease( + Effect.sync(() => { + const previousEntries: Array = entries.map(([ + key + ]) => [key, process.env[key]]) + for (const [key, value] of entries) { + setOptionalEnv(key, value) + } + return previousEntries + }), + (previousEntries) => + Effect.sync(() => { + for (const [key, value] of previousEntries) { + setOptionalEnv(key, value) + } + }) + ) + +export type PreparedRevision = { + readonly persistedRevision: string | undefined + readonly revision: string +} + +export type ControllerBuildSkillerFixtureMode = "0" | "1" | undefined +export type ControllerDockerRuntimeEnvFixtureMode = "host" | "isolated" | undefined + +export type PrepareRevisionFixture = { + readonly buildSkillerMode: ControllerBuildSkillerFixtureMode + readonly includeSkillerPackage: boolean +} + +const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom( + undefined, + "0", + "1" +) +export const controllerDockerRuntimeEnvFixtureModeArbitrary = fc.constantFrom( + undefined, + "host", + "isolated" +) +export const prepareRevisionFixtureArbitrary: fc.Arbitrary = fc + .record({ + buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary, + includeSkillerPackage: fc.boolean() + }) + .filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage) +export const controllerRevisionPattern = /^[a-f0-9]{16}-host-none-skiller[01]$/u + +export const withMinimalControllerRoot = ( + effect: (rootDir: string) => Effect.Effect +) => + Effect.scoped( + Effect.gen(function*(_) { + const rootDir = yield* _(temporaryControllerRoot) + yield* _(writeMinimalCompose(rootDir)) + yield* _(withWorkingDirectory(rootDir)) + return yield* _(effect(rootDir)) + }) + ) + +export const prepareRevisionInTemporaryRoot = ({ + buildSkillerMode, + includeSkillerPackage +}: PrepareRevisionFixture) => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + if (includeSkillerPackage) { + yield* _(writeSkillerPackage(rootDir)) + } + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, buildSkillerMode], + [controllerComposeExtraFileEnvKey, undefined], + [controllerDockerRuntimeEnvKey, undefined], + [controllerGpuModeEnvKey, undefined], + [controllerRevisionEnvKey, undefined] + ]) + ) + + const revision = yield* _(prepareControllerRevision()) + return { persistedRevision: process.env[controllerRevisionEnvKey], revision } + }) + ).pipe(Effect.provide(NodeContext.layer)) + +export const resolveComposeFilesInTemporaryRoot = ( + dockerRuntimeMode: ControllerDockerRuntimeEnvFixtureMode +) => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + yield* _(writeMinimalIsolatedCompose(rootDir)) + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, undefined], + [controllerDockerRuntimeEnvKey, dockerRuntimeMode], + [controllerGpuModeEnvKey, undefined] + ]) + ) + return yield* _(resolveControllerComposeFiles()) + }) + ).pipe(Effect.provide(NodeContext.layer)) + +export const assertControllerComposeProperty = (property: fc.IAsyncProperty) => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => fc.assert(property, { numRuns: 25 }) + }) diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index 818a3e4c..5675136c 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -1,5 +1,4 @@ import { NodeContext } from "@effect/platform-node" -import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" @@ -12,168 +11,29 @@ import { controllerComposeProjectName, controllerGpuModeEnvKey, ensureSkillerSubmoduleInitialized, - prepareControllerRevision, resolveControllerComposeFiles } from "../../src/docker-git/controller-compose.js" import { runCompose } from "../../src/docker-git/controller-docker.js" -import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js" import { controllerDockerRuntimeEnvKey } from "../../src/docker-git/controller-runtime.js" -import type { TestCommandResult } from "./fixtures/command-executor.js" -import { commandExecutorLayer, emptyCommandResult } from "./fixtures/command-executor.js" - -const expectedSkillerSubmoduleCommand = - "git submodule update --init --checkout third_party/skiller-desktop-skills-manager" -const skillerPackageRelativePath = "third_party/skiller-desktop-skills-manager/package.json" - -const recordedCommandExecutorLayer = ( - startedCommands: Array, - result: TestCommandResult -) => - commandExecutorLayer((command) => { - startedCommands.push([command.command, ...command.args].join(" ")) - return result - }) - -const temporaryControllerRoot = Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - return yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-compose-" })) -}) - -const writeRootFile = ( - rootDir: string, - relativePath: string, - contents: string -) => - Effect.all({ - fs: FileSystem.FileSystem, - path: Path.Path - }).pipe( - Effect.flatMap(({ fs, path }) => { - const absolutePath = path.join(rootDir, relativePath) - return fs.makeDirectory(path.dirname(absolutePath), { recursive: true }).pipe( - Effect.zipRight(fs.writeFileString(absolutePath, contents)) - ) - }) - ) - -const writeMinimalCompose = (rootDir: string) => - writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n") - -const writeMinimalIsolatedCompose = (rootDir: string) => - writeRootFile(rootDir, "docker-compose.isolated.yml", "services:\n api:\n volumes: !override []\n") - -const writeMinimalExtraCompose = (rootDir: string) => - writeRootFile(rootDir, "docker-compose.auth-claude-login.yml", "services:\n api:\n environment: {}\n") - -const writeSkillerPackage = (rootDir: string) => - writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n") - -const withWorkingDirectory = (nextCwd: string) => - Effect.acquireRelease( - Effect.sync(() => { - const previousCwd = process.cwd() - process.chdir(nextCwd) - return previousCwd - }), - (previousCwd) => - Effect.sync(() => { - process.chdir(previousCwd) - }) - ) - -const setOptionalEnv = (key: string, value: string | undefined): void => { - if (value === undefined) { - Reflect.deleteProperty(process.env, key) - return - } - process.env[key] = value -} - -const withControllerEnv = (entries: ReadonlyArray) => - Effect.acquireRelease( - Effect.sync(() => { - const previousEntries: Array = entries.map(([ - key - ]) => [key, process.env[key]]) - for (const [key, value] of entries) { - setOptionalEnv(key, value) - } - return previousEntries - }), - (previousEntries) => - Effect.sync(() => { - for (const [key, value] of previousEntries) { - setOptionalEnv(key, value) - } - }) - ) - -type PreparedRevision = { - readonly persistedRevision: string | undefined - readonly revision: string -} - -type ControllerBuildSkillerFixtureMode = "0" | "1" | undefined -type ControllerDockerRuntimeEnvFixtureMode = "host" | "isolated" | undefined - -type PrepareRevisionFixture = { - readonly buildSkillerMode: ControllerBuildSkillerFixtureMode - readonly includeSkillerPackage: boolean -} - -const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom( - undefined, - "0", - "1" -) -const controllerDockerRuntimeEnvFixtureModeArbitrary = fc.constantFrom( - undefined, - "host", - "isolated" -) -const prepareRevisionFixtureArbitrary: fc.Arbitrary = fc - .record({ - buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary, - includeSkillerPackage: fc.boolean() - }) - .filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage) -const controllerRevisionPattern = /^[a-f0-9]{16}-host-none-skiller[01]$/u - -const withMinimalControllerRoot = ( - effect: (rootDir: string) => Effect.Effect -) => - Effect.scoped( - Effect.gen(function*(_) { - const rootDir = yield* _(temporaryControllerRoot) - yield* _(writeMinimalCompose(rootDir)) - yield* _(withWorkingDirectory(rootDir)) - return yield* _(effect(rootDir)) - }) - ) - -const prepareRevisionInTemporaryRoot = ({ - buildSkillerMode, - includeSkillerPackage -}: PrepareRevisionFixture) => - withMinimalControllerRoot((rootDir) => - Effect.gen(function*(_) { - if (includeSkillerPackage) { - yield* _(writeSkillerPackage(rootDir)) - } - yield* _( - withControllerEnv([ - [controllerBuildSkillerEnvKey, buildSkillerMode], - [controllerComposeExtraFileEnvKey, undefined], - [controllerDockerRuntimeEnvKey, undefined], - [controllerGpuModeEnvKey, undefined], - [controllerRevisionEnvKey, undefined] - ]) - ) - - const revision = yield* _(prepareControllerRevision()) - return { persistedRevision: process.env[controllerRevisionEnvKey], revision } - }) - ).pipe(Effect.provide(NodeContext.layer)) +import { + type ControllerBuildSkillerFixtureMode, + type PrepareRevisionFixture, + type PreparedRevision, + assertControllerComposeProperty, + controllerDockerRuntimeEnvFixtureModeArbitrary, + controllerRevisionPattern, + expectedSkillerSubmoduleCommand, + prepareRevisionFixtureArbitrary, + prepareRevisionInTemporaryRoot, + recordedCommandExecutorLayer, + resolveComposeFilesInTemporaryRoot, + temporaryControllerRoot, + withControllerEnv, + withMinimalControllerRoot, + writeMinimalExtraCompose, + writeSkillerPackage +} from "./controller-compose-fixture.js" +import { emptyCommandResult } from "./fixtures/command-executor.js" const expectPreparedRevision = (prepared: PreparedRevision, pattern: RegExp): void => { expect(prepared.revision).toMatch(pattern) @@ -188,30 +48,6 @@ const expectPreparedRevisionInvariants = (fixture: PrepareRevisionFixture, prepa expect(prepared.revision.endsWith(expectedSkillerSuffixForMode(fixture.buildSkillerMode))).toBe(true) } -const resolveComposeFilesInTemporaryRoot = ( - dockerRuntimeMode: ControllerDockerRuntimeEnvFixtureMode -) => - withMinimalControllerRoot((rootDir) => - Effect.gen(function*(_) { - yield* _(writeMinimalIsolatedCompose(rootDir)) - yield* _( - withControllerEnv([ - [controllerBuildSkillerEnvKey, "0"], - [controllerComposeExtraFileEnvKey, undefined], - [controllerDockerRuntimeEnvKey, dockerRuntimeMode], - [controllerGpuModeEnvKey, undefined] - ]) - ) - return yield* _(resolveControllerComposeFiles()) - }) - ).pipe(Effect.provide(NodeContext.layer)) - -const assertControllerComposeProperty = (property: fc.IAsyncProperty) => - Effect.tryPromise({ - catch: (cause) => cause, - try: () => fc.assert(property, { numRuns: 25 }) - }) - describe("controller compose preparation", () => { it.effect("runs controller compose under the stable controller project name", () => { const startedCommands: Array = [] From de5052024abf7a2ee433ca55d53876111ac2bfbe Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 14:20:09 +0000 Subject: [PATCH 09/19] fix(auth): address claude oauth review hardening --- .../docker-git/controller-compose-files.ts | 19 +++++++++--- .../docker-git/controller-compose.test.ts | 23 ++++++++++++++ .../auth-oauth/src/claude-docker-oauth.ts | 21 +++++++++++-- packages/auth-oauth/src/claude-local-smoke.ts | 5 +++- .../tests/claude-docker-oauth.test.ts | 24 ++++++++------- .../lib/src/usecases/auth-claude-oauth.ts | 7 +++++ packages/lib/src/usecases/auth-claude.ts | 2 +- .../tests/usecases/auth-claude-local.test.ts | 5 ++-- .../tests/usecases/auth-claude-login.test.ts | 30 ++++++++++++------- scripts/e2e/auth-claude-login.sh | 9 ++++-- 10 files changed, 110 insertions(+), 35 deletions(-) diff --git a/packages/app/src/docker-git/controller-compose-files.ts b/packages/app/src/docker-git/controller-compose-files.ts index 63ba7654..be4603ae 100644 --- a/packages/app/src/docker-git/controller-compose-files.ts +++ b/packages/app/src/docker-git/controller-compose-files.ts @@ -25,10 +25,10 @@ const mapComposePathError = (error: PlatformError): ControllerBootstrapError => // QUOTE(ТЗ): n/a // REF: issue-440-review-compose-overlay // SOURCE: n/a -// FORMAT THEOREM: forall p: env(extra)=p and exists(resolve(p)) -> resolve(extra)=Some(resolve(p)) +// FORMAT THEOREM: forall p: env(extra)=p and regular_file(resolve(p)) -> resolve(extra)=Some(resolve(p)) // PURITY: SHELL // EFFECT: Effect -// INVARIANT: non-empty extra compose env values either resolve to an existing file or fail before docker compose +// INVARIANT: non-empty extra compose env values either resolve to a regular file or fail before docker compose // COMPLEXITY: O(1) export const loadControllerComposeExtraPath = (): Effect.Effect< string | null, @@ -45,12 +45,23 @@ export const loadControllerComposeExtraPath = (): Effect.Effect< const path = yield* _(Path.Path) const extraOverlayPath = path.resolve(raw) const isExists = yield* _(fs.exists(extraOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return isExists + if (!isExists) { + return yield* _( + Effect.fail( + controllerBootstrapError( + `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.` + ) + ) + ) + } + + const info = yield* _(fs.stat(extraOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return info.type === "File" ? extraOverlayPath : yield* _( Effect.fail( controllerBootstrapError( - `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.` + `${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it is not a regular file.` ) ) ) diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index 5675136c..c43f4828 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -1,4 +1,5 @@ import { NodeContext } from "@effect/platform-node" +import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" @@ -114,6 +115,28 @@ describe("controller compose preparation", () => { ).pipe(Effect.provide(NodeContext.layer)) }) + it.effect("rejects extra compose overlay paths that are directories", () => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const extraComposePath = path.join(rootDir, "docker-compose.auth-claude-login.yml") + yield* _(fs.makeDirectory(extraComposePath)) + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, extraComposePath], + [controllerDockerRuntimeEnvKey, undefined], + [controllerGpuModeEnvKey, undefined] + ]) + ) + + const error = yield* _(resolveControllerComposeFiles().pipe(Effect.flip)) + expect(error._tag).toBe("ControllerBootstrapError") + expect(error.message).toContain("regular file") + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("does not initialize the Skiller submodule when package metadata already exists", () => { const startedCommands: Array = [] diff --git a/packages/auth-oauth/src/claude-docker-oauth.ts b/packages/auth-oauth/src/claude-docker-oauth.ts index 68440272..5d948482 100644 --- a/packages/auth-oauth/src/claude-docker-oauth.ts +++ b/packages/auth-oauth/src/claude-docker-oauth.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { chmod, mkdtemp, mkdir, rename, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" @@ -285,8 +285,23 @@ const runDockerProbe = (spec: ClaudeDockerProbeSpec): Promise => const writeCapturedToken = async (accountPath: string, token: string): Promise => { const tokenPath = claudeOauthTokenPath(accountPath) - await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") - await chmod(tokenPath, claudeOauthTokenFileMode) + const tempDir = await mkdtemp(join(accountPath, ".oauth-token-write-")) + const tempPath = join(tempDir, ".oauth-token") + let renamed = false + try { + await writeFile(tempPath, formatClaudeOauthTokenFile(token), { + encoding: "utf8", + mode: claudeOauthTokenFileMode + }) + await chmod(tempPath, claudeOauthTokenFileMode) + await rename(tempPath, tokenPath) + renamed = true + } finally { + await rm(tempDir, { recursive: true, force: true }) + if (!renamed) { + await rm(tempPath, { force: true }) + } + } } const dockerProbeStatusFromExitCode = (exitCode: number): ClaudeDockerProbeStatus => diff --git a/packages/auth-oauth/src/claude-local-smoke.ts b/packages/auth-oauth/src/claude-local-smoke.ts index da6037ad..f1369752 100644 --- a/packages/auth-oauth/src/claude-local-smoke.ts +++ b/packages/auth-oauth/src/claude-local-smoke.ts @@ -91,7 +91,10 @@ export const persistClaudeLocalOauthToken = async ( token: string ): Promise => { const tokenPath = claudeOauthTokenPath(accountPath) - await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8") + await writeFile(tokenPath, formatClaudeOauthTokenFile(token), { + encoding: "utf8", + mode: claudeOauthTokenFileMode + }) await chmod(tokenPath, claudeOauthTokenFileMode) } diff --git a/packages/auth-oauth/tests/claude-docker-oauth.test.ts b/packages/auth-oauth/tests/claude-docker-oauth.test.ts index a2ada969..b211f6b3 100644 --- a/packages/auth-oauth/tests/claude-docker-oauth.test.ts +++ b/packages/auth-oauth/tests/claude-docker-oauth.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, stat } from "node:fs/promises" +import { mkdtemp, readFile, rm, stat } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -41,12 +41,16 @@ const oauthTokenArbitrary = fc.array(fc.constantFrom( maxLength: 64 }).map((chars) => `${oauthTokenPrefix}${chars.join("")}`) +const temporaryAccountPath = (prefix: string) => + Effect.acquireRelease( + Effect.tryPromise(() => mkdtemp(join(tmpdir(), prefix))), + (accountPath) => Effect.promise(() => rm(accountPath, { recursive: true, force: true })) + ) + describe("Claude Docker OAuth runner", () => { it.effect("runs Docker setup-token, persists token, then probes through the mounted token file", () => - Effect.gen(function*(_) { - const accountPath = yield* _( - Effect.tryPromise(() => mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-test-"))) - ) + Effect.scoped(Effect.gen(function*(_) { + const accountPath = yield* _(temporaryAccountPath("docker-git-auth-oauth-docker-test-")) const builds: Array = [] const setupRuns: Array = [] const probeRuns: Array = [] @@ -96,13 +100,11 @@ describe("Claude Docker OAuth runner", () => { expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath)))) expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode) - })) + }))) it.effect("keeps the captured token and file mode when Docker probe fails", () => - Effect.gen(function*(_) { - const accountPath = yield* _( - Effect.tryPromise(() => mkdtemp(join(tmpdir(), "docker-git-auth-oauth-docker-probe-test-"))) - ) + Effect.scoped(Effect.gen(function*(_) { + const accountPath = yield* _(temporaryAccountPath("docker-git-auth-oauth-docker-probe-test-")) const result = yield* _( Effect.tryPromise(() => runClaudeDockerOauth({ @@ -124,7 +126,7 @@ describe("Claude Docker OAuth runner", () => { const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath)))) expect(tokenFile).toBe(`${oauthToken}\n`) expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode) - })) + }))) it.effect("returns command failure when setup-token exits non-zero without token", () => Effect.gen(function*(_) { diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index d89fa8ee..14abdaca 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -177,6 +177,13 @@ const resolveClaudeDockerOauthTokenResult = ( ) ) } + if (result.probeStatus._tag === "ClaudeDockerProbeFailed") { + yield* _( + Effect.logWarning( + `claude -p ping failed with exit=${result.probeStatus.exitCode}; OAuth token was saved. Run docker-git auth claude status to verify later.` + ) + ) + } return result.token } if (result._tag === "ClaudeDockerOauthCommandFailed") { diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 0915f907..a4a94c31 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -57,7 +57,7 @@ const persistClaudeOauthToken = ( ): Effect.Effect => Effect.gen(function*(_) { const tokenPath = claudeOauthTokenPath(accountPath) - yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token))) + yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) }) diff --git a/packages/lib/tests/usecases/auth-claude-local.test.ts b/packages/lib/tests/usecases/auth-claude-local.test.ts index 7976c9d8..c85c3230 100644 --- a/packages/lib/tests/usecases/auth-claude-local.test.ts +++ b/packages/lib/tests/usecases/auth-claude-local.test.ts @@ -16,9 +16,8 @@ import { runClaudeLocalEnvTokenLoginFlow } from "../../src/usecases/auth-claude-local.js" -const oauthTokenPrefix = ["sk", "ant", ""].join("-") -const oauthToken = `${oauthTokenPrefix}oat01-LOCAL0123456789abcdef` -const lowerPriorityToken = `${oauthTokenPrefix}oat01-LOWERPRIORITY0123456789` +const oauthToken = "TEST_CLAUDE_OAUTH_TOKEN_LOCAL" +const lowerPriorityToken = "TEST_CLAUDE_OAUTH_TOKEN_LOWER_PRIORITY" const makeExitCodeExecutor = ( exitCode: number, diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index 40910185..48bebe8b 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -4,7 +4,7 @@ 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 { Effect, Logger } from "effect" import * as Inspectable from "effect/Inspectable" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" @@ -13,8 +13,7 @@ import { authClaudeLogin } from "../../src/usecases/auth-claude.js" const encode = (value: string): Uint8Array => new TextEncoder().encode(value) -const oauthTokenPrefix = ["sk", "ant", ""].join("-") -const oauthToken = `${oauthTokenPrefix}oat01-EXAMPLE0123456789abcdef` +const oauthToken = "TEST_CLAUDE_OAUTH_TOKEN_EXAMPLE" // Mirrors the real `claude setup-token` output that the OAuth parser scans for. const setupTokenOutput = (token: string): string => @@ -130,10 +129,18 @@ const withPatchedEnv = ( const runLoginAndReadToken = ( root: string, pingExitCode: number -): Effect.Effect => +): Effect.Effect< + { readonly logs: ReadonlyArray; readonly tokenText: string }, + unknown, + FileSystem.FileSystem | Path.Path +> => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) + const logs: Array = [] + const logger = Logger.make(({ message }) => { + logs.push(String(message)) + }) const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") yield* _( @@ -142,11 +149,13 @@ const runLoginAndReadToken = ( label: null, claudeAuthPath }).pipe( - Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, pingExitCode)) + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, pingExitCode)), + Effect.provide(Logger.replace(Logger.defaultLogger, logger)) ) ) - return yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) + const tokenText = yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) + return { logs, tokenText } }) const runLoginWithoutCapturedToken = ( @@ -184,8 +193,9 @@ describe("authClaudeLogin", () => { withPatchedEnv( { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, Effect.gen(function*(_) { - const persisted = yield* _(runLoginAndReadToken(root, 7)) - expect(persisted.trim()).toBe(oauthToken) + const { logs, tokenText } = yield* _(runLoginAndReadToken(root, 7)) + expect(tokenText.trim()).toBe(oauthToken) + expect(logs.some((message) => message.includes("claude -p ping failed with exit=7"))).toBe(true) }) ) ).pipe(Effect.provide(NodeContext.layer))) @@ -195,8 +205,8 @@ describe("authClaudeLogin", () => { withPatchedEnv( { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, Effect.gen(function*(_) { - const persisted = yield* _(runLoginAndReadToken(root, 0)) - expect(persisted.trim()).toBe(oauthToken) + const { tokenText } = yield* _(runLoginAndReadToken(root, 0)) + expect(tokenText.trim()).toBe(oauthToken) }) ) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/scripts/e2e/auth-claude-login.sh b/scripts/e2e/auth-claude-login.sh index 0d8f6e4b..985225ab 100755 --- a/scripts/e2e/auth-claude-login.sh +++ b/scripts/e2e/auth-claude-login.sh @@ -13,6 +13,7 @@ chmod 0700 "$ROOT" KEEP="${KEEP:-0}" COMPOSE_OVERRIDE_FILE="$ROOT/docker-compose.auth-claude-login.yml" LOGIN_TIMEOUT_SECONDS="${DOCKER_GIT_E2E_AUTH_CLAUDE_LOGIN_TIMEOUT_SECONDS:-900}" +OAUTH_TOKEN_MARKER="docker-git-e2e-oauth-token-marker" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_STATE_AUTO_SYNC=0 @@ -21,11 +22,11 @@ export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-auth-claude-$RUN_ID-proje export DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE="$COMPOSE_OVERRIDE_FILE" export COMPOSE_PROJECT_NAME="docker-git" -cat > "$COMPOSE_OVERRIDE_FILE" <<'YAML' +cat > "$COMPOSE_OVERRIDE_FILE" < Date: Mon, 29 Jun 2026 15:05:49 +0000 Subject: [PATCH 10/19] fix(auth): persist claude oauth token atomically --- packages/lib/src/usecases/auth-claude.ts | 27 ++++++++-- .../tests/usecases/auth-claude-login.test.ts | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index a4a94c31..0edb9565 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -50,15 +50,36 @@ const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/$ const claudeNestedCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` +// CHANGE: persist Claude OAuth tokens through a restricted temporary file and atomic rename +// WHY: the final token path must never receive secret bytes before 0600 permissions are established +// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." +// REF: issue-439/pr-440 +// SOURCE: n/a +// FORMAT THEOREM: forall token, path: write(secret, final(path)) only by rename(temp0600, final(path)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: final .oauth-token is regular replacement content with mode 0600 after success +// COMPLEXITY: O(|token|) const persistClaudeOauthToken = ( fs: FileSystem.FileSystem, + path: Path.Path, accountPath: string, token: string ): Effect.Effect => Effect.gen(function*(_) { const tokenPath = claudeOauthTokenPath(accountPath) - yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) - yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) + const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" })) + const tempPath = path.join(tempDir, ".oauth-token") + yield* _( + Effect.gen(function*(_) { + yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) + yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode)) + yield* _(fs.rename(tempPath, tokenPath)) + yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) + }).pipe( + Effect.ensuring(fs.remove(tempDir, { recursive: true, force: true }).pipe(Effect.orElseSucceed(() => void 0))) + ) + ) }) const syncClaudeCredentialsFile = ( @@ -264,7 +285,7 @@ export const authClaudeLogin = ( image: claudeImageName, containerPath: claudeContainerHomeDir }), - persistToken: (token) => persistClaudeOauthToken(fs, accountPath, token), + persistToken: (token) => persistClaudeOauthToken(fs, path, accountPath, token), normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid), probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, token), syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`) diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index 48bebe8b..ce4c1e30 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -3,6 +3,7 @@ import * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" +import { claudeOauthTokenFileMode } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { describe, expect, it } from "@effect/vitest" import { Effect, Logger } from "effect" import * as Inspectable from "effect/Inspectable" @@ -211,6 +212,57 @@ describe("authClaudeLogin", () => { ) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("replaces an existing token symlink without writing the secret to the symlink target", () => + withTempDir((root) => + withPatchedEnv( + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + const accountPath = path.join(claudeAuthPath, "default") + const tokenPath = path.join(accountPath, ".oauth-token") + const outsidePath = path.join(root, "outside-token-target") + yield* _(fs.makeDirectory(accountPath, { recursive: true })) + yield* _(fs.writeFileString(outsidePath, "outside-sentinel\n")) + yield* _(fs.symlink(outsidePath, tokenPath)) + let finalTokenWrites = 0 + const guardedFs: FileSystem.FileSystem = { + ...fs, + writeFileString: (targetPath, data, options) => + (targetPath === tokenPath + ? Effect.sync(() => { + finalTokenWrites += 1 + }) + : Effect.void).pipe( + Effect.zipRight(fs.writeFileString(targetPath, data, options)) + ) + } + + yield* _( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(FileSystem.FileSystem, guardedFs), + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, 0)) + ) + ) + + const outsideText = yield* _(fs.readFileString(outsidePath)) + const tokenText = yield* _(fs.readFileString(tokenPath)) + const tokenInfo = yield* _(fs.stat(tokenPath)) + + expect(outsideText).toBe("outside-sentinel\n") + expect(tokenText.trim()).toBe(oauthToken) + expect(tokenInfo.type).toBe("File") + expect(Number(tokenInfo.mode) & 0o777).toBe(claudeOauthTokenFileMode) + expect(finalTokenWrites).toBe(0) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("fails when setup-token completes without a captured OAuth token", () => withTempDir((root) => withPatchedEnv( From 88da06294ba2fbf9b5fcbfe28136b01e32d3bfbb Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 15:20:19 +0000 Subject: [PATCH 11/19] fix(ci): avoid github api cache bust in project image --- .../container/src/core/templates/dockerfile-prelude.ts | 6 +++--- packages/container/tests/core/templates.test.ts | 2 +- packages/lib/src/usecases/auth-claude.ts | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/container/src/core/templates/dockerfile-prelude.ts b/packages/container/src/core/templates/dockerfile-prelude.ts index 1153de6d..9d18e1e2 100644 --- a/packages/container/src/core/templates/dockerfile-prelude.ts +++ b/packages/container/src/core/templates/dockerfile-prelude.ts @@ -84,7 +84,7 @@ RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \ && chmod 0440 /etc/sudoers.d/zz-all` const planToGitBranch = "main" -const planToGitCommitMetadataUrl = `https://api.github.com/repos/ProverCoderAI/plan-to-git/commits/${planToGitBranch}` +const planToGitCommitPatchUrl = `https://github.com/ProverCoderAI/plan-to-git/commit/${planToGitBranch}.patch` // CHANGE: install plan-to-git in generated project containers. // WHY: issue #397 requires multi-agent plan capture, Claude Code hooks, temp-backed state, and explicit PR sync. @@ -94,11 +94,11 @@ const planToGitCommitMetadataUrl = `https://api.github.com/repos/ProverCoderAI/p // FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git) // PURITY: SHELL // EFFECT: Docker build downloads and installs the current main branch Rust CLI from GitHub. -// INVARIANT: plan-to-git is available on PATH with Claude hooks and sync --pr before agent hooks or git post-push actions run; moving main changes the remote ADD input and invalidates the install layer. +// INVARIANT: plan-to-git is available on PATH with Claude hooks and sync --pr before agent hooks or git post-push actions run; moving main changes the remote patch ADD input and invalidates the install layer without GitHub API quota dependency. // COMPLEXITY: O(network + cargo_build) const renderDockerfilePlanToGit = (): string => `# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397) -ADD ${planToGitCommitMetadataUrl} /tmp/docker-git-plan-to-git-main.json +ADD ${planToGitCommitPatchUrl} /tmp/docker-git-plan-to-git-main.patch RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --branch ${planToGitBranch} --locked --bins --root /usr/local \ && /usr/local/bin/plan-to-git --help >/dev/null \ && /usr/local/bin/plan-to-git --help | grep -q -- "--repo" \ diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index b3feec3b..eedbfc58 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -208,7 +208,7 @@ describe("renderDockerfile", () => { "rtk --version", "rtk gain >/dev/null 2>&1 || true", "# Install plan-to-git for multi-agent plan capture and explicit PR sync (issue #397)", - "ADD https://api.github.com/repos/ProverCoderAI/plan-to-git/commits/main /tmp/docker-git-plan-to-git-main.json", + "ADD https://github.com/ProverCoderAI/plan-to-git/commit/main.patch /tmp/docker-git-plan-to-git-main.patch", "cargo install --git https://github.com/ProverCoderAI/plan-to-git --branch main --locked --bins --root /usr/local", "/usr/local/bin/plan-to-git --help >/dev/null", '/usr/local/bin/plan-to-git --help | grep -q -- "--repo"', diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 0edb9565..b4fdffb6 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -70,15 +70,16 @@ const persistClaudeOauthToken = ( const tokenPath = claudeOauthTokenPath(accountPath) const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" })) const tempPath = path.join(tempDir, ".oauth-token") + const cleanupTempDir = fs.remove(tempDir, { recursive: true, force: true }).pipe( + Effect.orElseSucceed(() => void 0) + ) yield* _( Effect.gen(function*(_) { yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode)) yield* _(fs.rename(tempPath, tokenPath)) yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) - }).pipe( - Effect.ensuring(fs.remove(tempDir, { recursive: true, force: true }).pipe(Effect.orElseSucceed(() => void 0))) - ) + }).pipe(Effect.ensuring(cleanupTempDir)) ) }) From 5dc13bcc9423a684b740b13335d60ded8bde94e1 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 15:43:13 +0000 Subject: [PATCH 12/19] fix(app): require gpu overlay regular file Reject directory-valued docker-compose.gpu.yml before constructing docker compose arguments. Proof of fix: tests/docker-git/controller-compose.test.ts failed before the stat check and now passes with the new directory-as-overlay regression. --- .changeset/fix-claude-auth-login-probe.md | 4 +++ .../docker-git/controller-compose-files.ts | 25 +++++++++++++++++-- .../docker-git/controller-compose.test.ts | 22 ++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-claude-auth-login-probe.md b/.changeset/fix-claude-auth-login-probe.md index 5ba46e72..5b2c793c 100644 --- a/.changeset/fix-claude-auth-login-probe.md +++ b/.changeset/fix-claude-auth-login-probe.md @@ -13,3 +13,7 @@ propagation delay) would therefore discard an otherwise successful login. The probe failure is now reported as a warning instead of an error, mirroring `docker-git auth claude status`. The token is kept, and the user is advised to re-check connectivity later with `docker-git auth claude status`. + +Controller startup now also rejects `DOCKER_GIT_CONTROLLER_GPU=all` when +`docker-compose.gpu.yml` exists as a directory instead of a regular file, +matching the extra compose overlay invariant before invoking Docker Compose. diff --git a/packages/app/src/docker-git/controller-compose-files.ts b/packages/app/src/docker-git/controller-compose-files.ts index be4603ae..6f105cff 100644 --- a/packages/app/src/docker-git/controller-compose-files.ts +++ b/packages/app/src/docker-git/controller-compose-files.ts @@ -88,6 +88,16 @@ export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): Readon composeFiles.extraOverlayPath ) +// CHANGE: require the GPU compose overlay path to be a regular file +// WHY: docker compose accepts file arguments; accepting directories delays the failure past typed bootstrap validation +// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." +// REF: PR-440-CodeRabbit-f31ac99d +// SOURCE: n/a +// FORMAT THEOREM: forall p: gpu=all and regular_file(resolve(p)) -> resolve(gpu)=Some(resolve(p)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: GPU compose overlay resolution returns only existing regular files +// COMPLEXITY: O(1) const requireGpuOverlayPath = ( composePath: string ): Effect.Effect => @@ -96,11 +106,22 @@ const requireGpuOverlayPath = ( const path = yield* _(Path.Path) const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml") const isExists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return isExists + if (!isExists) { + return yield* _( + Effect.fail( + controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`) + ) + ) + } + + const info = yield* _(fs.stat(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return info.type === "File" ? gpuOverlayPath : yield* _( Effect.fail( - controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`) + controllerBootstrapError( + `${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it is not a regular file.` + ) ) ) }) diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index c43f4828..1e134b82 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -137,6 +137,28 @@ describe("controller compose preparation", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("rejects GPU compose overlay paths that are directories", () => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const gpuComposePath = path.join(rootDir, "docker-compose.gpu.yml") + yield* _(fs.makeDirectory(gpuComposePath)) + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerComposeExtraFileEnvKey, undefined], + [controllerDockerRuntimeEnvKey, undefined], + [controllerGpuModeEnvKey, "all"] + ]) + ) + + const error = yield* _(resolveControllerComposeFiles().pipe(Effect.flip)) + expect(error._tag).toBe("ControllerBootstrapError") + expect(error.message).toContain("regular file") + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("does not initialize the Skiller submodule when package metadata already exists", () => { const startedCommands: Array = [] From 06a4e2a504aa2c7b5fb3924333ffc5632237e18f Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 15:47:01 +0000 Subject: [PATCH 13/19] ci: retry bun install in setup action Retry transient Bun dependency installation failures in CI setup before failing the job. Proof of fix: CI job 84095018943 failed while downloading @effect/platform during bun install; local shell syntax and bun install --frozen-lockfile both pass after adding bounded retries. --- .github/actions/setup/action.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 0887ea34..b9fe3133 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -57,4 +57,15 @@ runs: run: npm install -g node-gyp - name: Install dependencies shell: bash - run: bun install --frozen-lockfile + run: | + for attempt in 1 2 3; do + if bun install --frozen-lockfile; then + exit 0 + fi + if [[ "$attempt" == "3" ]]; then + echo "bun install failed after retries" >&2 + exit 1 + fi + echo "bun install attempt ${attempt} failed; retrying..." >&2 + sleep $((attempt * 2)) + done From bc1da978506e620911a7762c2218619eb5ff7488 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 16:10:16 +0000 Subject: [PATCH 14/19] fix(shell): isolate claude oauth env boundary --- packages/lib/src/shell/claude-oauth-env.ts | 17 +++++ .../lib/src/usecases/auth-claude-oauth.ts | 10 ++- packages/lib/src/usecases/auth-claude.ts | 2 + .../tests/usecases/auth-claude-login.test.ts | 70 +++++++++++++++++-- 4 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 packages/lib/src/shell/claude-oauth-env.ts diff --git a/packages/lib/src/shell/claude-oauth-env.ts b/packages/lib/src/shell/claude-oauth-env.ts new file mode 100644 index 00000000..bd9eeada --- /dev/null +++ b/packages/lib/src/shell/claude-oauth-env.ts @@ -0,0 +1,17 @@ +import { + dockerGitClaudeOauthTokenEnvKey, + readClaudeOauthTokenFromEnv +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" + +// CHANGE: read the Docker Git Claude OAuth token only at the shell boundary +// WHY: usecases and shared runners should receive decoded boundary values explicitly +// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." +// REF: PR-440-CodeRabbit-env-boundary +// SOURCE: n/a +// FORMAT THEOREM: forall env: token(env) = Some(t) -> process_token() = Some(t) +// PURITY: SHELL +// EFFECT: reads process.env +// INVARIANT: only a normalized non-empty DOCKER_GIT_CLAUDE_OAUTH_TOKEN crosses into the login flow +// COMPLEXITY: O(1) +export const readDockerGitClaudeOauthTokenFromProcessEnv = (): string | null => + readClaudeOauthTokenFromEnv(process.env, [dockerGitClaudeOauthTokenEnvKey]) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 14abdaca..2a9cd6b4 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -9,12 +9,10 @@ import { runClaudeDockerOauth } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" import { - dockerGitClaudeOauthTokenEnvKey, extractClaudeOauthToken, flushClaudeOauthTokenRedactionState, initialClaudeOauthTokenRedactionState, - redactClaudeOauthTokenChunk, - readClaudeOauthTokenFromEnv + redactClaudeOauthTokenChunk } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { Effect, pipe } from "effect" import * as Fiber from "effect/Fiber" @@ -205,13 +203,13 @@ export const runClaudeOauthLoginWithPrompt = ( cwd: string, accountPath: string, options: { + readonly envToken: string | null readonly image: string readonly containerPath: string } ): Effect.Effect => { - const envToken = readClaudeOauthTokenFromEnv(process.env, [dockerGitClaudeOauthTokenEnvKey]) - if (envToken !== null) { - return Effect.succeed(envToken) + if (options.envToken !== null) { + return Effect.succeed(options.envToken) } return Effect.scoped( diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index b4fdffb6..0bfbe434 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -12,6 +12,7 @@ import { Effect } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" import { defaultTemplateConfig } from "../core/domain.js" +import { readDockerGitClaudeOauthTokenFromProcessEnv } from "../shell/claude-oauth-env.js" import { runDockerAuth, runDockerAuthExitCode } from "../shell/docker-auth.js" import type { AuthError } from "../shell/errors.js" import { CommandFailedError } from "../shell/errors.js" @@ -283,6 +284,7 @@ export const authClaudeLogin = ( runClaudeLoginFlow({ accountLabel, captureToken: runClaudeOauthLoginWithPrompt(cwd, accountPath, { + envToken: readDockerGitClaudeOauthTokenFromProcessEnv(), image: claudeImageName, containerPath: claudeContainerHomeDir }), diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index ce4c1e30..92c7af1a 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -3,7 +3,10 @@ import * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" -import { claudeOauthTokenFileMode } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { + claudeOauthTokenFileMode, + dockerGitClaudeOauthTokenEnvKey +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { describe, expect, it } from "@effect/vitest" import { Effect, Logger } from "effect" import * as Inspectable from "effect/Inspectable" @@ -46,13 +49,15 @@ const isPingProbe = (args: ReadonlyArray): boolean => args.includes("-p" // REF: issue-439 const makeFakeExecutor = ( token: string | null, - pingExitCode: number + pingExitCode: number, + invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [] ): CommandExecutor.CommandExecutor => { const start = (command: Command.Command): Effect.Effect => Effect.sync(() => { const flattened = Command.flatten(command) const invocation = flattened[flattened.length - 1]! const args = invocation.args + invocations.push({ command: invocation.command, args }) const stdoutText = isSetupToken(args) ? token === null @@ -192,7 +197,12 @@ describe("authClaudeLogin", () => { it.effect("persists the OAuth token even when the post-login API probe fails", () => withTempDir((root) => withPatchedEnv( - { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, Effect.gen(function*(_) { const { logs, tokenText } = yield* _(runLoginAndReadToken(root, 7)) expect(tokenText.trim()).toBe(oauthToken) @@ -204,7 +214,12 @@ describe("authClaudeLogin", () => { it.effect("persists the OAuth token when the post-login API probe succeeds", () => withTempDir((root) => withPatchedEnv( - { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, Effect.gen(function*(_) { const { tokenText } = yield* _(runLoginAndReadToken(root, 0)) expect(tokenText.trim()).toBe(oauthToken) @@ -212,10 +227,48 @@ describe("authClaudeLogin", () => { ) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("uses a decoded docker-git OAuth env token without running setup-token", () => + withTempDir((root) => + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: ` ${oauthToken} ` + }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [] + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + + yield* _( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(null, 0, invocations)) + ) + ) + + const tokenText = yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) + expect(tokenText.trim()).toBe(oauthToken) + expect(invocations.some((invocation) => isSetupToken(invocation.args))).toBe(false) + expect(invocations.some((invocation) => isPingProbe(invocation.args))).toBe(true) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("replaces an existing token symlink without writing the secret to the symlink target", () => withTempDir((root) => withPatchedEnv( - { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -266,7 +319,12 @@ describe("authClaudeLogin", () => { it.effect("fails when setup-token completes without a captured OAuth token", () => withTempDir((root) => withPatchedEnv( - { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, Effect.gen(function*(_) { yield* _(runLoginWithoutCapturedToken(root)) }) From 8abc88d22cffa7857e357f1878e24589545a73b8 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 16:29:11 +0000 Subject: [PATCH 15/19] fix(shell): address coderabbit oauth review --- .github/actions/setup/action.yml | 19 ++++++++++++++++++- packages/lib/src/shell/claude-oauth-env.ts | 17 ----------------- .../lib/src/usecases/auth-claude-oauth.ts | 10 ++-------- packages/lib/src/usecases/auth-claude.ts | 2 -- .../tests/usecases/auth-claude-login.test.ts | 9 +++++---- 5 files changed, 25 insertions(+), 32 deletions(-) delete mode 100644 packages/lib/src/shell/claude-oauth-env.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b9fe3133..fa70496d 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -58,8 +58,25 @@ runs: - name: Install dependencies shell: bash run: | + run_bun_install() { + local timeout_seconds=$((20 * 60)) + bun install --frozen-lockfile & + local install_pid="$!" + ( + sleep "$timeout_seconds" + echo "bun install exceeded 20 minutes; terminating" >&2 + kill "$install_pid" 2>/dev/null || true + ) & + local timeout_pid="$!" + local status=0 + wait "$install_pid" || status="$?" + kill "$timeout_pid" 2>/dev/null || true + wait "$timeout_pid" 2>/dev/null || true + return "$status" + } + for attempt in 1 2 3; do - if bun install --frozen-lockfile; then + if run_bun_install; then exit 0 fi if [[ "$attempt" == "3" ]]; then diff --git a/packages/lib/src/shell/claude-oauth-env.ts b/packages/lib/src/shell/claude-oauth-env.ts deleted file mode 100644 index bd9eeada..00000000 --- a/packages/lib/src/shell/claude-oauth-env.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - dockerGitClaudeOauthTokenEnvKey, - readClaudeOauthTokenFromEnv -} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" - -// CHANGE: read the Docker Git Claude OAuth token only at the shell boundary -// WHY: usecases and shared runners should receive decoded boundary values explicitly -// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." -// REF: PR-440-CodeRabbit-env-boundary -// SOURCE: n/a -// FORMAT THEOREM: forall env: token(env) = Some(t) -> process_token() = Some(t) -// PURITY: SHELL -// EFFECT: reads process.env -// INVARIANT: only a normalized non-empty DOCKER_GIT_CLAUDE_OAUTH_TOKEN crosses into the login flow -// COMPLEXITY: O(1) -export const readDockerGitClaudeOauthTokenFromProcessEnv = (): string | null => - readClaudeOauthTokenFromEnv(process.env, [dockerGitClaudeOauthTokenEnvKey]) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 2a9cd6b4..6bc0e70b 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -203,16 +203,11 @@ export const runClaudeOauthLoginWithPrompt = ( cwd: string, accountPath: string, options: { - readonly envToken: string | null readonly image: string readonly containerPath: string } -): Effect.Effect => { - if (options.envToken !== null) { - return Effect.succeed(options.envToken) - } - - return Effect.scoped( +): Effect.Effect => + Effect.scoped( Effect.gen(function*(_) { const executor = yield* _(CommandExecutor.CommandExecutor) const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) @@ -220,4 +215,3 @@ export const runClaudeOauthLoginWithPrompt = ( return yield* _(resolveClaudeDockerOauthTokenResult(result)) }) ) -} diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 0bfbe434..b4fdffb6 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -12,7 +12,6 @@ import { Effect } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" import { defaultTemplateConfig } from "../core/domain.js" -import { readDockerGitClaudeOauthTokenFromProcessEnv } from "../shell/claude-oauth-env.js" import { runDockerAuth, runDockerAuthExitCode } from "../shell/docker-auth.js" import type { AuthError } from "../shell/errors.js" import { CommandFailedError } from "../shell/errors.js" @@ -284,7 +283,6 @@ export const authClaudeLogin = ( runClaudeLoginFlow({ accountLabel, captureToken: runClaudeOauthLoginWithPrompt(cwd, accountPath, { - envToken: readDockerGitClaudeOauthTokenFromProcessEnv(), image: claudeImageName, containerPath: claudeContainerHomeDir }), diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index 92c7af1a..0db6a56f 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -227,14 +227,14 @@ describe("authClaudeLogin", () => { ) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("uses a decoded docker-git OAuth env token without running setup-token", () => + it.effect("ignores docker-git OAuth env token and captures setup-token output", () => withTempDir((root) => withPatchedEnv( { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined, - [dockerGitClaudeOauthTokenEnvKey]: ` ${oauthToken} ` + [dockerGitClaudeOauthTokenEnvKey]: "ENV_CLAUDE_OAUTH_TOKEN_SHOULD_NOT_WIN" }, Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) @@ -248,13 +248,14 @@ describe("authClaudeLogin", () => { label: null, claudeAuthPath }).pipe( - Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(null, 0, invocations)) + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, 0, invocations)) ) ) const tokenText = yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) expect(tokenText.trim()).toBe(oauthToken) - expect(invocations.some((invocation) => isSetupToken(invocation.args))).toBe(false) + expect(tokenText.trim()).not.toBe("ENV_CLAUDE_OAUTH_TOKEN_SHOULD_NOT_WIN") + expect(invocations.some((invocation) => isSetupToken(invocation.args))).toBe(true) expect(invocations.some((invocation) => isPingProbe(invocation.args))).toBe(true) }) ) From aedf7d3c6a803e80f588dd90a7e835cfc61e3773 Mon Sep 17 00:00:00 2001 From: konard Date: Mon, 29 Jun 2026 16:55:12 +0000 Subject: [PATCH 16/19] fix(test): isolate claude auth e2e token injection --- scripts/e2e/auth-claude-login.sh | 54 ++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/auth-claude-login.sh b/scripts/e2e/auth-claude-login.sh index 985225ab..4334f445 100755 --- a/scripts/e2e/auth-claude-login.sh +++ b/scripts/e2e/auth-claude-login.sh @@ -12,8 +12,10 @@ ROOT="$(mktemp -d "$ROOT_BASE/auth-claude-login.XXXXXX")" chmod 0700 "$ROOT" KEEP="${KEEP:-0}" COMPOSE_OVERRIDE_FILE="$ROOT/docker-compose.auth-claude-login.yml" +DOCKER_WRAPPER_DIR="$ROOT/docker-wrapper" +DOCKER_WRAPPER_FILE="$DOCKER_WRAPPER_DIR/docker" LOGIN_TIMEOUT_SECONDS="${DOCKER_GIT_E2E_AUTH_CLAUDE_LOGIN_TIMEOUT_SECONDS:-900}" -OAUTH_TOKEN_MARKER="docker-git-e2e-oauth-token-marker" +OAUTH_TOKEN_MARKER="sk-ant-oat01-docker-git-e2e-oauth-token-marker" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_STATE_AUTO_SYNC=0 @@ -22,11 +24,59 @@ export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-auth-claude-$RUN_ID-proje export DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE="$COMPOSE_OVERRIDE_FILE" export COMPOSE_PROJECT_NAME="docker-git" +mkdir -p "$DOCKER_WRAPPER_DIR" +cat > "$DOCKER_WRAPPER_FILE" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail + +REAL_DOCKER="/usr/bin/docker" +CLAUDE_AUTH_IMAGE="docker-git-auth-claude:latest" +args=("$@") +image_index=-1 + +for index in "${!args[@]}"; do + if [[ "${args[$index]}" == "$CLAUDE_AUTH_IMAGE" ]]; then + image_index="$index" + fi +done + +if [[ "$image_index" -ge 0 ]]; then + first_command_arg="${args[$((image_index + 1))]:-}" + second_command_arg="${args[$((image_index + 2))]:-}" + + if [[ "$first_command_arg" == "setup-token" ]]; then + : "${DOCKER_GIT_E2E_CLAUDE_SETUP_TOKEN_MARKER:?missing synthetic Claude OAuth token marker}" + cat <&2 + exit 7 + fi +fi + +exec "$REAL_DOCKER" "$@" +BASH +chmod 0755 "$DOCKER_WRAPPER_FILE" + cat > "$COMPOSE_OVERRIDE_FILE" < Date: Wed, 1 Jul 2026 18:38:50 +0000 Subject: [PATCH 17/19] fix(auth): isolate Claude probes and state sync locks --- .../auth-oauth/src/claude-docker-oauth.ts | 21 +- .../tests/claude-docker-oauth.test.ts | 9 + packages/lib/src/usecases/auth-claude.ts | 40 +++- packages/lib/src/usecases/state-repo.ts | 58 ++++-- packages/lib/src/usecases/state-repo/lock.ts | 76 +++++++- .../tests/usecases/auth-claude-login.test.ts | 100 +++++++++- .../usecases/state-repo-auto-sync.test.ts | 179 ++++++++++++++++++ 7 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 packages/lib/tests/usecases/state-repo-auto-sync.test.ts diff --git a/packages/auth-oauth/src/claude-docker-oauth.ts b/packages/auth-oauth/src/claude-docker-oauth.ts index 5d948482..bd655868 100644 --- a/packages/auth-oauth/src/claude-docker-oauth.ts +++ b/packages/auth-oauth/src/claude-docker-oauth.ts @@ -18,6 +18,7 @@ import { export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest" export const defaultClaudeDockerOauthContainerHome = "/claude-home" +const claudeDockerOauthProbeConfigDir = "/tmp/docker-git-claude-probe" export const claudeDockerOauthBaseImage = "node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc" export const claudeDockerOauthClaudeCodeVersion = "2.1.195" @@ -186,10 +187,20 @@ const buildDockerSetupTokenArgs = ( return args } +// CHANGE: probe captured OAuth tokens without reading persisted account settings +// WHY: account settings may enable bypassPermissions for real sessions, but Claude rejects that mode in privileged probe contexts +// QUOTE(ТЗ): "почему не работает команда bun run docker-git auth claude login" +// REF: user-report-2026-07-01-claude-auth-login +// SOURCE: n/a +// FORMAT THEOREM: forall token: probe(token) uses env(token) and not account(settings.json) +// PURITY: CORE +// INVARIANT: setup-token validation cannot fail because of account permission settings +// COMPLEXITY: O(1) const buildDockerProbeArgs = ( image: string, hostPath: string, - containerPath: string + containerPath: string, + oauthToken: string ): ReadonlyArray => { const args: Array = [ "run", @@ -204,9 +215,11 @@ const buildDockerProbeArgs = ( } args.push( "-e", - `CLAUDE_CONFIG_DIR=${containerPath}`, + `CLAUDE_CONFIG_DIR=${claudeDockerOauthProbeConfigDir}`, "-e", - `HOME=${containerPath}`, + `HOME=${claudeDockerOauthProbeConfigDir}`, + "-e", + `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`, image, "-p", "ping" @@ -341,7 +354,7 @@ export const runClaudeDockerOauth = async ( await writeCapturedToken(accountPath, result.token) const probeExitCode = await (options.runProbe ?? runDockerProbe)({ dockerCommand, - args: buildDockerProbeArgs(image, dockerHostPath, containerPath), + args: buildDockerProbeArgs(image, dockerHostPath, containerPath, result.token), cwd }) return { diff --git a/packages/auth-oauth/tests/claude-docker-oauth.test.ts b/packages/auth-oauth/tests/claude-docker-oauth.test.ts index b211f6b3..91e53bd1 100644 --- a/packages/auth-oauth/tests/claude-docker-oauth.test.ts +++ b/packages/auth-oauth/tests/claude-docker-oauth.test.ts @@ -40,6 +40,8 @@ const oauthTokenArbitrary = fc.array(fc.constantFrom( minLength: 24, maxLength: 64 }).map((chars) => `${oauthTokenPrefix}${chars.join("")}`) +const dockerEnvEntries = (args: ReadonlyArray): ReadonlyArray => + args.flatMap((arg, index) => args[index - 1] === "-e" ? [arg] : []) const temporaryAccountPath = (prefix: string) => Effect.acquireRelease( @@ -98,6 +100,13 @@ describe("Claude Docker OAuth runner", () => { expect(setupRuns[0]?.args.join(" ")).toContain(accountPath) expect(probeRuns).toHaveLength(1) expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) + expect(dockerEnvEntries(probeRuns[0]?.args ?? [])).toEqual( + expect.arrayContaining([ + "CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe", + "HOME=/tmp/docker-git-claude-probe", + `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}` + ]) + ) const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath)))) expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode) }))) diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index b4fdffb6..ecf99123 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -8,7 +8,7 @@ import { formatClaudeOauthTokenFile } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { renderClaudeDockerOauthDockerfile } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" -import { Effect } from "effect" +import { Effect, Match } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" import { defaultTemplateConfig } from "../core/domain.js" @@ -27,6 +27,9 @@ import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volati type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" +type ClaudeProbeAuth = + | { readonly _tag: "ClaudeProbeAccountConfig" } + | { readonly _tag: "ClaudeProbeOauthToken"; readonly token: string } type ClaudeAccountContext = { readonly accountLabel: string @@ -41,6 +44,7 @@ export const claudeAuthRoot = ".docker-git/.orch/auth/claude" const claudeImageName = "docker-git-auth-claude:latest" const claudeImageDir = ".docker-git/.orch/auth/claude/.image" const claudeContainerHomeDir = "/claude-home" +const claudeProbeConfigDir = "/tmp/docker-git-claude-probe" const claudeConfigFileName = ".claude.json" const claudeCredentialsFileName = ".credentials.json" const claudeCredentialsDirName = ".claude" @@ -178,6 +182,26 @@ const buildClaudeAuthEnv = ( ...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]) ] +// CHANGE: isolate non-interactive Claude OAuth probes from account settings +// WHY: account settings may intentionally use bypassPermissions for real sessions, but Claude rejects that mode under root/sudo probe contexts +// QUOTE(ТЗ): "почему не работает команда bun run docker-git auth claude login" +// REF: user-report-2026-07-01-claude-auth-login +// SOURCE: n/a +// FORMAT THEOREM: forall token: probe(token) reads token env and not account(settings.json) +// PURITY: CORE +// INVARIANT: probe uses the persisted OAuth token without inheriting account permission settings +// COMPLEXITY: O(1) +const buildClaudeProbeEnv = (auth: ClaudeProbeAuth): ReadonlyArray => + Match.value(auth).pipe( + Match.when({ _tag: "ClaudeProbeAccountConfig" }, () => buildClaudeAuthEnv(false)), + Match.when({ _tag: "ClaudeProbeOauthToken" }, ({ token }) => [ + `HOME=${claudeProbeConfigDir}`, + `CLAUDE_CONFIG_DIR=${claudeProbeConfigDir}`, + `CLAUDE_CODE_OAUTH_TOKEN=${token}` + ]), + Match.exhaustive + ) + const ensureClaudeOrchLayout = ( cwd: string ): Effect.Effect => @@ -252,7 +276,7 @@ const runClaudeLogout = ( const runClaudePingProbeExitCode = ( cwd: string, accountPath: string, - oauthToken: string | null + auth: ClaudeProbeAuth ): Effect.Effect => runDockerAuthExitCode( buildDockerAuthSpec({ @@ -260,7 +284,7 @@ const runClaudePingProbeExitCode = ( image: claudeImageName, hostPath: accountPath, containerPath: claudeContainerHomeDir, - env: buildClaudeAuthEnv(false, oauthToken), + env: buildClaudeProbeEnv(auth), args: ["-p", "ping"], interactive: false }) @@ -288,7 +312,10 @@ export const authClaudeLogin = ( }), persistToken: (token) => persistClaudeOauthToken(fs, path, accountPath, token), normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid), - probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, token), + probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, { + _tag: "ClaudeProbeOauthToken", + token + }), syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`) }).pipe(Effect.asVoid)) @@ -314,7 +341,10 @@ export const authClaudeStatus = ( } const oauthToken = method === "oauth-token" ? yield* _(readOauthToken(fs, accountPath)) : null - const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, oauthToken)) + const probeAuth: ClaudeProbeAuth = method === "oauth-token" && oauthToken !== null + ? { _tag: "ClaudeProbeOauthToken", token: oauthToken } + : { _tag: "ClaudeProbeAccountConfig" } + const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, probeAuth)) if (probeExitCode === 0) { yield* _(Effect.log(`Claude connected (${accountLabel}, ${method}).`)) return diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index e8f02f68..ed687296 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -42,7 +42,24 @@ import { withStateGitLock } from "./state-repo/lock.js" type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) +const resolveGitIndexLockPath = (path: Path.Path, root: string): string => path.join(root, ".git", "index.lock") const managedRepositoryCachePaths: ReadonlyArray = [".cache/git-mirrors", ".cache/packages"] + +const renderStateSyncFailure = (error: CommandFailedError | PlatformError): string => + error._tag === "CommandFailedError" + ? `${error.command} (exit ${error.exitCode})` + : String(error) + +const logStateAutoSyncFailure = ( + error: CommandFailedError | PlatformError +): Effect.Effect => + Effect.logWarning(`State auto-sync failed: ${renderStateSyncFailure(error)}`) + +const logStateAutoPullFailure = ( + error: CommandFailedError | PlatformError +): Effect.Effect => + Effect.logWarning(`State auto-pull failed: ${renderStateSyncFailure(error)}`) + const ensureStateIgnoreAndUntrackCaches = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -107,6 +124,7 @@ export const stateSync = (message: string | null) => withStateGitLock(stateSyncR const autoSyncStateRaw = (message: string): Effect.Effect => Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) const isRepoOk = yield* _(isGitRepo(root)) @@ -120,6 +138,18 @@ const autoSyncStateRaw = (message: string): Effect.Effect 0 ? isTruthyEnv(strictValue) : false + if (!isStrict) { + const indexLockPath = resolveGitIndexLockPath(path, root) + const hasIndexLock = yield* _(fs.exists(indexLockPath)) + if (hasIndexLock) { + yield* _( + Effect.logWarning( + `State auto-sync skipped: git index lock exists at ${indexLockPath}. Another git process may be running; retry later.` + ) + ) + return + } + } const effect = stateSyncRaw(message) if (isStrict) { yield* _(effect) @@ -128,14 +158,7 @@ const autoSyncStateRaw = (message: string): Effect.Effect - Effect.logWarning( - `State auto-sync failed: ${ - error._tag === "CommandFailedError" - ? `${error.command} (exit ${error.exitCode})` - : String(error) - }` - ), + onFailure: logStateAutoSyncFailure, onSuccess: () => Effect.void }) ) @@ -184,15 +207,28 @@ const autoPullStateRaw: Effect.Effect = Effect.gen(fu ) }).pipe( Effect.matchEffect({ - onFailure: (error) => Effect.logWarning(`State auto-pull failed: ${String(error)}`), + onFailure: logStateAutoPullFailure, onSuccess: () => Effect.void }), Effect.asVoid ) -export const autoSyncState = (message: string) => withStateGitLock(autoSyncStateRaw(message)) +export const autoSyncState = (message: string): Effect.Effect => + withStateGitLock(autoSyncStateRaw(message)).pipe( + Effect.matchEffect({ + onFailure: logStateAutoSyncFailure, + onSuccess: () => Effect.void + }), + Effect.asVoid + ) -export const autoPullState: Effect.Effect = withStateGitLock(autoPullStateRaw) +export const autoPullState: Effect.Effect = withStateGitLock(autoPullStateRaw).pipe( + Effect.matchEffect({ + onFailure: logStateAutoPullFailure, + onSuccess: () => Effect.void + }), + Effect.asVoid +) // Internal pull that takes an already-resolved root, reusing auth logic from pull-push. const statePullInternal = ( diff --git a/packages/lib/src/usecases/state-repo/lock.ts b/packages/lib/src/usecases/state-repo/lock.ts index 6db4e048..3cd39447 100644 --- a/packages/lib/src/usecases/state-repo/lock.ts +++ b/packages/lib/src/usecases/state-repo/lock.ts @@ -1,6 +1,49 @@ -import { Effect } from "effect" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Duration, Effect } from "effect" + +import { CommandFailedError } from "../../shell/errors.js" +import { defaultProjectsRoot } from "../menu-helpers.js" const stateGitLock = Effect.unsafeMakeSemaphore(1) +const stateGitLockRetryDelay = Duration.millis(100) +const stateGitLockMaxAttempts = 50 +const stateGitLockBusyExitCode = 75 + +const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) +const resolveStateLockPath = (root: string): string => `${root}.lock` + +const isStateFileLockBusy = (error: Extract): boolean => + error.reason === "AlreadyExists" || error.reason === "Busy" + +const acquireStateFileLock = ( + fs: FileSystem.FileSystem, + lockPath: string, + attempt: number = 0 +): Effect.Effect => + fs.makeDirectory(lockPath).pipe( + Effect.as(lockPath), + Effect.catchTag("SystemError", (error) => { + if (!isStateFileLockBusy(error)) { + return Effect.fail(error) + } + if (attempt >= stateGitLockMaxAttempts) { + return Effect.fail(new CommandFailedError({ command: "state git lock", exitCode: stateGitLockBusyExitCode })) + } + return Effect.sleep(stateGitLockRetryDelay).pipe( + Effect.zipRight(acquireStateFileLock(fs, lockPath, attempt + 1)) + ) + }) + ) + +const releaseStateFileLock = ( + fs: FileSystem.FileSystem, + lockPath: string +): Effect.Effect => + fs.remove(lockPath, { recursive: true, force: true }).pipe( + Effect.orElseSucceed(() => void 0) + ) /** * Serializes git operations against the shared `.docker-git` working tree. @@ -16,16 +59,29 @@ const stateGitLock = Effect.unsafeMakeSemaphore(1) * @complexity O(effect) * @throws Never - failures remain in the Effect error channel. */ -// CHANGE: serialize state repository git effects -// WHY: inventory auto-pull and create auto-sync can otherwise race on one git working tree -// QUOTE(ТЗ): "project not synchronized" -// REF: issue-372 -// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/372 -// FORMAT THEOREM: forall a,b in StateGitOps: overlap(guard(a), guard(b)) = false +// CHANGE: serialize state repository git effects across process and fiber boundaries +// WHY: auth auto-sync can run from separate docker-git processes and otherwise race on one git index +// QUOTE(ТЗ): "fatal: Unable to create '/home/dev/.docker-git/.git/index.lock': File exists." +// REF: user-report-2026-07-01-claude-auth-login +// SOURCE: n/a +// FORMAT THEOREM: forall a,b in StateGitOps: overlap(file_guard(a), file_guard(b)) = false // PURITY: SHELL -// EFFECT: Effect -// INVARIANT: a single process-local permit protects the shared state repo +// EFFECT: Effect +// INVARIANT: a single process-local permit and a state-root lock directory protect the shared state repo // COMPLEXITY: O(effect) export const withStateGitLock = ( effect: Effect.Effect -): Effect.Effect => effect.pipe(stateGitLock.withPermits(1)) +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const root = resolveStateRoot(path, process.cwd()) + const lockPath = resolveStateLockPath(root) + return yield* _( + Effect.acquireUseRelease( + acquireStateFileLock(fs, lockPath), + () => effect, + (acquiredLockPath) => releaseStateFileLock(fs, acquiredLockPath) + ) + ) + }).pipe(stateGitLock.withPermits(1)) diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index 0db6a56f..a6512f04 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -13,7 +13,7 @@ import * as Inspectable from "effect/Inspectable" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" -import { authClaudeLogin } from "../../src/usecases/auth-claude.js" +import { authClaudeLogin, authClaudeStatus } from "../../src/usecases/auth-claude.js" const encode = (value: string): Uint8Array => new TextEncoder().encode(value) @@ -43,6 +43,8 @@ const setupTokenOutputWithoutToken = (): string => const isSetupToken = (args: ReadonlyArray): boolean => args.includes("setup-token") const isPingProbe = (args: ReadonlyArray): boolean => args.includes("-p") && args.includes("ping") +const dockerEnvEntries = (args: ReadonlyArray): ReadonlyArray => + args.flatMap((arg, index) => args[index - 1] === "-e" ? [arg] : []) // CHANGE: fake docker executor that captures a setup-token and lets the ping probe fail // WHY: reproduce issue-439 where a successful OAuth login was discarded by a failing probe @@ -261,6 +263,102 @@ describe("authClaudeLogin", () => { ) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("runs the OAuth probe with a clean config dir instead of account permission settings", () => + withTempDir((root) => + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [] + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + const accountPath = path.join(claudeAuthPath, "default") + + yield* _(fs.makeDirectory(accountPath, { recursive: true })) + yield* _( + fs.writeFileString( + path.join(accountPath, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }, null, 2) + ) + ) + + yield* _( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, 0, invocations)) + ) + ) + + const pingInvocation = invocations.find((invocation) => + isPingProbe(invocation.args) && + dockerEnvEntries(invocation.args).includes(`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`) + ) + expect(pingInvocation).toBeDefined() + if (pingInvocation === undefined) { + return + } + + const envEntries = dockerEnvEntries(pingInvocation.args) + expect(envEntries).toContain(`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`) + expect(envEntries).toContain("HOME=/tmp/docker-git-claude-probe") + expect(envEntries).toContain("CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe") + expect(envEntries).not.toContain("HOME=/claude-home") + expect(envEntries).not.toContain("CLAUDE_CONFIG_DIR=/claude-home") + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("keeps Claude AI session status probes on the mounted account config", () => + withTempDir((root) => + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0", + DOCKER_GIT_PROJECTS_ROOT: undefined, + [dockerGitClaudeOauthTokenEnvKey]: undefined + }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const invocations: Array<{ readonly command: string; readonly args: ReadonlyArray }> = [] + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") + const accountPath = path.join(claudeAuthPath, "default") + + yield* _(fs.makeDirectory(accountPath, { recursive: true })) + yield* _(fs.writeFileString(path.join(accountPath, ".credentials.json"), "{\"session\":\"ok\"}\n")) + + yield* _( + authClaudeStatus({ + _tag: "AuthClaudeStatus", + label: null, + claudeAuthPath + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, 0, invocations)) + ) + ) + + const pingInvocation = invocations.find((invocation) => isPingProbe(invocation.args)) + expect(pingInvocation).toBeDefined() + if (pingInvocation === undefined) { + return + } + + const envEntries = dockerEnvEntries(pingInvocation.args) + expect(envEntries).toContain("HOME=/claude-home") + expect(envEntries).toContain("CLAUDE_CONFIG_DIR=/claude-home") + expect(envEntries.some((entry) => entry.startsWith("CLAUDE_CODE_OAUTH_TOKEN="))).toBe(false) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("replaces an existing token symlink without writing the secret to the symlink target", () => withTempDir((root) => withPatchedEnv( diff --git a/packages/lib/tests/usecases/state-repo-auto-sync.test.ts b/packages/lib/tests/usecases/state-repo-auto-sync.test.ts new file mode 100644 index 00000000..6cfa0a81 --- /dev/null +++ b/packages/lib/tests/usecases/state-repo-auto-sync.test.ts @@ -0,0 +1,179 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +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, Logger } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import { autoSyncState } from "../../src/usecases/state-repo.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +type DecideExitCode = (command: RecordedCommand) => number + +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-state-auto-sync-" })) + 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 makeFakeExecutor = ( + recorded: Array, + decideExitCode: DecideExitCode = () => 0 +): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.sync(() => { + const flattened = Command.flatten(command) + const invocation = flattened[flattened.length - 1]! + const recordedCommand: RecordedCommand = { command: invocation.command, args: invocation.args } + recorded.push(recordedCommand) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(decideExitCode(recordedCommand))), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout: Stream.empty, + toJSON: () => ({ _tag: "StateAutoSyncTestProcess", command: invocation.command, args: invocation.args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "StateAutoSyncTestProcess", + command: invocation.command, + args: invocation.args + }), + toString: () => `[StateAutoSyncTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const mutatingSyncGitCommands: ReadonlySet = new Set(["add", "rm", "commit", "fetch", "push", "reset", "stash"]) + +const isMutatingSyncGitCommand = (command: RecordedCommand): boolean => + command.command === "git" && mutatingSyncGitCommands.has(command.args[0] ?? "") + +describe("state repo auto sync", () => { + it.effect("skips non-strict auto-sync before git add when the git index is locked", () => + withTempDir((home) => + withPatchedEnv( + { + HOME: home, + DOCKER_GIT_PROJECTS_ROOT: undefined, + DOCKER_GIT_STATE_AUTO_SYNC: "1", + DOCKER_GIT_STATE_AUTO_SYNC_STRICT: undefined + }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const root = path.join(home, ".docker-git") + const indexLockPath = path.join(root, ".git", "index.lock") + const recorded: Array = [] + const logs: Array = [] + const logger = Logger.make(({ message }) => { + logs.push(String(message)) + }) + + yield* _(fs.makeDirectory(path.join(root, ".git"), { recursive: true })) + yield* _(fs.writeFileString(indexLockPath, "locked\n")) + + yield* _( + autoSyncState("chore(state): test").pipe( + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(recorded)), + Effect.provide(Logger.replace(Logger.defaultLogger, logger)) + ) + ) + + expect(recorded.filter(isMutatingSyncGitCommand)).toEqual([]) + expect(logs.some((message) => message.includes("State auto-sync skipped: git index lock exists"))).toBe(true) + expect(yield* _(fs.exists(`${root}.lock`))).toBe(false) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("releases the state lock when non-strict auto-sync logs a git failure", () => + withTempDir((home) => + withPatchedEnv( + { + HOME: home, + DOCKER_GIT_PROJECTS_ROOT: undefined, + DOCKER_GIT_STATE_AUTO_SYNC: "1", + DOCKER_GIT_STATE_AUTO_SYNC_STRICT: undefined + }, + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const root = path.join(home, ".docker-git") + const recorded: Array = [] + const logs: Array = [] + const logger = Logger.make(({ message }) => { + logs.push(String(message)) + }) + + yield* _(fs.makeDirectory(path.join(root, ".git"), { recursive: true })) + + yield* _( + autoSyncState("chore(state): test").pipe( + Effect.provideService( + CommandExecutor.CommandExecutor, + makeFakeExecutor(recorded, (command) => + command.command === "git" && command.args[0] === "rm" ? 23 : 0 + ) + ), + Effect.provide(Logger.replace(Logger.defaultLogger, logger)) + ) + ) + + expect(recorded.some((command) => command.command === "git" && command.args[0] === "rm")).toBe(true) + expect(logs.some((message) => message.includes("State auto-sync failed: git rm (exit 23)"))).toBe(true) + expect(yield* _(fs.exists(`${root}.lock`))).toBe(false) + }) + ) + ).pipe(Effect.provide(NodeContext.layer))) +}) From 0d8a4634b08fbb68c9d0f7f691b3080fdcfbdc5c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:37:20 +0000 Subject: [PATCH 18/19] fix(auth): route Claude status through controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof of fix:\n- Причина: AuthClaudeStatus парсился, но host API mode считал его unsupported и не имел /auth/claude/status endpoint.\n- Решение: добавлен controller/API status endpoint, app routing/client dispatch и OpenAPI контракт; статус читает controller state без запуска Claude CLI и без утечки секретов.\n- Доказательство: bun run --cwd packages/api test -- auth.test.ts openapi.test.ts; bun run --cwd packages/app test -- program-auth.test.ts parser-auth.test.ts; typecheck packages api/app/openapi. --- packages/api/src/api/contracts.ts | 8 + packages/api/src/api/openapi.ts | 18 ++ packages/api/src/http.ts | 10 + packages/api/src/services/auth.ts | 82 +++++ packages/api/tests/auth.test.ts | 103 +++++++ packages/api/tests/openapi.test.ts | 3 +- .../app/src/docker-git/api-client-auth.ts | 6 + packages/app/src/docker-git/api-client.ts | 1 + packages/app/src/docker-git/program-auth.ts | 8 + .../app/src/docker-git/program-unsupported.ts | 5 - .../app/tests/docker-git/parser-auth.test.ts | 11 + .../app/tests/docker-git/program-auth.test.ts | 15 + packages/openapi/openapi.json | 290 ++++++++++++++++++ packages/openapi/src/openapi-paths.ts | 138 +++++++++ 14 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 packages/app/tests/docker-git/program-auth.test.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..db05e89e 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -299,6 +299,14 @@ export type CodexAuthStatus = { readonly account: string | null } +export type ClaudeAuthStatus = { + readonly label: string + readonly message: string + readonly connected: boolean + readonly authPath: string + readonly method: "none" | "oauth-token" | "claude-ai-session" +} + export type GrokAuthStatus = { readonly label: string readonly message: string diff --git a/packages/api/src/api/openapi.ts b/packages/api/src/api/openapi.ts index 306eae30..17e5a480 100644 --- a/packages/api/src/api/openapi.ts +++ b/packages/api/src/api/openapi.ts @@ -251,6 +251,19 @@ export const CodexStatusResponseSchema = Schema.Struct({ status: CodexAuthStatusSchema }) +export const ClaudeAuthStatusSchema = Schema.Struct({ + authPath: Schema.String, + connected: Schema.Boolean, + label: Schema.String, + message: Schema.String, + method: Schema.Literal("none", "oauth-token", "claude-ai-session") +}) + +export const ClaudeStatusResponseSchema = Schema.Struct({ + ok: OptionalOkSchema, + status: ClaudeAuthStatusSchema +}) + export const GrokAuthStatusSchema = Schema.Struct({ authPath: Schema.String, connected: Schema.Boolean, @@ -601,6 +614,11 @@ const AuthGroup = HttpApiGroup.make("auth") .setUrlParams(QueryLabelSchema) .addSuccess(CodexStatusResponseSchema) ) + .add( + endpoint.get("claudeStatus", "/auth/claude/status") + .setUrlParams(QueryLabelSchema) + .addSuccess(ClaudeStatusResponseSchema) + ) .add( endpoint.post("githubLogin", "/auth/github/login") .setPayload(GithubAuthLoginRequestSchema) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index aeb9497a..73cb1dee 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -59,6 +59,7 @@ import { logoutGitAuth, logoutGitlabAuth, logoutGithubAuth, + readClaudeAuthStatus, readCodexAuthStatus, readGrokAuthStatus, readGitAuthStatus, @@ -1138,6 +1139,15 @@ export const makeRouter = () => { return yield* _(jsonResponse({ status }, 200)) }).pipe(Effect.catchAll(errorResponse)) ), + HttpRouter.get( + "/auth/claude/status", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const label = new URL(request.url, "http://localhost").searchParams.get("label") + const status = yield* _(readClaudeAuthStatus(label)) + return yield* _(jsonResponse({ status }, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), HttpRouter.get( "/auth/menu", Effect.gen(function*(_) { diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts index 593e4a15..277518a8 100644 --- a/packages/api/src/services/auth.ts +++ b/packages/api/src/services/auth.ts @@ -37,6 +37,7 @@ import type { CodexAuthLoginRequest, CodexAuthLogoutRequest, CodexAuthStatus, + ClaudeAuthStatus, GrokAuthLogoutRequest, GrokAuthStatus, GitAuthLoginRequest, @@ -64,6 +65,7 @@ export const gitlabAuthRequiredMessage = [ "If the repository requires access, run: docker-git auth gitlab login" ].join("\n") export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath +export const claudeAuthPath = ".docker-git/.orch/auth/claude" export const codexAuthPath = defaultTemplateConfig.codexAuthPath export const grokAuthPath = defaultTemplateConfig.grokAuthPath @@ -80,6 +82,7 @@ type JsonRecord = Readonly> type CodexRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor type CodexCommandError = CommandFailedError | PlatformError type GrokCommandError = CommandFailedError | PlatformError +type ClaudeAuthMethod = ClaudeAuthStatus["method"] const labelFromKey = (key: string): string => key.startsWith(githubTokenPrefix) ? key.slice(githubTokenPrefix.length) : "default" @@ -517,6 +520,85 @@ const grokAuthStatus = ( method }) +const claudeAuthStatus = ( + label: string, + authPath: string, + method: ClaudeAuthMethod +): ClaudeAuthStatus => ({ + label, + message: method === "none" + ? `Claude not connected (${label}).` + : `Claude connected (${label}, ${method}).`, + connected: method !== "none", + authPath, + method +}) + +const resolveClaudeAccountPath = ( + path: Path.Path, + label: string | null | undefined +): { + readonly accountLabel: string + readonly accountPath: string +} => { + const rootPath = resolvePathFromCwd(path, process.cwd(), claudeAuthPath) + const accountLabel = normalizeAccountLabel(label ?? null, "default") + return { + accountLabel, + accountPath: path.join(rootPath, accountLabel) + } +} + +const readNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + fs.readFileString(filePath).pipe( + Effect.map((text) => text.trim().length > 0), + Effect.orElseSucceed(() => false) + ) + +const resolveClaudeAuthMethod = ( + fs: FileSystem.FileSystem, + path: Path.Path, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasOauthToken = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".oauth-token"))) + if (hasOauthToken) { + return "oauth-token" + } + + const hasRootCredentials = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".credentials.json"))) + if (hasRootCredentials) { + return "claude-ai-session" + } + + const hasNestedCredentials = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".claude", ".credentials.json"))) + return hasNestedCredentials ? "claude-ai-session" : "none" + }) + +// CHANGE: expose Claude auth status through the controller without running the Claude CLI +// WHY: host API mode must route `docker-git auth claude status`; status should inspect controller state like Grok/Codex +// QUOTE(ТЗ): "Можешь сделать что бы работал claude status?" +// REF: user-report-2026-07-01-claude-status-api-mode +// SOURCE: n/a +// FORMAT THEOREM: forall label: status(label) = connected iff persisted Claude credential exists +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: secrets are never returned; only method/label/path are exposed +// COMPLEXITY: O(1) +export const readClaudeAuthStatus = ( + label?: string | null | undefined +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const { accountLabel, accountPath } = resolveClaudeAccountPath(path, label) + const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath)) + return claudeAuthStatus(accountLabel, accountPath, method) + }) + export const readGrokAuthStatus = ( label?: string | null | undefined ): Effect.Effect => diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts index 12330b1b..5c87c05d 100644 --- a/packages/api/tests/auth.test.ts +++ b/packages/api/tests/auth.test.ts @@ -19,6 +19,7 @@ import { logoutCodexAuth, logoutGitAuth, logoutGrokAuth, + readClaudeAuthStatus, readCodexAuthStatus, readGitAuthStatus, readGrokAuthStatus, @@ -514,6 +515,108 @@ describe("api auth", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("reads labeled Claude OAuth status from controller state", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a") + + yield* _(fs.makeDirectory(accountDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(accountDir, ".oauth-token"), "sk-ant-oat01-test\n")) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readClaudeAuthStatus("team-a")) + ) + ) + + expect(status.connected).toBe(true) + expect(status.label).toBe("team-a") + expect(status.method).toBe("oauth-token") + expect(status.authPath).toBe(accountDir) + expect(status.message).toBe("Claude connected (team-a, oauth-token).") + expect(JSON.stringify(status)).not.toContain("sk-ant-oat01-test") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("reads labeled Claude root session credentials from controller state", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a") + + yield* _(fs.makeDirectory(accountDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(accountDir, ".credentials.json"), "{\"session\":\"ok\"}\n")) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readClaudeAuthStatus("team-a")) + ) + ) + + expect(status.connected).toBe(true) + expect(status.label).toBe("team-a") + expect(status.method).toBe("claude-ai-session") + expect(status.authPath).toBe(accountDir) + expect(status.message).toBe("Claude connected (team-a, claude-ai-session).") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("reads labeled Claude nested session credentials from controller state", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a") + const nestedDir = path.join(accountDir, ".claude") + + yield* _(fs.makeDirectory(nestedDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(nestedDir, ".credentials.json"), "{\"session\":\"ok\"}\n")) + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readClaudeAuthStatus("team-a")) + ) + ) + + expect(status.connected).toBe(true) + expect(status.label).toBe("team-a") + expect(status.method).toBe("claude-ai-session") + expect(status.authPath).toBe(accountDir) + expect(status.message).toBe("Claude connected (team-a, claude-ai-session).") + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("reports missing default Claude auth from controller state", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "default") + + const status = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory(root, readClaudeAuthStatus(null)) + ) + ) + + expect(status.connected).toBe(false) + expect(status.label).toBe("default") + expect(status.method).toBe("none") + expect(status.authPath).toBe(accountDir) + expect(status.message).toBe("Claude not connected (default).") + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("removes labeled Grok auth from controller state", () => withTempDir((root) => Effect.gen(function*(_) { diff --git a/packages/api/tests/openapi.test.ts b/packages/api/tests/openapi.test.ts index dad37446..1b9e96d5 100644 --- a/packages/api/tests/openapi.test.ts +++ b/packages/api/tests/openapi.test.ts @@ -32,11 +32,12 @@ describe("openapi contract", () => { expect(paths["/auth/git/status"]).toBeDefined() expect(paths["/auth/gitlab/status"]).toBeDefined() expect(paths["/auth/codex/status"]).toBeDefined() + expect(paths["/auth/claude/status"]).toBeDefined() expect(paths["/auth/grok/status"]).toBeDefined() expect(paths["/auth/codex/login"]).toBeUndefined() expect(paths["/projects/{projectId}/auth/menu"]).toBeDefined() expect(paths["/projects/{projectId}/auth"]).toBeUndefined() - expect(Object.keys(paths)).toHaveLength(54) + expect(Object.keys(paths)).toHaveLength(55) })) it.effect("documents real HTTP success status codes for create and async endpoints", () => diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index ec371b80..b768cbca 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -22,6 +22,7 @@ import type { AuthCodexLoginCommand, AuthCodexLogoutCommand, AuthCodexStatusCommand, + AuthClaudeStatusCommand, AuthGithubLoginCommand, AuthGithubLogoutCommand, AuthGithubStatusCommand, @@ -203,6 +204,11 @@ export const codexStatus = (command: AuthCodexStatusCommand) => { return request("GET", `/auth/codex/status${query}`) } +export const claudeStatus = (command: AuthClaudeStatusCommand) => { + const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}` + return request("GET", `/auth/claude/status${query}`) +} + export const grokStatus = (command: AuthGrokStatusCommand) => { const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}` return request("GET", `/auth/grok/status${query}`) diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 785969b0..c023b5e1 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -28,6 +28,7 @@ export { codexLogin, codexLogout, codexStatus, + claudeStatus, githubLogin, githubLogout, githubStatus, diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 0dc64901..23f0aa6c 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -6,6 +6,7 @@ import { codexLogin, codexLogout, codexStatus, + claudeStatus, createAuthTerminalSession, githubLogin, githubLogout, @@ -44,6 +45,7 @@ export type RoutedAuthCommand = Extract< | "AuthGitStatus" | "AuthGitLogout" | "AuthClaudeLogin" + | "AuthClaudeStatus" | "AuthGeminiLogin" | "AuthGrokLogin" | "AuthGrokStatus" @@ -77,6 +79,7 @@ const routedAuthTags: Readonly> = { AuthGithubLogout: true, AuthGithubStatus: true, AuthClaudeLogin: true, + AuthClaudeStatus: true, AuthGeminiLogin: true, AuthGrokLogin: true, AuthGrokLogout: true, @@ -156,6 +159,10 @@ const handleClaudeLoginCommand = ( ) ) +const handleClaudeStatusCommand = ( + command: Extract +) => withControllerReady(pipe(claudeStatus(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) + const handleGeminiLoginCommand = ( command: Extract ) => @@ -214,6 +221,7 @@ export const dispatchRoutedAuthCommand = ( Match.when({ _tag: "AuthGitStatus" }, handleGitStatusCommand), Match.when({ _tag: "AuthGitLogout" }, handleGitLogoutCommand), Match.when({ _tag: "AuthClaudeLogin" }, handleClaudeLoginCommand), + Match.when({ _tag: "AuthClaudeStatus" }, handleClaudeStatusCommand), Match.when({ _tag: "AuthGeminiLogin" }, handleGeminiLoginCommand), Match.when({ _tag: "AuthGrokLogin" }, handleGrokLoginCommand), Match.when({ _tag: "AuthGrokStatus" }, handleGrokStatusCommand), diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index bb33082f..c250e58b 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -5,7 +5,6 @@ export type UnsupportedOperationalCommandTag = | "ScrapImport" | "McpPlaywrightUp" | "Apply" - | "AuthClaudeStatus" | "AuthClaudeLogout" | "AuthGeminiStatus" | "AuthGeminiLogout" @@ -26,10 +25,6 @@ export const unsupportedOperationalCommands: Record< command: "Apply", message: "Command Apply is not available in API-only host mode." }, - AuthClaudeStatus: { - command: "auth claude status", - message: "Claude status is not routed through the controller in host API mode." - }, AuthClaudeLogout: { command: "auth claude logout", message: "Claude logout is not routed through the controller in host API mode." diff --git a/packages/app/tests/docker-git/parser-auth.test.ts b/packages/app/tests/docker-git/parser-auth.test.ts index b501e2ef..82f5cd25 100644 --- a/packages/app/tests/docker-git/parser-auth.test.ts +++ b/packages/app/tests/docker-git/parser-auth.test.ts @@ -50,6 +50,17 @@ describe("parse auth commands", () => { expect(command.codexAuthPath).toBe(".docker-git/.orch/auth/codex") })) + it.effect("parses claude auth status into the controller-owned auth directory", () => + Effect.sync(() => { + const command = parseOrThrow(["auth", "claude", "status", "--label", "Team A"]) + expect(command._tag).toBe("AuthClaudeStatus") + if (command._tag !== "AuthClaudeStatus") { + throw new Error("expected AuthClaudeStatus command") + } + expect(command.label).toBe("Team A") + expect(command.claudeAuthPath).toBe(".docker-git/.orch/auth/claude") + })) + it.effect("parses gitlab token login", () => expectGitlabLoginCommand(["auth", "gitlab", "login", "--label", "Team A", "--token", "glpat-token"], (command) => { expect(command.label).toBe("Team A") diff --git a/packages/app/tests/docker-git/program-auth.test.ts b/packages/app/tests/docker-git/program-auth.test.ts new file mode 100644 index 00000000..21ca91eb --- /dev/null +++ b/packages/app/tests/docker-git/program-auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest" + +import { isRoutedAuthCommand } from "../../src/docker-git/program-auth.js" + +describe("program auth routing", () => { + it("routes Claude status through the controller in host API mode", () => { + expect( + isRoutedAuthCommand({ + _tag: "AuthClaudeStatus", + label: null, + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) + ).toBe(true) + }) +}) diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 1e797e52..301ada4d 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -10455,6 +10455,296 @@ } } }, + "/auth/claude/status": { + "get": { + "tags": [ + "auth" + ], + "operationId": "auth.claudeStatus", + "parameters": [ + { + "name": "label", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "object", + "required": [ + "authPath", + "connected", + "label", + "message", + "method" + ], + "properties": { + "authPath": { + "type": "string" + }, + "connected": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "none", + "oauth-token", + "claude-ai-session" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/HttpApiDecodeError" + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "401": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "404": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "409": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "500": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "message", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "details": { + "$id": "/schemas/unknown", + "title": "unknown" + }, + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + } + } + } + }, "/auth/github/login": { "post": { "tags": [ diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 21e1bdd6..7b0ba9ef 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -452,6 +452,22 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/claude/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["auth.claudeStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/github/login": { parameters: { query?: never; @@ -5144,6 +5160,128 @@ export interface operations { }; }; }; + "auth.claudeStatus": { + parameters: { + query?: { + label?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ok?: boolean; + status: { + authPath: string; + connected: boolean; + label: string; + message: string; + /** @enum {string} */ + method: "none" | "oauth-token" | "claude-ai-session"; + }; + }; + }; + }; + /** @description The request did not match the expected schema */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HttpApiDecodeError"] | { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + /** @description Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: { + command?: string; + /** unknown */ + details?: unknown; + message: string; + provider?: string; + type: string; + }; + }; + }; + }; + }; + }; "auth.githubLogin": { parameters: { query?: never; From ae12482baa5f5997c7fec6497c6e2367ed3b57bd Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:00:29 +0000 Subject: [PATCH 19/19] fix(auth): show Claude account in status Expose a nullable Claude account field in the controller status response and render it in the status message when safe identity metadata is available. Proof of fix: - Cause: Claude status reported only label/method, so a connected account could not be identified like Codex status. - Solution: read non-secret account metadata from persisted Claude config/credentials, keep token bytes out of the response, and update OpenAPI/contracts/tests. - Verification: bun run --cwd packages/api test -- auth.test.ts openapi.test.ts; bun run --cwd packages/app test -- program-auth.test.ts parser-auth.test.ts; bun run --cwd packages/lib lint. --- packages/api/src/api/contracts.ts | 1 + packages/api/src/api/openapi.ts | 1 + packages/api/src/services/auth.ts | 124 +++++++++++++- packages/api/tests/auth.test.ts | 23 ++- .../auth-oauth/src/claude-docker-oauth.ts | 2 +- .../tests/claude-docker-oauth.test.ts | 4 +- .../src/usecases/auth-claude-credentials.ts | 141 ++++++++++++++++ packages/lib/src/usecases/auth-claude.ts | 155 ++---------------- packages/lib/src/usecases/state-repo.ts | 128 +-------------- packages/lib/src/usecases/state-repo/init.ts | 128 +++++++++++++++ .../tests/usecases/auth-claude-login.test.ts | 4 +- packages/openapi/openapi.json | 11 ++ packages/openapi/src/openapi-paths.ts | 1 + 13 files changed, 448 insertions(+), 275 deletions(-) create mode 100644 packages/lib/src/usecases/auth-claude-credentials.ts create mode 100644 packages/lib/src/usecases/state-repo/init.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index db05e89e..a550af7e 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -304,6 +304,7 @@ export type ClaudeAuthStatus = { readonly message: string readonly connected: boolean readonly authPath: string + readonly account: string | null readonly method: "none" | "oauth-token" | "claude-ai-session" } diff --git a/packages/api/src/api/openapi.ts b/packages/api/src/api/openapi.ts index 17e5a480..a39639ac 100644 --- a/packages/api/src/api/openapi.ts +++ b/packages/api/src/api/openapi.ts @@ -252,6 +252,7 @@ export const CodexStatusResponseSchema = Schema.Struct({ }) export const ClaudeAuthStatusSchema = Schema.Struct({ + account: NullableStringSchema, authPath: Schema.String, connected: Schema.Boolean, label: Schema.String, diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts index 277518a8..5115f1e7 100644 --- a/packages/api/src/services/auth.ts +++ b/packages/api/src/services/auth.ts @@ -523,14 +523,18 @@ const grokAuthStatus = ( const claudeAuthStatus = ( label: string, authPath: string, - method: ClaudeAuthMethod + method: ClaudeAuthMethod, + account: string | null ): ClaudeAuthStatus => ({ label, message: method === "none" ? `Claude not connected (${label}).` - : `Claude connected (${label}, ${method}).`, + : account === null + ? `Claude connected (${label}, ${method}, account unavailable).` + : `Claude connected (${label}, ${method}, account: ${account}).`, connected: method !== "none", authPath, + account, method }) @@ -578,6 +582,119 @@ const resolveClaudeAuthMethod = ( return hasNestedCredentials ? "claude-ai-session" : "none" }) +const readJsonRecordFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + fs.readFileString(filePath).pipe( + Effect.flatMap((text) => + Effect.try({ + try: (): unknown => JSON.parse(text), + catch: () => null + }).pipe( + Effect.map((parsed) => isRecord(parsed) ? parsed : null), + Effect.catchAll(() => Effect.succeed(null)) + ) + ), + Effect.orElseSucceed(() => null) + ) + +const readFirstString = ( + record: JsonRecord, + keys: ReadonlyArray +): string | null => { + for (const key of keys) { + const value = readString(record, key) + if (value !== null) { + return value + } + } + return null +} + +const accountIdentityKeys: ReadonlyArray = [ + "emailAddress", + "email", + "preferred_username", + "displayName", + "name", + "accountUuid", + "account_id", + "accountId", + "userID", + "userId", + "organizationUuid" +] + +const accountContainerKeys: ReadonlyArray = [ + "oauthAccount", + "claudeAiOauth", + "account", + "user", + "profile" +] + +const jwtTokenKeys: ReadonlyArray = [ + "idToken", + "id_token", + "accessToken", + "access_token" +] + +const extractClaudeAccountFromRecord = (record: JsonRecord): string | null => { + const direct = readFirstString(record, accountIdentityKeys) + if (direct !== null) { + return direct + } + + const token = readFirstString(record, jwtTokenKeys) + const claims = decodeJwtClaims(token) + if (claims !== null) { + const claimAccount = readFirstString(claims, accountIdentityKeys) + if (claimAccount !== null) { + return claimAccount + } + } + + for (const key of accountContainerKeys) { + const nested = record[key] + if (isRecord(nested)) { + const account = extractClaudeAccountFromRecord(nested) + if (account !== null) { + return account + } + } + } + + return null +} + +const readClaudeAuthAccount = ( + fs: FileSystem.FileSystem, + path: Path.Path, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const candidates: ReadonlyArray = [ + path.join(accountPath, ".claude.json"), + path.join(accountPath, ".credentials.json"), + path.join(accountPath, ".claude", ".credentials.json") + ] + + for (const candidate of candidates) { + const record = yield* _(readJsonRecordFile(fs, candidate)) + if (record === null) { + continue + } + const account = extractClaudeAccountFromRecord(record) + if (account !== null) { + return account + } + } + + return null + }) + // CHANGE: expose Claude auth status through the controller without running the Claude CLI // WHY: host API mode must route `docker-git auth claude status`; status should inspect controller state like Grok/Codex // QUOTE(ТЗ): "Можешь сделать что бы работал claude status?" @@ -596,7 +713,8 @@ export const readClaudeAuthStatus = ( const path = yield* _(Path.Path) const { accountLabel, accountPath } = resolveClaudeAccountPath(path, label) const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath)) - return claudeAuthStatus(accountLabel, accountPath, method) + const account = method === "none" ? null : yield* _(readClaudeAuthAccount(fs, path, accountPath)) + return claudeAuthStatus(accountLabel, accountPath, method, account) }) export const readGrokAuthStatus = ( diff --git a/packages/api/tests/auth.test.ts b/packages/api/tests/auth.test.ts index 5c87c05d..3671c381 100644 --- a/packages/api/tests/auth.test.ts +++ b/packages/api/tests/auth.test.ts @@ -525,6 +525,12 @@ describe("api auth", () => { yield* _(fs.makeDirectory(accountDir, { recursive: true })) yield* _(fs.writeFileString(path.join(accountDir, ".oauth-token"), "sk-ant-oat01-test\n")) + yield* _( + fs.writeFileString( + path.join(accountDir, ".claude.json"), + JSON.stringify({ oauthAccount: { emailAddress: "team@example.com", accountUuid: "acc-1" } }, null, 2) + ) + ) const status = yield* _( withProjectsRoot( @@ -535,9 +541,10 @@ describe("api auth", () => { expect(status.connected).toBe(true) expect(status.label).toBe("team-a") + expect(status.account).toBe("team@example.com") expect(status.method).toBe("oauth-token") expect(status.authPath).toBe(accountDir) - expect(status.message).toBe("Claude connected (team-a, oauth-token).") + expect(status.message).toBe("Claude connected (team-a, oauth-token, account: team@example.com).") expect(JSON.stringify(status)).not.toContain("sk-ant-oat01-test") }) ).pipe(Effect.provide(NodeContext.layer))) @@ -551,7 +558,12 @@ describe("api auth", () => { const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a") yield* _(fs.makeDirectory(accountDir, { recursive: true })) - yield* _(fs.writeFileString(path.join(accountDir, ".credentials.json"), "{\"session\":\"ok\"}\n")) + yield* _( + fs.writeFileString( + path.join(accountDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { displayName: "Team Claude" } }, null, 2) + ) + ) const status = yield* _( withProjectsRoot( @@ -562,9 +574,10 @@ describe("api auth", () => { expect(status.connected).toBe(true) expect(status.label).toBe("team-a") + expect(status.account).toBe("Team Claude") expect(status.method).toBe("claude-ai-session") expect(status.authPath).toBe(accountDir) - expect(status.message).toBe("Claude connected (team-a, claude-ai-session).") + expect(status.message).toBe("Claude connected (team-a, claude-ai-session, account: Team Claude).") }) ).pipe(Effect.provide(NodeContext.layer))) @@ -589,9 +602,10 @@ describe("api auth", () => { expect(status.connected).toBe(true) expect(status.label).toBe("team-a") + expect(status.account).toBeNull() expect(status.method).toBe("claude-ai-session") expect(status.authPath).toBe(accountDir) - expect(status.message).toBe("Claude connected (team-a, claude-ai-session).") + expect(status.message).toBe("Claude connected (team-a, claude-ai-session, account unavailable).") }) ).pipe(Effect.provide(NodeContext.layer))) @@ -611,6 +625,7 @@ describe("api auth", () => { expect(status.connected).toBe(false) expect(status.label).toBe("default") + expect(status.account).toBeNull() expect(status.method).toBe("none") expect(status.authPath).toBe(accountDir) expect(status.message).toBe("Claude not connected (default).") diff --git a/packages/auth-oauth/src/claude-docker-oauth.ts b/packages/auth-oauth/src/claude-docker-oauth.ts index bd655868..66e57cbd 100644 --- a/packages/auth-oauth/src/claude-docker-oauth.ts +++ b/packages/auth-oauth/src/claude-docker-oauth.ts @@ -18,7 +18,7 @@ import { export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest" export const defaultClaudeDockerOauthContainerHome = "/claude-home" -const claudeDockerOauthProbeConfigDir = "/tmp/docker-git-claude-probe" +const claudeDockerOauthProbeConfigDir = "/claude-probe-home" export const claudeDockerOauthBaseImage = "node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc" export const claudeDockerOauthClaudeCodeVersion = "2.1.195" diff --git a/packages/auth-oauth/tests/claude-docker-oauth.test.ts b/packages/auth-oauth/tests/claude-docker-oauth.test.ts index 91e53bd1..b1c3d337 100644 --- a/packages/auth-oauth/tests/claude-docker-oauth.test.ts +++ b/packages/auth-oauth/tests/claude-docker-oauth.test.ts @@ -102,8 +102,8 @@ describe("Claude Docker OAuth runner", () => { expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"]) expect(dockerEnvEntries(probeRuns[0]?.args ?? [])).toEqual( expect.arrayContaining([ - "CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe", - "HOME=/tmp/docker-git-claude-probe", + "CLAUDE_CONFIG_DIR=/claude-probe-home", + "HOME=/claude-probe-home", `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}` ]) ) diff --git a/packages/lib/src/usecases/auth-claude-credentials.ts b/packages/lib/src/usecases/auth-claude-credentials.ts new file mode 100644 index 00000000..347a070c --- /dev/null +++ b/packages/lib/src/usecases/auth-claude-credentials.ts @@ -0,0 +1,141 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { + claudeOauthTokenFileMode, + claudeOauthTokenPath, + formatClaudeOauthTokenFile +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" +import { Effect } from "effect" + +import { isRegularFile } from "./auth-helpers.js" +import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js" + +type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" + +const claudeConfigFileName = ".claude.json" +const claudeCredentialsFileName = ".credentials.json" +const claudeCredentialsDirName = ".claude" + +export const claudeConfigPath = (accountPath: string): string => `${accountPath}/${claudeConfigFileName}` +export const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsFileName}` +export const claudeNestedCredentialsPath = (accountPath: string): string => + `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` + +// CHANGE: persist Claude OAuth tokens through a restricted temporary file and atomic rename +// WHY: the final token path must never receive secret bytes before 0600 permissions are established +// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." +// REF: issue-439/pr-440 +// SOURCE: n/a +// FORMAT THEOREM: forall token, path: write(secret, final(path)) only by rename(temp0600, final(path)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: final .oauth-token is regular replacement content with mode 0600 after success +// COMPLEXITY: O(|token|) +export const persistClaudeOauthToken = ( + fs: FileSystem.FileSystem, + path: Path.Path, + accountPath: string, + token: string +): Effect.Effect => + Effect.gen(function*(_) { + const tokenPath = claudeOauthTokenPath(accountPath) + const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" })) + const tempPath = path.join(tempDir, ".oauth-token") + const cleanupTempDir = fs.remove(tempDir, { recursive: true, force: true }).pipe( + Effect.orElseSucceed(() => void 0) + ) + yield* _( + Effect.gen(function*(_) { + yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) + yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode)) + yield* _(fs.rename(tempPath, tokenPath)) + yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) + }).pipe(Effect.ensuring(cleanupTempDir)) + ) + }) + +const syncClaudeCredentialsFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const nestedPath = claudeNestedCredentialsPath(accountPath) + const rootPath = claudeCredentialsPath(accountPath) + const isNestedExists = yield* _(isRegularFile(fs, nestedPath)) + if (isNestedExists) { + const nestedText = yield* _(readFileStringIfPresent(fs, nestedPath)) + if (nestedText !== null) { + yield* _(writeFileStringEnsuringParent(fs, path, rootPath, nestedText)) + yield* _(fs.chmod(rootPath, 0o600), Effect.orElseSucceed(() => void 0)) + } + return + } + + const isRootExists = yield* _(isRegularFile(fs, rootPath)) + if (isRootExists) { + const rootText = yield* _(readFileStringIfPresent(fs, rootPath)) + if (rootText === null) { + return + } + yield* _(writeFileStringEnsuringParent(fs, path, nestedPath, rootText)) + yield* _(fs.chmod(nestedPath, 0o600), Effect.orElseSucceed(() => void 0)) + } + }) + +const clearClaudeSessionCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(fs.remove(claudeCredentialsPath(accountPath), { force: true })) + yield* _(fs.remove(claudeNestedCredentialsPath(accountPath), { force: true })) + }) + +const hasNonEmptyOauthToken = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const tokenPath = claudeOauthTokenPath(accountPath) + const hasToken = yield* _(isRegularFile(fs, tokenPath)) + if (!hasToken) { + return false + } + const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) + return tokenText.trim().length > 0 + }) + +export const readOauthToken = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const tokenPath = claudeOauthTokenPath(accountPath) + const hasToken = yield* _(isRegularFile(fs, tokenPath)) + if (!hasToken) { + return null + } + + const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) + const token = tokenText.trim() + return token.length > 0 ? token : null + }) + +export const resolveClaudeAuthMethod = ( + fs: FileSystem.FileSystem, + path: Path.Path, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasOauthToken = yield* _(hasNonEmptyOauthToken(fs, accountPath)) + if (hasOauthToken) { + yield* _(clearClaudeSessionCredentials(fs, accountPath)) + return "oauth-token" + } + + yield* _(syncClaudeCredentialsFile(fs, path, accountPath)) + const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath))) + return hasCredentials ? "claude-ai-session" : "none" + }) diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index ecf99123..d2d7554d 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -2,12 +2,8 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" -import { - claudeOauthTokenFileMode, - claudeOauthTokenPath, - formatClaudeOauthTokenFile -} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { renderClaudeDockerOauthDockerfile } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth" +import { claudeOauthTokenPath } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" import { Effect, Match } from "effect" import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js" @@ -15,18 +11,24 @@ import { defaultTemplateConfig } from "../core/domain.js" import { runDockerAuth, runDockerAuthExitCode } from "../shell/docker-auth.js" import type { AuthError } from "../shell/errors.js" import { CommandFailedError } from "../shell/errors.js" +import { + claudeConfigPath, + claudeCredentialsPath, + claudeNestedCredentialsPath, + persistClaudeOauthToken, + readOauthToken, + resolveClaudeAuthMethod +} from "./auth-claude-credentials.js" import { runClaudeLoginFlow } from "./auth-claude-login-flow.js" import { runClaudeOauthLoginWithPrompt } from "./auth-claude-oauth.js" -import { buildDockerAuthSpec, isRegularFile, normalizeAccountLabel } from "./auth-helpers.js" +import { buildDockerAuthSpec, normalizeAccountLabel } from "./auth-helpers.js" import { migrateLegacyOrchLayout } from "./auth-sync.js" import { ensureDockerImage } from "./docker-image.js" import { resolvePathFromCwd } from "./path-helpers.js" import { withFsPathContext } from "./runtime.js" import { autoSyncState } from "./state-repo.js" -import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js" type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor -type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" type ClaudeProbeAuth = | { readonly _tag: "ClaudeProbeAccountConfig" } | { readonly _tag: "ClaudeProbeOauthToken"; readonly token: string } @@ -44,133 +46,7 @@ export const claudeAuthRoot = ".docker-git/.orch/auth/claude" const claudeImageName = "docker-git-auth-claude:latest" const claudeImageDir = ".docker-git/.orch/auth/claude/.image" const claudeContainerHomeDir = "/claude-home" -const claudeProbeConfigDir = "/tmp/docker-git-claude-probe" -const claudeConfigFileName = ".claude.json" -const claudeCredentialsFileName = ".credentials.json" -const claudeCredentialsDirName = ".claude" - -const claudeConfigPath = (accountPath: string): string => `${accountPath}/${claudeConfigFileName}` -const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsFileName}` -const claudeNestedCredentialsPath = (accountPath: string): string => - `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` - -// CHANGE: persist Claude OAuth tokens through a restricted temporary file and atomic rename -// WHY: the final token path must never receive secret bytes before 0600 permissions are established -// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." -// REF: issue-439/pr-440 -// SOURCE: n/a -// FORMAT THEOREM: forall token, path: write(secret, final(path)) only by rename(temp0600, final(path)) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: final .oauth-token is regular replacement content with mode 0600 after success -// COMPLEXITY: O(|token|) -const persistClaudeOauthToken = ( - fs: FileSystem.FileSystem, - path: Path.Path, - accountPath: string, - token: string -): Effect.Effect => - Effect.gen(function*(_) { - const tokenPath = claudeOauthTokenPath(accountPath) - const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" })) - const tempPath = path.join(tempDir, ".oauth-token") - const cleanupTempDir = fs.remove(tempDir, { recursive: true, force: true }).pipe( - Effect.orElseSucceed(() => void 0) - ) - yield* _( - Effect.gen(function*(_) { - yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) - yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode)) - yield* _(fs.rename(tempPath, tokenPath)) - yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) - }).pipe(Effect.ensuring(cleanupTempDir)) - ) - }) - -const syncClaudeCredentialsFile = ( - fs: FileSystem.FileSystem, - path: Path.Path, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const nestedPath = claudeNestedCredentialsPath(accountPath) - const rootPath = claudeCredentialsPath(accountPath) - const isNestedExists = yield* _(isRegularFile(fs, nestedPath)) - if (isNestedExists) { - const nestedText = yield* _(readFileStringIfPresent(fs, nestedPath)) - if (nestedText !== null) { - yield* _(writeFileStringEnsuringParent(fs, path, rootPath, nestedText)) - yield* _(fs.chmod(rootPath, 0o600), Effect.orElseSucceed(() => void 0)) - } - return - } - - const isRootExists = yield* _(isRegularFile(fs, rootPath)) - if (isRootExists) { - const rootText = yield* _(readFileStringIfPresent(fs, rootPath)) - if (rootText === null) { - return - } - yield* _(writeFileStringEnsuringParent(fs, path, nestedPath, rootText)) - yield* _(fs.chmod(nestedPath, 0o600), Effect.orElseSucceed(() => void 0)) - } - }) - -const clearClaudeSessionCredentials = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(fs.remove(claudeCredentialsPath(accountPath), { force: true })) - yield* _(fs.remove(claudeNestedCredentialsPath(accountPath), { force: true })) - }) - -const hasNonEmptyOauthToken = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const tokenPath = claudeOauthTokenPath(accountPath) - const hasToken = yield* _(isRegularFile(fs, tokenPath)) - if (!hasToken) { - return false - } - const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) - return tokenText.trim().length > 0 - }) - -const readOauthToken = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const tokenPath = claudeOauthTokenPath(accountPath) - const hasToken = yield* _(isRegularFile(fs, tokenPath)) - if (!hasToken) { - return null - } - - const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) - const token = tokenText.trim() - return token.length > 0 ? token : null - }) - -const resolveClaudeAuthMethod = ( - fs: FileSystem.FileSystem, - path: Path.Path, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasOauthToken = yield* _(hasNonEmptyOauthToken(fs, accountPath)) - if (hasOauthToken) { - yield* _(clearClaudeSessionCredentials(fs, accountPath)) - return "oauth-token" - } - - yield* _(syncClaudeCredentialsFile(fs, path, accountPath)) - const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath))) - return hasCredentials ? "claude-ai-session" : "none" - }) +const claudeProbeConfigDir = "/claude-probe-home" const buildClaudeAuthEnv = ( isInteractive: boolean, @@ -312,10 +188,11 @@ export const authClaudeLogin = ( }), persistToken: (token) => persistClaudeOauthToken(fs, path, accountPath, token), normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid), - probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, { - _tag: "ClaudeProbeOauthToken", - token - }), + probeToken: (token) => + runClaudePingProbeExitCode(cwd, accountPath, { + _tag: "ClaudeProbeOauthToken", + token + }), syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`) }).pipe(Effect.asVoid)) diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index ed687296..e087cca3 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -3,16 +3,9 @@ import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import { runCommandExitCode } from "../shell/command-runner.js" import { CommandFailedError } from "../shell/errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" -import { adoptRemoteHistoryIfOrphan } from "./state-repo/adopt-remote.js" -import { - resolveGitlabTokenForOrigin, - selectStateInitEffect, - selectStatePullEffect, - selectStateSyncEffect -} from "./state-repo/auth-effects.js" +import { resolveGitlabTokenForOrigin, selectStatePullEffect, selectStateSyncEffect } from "./state-repo/auth-effects.js" import { autoPullEnvKey, autoSyncEnvKey, @@ -35,9 +28,9 @@ import { normalizeOriginUrlIfNeeded, shouldLogGithubAuthHintForStateSyncFailure } from "./state-repo/github-auth-state.js" -import type { GitAuthEnv } from "./state-repo/github-auth.js" import { resolveGithubToken } from "./state-repo/github-auth.js" import { ensureStateGitignore } from "./state-repo/gitignore.js" +import { type StateInitInput, stateInitRaw } from "./state-repo/init.js" import { withStateGitLock } from "./state-repo/lock.js" type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor @@ -52,13 +45,11 @@ const renderStateSyncFailure = (error: CommandFailedError | PlatformError): stri const logStateAutoSyncFailure = ( error: CommandFailedError | PlatformError -): Effect.Effect => - Effect.logWarning(`State auto-sync failed: ${renderStateSyncFailure(error)}`) +): Effect.Effect => Effect.logWarning(`State auto-sync failed: ${renderStateSyncFailure(error)}`) const logStateAutoPullFailure = ( error: CommandFailedError | PlatformError -): Effect.Effect => - Effect.logWarning(`State auto-pull failed: ${renderStateSyncFailure(error)}`) +): Effect.Effect => Effect.logWarning(`State auto-pull failed: ${renderStateSyncFailure(error)}`) const ensureStateIgnoreAndUntrackCaches = ( fs: FileSystem.FileSystem, @@ -264,117 +255,6 @@ const statePullInternal = ( yield* _(effect) }).pipe(Effect.asVoid) -type StateInitInput = { - readonly repoUrl: string - readonly repoRef: string - readonly token?: string -} - -const cloneStateRepo = ( - root: string, - input: StateInitInput, - env: GitAuthEnv -): Effect.Effect => - Effect.gen(function*(_) { - const cloneWithBranch = ["clone", "--branch", input.repoRef, input.repoUrl, root] - const cloneBranchExit = yield* _( - runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env }) - ) - if (cloneBranchExit === successExitCode) { - return - } - - // Empty remotes (no branch yet) and remotes without the requested branch can fail here. - // Fall back to cloning the default branch so we can still set up the repo and create the branch locally. - yield* _( - Effect.logWarning( - `git clone --branch ${input.repoRef} failed (exit ${cloneBranchExit}); retrying without --branch` - ) - ) - const cloneDefault = ["clone", input.repoUrl, root] - const cloneDefaultExit = yield* _( - runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env }) - ) - if (cloneDefaultExit !== successExitCode) { - return yield* _(Effect.fail(new CommandFailedError({ command: "git clone", exitCode: cloneDefaultExit }))) - } - }).pipe(Effect.asVoid) - -const initRepoIfNeeded = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string, - input: StateInitInput, - env: GitAuthEnv -): Effect.Effect => - Effect.gen(function*(_) { - yield* _(fs.makeDirectory(root, { recursive: true })) - - const gitDir = path.join(root, ".git") - const hasGit = yield* _(fs.exists(gitDir)) - if (hasGit) { - return - } - - const entries = yield* _(fs.readDirectory(root)) - if (entries.length === 0) { - yield* _(cloneStateRepo(root, input, env)) - yield* _(Effect.log(`State dir cloned: ${root}`)) - return - } - - yield* _(git(root, ["init", "--initial-branch=main"], env)) - }).pipe(Effect.asVoid) - -const ensureOriginRemote = ( - root: string, - repoUrl: string, - env: GitAuthEnv -): Effect.Effect => - Effect.gen(function*(_) { - const urlExitCode = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], env)) - if (urlExitCode === successExitCode) { - return - } - yield* _(git(root, ["remote", "add", "origin", repoUrl], env)) - }) - -const checkoutBranchBestEffort = ( - root: string, - repoRef: string, - env: GitAuthEnv -): Effect.Effect => - Effect.gen(function*(_) { - const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", repoRef], env)) - if (checkoutExit === successExitCode) { - return - } - yield* _(Effect.logWarning(`git checkout -B ${repoRef} failed (exit ${checkoutExit})`)) - }) - -const stateInitRaw = ( - input: StateInitInput -): Effect.Effect => { - const doInit = (env: GitAuthEnv) => - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const root = resolveStateRoot(path, process.cwd()) - - yield* _(initRepoIfNeeded(fs, path, root, input, env)) - yield* _(ensureOriginRemote(root, input.repoUrl, env)) - yield* _(adoptRemoteHistoryIfOrphan(root, input.repoRef, env)) - yield* _(checkoutBranchBestEffort(root, input.repoRef, env)) - yield* _(ensureStateGitignore(fs, path, root)) - - yield* _(Effect.log(`State dir ready: ${root}`)) - yield* _(Effect.log(`Remote: ${input.repoUrl}`)) - }).pipe(Effect.asVoid) - - const token = input.token?.trim() ?? "" - return selectStateInitEffect(input.repoUrl, token, doInit) -} - export const stateInit = (input: StateInitInput) => withStateGitLock(stateInitRaw(input)) export { stateCommit, stateStatus } from "./state-repo/local-ops.js" diff --git a/packages/lib/src/usecases/state-repo/init.ts b/packages/lib/src/usecases/state-repo/init.ts new file mode 100644 index 00000000..2e16e494 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/init.ts @@ -0,0 +1,128 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { runCommandExitCode } from "../../shell/command-runner.js" +import { CommandFailedError } from "../../shell/errors.js" +import { defaultProjectsRoot } from "../menu-helpers.js" +import { adoptRemoteHistoryIfOrphan } from "./adopt-remote.js" +import { selectStateInitEffect } from "./auth-effects.js" +import { git, gitExitCode, successExitCode } from "./git-commands.js" +import type { GitAuthEnv } from "./github-auth.js" +import { ensureStateGitignore } from "./gitignore.js" + +type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + +export type StateInitInput = { + readonly repoUrl: string + readonly repoRef: string + readonly token?: string +} + +const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) + +const cloneStateRepo = ( + root: string, + input: StateInitInput, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + const cloneWithBranch = ["clone", "--branch", input.repoRef, input.repoUrl, root] + const cloneBranchExit = yield* _( + runCommandExitCode({ cwd: root, command: "git", args: cloneWithBranch, env }) + ) + if (cloneBranchExit === successExitCode) { + return + } + + // Empty remotes and remotes without the requested branch can fail here. + yield* _( + Effect.logWarning( + `git clone --branch ${input.repoRef} failed (exit ${cloneBranchExit}); retrying without --branch` + ) + ) + const cloneDefault = ["clone", input.repoUrl, root] + const cloneDefaultExit = yield* _( + runCommandExitCode({ cwd: root, command: "git", args: cloneDefault, env }) + ) + if (cloneDefaultExit !== successExitCode) { + return yield* _(Effect.fail(new CommandFailedError({ command: "git clone", exitCode: cloneDefaultExit }))) + } + }).pipe(Effect.asVoid) + +const initRepoIfNeeded = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + input: StateInitInput, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(fs.makeDirectory(root, { recursive: true })) + + const gitDir = path.join(root, ".git") + const hasGit = yield* _(fs.exists(gitDir)) + if (hasGit) { + return + } + + const entries = yield* _(fs.readDirectory(root)) + if (entries.length === 0) { + yield* _(cloneStateRepo(root, input, env)) + yield* _(Effect.log(`State dir cloned: ${root}`)) + return + } + + yield* _(git(root, ["init", "--initial-branch=main"], env)) + }).pipe(Effect.asVoid) + +const ensureOriginRemote = ( + root: string, + repoUrl: string, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + const urlExitCode = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], env)) + if (urlExitCode === successExitCode) { + return + } + yield* _(git(root, ["remote", "add", "origin", repoUrl], env)) + }) + +const checkoutBranchBestEffort = ( + root: string, + repoRef: string, + env: GitAuthEnv +): Effect.Effect => + Effect.gen(function*(_) { + const checkoutExit = yield* _(gitExitCode(root, ["checkout", "-B", repoRef], env)) + if (checkoutExit === successExitCode) { + return + } + yield* _(Effect.logWarning(`git checkout -B ${repoRef} failed (exit ${checkoutExit})`)) + }) + +export const stateInitRaw = ( + input: StateInitInput +): Effect.Effect => { + const doInit = (env: GitAuthEnv) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const root = resolveStateRoot(path, process.cwd()) + + yield* _(initRepoIfNeeded(fs, path, root, input, env)) + yield* _(ensureOriginRemote(root, input.repoUrl, env)) + yield* _(adoptRemoteHistoryIfOrphan(root, input.repoRef, env)) + yield* _(checkoutBranchBestEffort(root, input.repoRef, env)) + yield* _(ensureStateGitignore(fs, path, root)) + + yield* _(Effect.log(`State dir ready: ${root}`)) + yield* _(Effect.log(`Remote: ${input.repoUrl}`)) + }).pipe(Effect.asVoid) + + const token = input.token?.trim() ?? "" + return selectStateInitEffect(input.repoUrl, token, doInit) +} diff --git a/packages/lib/tests/usecases/auth-claude-login.test.ts b/packages/lib/tests/usecases/auth-claude-login.test.ts index a6512f04..1c67b8c2 100644 --- a/packages/lib/tests/usecases/auth-claude-login.test.ts +++ b/packages/lib/tests/usecases/auth-claude-login.test.ts @@ -308,8 +308,8 @@ describe("authClaudeLogin", () => { const envEntries = dockerEnvEntries(pingInvocation.args) expect(envEntries).toContain(`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`) - expect(envEntries).toContain("HOME=/tmp/docker-git-claude-probe") - expect(envEntries).toContain("CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe") + expect(envEntries).toContain("HOME=/claude-probe-home") + expect(envEntries).toContain("CLAUDE_CONFIG_DIR=/claude-probe-home") expect(envEntries).not.toContain("HOME=/claude-home") expect(envEntries).not.toContain("CLAUDE_CONFIG_DIR=/claude-home") }) diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 301ada4d..0a5ec878 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -10489,6 +10489,7 @@ "status": { "type": "object", "required": [ + "account", "authPath", "connected", "label", @@ -10496,6 +10497,16 @@ "method" ], "properties": { + "account": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "authPath": { "type": "string" }, diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 7b0ba9ef..9e4a662f 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -5180,6 +5180,7 @@ export interface operations { "application/json": { ok?: boolean; status: { + account: string | null; authPath: string; connected: boolean; label: string;