Skip to content

Commit 72bf8eb

Browse files
committed
fix(claude): keep OAuth token when post-login API probe fails
docker-git auth claude login created and persisted the OAuth token, then ran a 'claude -p ping' probe and hard-failed (exit 1) on any non-zero probe exit, discarding an otherwise successful login. Transient probe failures (network, rate limit, token propagation delay) must not invalidate a saved token. The probe failure is now logged as a warning, mirroring authClaudeStatus. Adds a regression test asserting the token is persisted even when the probe returns non-zero. Fixes #439
1 parent 2738a0c commit 72bf8eb

4 files changed

Lines changed: 187 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@prover-coder-ai/docker-git": patch
3+
---
4+
5+
Fix `docker-git auth claude login` failing after a successful OAuth login.
6+
7+
After `claude setup-token` created and persisted the OAuth token, the login
8+
command ran a verification probe (`claude -p ping`) and treated any non-zero
9+
exit as a hard failure, exiting with code 1 even though the token was already
10+
saved. A transient probe failure (network hiccup, rate limit, or token
11+
propagation delay) would therefore discard an otherwise successful login.
12+
13+
The probe failure is now reported as a warning instead of an error, mirroring
14+
`docker-git auth claude status`. The token is kept, and the user is advised to
15+
re-check connectivity later with `docker-git auth claude status`.

.gitkeep

Lines changed: 0 additions & 1 deletion
This file was deleted.

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,14 +268,19 @@ export const authClaudeLogin = (
268268
yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}\n`))
269269
yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 0o600), Effect.orElseSucceed(() => void 0))
270270
yield* _(resolveClaudeAuthMethod(fs, path, accountPath))
271+
// CHANGE: treat a failing post-login API probe as a warning instead of a hard error
272+
// WHY: the OAuth token is already created and persisted; a transient probe failure
273+
// (network hiccup, rate limit, token propagation delay) must not discard a
274+
// successful login. Mirrors authClaudeStatus, which only warns on probe failure.
275+
// REF: issue-439
276+
// SOURCE: n/a
271277
const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token))
272278
if (probeExitCode !== 0) {
273279
yield* _(
274-
Effect.fail(
275-
new CommandFailedError({
276-
command: "claude setup-token",
277-
exitCode: probeExitCode
278-
})
280+
Effect.logWarning(
281+
`Claude OAuth token saved (${accountLabel}), but the API probe failed (exit=${probeExitCode}). ` +
282+
`The token may need a moment to activate, or there was a transient network issue. ` +
283+
`Verify later with 'docker-git auth claude status'.`
279284
)
280285
)
281286
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import * as Command from "@effect/platform/Command"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { NodeContext } from "@effect/platform-node"
6+
import { describe, expect, it } from "@effect/vitest"
7+
import { Effect } from "effect"
8+
import * as Inspectable from "effect/Inspectable"
9+
import * as Sink from "effect/Sink"
10+
import * as Stream from "effect/Stream"
11+
12+
import { authClaudeLogin } from "../../src/usecases/auth-claude.js"
13+
14+
const encode = (value: string): Uint8Array => new TextEncoder().encode(value)
15+
16+
const oauthToken = "sk-ant-oat01-EXAMPLE0123456789abcdef"
17+
18+
// Mirrors the real `claude setup-token` output that the OAuth parser scans for.
19+
const setupTokenOutput = (token: string): string =>
20+
[
21+
"Welcome to Claude Code",
22+
"",
23+
" ✓ Long-lived authentication token created successfully!",
24+
"",
25+
" Your OAuth token (valid for 1 year):",
26+
"",
27+
` ${token}`,
28+
"",
29+
" Store this token securely. You won't be able to see it again."
30+
].join("\n")
31+
32+
const isSetupToken = (args: ReadonlyArray<string>): boolean => args.includes("setup-token")
33+
const isPingProbe = (args: ReadonlyArray<string>): boolean => args.includes("-p") && args.includes("ping")
34+
35+
// CHANGE: fake docker executor that captures a setup-token and lets the ping probe fail
36+
// WHY: reproduce issue-439 where a successful OAuth login was discarded by a failing probe
37+
// REF: issue-439
38+
const makeFakeExecutor = (
39+
token: string,
40+
pingExitCode: number
41+
): CommandExecutor.CommandExecutor => {
42+
const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> =>
43+
Effect.sync(() => {
44+
const flattened = Command.flatten(command)
45+
const invocation = flattened[flattened.length - 1]!
46+
const args = invocation.args
47+
48+
const stdoutText = isSetupToken(args) ? setupTokenOutput(token) : ""
49+
const exitCode = isPingProbe(args) ? pingExitCode : 0
50+
const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText))
51+
52+
const process: CommandExecutor.Process = {
53+
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
54+
pid: CommandExecutor.ProcessId(1),
55+
exitCode: Effect.succeed(CommandExecutor.ExitCode(exitCode)),
56+
isRunning: Effect.succeed(false),
57+
kill: (_signal) => Effect.void,
58+
stderr: Stream.empty,
59+
stdin: Sink.drain,
60+
stdout,
61+
toJSON: () => ({ _tag: "ClaudeLoginTestProcess", command: invocation.command, args }),
62+
[Inspectable.NodeInspectSymbol]: () => ({
63+
_tag: "ClaudeLoginTestProcess",
64+
command: invocation.command,
65+
args
66+
}),
67+
toString: () => `[ClaudeLoginTestProcess ${invocation.command}]`
68+
}
69+
70+
return process
71+
})
72+
73+
return CommandExecutor.makeExecutor(start)
74+
}
75+
76+
const withTempDir = <A, E, R>(
77+
use: (tempDir: string) => Effect.Effect<A, E, R>
78+
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
79+
Effect.scoped(
80+
Effect.gen(function*(_) {
81+
const fs = yield* _(FileSystem.FileSystem)
82+
const tempDir = yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-auth-claude-" }))
83+
return yield* _(use(tempDir))
84+
})
85+
)
86+
87+
const withPatchedEnv = <A, E, R>(
88+
patch: Readonly<Record<string, string | undefined>>,
89+
effect: Effect.Effect<A, E, R>
90+
): Effect.Effect<A, E, R> =>
91+
Effect.acquireUseRelease(
92+
Effect.sync(() => {
93+
const previous = new Map<string, string | undefined>()
94+
for (const [key, value] of Object.entries(patch)) {
95+
previous.set(key, process.env[key])
96+
if (value === undefined) {
97+
delete process.env[key]
98+
} else {
99+
process.env[key] = value
100+
}
101+
}
102+
return previous
103+
}),
104+
() => effect,
105+
(previous) =>
106+
Effect.sync(() => {
107+
for (const [key, value] of previous.entries()) {
108+
if (value === undefined) {
109+
delete process.env[key]
110+
} else {
111+
process.env[key] = value
112+
}
113+
}
114+
})
115+
)
116+
117+
const runLoginAndReadToken = (
118+
root: string,
119+
pingExitCode: number
120+
): Effect.Effect<string, unknown, FileSystem.FileSystem | Path.Path> =>
121+
Effect.gen(function*(_) {
122+
const fs = yield* _(FileSystem.FileSystem)
123+
const path = yield* _(Path.Path)
124+
const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude")
125+
126+
yield* _(
127+
authClaudeLogin({
128+
_tag: "AuthClaudeLogin",
129+
label: null,
130+
claudeAuthPath
131+
}).pipe(
132+
Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, pingExitCode))
133+
)
134+
)
135+
136+
return yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token")))
137+
})
138+
139+
describe("authClaudeLogin", () => {
140+
// Regression for issue-439: a non-zero probe exit must not discard a created token.
141+
it.effect("persists the OAuth token even when the post-login API probe fails", () =>
142+
withTempDir((root) =>
143+
withPatchedEnv(
144+
{ HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined },
145+
Effect.gen(function*(_) {
146+
const persisted = yield* _(runLoginAndReadToken(root, 7))
147+
expect(persisted.trim()).toBe(oauthToken)
148+
})
149+
)
150+
).pipe(Effect.provide(NodeContext.layer)))
151+
152+
it.effect("persists the OAuth token when the post-login API probe succeeds", () =>
153+
withTempDir((root) =>
154+
withPatchedEnv(
155+
{ HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined },
156+
Effect.gen(function*(_) {
157+
const persisted = yield* _(runLoginAndReadToken(root, 0))
158+
expect(persisted.trim()).toBe(oauthToken)
159+
})
160+
)
161+
).pipe(Effect.provide(NodeContext.layer)))
162+
})

0 commit comments

Comments
 (0)