Skip to content

Commit c114e64

Browse files
committed
fix(auth): isolate Claude probes and state sync locks
1 parent aedf7d3 commit c114e64

7 files changed

Lines changed: 452 additions & 31 deletions

File tree

packages/auth-oauth/src/claude-docker-oauth.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818

1919
export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest"
2020
export const defaultClaudeDockerOauthContainerHome = "/claude-home"
21+
const claudeDockerOauthProbeConfigDir = "/tmp/docker-git-claude-probe"
2122
export const claudeDockerOauthBaseImage =
2223
"node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc"
2324
export const claudeDockerOauthClaudeCodeVersion = "2.1.195"
@@ -186,10 +187,20 @@ const buildDockerSetupTokenArgs = (
186187
return args
187188
}
188189

190+
// CHANGE: probe captured OAuth tokens without reading persisted account settings
191+
// WHY: account settings may enable bypassPermissions for real sessions, but Claude rejects that mode in privileged probe contexts
192+
// QUOTE(ТЗ): "почему не работает команда bun run docker-git auth claude login"
193+
// REF: user-report-2026-07-01-claude-auth-login
194+
// SOURCE: n/a
195+
// FORMAT THEOREM: forall token: probe(token) uses env(token) and not account(settings.json)
196+
// PURITY: CORE
197+
// INVARIANT: setup-token validation cannot fail because of account permission settings
198+
// COMPLEXITY: O(1)
189199
const buildDockerProbeArgs = (
190200
image: string,
191201
hostPath: string,
192-
containerPath: string
202+
containerPath: string,
203+
oauthToken: string
193204
): ReadonlyArray<string> => {
194205
const args: Array<string> = [
195206
"run",
@@ -204,9 +215,11 @@ const buildDockerProbeArgs = (
204215
}
205216
args.push(
206217
"-e",
207-
`CLAUDE_CONFIG_DIR=${containerPath}`,
218+
`CLAUDE_CONFIG_DIR=${claudeDockerOauthProbeConfigDir}`,
208219
"-e",
209-
`HOME=${containerPath}`,
220+
`HOME=${claudeDockerOauthProbeConfigDir}`,
221+
"-e",
222+
`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`,
210223
image,
211224
"-p",
212225
"ping"
@@ -341,7 +354,7 @@ export const runClaudeDockerOauth = async (
341354
await writeCapturedToken(accountPath, result.token)
342355
const probeExitCode = await (options.runProbe ?? runDockerProbe)({
343356
dockerCommand,
344-
args: buildDockerProbeArgs(image, dockerHostPath, containerPath),
357+
args: buildDockerProbeArgs(image, dockerHostPath, containerPath, result.token),
345358
cwd
346359
})
347360
return {

packages/auth-oauth/tests/claude-docker-oauth.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const oauthTokenArbitrary = fc.array(fc.constantFrom(
4040
minLength: 24,
4141
maxLength: 64
4242
}).map((chars) => `${oauthTokenPrefix}${chars.join("")}`)
43+
const dockerEnvEntries = (args: ReadonlyArray<string>): ReadonlyArray<string> =>
44+
args.flatMap((arg, index) => args[index - 1] === "-e" ? [arg] : [])
4345

4446
const temporaryAccountPath = (prefix: string) =>
4547
Effect.acquireRelease(
@@ -98,6 +100,13 @@ describe("Claude Docker OAuth runner", () => {
98100
expect(setupRuns[0]?.args.join(" ")).toContain(accountPath)
99101
expect(probeRuns).toHaveLength(1)
100102
expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"])
103+
expect(dockerEnvEntries(probeRuns[0]?.args ?? [])).toEqual(
104+
expect.arrayContaining([
105+
"CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe",
106+
"HOME=/tmp/docker-git-claude-probe",
107+
`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`
108+
])
109+
)
101110
const tokenMode = yield* _(Effect.tryPromise(() => stat(claudeOauthTokenPath(accountPath))))
102111
expect(tokenMode.mode & 0o777).toBe(claudeOauthTokenFileMode)
103112
})))

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
formatClaudeOauthTokenFile
99
} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token"
1010
import { renderClaudeDockerOauthDockerfile } from "@prover-coder-ai/docker-git-auth-oauth/claude-docker-oauth"
11-
import { Effect } from "effect"
11+
import { Effect, Match } from "effect"
1212

1313
import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js"
1414
import { defaultTemplateConfig } from "../core/domain.js"
@@ -27,6 +27,9 @@ import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volati
2727

2828
type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
2929
type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session"
30+
type ClaudeProbeAuth =
31+
| { readonly _tag: "ClaudeProbeAccountConfig" }
32+
| { readonly _tag: "ClaudeProbeOauthToken"; readonly token: string }
3033

3134
type ClaudeAccountContext = {
3235
readonly accountLabel: string
@@ -41,6 +44,7 @@ export const claudeAuthRoot = ".docker-git/.orch/auth/claude"
4144
const claudeImageName = "docker-git-auth-claude:latest"
4245
const claudeImageDir = ".docker-git/.orch/auth/claude/.image"
4346
const claudeContainerHomeDir = "/claude-home"
47+
const claudeProbeConfigDir = "/tmp/docker-git-claude-probe"
4448
const claudeConfigFileName = ".claude.json"
4549
const claudeCredentialsFileName = ".credentials.json"
4650
const claudeCredentialsDirName = ".claude"
@@ -178,6 +182,26 @@ const buildClaudeAuthEnv = (
178182
...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`])
179183
]
180184

185+
// CHANGE: isolate non-interactive Claude OAuth probes from account settings
186+
// WHY: account settings may intentionally use bypassPermissions for real sessions, but Claude rejects that mode under root/sudo probe contexts
187+
// QUOTE(ТЗ): "почему не работает команда bun run docker-git auth claude login"
188+
// REF: user-report-2026-07-01-claude-auth-login
189+
// SOURCE: n/a
190+
// FORMAT THEOREM: forall token: probe(token) reads token env and not account(settings.json)
191+
// PURITY: CORE
192+
// INVARIANT: probe uses the persisted OAuth token without inheriting account permission settings
193+
// COMPLEXITY: O(1)
194+
const buildClaudeProbeEnv = (auth: ClaudeProbeAuth): ReadonlyArray<string> =>
195+
Match.value(auth).pipe(
196+
Match.when({ _tag: "ClaudeProbeAccountConfig" }, () => buildClaudeAuthEnv(false)),
197+
Match.when({ _tag: "ClaudeProbeOauthToken" }, ({ token }) => [
198+
`HOME=${claudeProbeConfigDir}`,
199+
`CLAUDE_CONFIG_DIR=${claudeProbeConfigDir}`,
200+
`CLAUDE_CODE_OAUTH_TOKEN=${token}`
201+
]),
202+
Match.exhaustive
203+
)
204+
181205
const ensureClaudeOrchLayout = (
182206
cwd: string
183207
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
@@ -252,15 +276,15 @@ const runClaudeLogout = (
252276
const runClaudePingProbeExitCode = (
253277
cwd: string,
254278
accountPath: string,
255-
oauthToken: string | null
279+
auth: ClaudeProbeAuth
256280
): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> =>
257281
runDockerAuthExitCode(
258282
buildDockerAuthSpec({
259283
cwd,
260284
image: claudeImageName,
261285
hostPath: accountPath,
262286
containerPath: claudeContainerHomeDir,
263-
env: buildClaudeAuthEnv(false, oauthToken),
287+
env: buildClaudeProbeEnv(auth),
264288
args: ["-p", "ping"],
265289
interactive: false
266290
})
@@ -288,7 +312,10 @@ export const authClaudeLogin = (
288312
}),
289313
persistToken: (token) => persistClaudeOauthToken(fs, path, accountPath, token),
290314
normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid),
291-
probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, token),
315+
probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, {
316+
_tag: "ClaudeProbeOauthToken",
317+
token
318+
}),
292319
syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`)
293320
}).pipe(Effect.asVoid))
294321

@@ -314,7 +341,10 @@ export const authClaudeStatus = (
314341
}
315342

316343
const oauthToken = method === "oauth-token" ? yield* _(readOauthToken(fs, accountPath)) : null
317-
const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, oauthToken))
344+
const probeAuth: ClaudeProbeAuth = method === "oauth-token" && oauthToken !== null
345+
? { _tag: "ClaudeProbeOauthToken", token: oauthToken }
346+
: { _tag: "ClaudeProbeAccountConfig" }
347+
const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, probeAuth))
318348
if (probeExitCode === 0) {
319349
yield* _(Effect.log(`Claude connected (${accountLabel}, ${method}).`))
320350
return

packages/lib/src/usecases/state-repo.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,24 @@ import { withStateGitLock } from "./state-repo/lock.js"
4242

4343
type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
4444
const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))
45+
const resolveGitIndexLockPath = (path: Path.Path, root: string): string => path.join(root, ".git", "index.lock")
4546
const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]
47+
48+
const renderStateSyncFailure = (error: CommandFailedError | PlatformError): string =>
49+
error._tag === "CommandFailedError"
50+
? `${error.command} (exit ${error.exitCode})`
51+
: String(error)
52+
53+
const logStateAutoSyncFailure = (
54+
error: CommandFailedError | PlatformError
55+
): Effect.Effect<void> =>
56+
Effect.logWarning(`State auto-sync failed: ${renderStateSyncFailure(error)}`)
57+
58+
const logStateAutoPullFailure = (
59+
error: CommandFailedError | PlatformError
60+
): Effect.Effect<void> =>
61+
Effect.logWarning(`State auto-pull failed: ${renderStateSyncFailure(error)}`)
62+
4663
const ensureStateIgnoreAndUntrackCaches = (
4764
fs: FileSystem.FileSystem,
4865
path: Path.Path,
@@ -107,6 +124,7 @@ export const stateSync = (message: string | null) => withStateGitLock(stateSyncR
107124

108125
const autoSyncStateRaw = (message: string): Effect.Effect<void, never, StateRepoEnv> =>
109126
Effect.gen(function*(_) {
127+
const fs = yield* _(FileSystem.FileSystem)
110128
const path = yield* _(Path.Path)
111129
const root = resolveStateRoot(path, process.cwd())
112130
const isRepoOk = yield* _(isGitRepo(root))
@@ -120,6 +138,18 @@ const autoSyncStateRaw = (message: string): Effect.Effect<void, never, StateRepo
120138
}
121139
const strictValue = process.env[autoSyncStrictEnvKey]
122140
const isStrict = strictValue !== undefined && strictValue.trim().length > 0 ? isTruthyEnv(strictValue) : false
141+
if (!isStrict) {
142+
const indexLockPath = resolveGitIndexLockPath(path, root)
143+
const hasIndexLock = yield* _(fs.exists(indexLockPath))
144+
if (hasIndexLock) {
145+
yield* _(
146+
Effect.logWarning(
147+
`State auto-sync skipped: git index lock exists at ${indexLockPath}. Another git process may be running; retry later.`
148+
)
149+
)
150+
return
151+
}
152+
}
123153
const effect = stateSyncRaw(message)
124154
if (isStrict) {
125155
yield* _(effect)
@@ -128,14 +158,7 @@ const autoSyncStateRaw = (message: string): Effect.Effect<void, never, StateRepo
128158
yield* _(
129159
effect.pipe(
130160
Effect.matchEffect({
131-
onFailure: (error) =>
132-
Effect.logWarning(
133-
`State auto-sync failed: ${
134-
error._tag === "CommandFailedError"
135-
? `${error.command} (exit ${error.exitCode})`
136-
: String(error)
137-
}`
138-
),
161+
onFailure: logStateAutoSyncFailure,
139162
onSuccess: () => Effect.void
140163
})
141164
)
@@ -184,15 +207,28 @@ const autoPullStateRaw: Effect.Effect<void, never, StateRepoEnv> = Effect.gen(fu
184207
)
185208
}).pipe(
186209
Effect.matchEffect({
187-
onFailure: (error) => Effect.logWarning(`State auto-pull failed: ${String(error)}`),
210+
onFailure: logStateAutoPullFailure,
188211
onSuccess: () => Effect.void
189212
}),
190213
Effect.asVoid
191214
)
192215

193-
export const autoSyncState = (message: string) => withStateGitLock(autoSyncStateRaw(message))
216+
export const autoSyncState = (message: string): Effect.Effect<void, never, StateRepoEnv> =>
217+
withStateGitLock(autoSyncStateRaw(message)).pipe(
218+
Effect.matchEffect({
219+
onFailure: logStateAutoSyncFailure,
220+
onSuccess: () => Effect.void
221+
}),
222+
Effect.asVoid
223+
)
194224

195-
export const autoPullState: Effect.Effect<void, never, StateRepoEnv> = withStateGitLock(autoPullStateRaw)
225+
export const autoPullState: Effect.Effect<void, never, StateRepoEnv> = withStateGitLock(autoPullStateRaw).pipe(
226+
Effect.matchEffect({
227+
onFailure: logStateAutoPullFailure,
228+
onSuccess: () => Effect.void
229+
}),
230+
Effect.asVoid
231+
)
196232

197233
// Internal pull that takes an already-resolved root, reusing auth logic from pull-push.
198234
const statePullInternal = (
Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,49 @@
1-
import { Effect } from "effect"
1+
import type { PlatformError } from "@effect/platform/Error"
2+
import * as FileSystem from "@effect/platform/FileSystem"
3+
import * as Path from "@effect/platform/Path"
4+
import { Duration, Effect } from "effect"
5+
6+
import { CommandFailedError } from "../../shell/errors.js"
7+
import { defaultProjectsRoot } from "../menu-helpers.js"
28

39
const stateGitLock = Effect.unsafeMakeSemaphore(1)
10+
const stateGitLockRetryDelay = Duration.millis(100)
11+
const stateGitLockMaxAttempts = 50
12+
const stateGitLockBusyExitCode = 75
13+
14+
const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))
15+
const resolveStateLockPath = (root: string): string => `${root}.lock`
16+
17+
const isStateFileLockBusy = (error: Extract<PlatformError, { readonly _tag: "SystemError" }>): boolean =>
18+
error.reason === "AlreadyExists" || error.reason === "Busy"
19+
20+
const acquireStateFileLock = (
21+
fs: FileSystem.FileSystem,
22+
lockPath: string,
23+
attempt: number = 0
24+
): Effect.Effect<string, CommandFailedError | PlatformError> =>
25+
fs.makeDirectory(lockPath).pipe(
26+
Effect.as(lockPath),
27+
Effect.catchTag("SystemError", (error) => {
28+
if (!isStateFileLockBusy(error)) {
29+
return Effect.fail(error)
30+
}
31+
if (attempt >= stateGitLockMaxAttempts) {
32+
return Effect.fail(new CommandFailedError({ command: "state git lock", exitCode: stateGitLockBusyExitCode }))
33+
}
34+
return Effect.sleep(stateGitLockRetryDelay).pipe(
35+
Effect.zipRight(acquireStateFileLock(fs, lockPath, attempt + 1))
36+
)
37+
})
38+
)
39+
40+
const releaseStateFileLock = (
41+
fs: FileSystem.FileSystem,
42+
lockPath: string
43+
): Effect.Effect<void> =>
44+
fs.remove(lockPath, { recursive: true, force: true }).pipe(
45+
Effect.orElseSucceed(() => void 0)
46+
)
447

548
/**
649
* Serializes git operations against the shared `.docker-git` working tree.
@@ -16,16 +59,29 @@ const stateGitLock = Effect.unsafeMakeSemaphore(1)
1659
* @complexity O(effect)
1760
* @throws Never - failures remain in the Effect error channel.
1861
*/
19-
// CHANGE: serialize state repository git effects
20-
// WHY: inventory auto-pull and create auto-sync can otherwise race on one git working tree
21-
// QUOTE(ТЗ): "project not synchronized"
22-
// REF: issue-372
23-
// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/372
24-
// FORMAT THEOREM: forall a,b in StateGitOps: overlap(guard(a), guard(b)) = false
62+
// CHANGE: serialize state repository git effects across process and fiber boundaries
63+
// WHY: auth auto-sync can run from separate docker-git processes and otherwise race on one git index
64+
// QUOTE(ТЗ): "fatal: Unable to create '/home/dev/.docker-git/.git/index.lock': File exists."
65+
// REF: user-report-2026-07-01-claude-auth-login
66+
// SOURCE: n/a
67+
// FORMAT THEOREM: forall a,b in StateGitOps: overlap(file_guard(a), file_guard(b)) = false
2568
// PURITY: SHELL
26-
// EFFECT: Effect<A, E, R>
27-
// INVARIANT: a single process-local permit protects the shared state repo
69+
// EFFECT: Effect<A, E | CommandFailedError | PlatformError, R | FileSystem | Path>
70+
// INVARIANT: a single process-local permit and a state-root lock directory protect the shared state repo
2871
// COMPLEXITY: O(effect)
2972
export const withStateGitLock = <A, E, R>(
3073
effect: Effect.Effect<A, E, R>
31-
): Effect.Effect<A, E, R> => effect.pipe(stateGitLock.withPermits(1))
74+
): Effect.Effect<A, E | CommandFailedError | PlatformError, R | FileSystem.FileSystem | Path.Path> =>
75+
Effect.gen(function*(_) {
76+
const fs = yield* _(FileSystem.FileSystem)
77+
const path = yield* _(Path.Path)
78+
const root = resolveStateRoot(path, process.cwd())
79+
const lockPath = resolveStateLockPath(root)
80+
return yield* _(
81+
Effect.acquireUseRelease(
82+
acquireStateFileLock(fs, lockPath),
83+
() => effect,
84+
(acquiredLockPath) => releaseStateFileLock(fs, acquiredLockPath)
85+
)
86+
)
87+
}).pipe(stateGitLock.withPermits(1))

0 commit comments

Comments
 (0)