From ddae8c879fe1c7236be1ebf125f9126fced47fff Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:08:08 +0000 Subject: [PATCH 1/2] fix(claude): stabilize oauth auth and permission parity --- README.md | 6 + .../tests/docker-git/entrypoint-auth.test.ts | 14 +- .../src/core/templates-entrypoint/claude.ts | 79 ++++++-- packages/lib/src/shell/docker-auth.ts | 168 ++++++++++++++++-- .../lib/src/usecases/auth-claude-oauth.ts | 5 +- packages/lib/src/usecases/auth-claude.ts | 74 ++++++-- packages/lib/src/usecases/auth-sync.ts | 25 ++- packages/lib/tests/shell/docker-auth.test.ts | 37 ++++ packages/lib/tests/usecases/auth-sync.test.ts | 72 ++++++++ 9 files changed, 434 insertions(+), 46 deletions(-) create mode 100644 packages/lib/tests/shell/docker-auth.test.ts diff --git a/README.md b/README.md index 81647bf2..682b6131 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,12 @@ This avoids `refresh_token` rotation issues that can happen when copying `auth.j Disable sharing (per-project auth): - Set `CODEX_SHARE_AUTH=0` in `.orch/env/project.env`. +## Claude Code Defaults + +On container start, docker-git syncs Claude Code user settings under `$CLAUDE_CONFIG_DIR/settings.json`: +- `permissions.defaultMode = "bypassPermissions"` so local disposable containers behave like docker-git Codex containers (no permission prompts). +- Existing unrelated Claude settings are preserved. + ## Playwright MCP (Chromium Sidecar) Enable during create/clone: diff --git a/packages/app/tests/docker-git/entrypoint-auth.test.ts b/packages/app/tests/docker-git/entrypoint-auth.test.ts index 9cfa25cc..9fe1a572 100644 --- a/packages/app/tests/docker-git/entrypoint-auth.test.ts +++ b/packages/app/tests/docker-git/entrypoint-auth.test.ts @@ -39,8 +39,20 @@ describe("renderEntrypoint auth bridge", () => { expect(entrypoint).toContain("CLAUDE_CONFIG_DIR=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"") expect(entrypoint).toContain("docker_git_ensure_claude_cli()") expect(entrypoint).toContain("claude cli.js not found under npm global root; skip shim restore") + expect(entrypoint).toContain("CLAUDE_PERMISSION_SETTINGS_FILE=\"$CLAUDE_CONFIG_DIR/settings.json\"") + expect(entrypoint).toContain("docker_git_sync_claude_permissions()") + expect(entrypoint).toContain( + "const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {}" + ) + expect(entrypoint).toContain("defaultMode: \"bypassPermissions\"") + expect(entrypoint).toContain("CLAUDE_TOKEN_FILE=\"$CLAUDE_CONFIG_DIR/.oauth-token\"") expect(entrypoint).toContain("CLAUDE_CREDENTIALS_FILE=\"$CLAUDE_CONFIG_DIR/.credentials.json\"") - expect(entrypoint).toContain("if [[ -s \"$CLAUDE_CREDENTIALS_FILE\" ]]; then") + expect(entrypoint).toContain("CLAUDE_NESTED_CREDENTIALS_FILE=\"$CLAUDE_CONFIG_DIR/.claude/.credentials.json\"") + expect(entrypoint).toContain("docker_git_prepare_claude_auth_mode()") + expect(entrypoint).toContain( + "rm -f \"$CLAUDE_CREDENTIALS_FILE\" \"$CLAUDE_NESTED_CREDENTIALS_FILE\" \"$CLAUDE_HOME_DIR/.credentials.json\" || true" + ) + expect(entrypoint).toContain("if [[ ! -s \"$CLAUDE_TOKEN_FILE\" ]]; then") expect(entrypoint).toContain("CLAUDE_SETTINGS_FILE=\"${CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}\"") expect(entrypoint).toContain("nextServers.playwright = {") expect(entrypoint).toContain("command: \"docker-git-playwright-mcp\"") diff --git a/packages/lib/src/core/templates-entrypoint/claude.ts b/packages/lib/src/core/templates-entrypoint/claude.ts index b98e7e7d..53db50a0 100644 --- a/packages/lib/src/core/templates-entrypoint/claude.ts +++ b/packages/lib/src/core/templates-entrypoint/claude.ts @@ -34,6 +34,17 @@ mkdir -p "$CLAUDE_CONFIG_DIR" || true CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__" CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__" mkdir -p "$CLAUDE_HOME_DIR" || true +CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" +CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json" +CLAUDE_NESTED_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.claude/.credentials.json" + +docker_git_prepare_claude_auth_mode() { + if [[ -s "$CLAUDE_TOKEN_FILE" ]]; then + rm -f "$CLAUDE_CREDENTIALS_FILE" "$CLAUDE_NESTED_CREDENTIALS_FILE" "$CLAUDE_HOME_DIR/.credentials.json" || true + fi +} + +docker_git_prepare_claude_auth_mode docker_git_link_claude_file() { local source_path="$1" @@ -61,17 +72,13 @@ docker_git_link_claude_home_file() { docker_git_link_claude_home_file ".oauth-token" docker_git_link_claude_home_file ".config.json" docker_git_link_claude_home_file ".claude.json" -docker_git_link_claude_home_file ".credentials.json" +if [[ ! -s "$CLAUDE_TOKEN_FILE" ]]; then + docker_git_link_claude_home_file ".credentials.json" +fi docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON" -CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" -CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json" docker_git_refresh_claude_oauth_token() { local token="" - if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then - unset CLAUDE_CODE_OAUTH_TOKEN || true - return 0 - fi if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" fi @@ -133,6 +140,53 @@ EOF docker_git_ensure_claude_cli` +const renderClaudePermissionSettingsConfig = (): string => + String.raw`# Claude Code: keep permission settings in sync with docker-git defaults +CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json" +docker_git_sync_claude_permissions() { + CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_PERMISSION_SETTINGS_FILE" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_PERMISSION_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) { + process.exit(0) +} + +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { + settings = {} +} + +const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {} +const nextPermissions = { + ...currentPermissions, + defaultMode: "bypassPermissions" +} +const nextSettings = { + ...settings, + permissions: nextPermissions +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_permissions +chmod 0600 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true +chown 1000:1000 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true` + const renderClaudeMcpPlaywrightConfig = (): string => String.raw`# Claude Code: keep Playwright MCP config in sync with container settings CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" @@ -295,11 +349,8 @@ set -euo pipefail CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__" CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}" CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" -CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json" -if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then - unset CLAUDE_CODE_OAUTH_TOKEN || true -elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then +if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" export CLAUDE_CODE_OAUTH_TOKEN else @@ -320,10 +371,7 @@ printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE" printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE" cat <<'EOF' >> "$CLAUDE_PROFILE" CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token" -CLAUDE_CREDENTIALS_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json" -if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then - unset CLAUDE_CODE_OAUTH_TOKEN || true -elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then +if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" else unset CLAUDE_CODE_OAUTH_TOKEN || true @@ -340,6 +388,7 @@ export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => [ renderClaudeAuthConfig(config), renderClaudeCliInstall(), + renderClaudePermissionSettingsConfig(), renderClaudeMcpPlaywrightConfig(), renderClaudeGlobalPromptSetup(config), renderClaudeWrapperSetup(), diff --git a/packages/lib/src/shell/docker-auth.ts b/packages/lib/src/shell/docker-auth.ts index 61fd3b46..aa2aedd5 100644 --- a/packages/lib/src/shell/docker-auth.ts +++ b/packages/lib/src/shell/docker-auth.ts @@ -1,6 +1,7 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import type { Effect } from "effect" +import { readFileSync } from "node:fs" +import { Effect } from "effect" import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js" @@ -20,6 +21,122 @@ export type DockerAuthSpec = { readonly interactive: boolean } +type DockerMountBinding = { + readonly source: string + readonly destination: string +} + +const resolveEnvValue = (key: string): string | null => { + const value = process.env[key]?.trim() + return value && value.length > 0 ? value : null +} + +const trimTrailingSlash = (value: string): string => value.replace(/[\\/]+$/u, "") + +const pathStartsWith = (candidate: string, prefix: string): boolean => + candidate === prefix || candidate.startsWith(`${prefix}/`) || candidate.startsWith(`${prefix}\\`) + +const translatePathPrefix = (candidate: string, sourcePrefix: string, targetPrefix: string): string | null => + pathStartsWith(candidate, sourcePrefix) + ? `${targetPrefix}${candidate.slice(sourcePrefix.length)}` + : null + +const resolveContainerProjectsRoot = (): string | null => { + const explicit = resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT") + if (explicit !== null) { + return explicit + } + + const home = resolveEnvValue("HOME") ?? resolveEnvValue("USERPROFILE") + return home === null ? null : `${trimTrailingSlash(home)}/.docker-git` +} + +const resolveProjectsRootHostOverride = (): string | null => resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT_HOST") + +const resolveCurrentContainerId = (): string | null => { + const fromEnv = resolveEnvValue("HOSTNAME") + if (fromEnv !== null) { + return fromEnv + } + + try { + const fromHostnameFile = readFileSync("/etc/hostname", "utf8").trim() + return fromHostnameFile.length > 0 ? fromHostnameFile : null + } catch { + return null + } +} + +const parseDockerInspectMounts = (raw: string): ReadonlyArray => { + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return [] + } + return parsed.flatMap((item) => { + if (typeof item !== "object" || item === null) { + return [] + } + const source = Reflect.get(item, "Source") + const destination = Reflect.get(item, "Destination") + return typeof source === "string" && typeof destination === "string" + ? [{ source, destination }] + : [] + }) + } catch { + return [] + } +} + +export const remapDockerBindHostPathFromMounts = ( + hostPath: string, + mounts: ReadonlyArray +): string => { + const match = mounts + .filter((mount) => pathStartsWith(hostPath, mount.destination)) + .sort((left, right) => right.destination.length - left.destination.length)[0] + + if (match === undefined) { + return hostPath + } + + return `${match.source}${hostPath.slice(match.destination.length)}` +} + +export const resolveDockerVolumeHostPath = ( + cwd: string, + hostPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const containerProjectsRoot = resolveContainerProjectsRoot() + const hostProjectsRoot = resolveProjectsRootHostOverride() + if (containerProjectsRoot !== null && hostProjectsRoot !== null) { + const remapped = translatePathPrefix(hostPath, containerProjectsRoot, hostProjectsRoot) + if (remapped !== null) { + return remapped + } + } + + const containerId = resolveCurrentContainerId() + if (containerId === null) { + return hostPath + } + + const mountsJson = yield* _( + runCommandCapture( + { + cwd, + command: "docker", + args: ["inspect", containerId, "--format", "{{json .Mounts}}"] + }, + [0], + () => new Error("docker inspect current container failed") + ).pipe(Effect.orElseSucceed(() => "")) + ) + + return remapDockerBindHostPathFromMounts(hostPath, parseDockerInspectMounts(mountsJson)) + }) + export const resolveDefaultDockerUser = (): string | null => { const getUid = Reflect.get(process, "getuid") const getGid = Reflect.get(process, "getgid") @@ -93,11 +210,20 @@ export const runDockerAuth = ( okExitCodes: ReadonlyArray, onFailure: (exitCode: number) => E ): Effect.Effect => - runCommandWithExitCodes( - { cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) }, - okExitCodes, - onFailure - ) + Effect.gen(function*(_) { + const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath)) + yield* _( + runCommandWithExitCodes( + { + cwd: spec.cwd, + command: "docker", + args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } }) + }, + okExitCodes, + onFailure + ) + ) + }) // CHANGE: run a docker auth command and capture stdout // WHY: obtain tokens from container auth flows @@ -114,11 +240,20 @@ export const runDockerAuthCapture = ( okExitCodes: ReadonlyArray, onFailure: (exitCode: number) => E ): Effect.Effect => - runCommandCapture( - { cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) }, - okExitCodes, - onFailure - ) + Effect.gen(function*(_) { + const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath)) + return yield* _( + runCommandCapture( + { + cwd: spec.cwd, + command: "docker", + args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } }) + }, + okExitCodes, + onFailure + ) + ) + }) // CHANGE: run a docker auth command and return the exit code // WHY: allow status checks without throwing @@ -133,4 +268,13 @@ export const runDockerAuthCapture = ( export const runDockerAuthExitCode = ( spec: DockerAuthSpec ): Effect.Effect => - runCommandExitCode({ cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) }) + Effect.gen(function*(_) { + const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath)) + return yield* _( + runCommandExitCode({ + cwd: spec.cwd, + command: "docker", + args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } }) + }) + ) + }) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 6bd1dc51..7ecee5cd 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" -import { resolveDefaultDockerUser } from "../shell/docker-auth.js" +import { resolveDefaultDockerUser, resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN" @@ -257,7 +257,8 @@ export const runClaudeOauthLoginWithPrompt = ( return Effect.scoped( Effect.gen(function*(_) { const executor = yield* _(CommandExecutor.CommandExecutor) - const spec = buildDockerSetupTokenSpec(cwd, accountPath, options.image, options.containerPath) + 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 } diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index a144e384..6d99cfa0 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -18,6 +18,7 @@ import { withFsPathContext } from "./runtime.js" import { autoSyncState } from "./state-repo.js" type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" type ClaudeAccountContext = { readonly accountLabel: string @@ -78,6 +79,15 @@ const syncClaudeCredentialsFile = ( } }) +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 @@ -92,12 +102,48 @@ const hasNonEmptyOauthToken = ( 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, + 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, accountPath)) + const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath))) + return hasCredentials ? "claude-ai-session" : "none" + }) + const buildClaudeAuthEnv = ( - interactive: boolean + interactive: boolean, + oauthToken: string | null = null ): ReadonlyArray => - interactive - ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] - : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`] + [ + ...(interactive + ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] + : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`]), + ...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]) + ] const ensureClaudeOrchLayout = ( cwd: string @@ -187,7 +233,8 @@ const runClaudeLogout = ( const runClaudePingProbeExitCode = ( cwd: string, - accountPath: string + accountPath: string, + oauthToken: string | null ): Effect.Effect => runDockerAuthExitCode( buildDockerAuthSpec({ @@ -195,7 +242,7 @@ const runClaudePingProbeExitCode = ( image: claudeImageName, hostPath: accountPath, containerPath: claudeContainerHomeDir, - env: buildClaudeAuthEnv(false), + env: buildClaudeAuthEnv(false, oauthToken), args: ["-p", "ping"], interactive: false }) @@ -225,8 +272,8 @@ export const authClaudeLogin = ( ) yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}\n`)) yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 0o600), Effect.orElseSucceed(() => void 0)) - yield* _(syncClaudeCredentialsFile(fs, accountPath)) - const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath)) + yield* _(resolveClaudeAuthMethod(fs, accountPath)) + const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token)) if (probeExitCode !== 0) { yield* _( Effect.fail( @@ -257,21 +304,18 @@ export const authClaudeStatus = ( ): Effect.Effect => withClaudeAuth(command, ({ accountLabel, accountPath, cwd, fs }) => Effect.gen(function*(_) { - yield* _(syncClaudeCredentialsFile(fs, accountPath)) - const hasOauthToken = yield* _(hasNonEmptyOauthToken(fs, accountPath)) - const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath))) - if (!hasOauthToken && !hasCredentials) { + const method = yield* _(resolveClaudeAuthMethod(fs, accountPath)) + if (method === "none") { yield* _(Effect.log(`Claude not connected (${accountLabel}).`)) return } - const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath)) + const oauthToken = method === "oauth-token" ? yield* _(readOauthToken(fs, accountPath)) : null + const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, oauthToken)) if (probeExitCode === 0) { - const method = hasCredentials ? "claude-ai-session" : "oauth-token" yield* _(Effect.log(`Claude connected (${accountLabel}, ${method}).`)) return } - const method = hasCredentials ? "claude-ai-session" : "oauth-token" yield* _( Effect.logWarning( `Claude session exists but API probe failed (${accountLabel}, ${method}, exit=${probeExitCode}). Run 'docker-git auth claude login'.` diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 8808f398..aef516c2 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -194,6 +194,25 @@ const syncClaudeCredentialsJson = ( updateLabel: "Claude credentials" }) +const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + + const info = yield* _(fs.stat(filePath)) + if (info.type !== "File") { + return false + } + + const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return text.trim().length > 0 + }) + // CHANGE: seed docker-git Claude auth store from host-level Claude files // WHY: Claude Code (v2+) keeps OAuth session in ~/.claude.json and ~/.claude/.credentials.json // QUOTE(ТЗ): "глобальная авторизация для клода ... должна сама везде настроиться" @@ -221,11 +240,15 @@ export const ensureClaudeAuthSeedFromHome = ( const claudeRoot = resolvePathFromBase(path, baseDir, claudeAuthPath) const targetAccountDir = path.join(claudeRoot, "default") const targetClaudeJson = path.join(targetAccountDir, ".claude.json") + const targetOauthToken = path.join(targetAccountDir, ".oauth-token") const targetCredentials = path.join(targetAccountDir, ".credentials.json") + const hasTargetOauthToken = yield* _(hasNonEmptyFile(fs, targetOauthToken)) yield* _(fs.makeDirectory(targetAccountDir, { recursive: true })) yield* _(syncClaudeHomeJson(fs, path, sourceClaudeJson, targetClaudeJson)) - yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials)) + if (!hasTargetOauthToken) { + yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials)) + } }) ) diff --git a/packages/lib/tests/shell/docker-auth.test.ts b/packages/lib/tests/shell/docker-auth.test.ts new file mode 100644 index 00000000..db50a2d4 --- /dev/null +++ b/packages/lib/tests/shell/docker-auth.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest" + +import { remapDockerBindHostPathFromMounts } from "../../src/shell/docker-auth.js" + +describe("remapDockerBindHostPathFromMounts", () => { + it("maps nested bind paths through the current container mount source", () => { + const next = remapDockerBindHostPathFromMounts("/home/dev/.docker-git/.orch/auth/claude/default", [ + { + source: "/home/user/.docker-git", + destination: "/home/dev/.docker-git" + } + ]) + + expect(next).toBe("/home/user/.docker-git/.orch/auth/claude/default") + }) + + it("prefers the longest matching destination prefix", () => { + const next = remapDockerBindHostPathFromMounts("/home/dev/.docker-git/provercoderai/repo/.orch/auth/gh", [ + { + source: "/home/user/.docker-git", + destination: "/home/dev/.docker-git" + }, + { + source: "/srv/docker-git/provercoderai/repo", + destination: "/home/dev/.docker-git/provercoderai/repo" + } + ]) + + expect(next).toBe("/srv/docker-git/provercoderai/repo/.orch/auth/gh") + }) + + it("keeps the original path when no mount matches", () => { + const hostPath = "/tmp/docker-git-auth" + + expect(remapDockerBindHostPathFromMounts(hostPath, [])).toBe(hostPath) + }) +}) diff --git a/packages/lib/tests/usecases/auth-sync.test.ts b/packages/lib/tests/usecases/auth-sync.test.ts index 0fc38914..d824dd3f 100644 --- a/packages/lib/tests/usecases/auth-sync.test.ts +++ b/packages/lib/tests/usecases/auth-sync.test.ts @@ -205,4 +205,76 @@ describe("syncGithubAuthKeys", () => { expect(seededCredentialsText).toContain("\"claudeAiOauth\"") }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not reseed Claude session credentials when oauth token already exists", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const hostHome = path.join(root, "host-home") + const hostClaudeDir = path.join(hostHome, ".claude") + const hostClaudeJson = path.join(hostHome, ".claude.json") + const hostCredentialsJson = path.join(hostClaudeDir, ".credentials.json") + const targetAccountDir = path.join( + root, + ".docker-git", + ".orch", + "auth", + "claude", + "default" + ) + const targetOauthToken = path.join(targetAccountDir, ".oauth-token") + const targetCredentials = path.join(targetAccountDir, ".credentials.json") + + yield* _(fs.makeDirectory(hostClaudeDir, { recursive: true })) + yield* _(fs.makeDirectory(targetAccountDir, { recursive: true })) + yield* _(fs.writeFileString(targetOauthToken, "oauth-token-value\n")) + yield* _( + fs.writeFileString( + hostClaudeJson, + JSON.stringify( + { + oauthAccount: { accountUuid: "acc-2" }, + userID: "user-2" + }, + null, + 2 + ) + ) + ) + yield* _( + fs.writeFileString( + hostCredentialsJson, + JSON.stringify( + { + claudeAiOauth: { accessToken: "token-2" } + }, + null, + 2 + ) + ) + ) + + const previousHome = process.env["HOME"] + yield* _( + Effect.addFinalizer(() => + Effect.sync(() => { + if (previousHome === undefined) { + delete process.env["HOME"] + } else { + process.env["HOME"] = previousHome + } + }) + ) + ) + yield* _(Effect.sync(() => { + process.env["HOME"] = hostHome + })) + + yield* _(ensureClaudeAuthSeedFromHome(root, ".docker-git/.orch/auth/claude")) + + const hasSeededCredentials = yield* _(fs.exists(targetCredentials)) + expect(hasSeededCredentials).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) }) From 1384db81e7febc400114742a5744c152735c144b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:28:38 +0000 Subject: [PATCH 2/2] fix(lib): resolve CI lint regressions for issue-108 --- .../claude-extra-config.ts | 121 ++++++++++++++ .../src/core/templates-entrypoint/claude.ts | 121 +------------- packages/lib/src/shell/docker-auth.ts | 92 +++++++---- packages/lib/src/usecases/auth-claude.ts | 13 +- .../lib/src/usecases/auth-sync-claude-seed.ts | 154 ++++++++++++++++++ packages/lib/src/usecases/auth-sync.ts | 147 +---------------- 6 files changed, 343 insertions(+), 305 deletions(-) create mode 100644 packages/lib/src/core/templates-entrypoint/claude-extra-config.ts create mode 100644 packages/lib/src/usecases/auth-sync-claude-seed.ts diff --git a/packages/lib/src/core/templates-entrypoint/claude-extra-config.ts b/packages/lib/src/core/templates-entrypoint/claude-extra-config.ts new file mode 100644 index 00000000..8215eb4c --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/claude-extra-config.ts @@ -0,0 +1,121 @@ +import type { TemplateConfig } from "../domain.js" + +const entrypointClaudeGlobalPromptTemplate = String + .raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) +CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md" +CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}" +CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" +REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}" +REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}" + +if [[ "$REPO_REF_VALUE" == issue-* ]]; then + ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" + ISSUE_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then + ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO_VALUE" ]]; then + ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" + fi + fi + if [[ -n "$ISSUE_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" + fi +elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then + PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then + PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO_VALUE" ]]; then + PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" + fi + fi + if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + elif [[ -n "$PR_ID_VALUE" ]]; then + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" + else + CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" + fi +fi + +if [[ "$CLAUDE_AUTO_SYSTEM_PROMPT" == "1" ]]; then + mkdir -p "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" + chown 1000:1000 "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" 2>/dev/null || true + if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then + cat < "$CLAUDE_GLOBAL_PROMPT_FILE" + +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, claude, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +Рабочая папка проекта (git clone): __TARGET_DIR__ +Доступные workspace пути: __TARGET_DIR__ +$CLAUDE_WORKSPACE_CONTEXT +Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__ +Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. +Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции. + +EOF + chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true + fi +fi + +export CLAUDE_AUTO_SYSTEM_PROMPT` + +const escapeForDoubleQuotes = (value: string): string => { + const backslash = String.fromCodePoint(92) + const quote = String.fromCodePoint(34) + const escapedBackslash = `${backslash}${backslash}` + const escapedQuote = `${backslash}${quote}` + return value + .replaceAll(backslash, escapedBackslash) + .replaceAll(quote, escapedQuote) +} + +export const renderClaudeGlobalPromptSetup = (config: TemplateConfig): string => + entrypointClaudeGlobalPromptTemplate + .replaceAll("__TARGET_DIR__", config.targetDir) + .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) + +export const renderClaudeWrapperSetup = (): string => + String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude" +if command -v claude >/dev/null 2>&1; then + CURRENT_CLAUDE_BIN="$(command -v claude)" + CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")" + CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" + + # If a wrapper already exists but points to a missing real binary, recover from /usr/bin. + if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then + CURRENT_CLAUDE_BIN="/usr/bin/claude" + CLAUDE_REAL_DIR="/usr/bin" + CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" + fi + + # Keep the "real" binary in the same directory as the original command to preserve relative symlinks. + if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then + mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN" + fi + if [[ -e "$CLAUDE_REAL_BIN" ]]; then + cat <<'EOF' > "$CLAUDE_WRAPPER_BIN" +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__" +CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}" +CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" + +if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then + CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" + export CLAUDE_CODE_OAUTH_TOKEN +else + unset CLAUDE_CODE_OAUTH_TOKEN || true +fi + +exec "$CLAUDE_REAL_BIN" "$@" +EOF + sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true + chmod 0755 "$CLAUDE_WRAPPER_BIN" || true + fi +fi` diff --git a/packages/lib/src/core/templates-entrypoint/claude.ts b/packages/lib/src/core/templates-entrypoint/claude.ts index 53db50a0..88be1107 100644 --- a/packages/lib/src/core/templates-entrypoint/claude.ts +++ b/packages/lib/src/core/templates-entrypoint/claude.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderClaudeGlobalPromptSetup, renderClaudeWrapperSetup } from "./claude-extra-config.js" const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude` @@ -244,126 +245,6 @@ NODE docker_git_sync_claude_playwright_mcp chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` -const entrypointClaudeGlobalPromptTemplate = String - .raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) -CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md" -CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}" -CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" -REPO_REF_VALUE="${"$"}{REPO_REF:-__REPO_REF_DEFAULT__}" -REPO_URL_VALUE="${"$"}{REPO_URL:-__REPO_URL_DEFAULT__}" - -if [[ "$REPO_REF_VALUE" == issue-* ]]; then - ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" - ISSUE_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then - ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO_VALUE" ]]; then - ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" - fi - fi - if [[ -n "$ISSUE_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" - else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: issue #$ISSUE_ID_VALUE" - fi -elif [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then - PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL_VALUE="" - if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then - PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO_VALUE" ]]; then - PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" - fi - fi - if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" - elif [[ -n "$PR_ID_VALUE" ]]; then - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: PR #$PR_ID_VALUE" - else - CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: pull request ($REPO_REF_VALUE)" - fi -fi - -if [[ "$CLAUDE_AUTO_SYSTEM_PROMPT" == "1" ]]; then - mkdir -p "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" - chown 1000:1000 "$(dirname "$CLAUDE_GLOBAL_PROMPT_FILE")" 2>/dev/null || true - if [[ ! -f "$CLAUDE_GLOBAL_PROMPT_FILE" ]] || grep -q "^$" "$CLAUDE_GLOBAL_PROMPT_FILE"; then - cat < "$CLAUDE_GLOBAL_PROMPT_FILE" - -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, claude, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -Рабочая папка проекта (git clone): __TARGET_DIR__ -Доступные workspace пути: __TARGET_DIR__ -$CLAUDE_WORKSPACE_CONTEXT -Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__ -Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе. -Если ты видишь файлы AGENTS.md или CLAUDE.md внутри проекта, ты обязан их читать и соблюдать инструкции. - -EOF - chmod 0644 "$CLAUDE_GLOBAL_PROMPT_FILE" || true - chown 1000:1000 "$CLAUDE_GLOBAL_PROMPT_FILE" || true - fi -fi - -export CLAUDE_AUTO_SYSTEM_PROMPT` - -const escapeForDoubleQuotes = (value: string): string => { - const backslash = String.fromCodePoint(92) - const quote = String.fromCodePoint(34) - const escapedBackslash = `${backslash}${backslash}` - const escapedQuote = `${backslash}${quote}` - return value - .replaceAll(backslash, escapedBackslash) - .replaceAll(quote, escapedQuote) -} - -const renderClaudeGlobalPromptSetup = (config: TemplateConfig): string => - entrypointClaudeGlobalPromptTemplate - .replaceAll("__TARGET_DIR__", config.targetDir) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) - .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) - -const renderClaudeWrapperSetup = (): string => - String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude" -if command -v claude >/dev/null 2>&1; then - CURRENT_CLAUDE_BIN="$(command -v claude)" - CLAUDE_REAL_DIR="$(dirname "$CURRENT_CLAUDE_BIN")" - CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" - - # If a wrapper already exists but points to a missing real binary, recover from /usr/bin. - if [[ "$CURRENT_CLAUDE_BIN" == "$CLAUDE_WRAPPER_BIN" && ! -e "$CLAUDE_REAL_BIN" && -x "/usr/bin/claude" ]]; then - CURRENT_CLAUDE_BIN="/usr/bin/claude" - CLAUDE_REAL_DIR="/usr/bin" - CLAUDE_REAL_BIN="$CLAUDE_REAL_DIR/.docker-git-claude-real" - fi - - # Keep the "real" binary in the same directory as the original command to preserve relative symlinks. - if [[ "$CURRENT_CLAUDE_BIN" != "$CLAUDE_REAL_BIN" && ! -e "$CLAUDE_REAL_BIN" ]]; then - mv "$CURRENT_CLAUDE_BIN" "$CLAUDE_REAL_BIN" - fi - if [[ -e "$CLAUDE_REAL_BIN" ]]; then - cat <<'EOF' > "$CLAUDE_WRAPPER_BIN" -#!/usr/bin/env bash -set -euo pipefail - -CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__" -CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}" -CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token" - -if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then - CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")" - export CLAUDE_CODE_OAUTH_TOKEN -else - unset CLAUDE_CODE_OAUTH_TOKEN || true -fi - -exec "$CLAUDE_REAL_BIN" "$@" -EOF - sed -i "s#__CLAUDE_REAL_BIN__#$CLAUDE_REAL_BIN#g" "$CLAUDE_WRAPPER_BIN" || true - chmod 0755 "$CLAUDE_WRAPPER_BIN" || true - fi -fi` - const renderClaudeProfileSetup = (): string => String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" diff --git a/packages/lib/src/shell/docker-auth.ts b/packages/lib/src/shell/docker-auth.ts index aa2aedd5..1c1a6f1b 100644 --- a/packages/lib/src/shell/docker-auth.ts +++ b/packages/lib/src/shell/docker-auth.ts @@ -1,6 +1,5 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import { readFileSync } from "node:fs" import { Effect } from "effect" import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js" @@ -31,7 +30,17 @@ const resolveEnvValue = (key: string): string | null => { return value && value.length > 0 ? value : null } -const trimTrailingSlash = (value: string): string => value.replace(/[\\/]+$/u, "") +const trimTrailingSlash = (value: string): string => { + let end = value.length + while (end > 0) { + const char = value[end - 1] + if (char !== "/" && char !== "\\") { + break + } + end -= 1 + } + return value.slice(0, end) +} const pathStartsWith = (candidate: string, prefix: string): boolean => candidate === prefix || candidate.startsWith(`${prefix}/`) || candidate.startsWith(`${prefix}\\`) @@ -53,50 +62,62 @@ const resolveContainerProjectsRoot = (): string | null => { const resolveProjectsRootHostOverride = (): string | null => resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT_HOST") -const resolveCurrentContainerId = (): string | null => { +const resolveCurrentContainerId = ( + cwd: string +): Effect.Effect => { const fromEnv = resolveEnvValue("HOSTNAME") if (fromEnv !== null) { - return fromEnv + return Effect.succeed(fromEnv) } - try { - const fromHostnameFile = readFileSync("/etc/hostname", "utf8").trim() - return fromHostnameFile.length > 0 ? fromHostnameFile : null - } catch { - return null - } + return runCommandCapture( + { + cwd, + command: "hostname", + args: [] + }, + [0], + () => new Error("hostname failed") + ).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + Effect.map((value) => (value.length > 0 ? value : null)) + ) } -const parseDockerInspectMounts = (raw: string): ReadonlyArray => { - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return [] - } - return parsed.flatMap((item) => { - if (typeof item !== "object" || item === null) { +const parseDockerInspectMounts = (raw: string): ReadonlyArray => + raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line) => { + const separator = line.indexOf("\t") + if (separator <= 0 || separator >= line.length - 1) { + return [] + } + const source = line.slice(0, separator).trim() + const destination = line.slice(separator + 1).trim() + if (source.length === 0 || destination.length === 0) { return [] } - const source = Reflect.get(item, "Source") - const destination = Reflect.get(item, "Destination") - return typeof source === "string" && typeof destination === "string" - ? [{ source, destination }] - : [] + return [{ source, destination }] }) - } catch { - return [] - } -} export const remapDockerBindHostPathFromMounts = ( hostPath: string, mounts: ReadonlyArray ): string => { - const match = mounts - .filter((mount) => pathStartsWith(hostPath, mount.destination)) - .sort((left, right) => right.destination.length - left.destination.length)[0] + let match: DockerMountBinding | null = null + for (const mount of mounts) { + if (!pathStartsWith(hostPath, mount.destination)) { + continue + } + if (match === null || mount.destination.length > match.destination.length) { + match = mount + } + } - if (match === undefined) { + if (match === null) { return hostPath } @@ -117,7 +138,7 @@ export const resolveDockerVolumeHostPath = ( } } - const containerId = resolveCurrentContainerId() + const containerId = yield* _(resolveCurrentContainerId(cwd)) if (containerId === null) { return hostPath } @@ -127,7 +148,12 @@ export const resolveDockerVolumeHostPath = ( { cwd, command: "docker", - args: ["inspect", containerId, "--format", "{{json .Mounts}}"] + args: [ + "inspect", + containerId, + "--format", + String.raw`{{range .Mounts}}{{println .Source "\t" .Destination}}{{end}}` + ] }, [0], () => new Error("docker inspect current container failed") diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 6d99cfa0..d6b9b707 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -137,13 +137,12 @@ const resolveClaudeAuthMethod = ( const buildClaudeAuthEnv = ( interactive: boolean, oauthToken: string | null = null -): ReadonlyArray => - [ - ...(interactive - ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] - : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`]), - ...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]) - ] +): ReadonlyArray => [ + ...(interactive + ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] + : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`]), + ...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]) +] const ensureClaudeOrchLayout = ( cwd: string diff --git a/packages/lib/src/usecases/auth-sync-claude-seed.ts b/packages/lib/src/usecases/auth-sync-claude-seed.ts new file mode 100644 index 00000000..7ad05046 --- /dev/null +++ b/packages/lib/src/usecases/auth-sync-claude-seed.ts @@ -0,0 +1,154 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { + hasClaudeCredentials, + hasClaudeOauthAccount, + parseJsonRecord, + resolvePathFromBase +} from "./auth-sync-helpers.js" +import { withFsPathContext } from "./runtime.js" + +type ClaudeJsonSyncSpec = { + readonly sourcePath: string + readonly targetPath: string + readonly hasRequiredData: (record: Parameters[0]) => boolean + readonly onWrite: (targetPath: string) => Effect.Effect + readonly seedLabel: string + readonly updateLabel: string +} + +const syncClaudeJsonFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + spec: ClaudeJsonSyncSpec +): Effect.Effect => + Effect.gen(function*(_) { + const sourceExists = yield* _(fs.exists(spec.sourcePath)) + if (!sourceExists) { + return + } + + const sourceInfo = yield* _(fs.stat(spec.sourcePath)) + if (sourceInfo.type !== "File") { + return + } + + const sourceText = yield* _(fs.readFileString(spec.sourcePath)) + const sourceJson = yield* _(parseJsonRecord(sourceText)) + if (!spec.hasRequiredData(sourceJson)) { + return + } + + const targetExists = yield* _(fs.exists(spec.targetPath)) + if (!targetExists) { + yield* _(fs.makeDirectory(path.dirname(spec.targetPath), { recursive: true })) + yield* _(fs.copyFile(spec.sourcePath, spec.targetPath)) + yield* _(spec.onWrite(spec.targetPath)) + yield* _(Effect.log(`Seeded ${spec.seedLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) + return + } + + const targetInfo = yield* _(fs.stat(spec.targetPath)) + if (targetInfo.type !== "File") { + return + } + + const targetText = yield* _(fs.readFileString(spec.targetPath), Effect.orElseSucceed(() => "")) + const targetJson = yield* _(parseJsonRecord(targetText)) + if (!spec.hasRequiredData(targetJson)) { + yield* _(fs.writeFileString(spec.targetPath, sourceText)) + yield* _(spec.onWrite(spec.targetPath)) + yield* _(Effect.log(`Updated ${spec.updateLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) + } + }) + +const syncClaudeHomeJson = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + syncClaudeJsonFile(fs, path, { + sourcePath, + targetPath, + hasRequiredData: hasClaudeOauthAccount, + onWrite: () => Effect.void, + seedLabel: "Claude auth file", + updateLabel: "Claude auth file" + }) + +const syncClaudeCredentialsJson = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + syncClaudeJsonFile(fs, path, { + sourcePath, + targetPath, + hasRequiredData: hasClaudeCredentials, + onWrite: (pathToChmod) => fs.chmod(pathToChmod, 0o600).pipe(Effect.orElseSucceed(() => void 0)), + seedLabel: "Claude credentials", + updateLabel: "Claude credentials" + }) + +const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + + const info = yield* _(fs.stat(filePath)) + if (info.type !== "File") { + return false + } + + const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return text.trim().length > 0 + }) + +// CHANGE: seed docker-git Claude auth store from host-level Claude files +// WHY: Claude Code (v2+) keeps OAuth session in ~/.claude.json and ~/.claude/.credentials.json +// QUOTE(ТЗ): "глобальная авторизация для клода ... должна сама везде настроиться" +// REF: user-request-2026-03-04-claude-global-auth-seed +// SOURCE: https://docs.anthropic.com/en/docs/claude-code/settings (section: \"Files and settings\", mentions ~/.claude.json) +// FORMAT THEOREM: ∀p: project(p) → (host_claude_auth_exists → project_claude_auth_seeded) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: never deletes existing auth data; only seeds missing/incomplete Claude auth files +// COMPLEXITY: O(1) +export const ensureClaudeAuthSeedFromHome = ( + baseDir: string, + claudeAuthPath: string +): Effect.Effect => + withFsPathContext(({ fs, path }) => + Effect.gen(function*(_) { + const homeDir = (process.env["HOME"] ?? "").trim() + if (homeDir.length === 0) { + return + } + + const sourceClaudeJson = path.join(homeDir, ".claude.json") + const sourceCredentials = path.join(homeDir, ".claude", ".credentials.json") + + const claudeRoot = resolvePathFromBase(path, baseDir, claudeAuthPath) + const targetAccountDir = path.join(claudeRoot, "default") + const targetClaudeJson = path.join(targetAccountDir, ".claude.json") + const targetOauthToken = path.join(targetAccountDir, ".oauth-token") + const targetCredentials = path.join(targetAccountDir, ".credentials.json") + const hasTargetOauthToken = yield* _(hasNonEmptyFile(fs, targetOauthToken)) + + yield* _(fs.makeDirectory(targetAccountDir, { recursive: true })) + yield* _(syncClaudeHomeJson(fs, path, sourceClaudeJson, targetClaudeJson)) + if (!hasTargetOauthToken) { + yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials)) + } + }) + ) diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index aef516c2..d4824006 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -7,11 +7,8 @@ import { copyCodexFile, copyDirIfEmpty } from "./auth-copy.js" import { type AuthSyncSpec, defaultCodexConfig, - hasClaudeCredentials, - hasClaudeOauthAccount, isGithubTokenKey, type LegacyOrchPaths, - parseJsonRecord, resolvePathFromBase, shouldCopyEnv, shouldRewriteDockerGitCodexConfig, @@ -20,6 +17,8 @@ import { import { parseEnvEntries, removeEnvKey, upsertEnvKey } from "./env-file.js" import { withFsPathContext } from "./runtime.js" +export { ensureClaudeAuthSeedFromHome } from "./auth-sync-claude-seed.js" + // CHANGE: synchronize GitHub auth keys between env files // WHY: avoid stale per-project tokens that cause clone auth failures after token rotation // QUOTE(ТЗ): n/a @@ -110,148 +109,6 @@ const copyFileIfNeeded = ( }) ) -type ClaudeJsonSyncSpec = { - readonly sourcePath: string - readonly targetPath: string - readonly hasRequiredData: (record: Parameters[0]) => boolean - readonly onWrite: (targetPath: string) => Effect.Effect - readonly seedLabel: string - readonly updateLabel: string -} - -const syncClaudeJsonFile = ( - fs: FileSystem.FileSystem, - path: Path.Path, - spec: ClaudeJsonSyncSpec -): Effect.Effect => - Effect.gen(function*(_) { - const sourceExists = yield* _(fs.exists(spec.sourcePath)) - if (!sourceExists) { - return - } - - const sourceInfo = yield* _(fs.stat(spec.sourcePath)) - if (sourceInfo.type !== "File") { - return - } - - const sourceText = yield* _(fs.readFileString(spec.sourcePath)) - const sourceJson = yield* _(parseJsonRecord(sourceText)) - if (!spec.hasRequiredData(sourceJson)) { - return - } - - const targetExists = yield* _(fs.exists(spec.targetPath)) - if (!targetExists) { - yield* _(fs.makeDirectory(path.dirname(spec.targetPath), { recursive: true })) - yield* _(fs.copyFile(spec.sourcePath, spec.targetPath)) - yield* _(spec.onWrite(spec.targetPath)) - yield* _(Effect.log(`Seeded ${spec.seedLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) - return - } - - const targetInfo = yield* _(fs.stat(spec.targetPath)) - if (targetInfo.type !== "File") { - return - } - - const targetText = yield* _(fs.readFileString(spec.targetPath), Effect.orElseSucceed(() => "")) - const targetJson = yield* _(parseJsonRecord(targetText)) - if (!spec.hasRequiredData(targetJson)) { - yield* _(fs.writeFileString(spec.targetPath, sourceText)) - yield* _(spec.onWrite(spec.targetPath)) - yield* _(Effect.log(`Updated ${spec.updateLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) - } - }) - -const syncClaudeHomeJson = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - syncClaudeJsonFile(fs, path, { - sourcePath, - targetPath, - hasRequiredData: hasClaudeOauthAccount, - onWrite: () => Effect.void, - seedLabel: "Claude auth file", - updateLabel: "Claude auth file" - }) - -const syncClaudeCredentialsJson = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - syncClaudeJsonFile(fs, path, { - sourcePath, - targetPath, - hasRequiredData: hasClaudeCredentials, - onWrite: (pathToChmod) => fs.chmod(pathToChmod, 0o600).pipe(Effect.orElseSucceed(() => void 0)), - seedLabel: "Claude credentials", - updateLabel: "Claude credentials" - }) - -const hasNonEmptyFile = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - - const info = yield* _(fs.stat(filePath)) - if (info.type !== "File") { - return false - } - - const text = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) - return text.trim().length > 0 - }) - -// CHANGE: seed docker-git Claude auth store from host-level Claude files -// WHY: Claude Code (v2+) keeps OAuth session in ~/.claude.json and ~/.claude/.credentials.json -// QUOTE(ТЗ): "глобальная авторизация для клода ... должна сама везде настроиться" -// REF: user-request-2026-03-04-claude-global-auth-seed -// SOURCE: https://docs.anthropic.com/en/docs/claude-code/settings (section: \"Files and settings\", mentions ~/.claude.json) -// FORMAT THEOREM: ∀p: project(p) → (host_claude_auth_exists → project_claude_auth_seeded) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: never deletes existing auth data; only seeds missing/incomplete Claude auth files -// COMPLEXITY: O(1) -export const ensureClaudeAuthSeedFromHome = ( - baseDir: string, - claudeAuthPath: string -): Effect.Effect => - withFsPathContext(({ fs, path }) => - Effect.gen(function*(_) { - const homeDir = (process.env["HOME"] ?? "").trim() - if (homeDir.length === 0) { - return - } - - const sourceClaudeJson = path.join(homeDir, ".claude.json") - const sourceCredentials = path.join(homeDir, ".claude", ".credentials.json") - - const claudeRoot = resolvePathFromBase(path, baseDir, claudeAuthPath) - const targetAccountDir = path.join(claudeRoot, "default") - const targetClaudeJson = path.join(targetAccountDir, ".claude.json") - const targetOauthToken = path.join(targetAccountDir, ".oauth-token") - const targetCredentials = path.join(targetAccountDir, ".credentials.json") - const hasTargetOauthToken = yield* _(hasNonEmptyFile(fs, targetOauthToken)) - - yield* _(fs.makeDirectory(targetAccountDir, { recursive: true })) - yield* _(syncClaudeHomeJson(fs, path, sourceClaudeJson, targetClaudeJson)) - if (!hasTargetOauthToken) { - yield* _(syncClaudeCredentialsJson(fs, path, sourceCredentials, targetCredentials)) - } - }) - ) - // CHANGE: ensure Codex config exists with full-access defaults // WHY: enable all codex commands without extra prompts inside containers // QUOTE(ТЗ): "сразу настраивал полностью весь доступ ко всем командам"