Skip to content

Commit fca4794

Browse files
committed
fix(auth): stabilize claude auth sync and container bootstrap
1 parent a3f9900 commit fca4794

File tree

10 files changed

+496
-83
lines changed

10 files changed

+496
-83
lines changed

packages/app/src/docker-git/menu-project-auth-claude.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Effect } from "effect"
44

55
const oauthTokenFileName = ".oauth-token"
66
const legacyConfigFileName = ".config.json"
7+
const credentialsFileName = ".credentials.json"
8+
const nestedCredentialsFileName = ".claude/.credentials.json"
79

810
const hasFileAtPath = (
911
fs: FileSystem.FileSystem,
@@ -53,7 +55,19 @@ export const hasClaudeAccountCredentials = (
5355
fs: FileSystem.FileSystem,
5456
accountPath: string
5557
): Effect.Effect<boolean, PlatformError> =>
56-
hasFileAtPath(fs, `${accountPath}/${legacyConfigFileName}`).pipe(
58+
hasFileAtPath(fs, `${accountPath}/${credentialsFileName}`).pipe(
59+
Effect.flatMap((hasCredentialsFile) => {
60+
if (hasCredentialsFile) {
61+
return Effect.succeed(true)
62+
}
63+
return hasFileAtPath(fs, `${accountPath}/${nestedCredentialsFileName}`)
64+
}),
65+
Effect.flatMap((hasNestedCredentialsFile) => {
66+
if (hasNestedCredentialsFile) {
67+
return Effect.succeed(true)
68+
}
69+
return hasFileAtPath(fs, `${accountPath}/${legacyConfigFileName}`)
70+
}),
5771
Effect.flatMap((hasConfig) => {
5872
if (hasConfig) {
5973
return Effect.succeed(true)

packages/app/tests/docker-git/entrypoint-auth.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,21 @@ describe("renderEntrypoint auth bridge", () => {
3737
expect(entrypoint).toContain("CLAUDE_CONFIG_DIR=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"")
3838
expect(entrypoint).toContain("docker_git_ensure_claude_cli()")
3939
expect(entrypoint).toContain("claude cli.js not found under npm global root; skip shim restore")
40-
expect(entrypoint).toContain("CLAUDE_SETTINGS_FILE=\"$CLAUDE_CONFIG_DIR/.claude.json\"")
40+
expect(entrypoint).toContain("CLAUDE_CREDENTIALS_FILE=\"$CLAUDE_CONFIG_DIR/.credentials.json\"")
41+
expect(entrypoint).toContain("if [[ -s \"$CLAUDE_CREDENTIALS_FILE\" ]]; then")
42+
expect(entrypoint).toContain("CLAUDE_SETTINGS_FILE=\"${CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}\"")
4143
expect(entrypoint).toContain("nextServers.playwright = {")
4244
expect(entrypoint).toContain("command: \"docker-git-playwright-mcp\"")
4345
expect(entrypoint).toContain("CLAUDE_ROOT_TOKEN_FILE=\"$CLAUDE_AUTH_ROOT/.oauth-token\"")
4446
expect(entrypoint).toContain("CLAUDE_ROOT_CONFIG_FILE=\"$CLAUDE_AUTH_ROOT/.config.json\"")
47+
expect(entrypoint).toContain("CLAUDE_HOME_DIR=\"/home/dev/.claude\"")
48+
expect(entrypoint).toContain("CLAUDE_HOME_JSON=\"/home/dev/.claude.json\"")
49+
expect(entrypoint).toContain("docker_git_link_claude_home_file()")
50+
expect(entrypoint).toContain("docker_git_link_claude_home_file \".oauth-token\"")
51+
expect(entrypoint).toContain("docker_git_link_claude_home_file \".config.json\"")
52+
expect(entrypoint).toContain("docker_git_link_claude_home_file \".claude.json\"")
53+
expect(entrypoint).toContain("docker_git_link_claude_home_file \".credentials.json\"")
54+
expect(entrypoint).toContain("docker_git_link_claude_file \"$CLAUDE_CONFIG_DIR/.claude.json\" \"$CLAUDE_HOME_JSON\"")
4555
expect(entrypoint).toContain("CLAUDE_GLOBAL_PROMPT_FILE=\"/home/dev/.claude/CLAUDE.md\"")
4656
expect(entrypoint).toContain("CLAUDE_AUTO_SYSTEM_PROMPT=\"${CLAUDE_AUTO_SYSTEM_PROMPT:-1}\"")
4757
expect(entrypoint).toContain("docker-git-managed:claude-md")

packages/lib/src/core/templates-entrypoint/claude.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TemplateConfig } from "../domain.js"
22

33
const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude`
4+
const claudeHomeContainerPath = (sshUser: string): string => `/home/${sshUser}/.claude`
45

56
const renderClaudeAuthConfig = (config: TemplateConfig): string =>
67
String
@@ -32,14 +33,55 @@ fi
3233
export CLAUDE_CONFIG_DIR
3334
3435
mkdir -p "$CLAUDE_CONFIG_DIR" || true
36+
CLAUDE_HOME_DIR="${claudeHomeContainerPath(config.sshUser)}"
37+
CLAUDE_HOME_JSON="/home/${config.sshUser}/.claude.json"
38+
mkdir -p "$CLAUDE_HOME_DIR" || true
39+
40+
docker_git_link_claude_file() {
41+
local source_path="$1"
42+
local link_path="$2"
43+
44+
# Preserve user-created regular files and seed config dir once.
45+
if [[ -e "$link_path" && ! -L "$link_path" ]]; then
46+
if [[ -f "$link_path" && ! -e "$source_path" ]]; then
47+
cp "$link_path" "$source_path" || true
48+
chmod 0600 "$source_path" || true
49+
fi
50+
return 0
51+
fi
52+
53+
ln -sfn "$source_path" "$link_path" || true
54+
}
55+
56+
docker_git_link_claude_home_file() {
57+
local relative_path="$1"
58+
local source_path="$CLAUDE_CONFIG_DIR/$relative_path"
59+
local link_path="$CLAUDE_HOME_DIR/$relative_path"
60+
docker_git_link_claude_file "$source_path" "$link_path"
61+
}
62+
63+
docker_git_link_claude_home_file ".oauth-token"
64+
docker_git_link_claude_home_file ".config.json"
65+
docker_git_link_claude_home_file ".claude.json"
66+
docker_git_link_claude_home_file ".credentials.json"
67+
docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON"
3568
3669
CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
70+
CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
3771
docker_git_refresh_claude_oauth_token() {
3872
local token=""
73+
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
74+
unset CLAUDE_CODE_OAUTH_TOKEN || true
75+
return 0
76+
fi
3977
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
4078
token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
4179
fi
42-
export CLAUDE_CODE_OAUTH_TOKEN="$token"
80+
if [[ -n "$token" ]]; then
81+
export CLAUDE_CODE_OAUTH_TOKEN="$token"
82+
else
83+
unset CLAUDE_CODE_OAUTH_TOKEN || true
84+
fi
4385
}
4486
4587
docker_git_refresh_claude_oauth_token`
@@ -89,7 +131,7 @@ docker_git_ensure_claude_cli`
89131

90132
const renderClaudeMcpPlaywrightConfig = (): string =>
91133
String.raw`# Claude Code: keep Playwright MCP config in sync with container settings
92-
CLAUDE_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/.claude.json"
134+
CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}"
93135
docker_git_sync_claude_playwright_mcp() {
94136
CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="$MCP_PLAYWRIGHT_ENABLE" node - <<'NODE'
95137
const fs = require("node:fs")
@@ -248,8 +290,11 @@ set -euo pipefail
248290
CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__"
249291
CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}"
250292
CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
293+
CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
251294
252-
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
295+
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
296+
unset CLAUDE_CODE_OAUTH_TOKEN || true
297+
elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
253298
CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
254299
export CLAUDE_CODE_OAUTH_TOKEN
255300
else
@@ -270,7 +315,10 @@ printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE"
270315
printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE"
271316
cat <<'EOF' >> "$CLAUDE_PROFILE"
272317
CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token"
273-
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
318+
CLAUDE_CREDENTIALS_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
319+
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
320+
unset CLAUDE_CODE_OAUTH_TOKEN || true
321+
elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
274322
export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
275323
else
276324
unset CLAUDE_CODE_OAUTH_TOKEN || true
@@ -280,7 +328,7 @@ chmod 0644 "$CLAUDE_PROFILE" || true
280328
281329
docker_git_upsert_ssh_env "CLAUDE_AUTH_LABEL" "$CLAUDE_AUTH_LABEL"
282330
docker_git_upsert_ssh_env "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR"
283-
docker_git_upsert_ssh_env "CLAUDE_CODE_OAUTH_TOKEN" "$CLAUDE_CODE_OAUTH_TOKEN"
331+
docker_git_upsert_ssh_env "CLAUDE_CODE_OAUTH_TOKEN" "${"$"}{CLAUDE_CODE_OAUTH_TOKEN:-}"
284332
docker_git_upsert_ssh_env "CLAUDE_AUTO_SYSTEM_PROMPT" "$CLAUDE_AUTO_SYSTEM_PROMPT"`
285333

286334
export const renderEntrypointClaudeConfig = (config: TemplateConfig): string =>

packages/lib/src/shell/docker-auth.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@ export type DockerAuthSpec = {
1414
readonly image: string
1515
readonly volume: DockerVolume
1616
readonly entrypoint?: string
17+
readonly user?: string
1718
readonly env?: string | ReadonlyArray<string>
1819
readonly args: ReadonlyArray<string>
1920
readonly interactive: boolean
2021
}
2122

23+
const resolveDefaultDockerUser = (): string | null => {
24+
const getUid = (process as { readonly getuid?: () => number }).getuid
25+
const getGid = (process as { readonly getgid?: () => number }).getgid
26+
if (typeof getUid !== "function" || typeof getGid !== "function") {
27+
return null
28+
}
29+
return `${getUid()}:${getGid()}`
30+
}
31+
2232
const appendEnvArgs = (base: Array<string>, env: string | ReadonlyArray<string>) => {
2333
if (typeof env === "string") {
2434
const trimmed = env.trim()
@@ -38,6 +48,10 @@ const appendEnvArgs = (base: Array<string>, env: string | ReadonlyArray<string>)
3848

3949
const buildDockerArgs = (spec: DockerAuthSpec): ReadonlyArray<string> => {
4050
const base: Array<string> = ["run", "--rm"]
51+
const dockerUser = (spec.user ?? "").trim() || resolveDefaultDockerUser()
52+
if (dockerUser !== null) {
53+
base.push("--user", dockerUser)
54+
}
4155
if (spec.interactive) {
4256
base.push("-it")
4357
}

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { PlatformError } from "@effect/platform/Error"
22
import type * as FileSystem from "@effect/platform/FileSystem"
3-
import type * as Path from "@effect/platform/Path"
3+
import * as Path from "@effect/platform/Path"
44
import { Effect } from "effect"
55

66
import type { CreateCommand } from "../../core/domain.js"
77
import type { FileExistsError } from "../../shell/errors.js"
88
import { writeProjectFiles } from "../../shell/files.js"
9-
import { ensureCodexConfigFile, migrateLegacyOrchLayout, syncAuthArtifacts } from "../auth-sync.js"
9+
import {
10+
ensureClaudeAuthSeedFromHome,
11+
ensureCodexConfigFile,
12+
migrateLegacyOrchLayout,
13+
syncAuthArtifacts
14+
} from "../auth-sync.js"
1015
import { findAuthorizedKeysSource, resolveAuthorizedKeysPath } from "../path-helpers.js"
1116
import { withFsPathContext } from "../runtime.js"
1217
import { resolvePathFromBase } from "./paths.js"
@@ -122,6 +127,7 @@ export const prepareProjectFiles = (
122127
options: PrepareProjectFilesOptions
123128
): Effect.Effect<ReadonlyArray<string>, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> =>
124129
Effect.gen(function*(_) {
130+
const path = yield* _(Path.Path)
125131
const rewriteManagedFiles = options.force || options.forceEnv
126132
const envOnlyRefresh = options.forceEnv && !options.force
127133
const createdFiles = yield* _(
@@ -138,6 +144,8 @@ export const prepareProjectFiles = (
138144
)
139145
)
140146
yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath))
147+
const globalClaudeAuthPath = path.join(path.dirname(globalConfig.codexAuthPath), "claude")
148+
yield* _(ensureClaudeAuthSeedFromHome(baseDir, globalClaudeAuthPath))
141149
yield* _(
142150
syncAuthArtifacts({
143151
sourceBase: baseDir,

packages/lib/src/usecases/apply.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type * as ShellErrors from "../shell/errors.js"
1313
import { writeProjectFiles } from "../shell/files.js"
1414
import { resolveBaseDir } from "../shell/paths.js"
1515
import { applyTemplateOverrides, hasApplyOverrides } from "./apply-overrides.js"
16-
import { ensureCodexConfigFile } from "./auth-sync.js"
16+
import { ensureClaudeAuthSeedFromHome, ensureCodexConfigFile } from "./auth-sync.js"
1717
import { findDockerGitConfigPaths } from "./docker-git-config-search.js"
1818
import { defaultProjectsRoot, findExistingUpwards } from "./path-helpers.js"
1919
import { runDockerComposeUpWithPortCheck } from "./projects-up.js"
@@ -45,6 +45,7 @@ export const applyProjectFiles = (
4545
const resolvedTemplate = applyTemplateOverrides(config.template, command)
4646
yield* _(writeProjectFiles(projectDir, resolvedTemplate, true))
4747
yield* _(ensureCodexConfigFile(projectDir, resolvedTemplate.codexAuthPath))
48+
yield* _(ensureClaudeAuthSeedFromHome(defaultProjectsRoot(projectDir), ".orch/auth/claude"))
4849
return resolvedTemplate
4950
})
5051

@@ -321,6 +322,7 @@ const applyProjectWithUp = (
321322
Effect.gen(function*(_) {
322323
yield* _(Effect.log(`Applying docker-git config and refreshing container in ${projectDir}...`))
323324
yield* _(ensureDockerDaemonAccess(process.cwd()))
325+
yield* _(ensureClaudeAuthSeedFromHome(defaultProjectsRoot(projectDir), ".orch/auth/claude"))
324326
if (hasApplyOverrides(command)) {
325327
yield* _(applyProjectFiles(projectDir, command))
326328
}

packages/lib/src/usecases/auth-claude-oauth.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AuthError, CommandFailedError } from "../shell/errors.js"
1010

1111
const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN"
1212
const tokenMarker = "Your OAuth token (valid for 1 year):"
13+
const tokenFooterMarker = "Store this token securely."
1314
const outputWindowSize = 262_144
1415

1516
const oauthTokenRegex = /([A-Za-z0-9][A-Za-z0-9._-]{20,})/u
@@ -85,8 +86,23 @@ const extractOauthToken = (rawOutput: string): string | null => {
8586
}
8687

8788
const tail = normalized.slice(markerIndex + tokenMarker.length)
88-
const match = oauthTokenRegex.exec(tail)
89-
return match?.[1] ?? null
89+
const footerIndex = tail.indexOf(tokenFooterMarker)
90+
const tokenSection = footerIndex === -1 ? tail : tail.slice(0, footerIndex)
91+
92+
// CHANGE: join wrapped lines in token section before parsing
93+
// WHY: some terminals hard-wrap long OAuth tokens with newline characters
94+
// REF: issue-377
95+
// SOURCE: n/a
96+
// PURITY: CORE
97+
// INVARIANT: only whitespace is removed; token alphabet remains intact
98+
const compactSection = tokenSection.replaceAll(/\s+/gu, "")
99+
const compactMatch = oauthTokenRegex.exec(compactSection)
100+
if (compactMatch?.[1] !== undefined) {
101+
return compactMatch[1]
102+
}
103+
104+
const directMatch = oauthTokenRegex.exec(tokenSection)
105+
return directMatch?.[1] ?? null
90106
}
91107

92108
const oauthTokenFromEnv = (): string | null => {
@@ -120,12 +136,17 @@ const buildDockerSetupTokenSpec = (
120136
image,
121137
hostPath: accountPath,
122138
containerPath,
123-
env: [`CLAUDE_CONFIG_DIR=${containerPath}`],
139+
env: [`CLAUDE_CONFIG_DIR=${containerPath}`, `HOME=${containerPath}`, "BROWSER=echo"],
124140
args: ["setup-token"]
125141
})
126142

127143
const buildDockerSetupTokenArgs = (spec: DockerSetupTokenSpec): ReadonlyArray<string> => {
128144
const base: Array<string> = ["run", "--rm", "-i", "-t", "-v", `${spec.hostPath}:${spec.containerPath}`]
145+
const getUid = (process as { readonly getuid?: () => number }).getuid
146+
const getGid = (process as { readonly getgid?: () => number }).getgid
147+
if (typeof getUid === "function" && typeof getGid === "function") {
148+
base.push("--user", `${getUid()}:${getGid()}`)
149+
}
129150
for (const entry of spec.env) {
130151
const trimmed = entry.trim()
131152
if (trimmed.length === 0) {
@@ -187,9 +208,6 @@ const pumpDockerOutput = (
187208
).pipe(Effect.asVoid)
188209
}
189210

190-
const ensureExitOk = (exitCode: number): Effect.Effect<void, CommandFailedError> =>
191-
exitCode === 0 ? Effect.void : Effect.fail(new CommandFailedError({ command: "claude setup-token", exitCode }))
192-
193211
const resolveCapturedToken = (token: string | null): Effect.Effect<string, AuthError> =>
194212
token === null
195213
? Effect.fail(
@@ -200,6 +218,29 @@ const resolveCapturedToken = (token: string | null): Effect.Effect<string, AuthE
200218
)
201219
: ensureOauthToken(token)
202220

221+
const resolveLoginResult = (
222+
token: string | null,
223+
exitCode: number
224+
): Effect.Effect<string, AuthError | CommandFailedError> =>
225+
Effect.gen(function*(_) {
226+
if (token !== null) {
227+
if (exitCode !== 0) {
228+
yield* _(
229+
Effect.logWarning(
230+
`claude setup-token returned exit=${exitCode}, but OAuth token was captured; continuing.`
231+
)
232+
)
233+
}
234+
return yield* _(ensureOauthToken(token))
235+
}
236+
237+
if (exitCode !== 0) {
238+
yield* _(Effect.fail(new CommandFailedError({ command: "claude setup-token", exitCode })))
239+
}
240+
241+
return yield* _(resolveCapturedToken(token))
242+
})
243+
203244
export const runClaudeOauthLoginWithPrompt = (
204245
cwd: string,
205246
accountPath: string,
@@ -226,9 +267,7 @@ export const runClaudeOauthLoginWithPrompt = (
226267
const exitCode = yield* _(proc.exitCode.pipe(Effect.map(Number)))
227268
yield* _(Fiber.join(stdoutFiber))
228269
yield* _(Fiber.join(stderrFiber))
229-
yield* _(ensureExitOk(exitCode))
230-
231-
return yield* _(resolveCapturedToken(tokenBox.value))
270+
return yield* _(resolveLoginResult(tokenBox.value, exitCode))
232271
})
233272
)
234273
}

0 commit comments

Comments
 (0)