Skip to content

Commit ddae8c8

Browse files
committed
fix(claude): stabilize oauth auth and permission parity
1 parent 89ade10 commit ddae8c8

File tree

9 files changed

+434
-46
lines changed

9 files changed

+434
-46
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ This avoids `refresh_token` rotation issues that can happen when copying `auth.j
115115
Disable sharing (per-project auth):
116116
- Set `CODEX_SHARE_AUTH=0` in `.orch/env/project.env`.
117117

118+
## Claude Code Defaults
119+
120+
On container start, docker-git syncs Claude Code user settings under `$CLAUDE_CONFIG_DIR/settings.json`:
121+
- `permissions.defaultMode = "bypassPermissions"` so local disposable containers behave like docker-git Codex containers (no permission prompts).
122+
- Existing unrelated Claude settings are preserved.
123+
118124
## Playwright MCP (Chromium Sidecar)
119125

120126
Enable during create/clone:

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,20 @@ describe("renderEntrypoint auth bridge", () => {
3939
expect(entrypoint).toContain("CLAUDE_CONFIG_DIR=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"")
4040
expect(entrypoint).toContain("docker_git_ensure_claude_cli()")
4141
expect(entrypoint).toContain("claude cli.js not found under npm global root; skip shim restore")
42+
expect(entrypoint).toContain("CLAUDE_PERMISSION_SETTINGS_FILE=\"$CLAUDE_CONFIG_DIR/settings.json\"")
43+
expect(entrypoint).toContain("docker_git_sync_claude_permissions()")
44+
expect(entrypoint).toContain(
45+
"const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {}"
46+
)
47+
expect(entrypoint).toContain("defaultMode: \"bypassPermissions\"")
48+
expect(entrypoint).toContain("CLAUDE_TOKEN_FILE=\"$CLAUDE_CONFIG_DIR/.oauth-token\"")
4249
expect(entrypoint).toContain("CLAUDE_CREDENTIALS_FILE=\"$CLAUDE_CONFIG_DIR/.credentials.json\"")
43-
expect(entrypoint).toContain("if [[ -s \"$CLAUDE_CREDENTIALS_FILE\" ]]; then")
50+
expect(entrypoint).toContain("CLAUDE_NESTED_CREDENTIALS_FILE=\"$CLAUDE_CONFIG_DIR/.claude/.credentials.json\"")
51+
expect(entrypoint).toContain("docker_git_prepare_claude_auth_mode()")
52+
expect(entrypoint).toContain(
53+
"rm -f \"$CLAUDE_CREDENTIALS_FILE\" \"$CLAUDE_NESTED_CREDENTIALS_FILE\" \"$CLAUDE_HOME_DIR/.credentials.json\" || true"
54+
)
55+
expect(entrypoint).toContain("if [[ ! -s \"$CLAUDE_TOKEN_FILE\" ]]; then")
4456
expect(entrypoint).toContain("CLAUDE_SETTINGS_FILE=\"${CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}\"")
4557
expect(entrypoint).toContain("nextServers.playwright = {")
4658
expect(entrypoint).toContain("command: \"docker-git-playwright-mcp\"")

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

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ mkdir -p "$CLAUDE_CONFIG_DIR" || true
3434
CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__"
3535
CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__"
3636
mkdir -p "$CLAUDE_HOME_DIR" || true
37+
CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
38+
CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
39+
CLAUDE_NESTED_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.claude/.credentials.json"
40+
41+
docker_git_prepare_claude_auth_mode() {
42+
if [[ -s "$CLAUDE_TOKEN_FILE" ]]; then
43+
rm -f "$CLAUDE_CREDENTIALS_FILE" "$CLAUDE_NESTED_CREDENTIALS_FILE" "$CLAUDE_HOME_DIR/.credentials.json" || true
44+
fi
45+
}
46+
47+
docker_git_prepare_claude_auth_mode
3748
3849
docker_git_link_claude_file() {
3950
local source_path="$1"
@@ -61,17 +72,13 @@ docker_git_link_claude_home_file() {
6172
docker_git_link_claude_home_file ".oauth-token"
6273
docker_git_link_claude_home_file ".config.json"
6374
docker_git_link_claude_home_file ".claude.json"
64-
docker_git_link_claude_home_file ".credentials.json"
75+
if [[ ! -s "$CLAUDE_TOKEN_FILE" ]]; then
76+
docker_git_link_claude_home_file ".credentials.json"
77+
fi
6578
docker_git_link_claude_file "$CLAUDE_CONFIG_DIR/.claude.json" "$CLAUDE_HOME_JSON"
6679
67-
CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
68-
CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
6980
docker_git_refresh_claude_oauth_token() {
7081
local token=""
71-
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
72-
unset CLAUDE_CODE_OAUTH_TOKEN || true
73-
return 0
74-
fi
7582
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
7683
token="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
7784
fi
@@ -133,6 +140,53 @@ EOF
133140
134141
docker_git_ensure_claude_cli`
135142

143+
const renderClaudePermissionSettingsConfig = (): string =>
144+
String.raw`# Claude Code: keep permission settings in sync with docker-git defaults
145+
CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_CONFIG_DIR/settings.json"
146+
docker_git_sync_claude_permissions() {
147+
CLAUDE_PERMISSION_SETTINGS_FILE="$CLAUDE_PERMISSION_SETTINGS_FILE" node - <<'NODE'
148+
const fs = require("node:fs")
149+
const path = require("node:path")
150+
151+
const settingsPath = process.env.CLAUDE_PERMISSION_SETTINGS_FILE
152+
if (typeof settingsPath !== "string" || settingsPath.length === 0) {
153+
process.exit(0)
154+
}
155+
156+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
157+
158+
let settings = {}
159+
try {
160+
const raw = fs.readFileSync(settingsPath, "utf8")
161+
const parsed = JSON.parse(raw)
162+
settings = isRecord(parsed) ? parsed : {}
163+
} catch {
164+
settings = {}
165+
}
166+
167+
const currentPermissions = isRecord(settings.permissions) ? settings.permissions : {}
168+
const nextPermissions = {
169+
...currentPermissions,
170+
defaultMode: "bypassPermissions"
171+
}
172+
const nextSettings = {
173+
...settings,
174+
permissions: nextPermissions
175+
}
176+
177+
if (JSON.stringify(settings) === JSON.stringify(nextSettings)) {
178+
process.exit(0)
179+
}
180+
181+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
182+
fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 })
183+
NODE
184+
}
185+
186+
docker_git_sync_claude_permissions
187+
chmod 0600 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true
188+
chown 1000:1000 "$CLAUDE_PERMISSION_SETTINGS_FILE" 2>/dev/null || true`
189+
136190
const renderClaudeMcpPlaywrightConfig = (): string =>
137191
String.raw`# Claude Code: keep Playwright MCP config in sync with container settings
138192
CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}"
@@ -295,11 +349,8 @@ set -euo pipefail
295349
CLAUDE_REAL_BIN="__CLAUDE_REAL_BIN__"
296350
CLAUDE_CONFIG_DIR="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}"
297351
CLAUDE_TOKEN_FILE="$CLAUDE_CONFIG_DIR/.oauth-token"
298-
CLAUDE_CREDENTIALS_FILE="$CLAUDE_CONFIG_DIR/.credentials.json"
299352
300-
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
301-
unset CLAUDE_CODE_OAUTH_TOKEN || true
302-
elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
353+
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
303354
CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
304355
export CLAUDE_CODE_OAUTH_TOKEN
305356
else
@@ -320,10 +371,7 @@ printf "export CLAUDE_CONFIG_DIR=%q\n" "$CLAUDE_CONFIG_DIR" >> "$CLAUDE_PROFILE"
320371
printf "export CLAUDE_AUTO_SYSTEM_PROMPT=%q\n" "$CLAUDE_AUTO_SYSTEM_PROMPT" >> "$CLAUDE_PROFILE"
321372
cat <<'EOF' >> "$CLAUDE_PROFILE"
322373
CLAUDE_TOKEN_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.oauth-token"
323-
CLAUDE_CREDENTIALS_FILE="${"$"}{CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
324-
if [[ -s "$CLAUDE_CREDENTIALS_FILE" ]]; then
325-
unset CLAUDE_CODE_OAUTH_TOKEN || true
326-
elif [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
374+
if [[ -f "$CLAUDE_TOKEN_FILE" ]]; then
327375
export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$CLAUDE_TOKEN_FILE")"
328376
else
329377
unset CLAUDE_CODE_OAUTH_TOKEN || true
@@ -340,6 +388,7 @@ export const renderEntrypointClaudeConfig = (config: TemplateConfig): string =>
340388
[
341389
renderClaudeAuthConfig(config),
342390
renderClaudeCliInstall(),
391+
renderClaudePermissionSettingsConfig(),
343392
renderClaudeMcpPlaywrightConfig(),
344393
renderClaudeGlobalPromptSetup(config),
345394
renderClaudeWrapperSetup(),

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

Lines changed: 156 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
3-
import type { Effect } from "effect"
3+
import { readFileSync } from "node:fs"
4+
import { Effect } from "effect"
45

56
import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js"
67

@@ -20,6 +21,122 @@ export type DockerAuthSpec = {
2021
readonly interactive: boolean
2122
}
2223

24+
type DockerMountBinding = {
25+
readonly source: string
26+
readonly destination: string
27+
}
28+
29+
const resolveEnvValue = (key: string): string | null => {
30+
const value = process.env[key]?.trim()
31+
return value && value.length > 0 ? value : null
32+
}
33+
34+
const trimTrailingSlash = (value: string): string => value.replace(/[\\/]+$/u, "")
35+
36+
const pathStartsWith = (candidate: string, prefix: string): boolean =>
37+
candidate === prefix || candidate.startsWith(`${prefix}/`) || candidate.startsWith(`${prefix}\\`)
38+
39+
const translatePathPrefix = (candidate: string, sourcePrefix: string, targetPrefix: string): string | null =>
40+
pathStartsWith(candidate, sourcePrefix)
41+
? `${targetPrefix}${candidate.slice(sourcePrefix.length)}`
42+
: null
43+
44+
const resolveContainerProjectsRoot = (): string | null => {
45+
const explicit = resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT")
46+
if (explicit !== null) {
47+
return explicit
48+
}
49+
50+
const home = resolveEnvValue("HOME") ?? resolveEnvValue("USERPROFILE")
51+
return home === null ? null : `${trimTrailingSlash(home)}/.docker-git`
52+
}
53+
54+
const resolveProjectsRootHostOverride = (): string | null => resolveEnvValue("DOCKER_GIT_PROJECTS_ROOT_HOST")
55+
56+
const resolveCurrentContainerId = (): string | null => {
57+
const fromEnv = resolveEnvValue("HOSTNAME")
58+
if (fromEnv !== null) {
59+
return fromEnv
60+
}
61+
62+
try {
63+
const fromHostnameFile = readFileSync("/etc/hostname", "utf8").trim()
64+
return fromHostnameFile.length > 0 ? fromHostnameFile : null
65+
} catch {
66+
return null
67+
}
68+
}
69+
70+
const parseDockerInspectMounts = (raw: string): ReadonlyArray<DockerMountBinding> => {
71+
try {
72+
const parsed = JSON.parse(raw) as unknown
73+
if (!Array.isArray(parsed)) {
74+
return []
75+
}
76+
return parsed.flatMap((item) => {
77+
if (typeof item !== "object" || item === null) {
78+
return []
79+
}
80+
const source = Reflect.get(item, "Source")
81+
const destination = Reflect.get(item, "Destination")
82+
return typeof source === "string" && typeof destination === "string"
83+
? [{ source, destination }]
84+
: []
85+
})
86+
} catch {
87+
return []
88+
}
89+
}
90+
91+
export const remapDockerBindHostPathFromMounts = (
92+
hostPath: string,
93+
mounts: ReadonlyArray<DockerMountBinding>
94+
): string => {
95+
const match = mounts
96+
.filter((mount) => pathStartsWith(hostPath, mount.destination))
97+
.sort((left, right) => right.destination.length - left.destination.length)[0]
98+
99+
if (match === undefined) {
100+
return hostPath
101+
}
102+
103+
return `${match.source}${hostPath.slice(match.destination.length)}`
104+
}
105+
106+
export const resolveDockerVolumeHostPath = (
107+
cwd: string,
108+
hostPath: string
109+
): Effect.Effect<string, never, CommandExecutor.CommandExecutor> =>
110+
Effect.gen(function*(_) {
111+
const containerProjectsRoot = resolveContainerProjectsRoot()
112+
const hostProjectsRoot = resolveProjectsRootHostOverride()
113+
if (containerProjectsRoot !== null && hostProjectsRoot !== null) {
114+
const remapped = translatePathPrefix(hostPath, containerProjectsRoot, hostProjectsRoot)
115+
if (remapped !== null) {
116+
return remapped
117+
}
118+
}
119+
120+
const containerId = resolveCurrentContainerId()
121+
if (containerId === null) {
122+
return hostPath
123+
}
124+
125+
const mountsJson = yield* _(
126+
runCommandCapture(
127+
{
128+
cwd,
129+
command: "docker",
130+
args: ["inspect", containerId, "--format", "{{json .Mounts}}"]
131+
},
132+
[0],
133+
() => new Error("docker inspect current container failed")
134+
).pipe(Effect.orElseSucceed(() => ""))
135+
)
136+
137+
return remapDockerBindHostPathFromMounts(hostPath, parseDockerInspectMounts(mountsJson))
138+
})
139+
23140
export const resolveDefaultDockerUser = (): string | null => {
24141
const getUid = Reflect.get(process, "getuid")
25142
const getGid = Reflect.get(process, "getgid")
@@ -93,11 +210,20 @@ export const runDockerAuth = <E>(
93210
okExitCodes: ReadonlyArray<number>,
94211
onFailure: (exitCode: number) => E
95212
): Effect.Effect<void, E | PlatformError, CommandExecutor.CommandExecutor> =>
96-
runCommandWithExitCodes(
97-
{ cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) },
98-
okExitCodes,
99-
onFailure
100-
)
213+
Effect.gen(function*(_) {
214+
const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath))
215+
yield* _(
216+
runCommandWithExitCodes(
217+
{
218+
cwd: spec.cwd,
219+
command: "docker",
220+
args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
221+
},
222+
okExitCodes,
223+
onFailure
224+
)
225+
)
226+
})
101227

102228
// CHANGE: run a docker auth command and capture stdout
103229
// WHY: obtain tokens from container auth flows
@@ -114,11 +240,20 @@ export const runDockerAuthCapture = <E>(
114240
okExitCodes: ReadonlyArray<number>,
115241
onFailure: (exitCode: number) => E
116242
): Effect.Effect<string, E | PlatformError, CommandExecutor.CommandExecutor> =>
117-
runCommandCapture(
118-
{ cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) },
119-
okExitCodes,
120-
onFailure
121-
)
243+
Effect.gen(function*(_) {
244+
const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath))
245+
return yield* _(
246+
runCommandCapture(
247+
{
248+
cwd: spec.cwd,
249+
command: "docker",
250+
args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
251+
},
252+
okExitCodes,
253+
onFailure
254+
)
255+
)
256+
})
122257

123258
// CHANGE: run a docker auth command and return the exit code
124259
// WHY: allow status checks without throwing
@@ -133,4 +268,13 @@ export const runDockerAuthCapture = <E>(
133268
export const runDockerAuthExitCode = (
134269
spec: DockerAuthSpec
135270
): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> =>
136-
runCommandExitCode({ cwd: spec.cwd, command: "docker", args: buildDockerArgs(spec) })
271+
Effect.gen(function*(_) {
272+
const hostPath = yield* _(resolveDockerVolumeHostPath(spec.cwd, spec.volume.hostPath))
273+
return yield* _(
274+
runCommandExitCode({
275+
cwd: spec.cwd,
276+
command: "docker",
277+
args: buildDockerArgs({ ...spec, volume: { ...spec.volume, hostPath } })
278+
})
279+
)
280+
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as Fiber from "effect/Fiber"
66
import type * as Scope from "effect/Scope"
77
import * as Stream from "effect/Stream"
88

9-
import { resolveDefaultDockerUser } from "../shell/docker-auth.js"
9+
import { resolveDefaultDockerUser, resolveDockerVolumeHostPath } from "../shell/docker-auth.js"
1010
import { AuthError, CommandFailedError } from "../shell/errors.js"
1111

1212
const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN"
@@ -257,7 +257,8 @@ export const runClaudeOauthLoginWithPrompt = (
257257
return Effect.scoped(
258258
Effect.gen(function*(_) {
259259
const executor = yield* _(CommandExecutor.CommandExecutor)
260-
const spec = buildDockerSetupTokenSpec(cwd, accountPath, options.image, options.containerPath)
260+
const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath))
261+
const spec = buildDockerSetupTokenSpec(cwd, hostPath, options.image, options.containerPath)
261262
const proc = yield* _(startDockerProcess(executor, spec))
262263

263264
const tokenBox: { value: string | null } = { value: null }

0 commit comments

Comments
 (0)