Skip to content

Commit 79cc901

Browse files
committed
fix(auth): prompt for Claude OAuth code
1 parent 3c924a0 commit 79cc901

3 files changed

Lines changed: 307 additions & 18 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const buildDockerArgs = (spec: DockerAuthSpec): ReadonlyArray<string> => {
5151
return [...base, spec.image, ...spec.args]
5252
}
5353

54+
// CHANGE: expose docker CLI args builder for advanced auth flows (stdin piping)
55+
// WHY: some OAuth CLIs (Claude Code) don't reliably render their input UI; docker-git needs to drive stdin explicitly
56+
// REF: issue-61
57+
// SOURCE: n/a
58+
// PURITY: CORE
59+
// INVARIANT: args match those used by runDockerAuth / runDockerAuthCapture
60+
export const buildDockerAuthArgs = (spec: DockerAuthSpec): ReadonlyArray<string> => buildDockerArgs(spec)
61+
5462
// CHANGE: run a docker auth command with controlled exit codes
5563
// WHY: reuse container auth flow for gh/codex
5664
// QUOTE(ТЗ): "поднимал отдельный контейнер где будет установлен чисто gh или чисто codex"
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import * as Command from "@effect/platform/Command"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import type { PlatformError } from "@effect/platform/Error"
4+
import { Effect, pipe } from "effect"
5+
import * as Deferred from "effect/Deferred"
6+
import * as Fiber from "effect/Fiber"
7+
import type * as Scope from "effect/Scope"
8+
import * as Stream from "effect/Stream"
9+
import { writeSync } from "node:fs"
10+
11+
import { AuthError, CommandFailedError } from "../shell/errors.js"
12+
13+
type ClaudeAuthorizeInfo = {
14+
readonly authorizeUrl: string
15+
readonly state: string | null
16+
}
17+
18+
type FirstOutcome =
19+
| { readonly _tag: "Exit"; readonly exitCode: number }
20+
| { readonly _tag: "AuthorizeUrl"; readonly info: ClaudeAuthorizeInfo }
21+
22+
const oauthCodeEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_CODE"
23+
const authorizeUrlRegex = /https:\/\/claude\.ai\/oauth\/authorize\S*/u
24+
25+
const extractAuthorizeUrl = (line: string): string | null => {
26+
const match = authorizeUrlRegex.exec(line)
27+
return match?.[0] ?? null
28+
}
29+
30+
const readQueryParam = (raw: string, key: string): string | null => {
31+
const match = new RegExp(String.raw`[?&]${key}=([^&#\s]+)`, "u").exec(raw)
32+
return match?.[1] ?? null
33+
}
34+
35+
const normalizeOauthPaste = (raw: string, authorizeState: string | null): string => {
36+
const trimmed = raw.trim()
37+
if (trimmed.length === 0) {
38+
return ""
39+
}
40+
if (trimmed.includes("#")) {
41+
return trimmed
42+
}
43+
const callbackCode = readQueryParam(trimmed, "code")
44+
const callbackState = readQueryParam(trimmed, "state")
45+
if (callbackCode !== null) {
46+
return callbackState === null ? callbackCode : `${callbackCode}#${callbackState}`
47+
}
48+
return authorizeState === null ? trimmed : `${trimmed}#${authorizeState}`
49+
}
50+
51+
const oauthCodeFromEnv = (): string | null => {
52+
const value = (process.env[oauthCodeEnvKey] ?? "").trim()
53+
return value.length > 0 ? value : null
54+
}
55+
56+
const ensureInteractiveStdin = (): Effect.Effect<void, AuthError> =>
57+
process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === "function"
58+
? Effect.void
59+
: Effect.fail(
60+
new AuthError({
61+
message:
62+
`Claude auth login needs an interactive TTY, or set ${oauthCodeEnvKey} to the OAuth Authentication Code.`
63+
})
64+
)
65+
66+
const readHiddenLine = (prompt: string): Effect.Effect<string, AuthError> =>
67+
Effect.async<string, AuthError>((resume) => {
68+
const previousRaw = process.stdin.isRaw
69+
let buffer = ""
70+
71+
const cleanup = () => {
72+
process.stdin.off("data", onData)
73+
process.stdin.setRawMode(previousRaw)
74+
}
75+
76+
const done = (value: string) => {
77+
cleanup()
78+
writeSync(1, "\n")
79+
resume(Effect.succeed(value))
80+
}
81+
82+
const fail = (message: string) => {
83+
cleanup()
84+
resume(Effect.fail(new AuthError({ message })))
85+
}
86+
87+
const onData = (chunk: Buffer | string) => {
88+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8")
89+
for (const ch of text) {
90+
if (ch === "\u0003") {
91+
fail("Claude auth login cancelled.")
92+
return
93+
}
94+
if (ch === "\r" || ch === "\n") {
95+
done(buffer)
96+
return
97+
}
98+
if (ch === "\u007F") {
99+
buffer = buffer.slice(0, Math.max(0, buffer.length - 1))
100+
continue
101+
}
102+
buffer += ch
103+
}
104+
}
105+
106+
writeSync(1, prompt)
107+
process.stdin.setRawMode(true)
108+
process.stdin.resume()
109+
process.stdin.on("data", onData)
110+
111+
return Effect.sync(() => {
112+
cleanup()
113+
})
114+
})
115+
116+
const resolveOauthCodeInput = (info: ClaudeAuthorizeInfo): Effect.Effect<string, AuthError> => {
117+
const fromEnv = oauthCodeFromEnv()
118+
if (fromEnv !== null) {
119+
return Effect.succeed(normalizeOauthPaste(fromEnv, info.state))
120+
}
121+
return ensureInteractiveStdin().pipe(
122+
Effect.zipRight(
123+
readHiddenLine(
124+
"\n[docker-git] Paste the Authentication Code from the browser and press Enter (input hidden; Ctrl+C to cancel):\n> "
125+
)
126+
),
127+
Effect.map((value) => normalizeOauthPaste(value, info.state)),
128+
Effect.filterOrFail(
129+
(value) => value.trim().length > 0,
130+
() => new AuthError({ message: "Claude auth login requires a non-empty Authentication Code." })
131+
)
132+
)
133+
}
134+
135+
const writeChunk = (fd: number, chunk: Uint8Array): Effect.Effect<void> =>
136+
Effect.sync(() => {
137+
writeSync(fd, chunk)
138+
}).pipe(Effect.asVoid)
139+
140+
const pumpDockerOutput = (
141+
source: Stream.Stream<Uint8Array, PlatformError>,
142+
fd: number,
143+
oauth: Deferred.Deferred<ClaudeAuthorizeInfo>
144+
): Effect.Effect<void, PlatformError> => {
145+
const decoder = new TextDecoder("utf-8")
146+
let remainder = ""
147+
148+
return pipe(
149+
source,
150+
Stream.runForEach((chunk) =>
151+
pipe(
152+
writeChunk(fd, chunk),
153+
Effect.zipRight(
154+
Effect.sync((): ClaudeAuthorizeInfo | null => {
155+
remainder += decoder.decode(chunk)
156+
// Keep only a sliding window so repeated scans stay cheap.
157+
if (remainder.length > 8192) {
158+
remainder = remainder.slice(-8192)
159+
}
160+
const authorizeUrl = extractAuthorizeUrl(remainder)
161+
if (authorizeUrl === null) {
162+
return null
163+
}
164+
const state = readQueryParam(authorizeUrl, "state")
165+
return { authorizeUrl, state }
166+
})
167+
),
168+
Effect.flatMap((info) =>
169+
info === null
170+
? Effect.void
171+
: Deferred.succeed(oauth, info).pipe(Effect.asVoid)
172+
),
173+
Effect.asVoid
174+
)
175+
)
176+
).pipe(Effect.asVoid)
177+
}
178+
179+
type DockerLoginSpec = {
180+
readonly cwd: string
181+
readonly image: string
182+
readonly hostPath: string
183+
readonly containerPath: string
184+
readonly env: ReadonlyArray<string>
185+
readonly args: ReadonlyArray<string>
186+
readonly tty: boolean
187+
}
188+
189+
const buildDockerLoginSpec = (
190+
cwd: string,
191+
accountPath: string,
192+
image: string,
193+
containerPath: string
194+
): DockerLoginSpec => ({
195+
cwd,
196+
image,
197+
hostPath: accountPath,
198+
containerPath,
199+
env: [`CLAUDE_CONFIG_DIR=${containerPath}`, "BROWSER=echo"],
200+
args: ["auth", "login"],
201+
tty: process.stdin.isTTY && process.stdout.isTTY
202+
})
203+
204+
const buildDockerLoginArgs = (spec: DockerLoginSpec): ReadonlyArray<string> => {
205+
const base: Array<string> = ["run", "--rm", "-i"]
206+
if (spec.tty) {
207+
base.push("-t")
208+
}
209+
base.push("-v", `${spec.hostPath}:${spec.containerPath}`)
210+
for (const entry of spec.env) {
211+
const trimmed = entry.trim()
212+
if (trimmed.length === 0) {
213+
continue
214+
}
215+
base.push("-e", trimmed)
216+
}
217+
return [...base, spec.image, ...spec.args]
218+
}
219+
220+
const startDockerProcess = (
221+
executor: CommandExecutor.CommandExecutor,
222+
spec: DockerLoginSpec
223+
): Effect.Effect<CommandExecutor.Process, PlatformError, Scope.Scope> =>
224+
executor.start(
225+
pipe(
226+
Command.make("docker", ...buildDockerLoginArgs(spec)),
227+
Command.workingDirectory(spec.cwd),
228+
Command.stdin("pipe"),
229+
Command.stdout("pipe"),
230+
Command.stderr("pipe")
231+
)
232+
)
233+
234+
const awaitFirstOutcome = (
235+
proc: CommandExecutor.Process,
236+
oauth: Deferred.Deferred<ClaudeAuthorizeInfo>
237+
): Effect.Effect<FirstOutcome, PlatformError> =>
238+
Effect.race(
239+
Deferred.await(oauth).pipe(Effect.map((info) => ({ _tag: "AuthorizeUrl" as const, info }))),
240+
proc.exitCode.pipe(Effect.map((exitCode) => ({ _tag: "Exit" as const, exitCode: Number(exitCode) })))
241+
)
242+
243+
const ensureExitOk = (exitCode: number): Effect.Effect<void, CommandFailedError> =>
244+
exitCode === 0 ? Effect.void : Effect.fail(new CommandFailedError({ command: "claude auth login", exitCode }))
245+
246+
const feedOauthCode = (proc: CommandExecutor.Process, code: string): Effect.Effect<void, PlatformError> => {
247+
const bytes = new TextEncoder().encode(`${code}\n`)
248+
return pipe(Stream.make(bytes), Stream.run(proc.stdin), Effect.asVoid)
249+
}
250+
251+
const finishClaudeLogin = (
252+
outcome: FirstOutcome,
253+
proc: CommandExecutor.Process
254+
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError> => {
255+
if (outcome._tag === "Exit") {
256+
return ensureExitOk(outcome.exitCode)
257+
}
258+
return resolveOauthCodeInput(outcome.info).pipe(
259+
Effect.flatMap((code) => feedOauthCode(proc, code)),
260+
Effect.zipRight(proc.exitCode.pipe(Effect.map(Number), Effect.flatMap((exitCode) => ensureExitOk(exitCode)))),
261+
Effect.asVoid
262+
)
263+
}
264+
265+
export const runClaudeOauthLoginWithPrompt = (
266+
cwd: string,
267+
accountPath: string,
268+
options: {
269+
readonly image: string
270+
readonly containerPath: string
271+
}
272+
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
273+
Effect.scoped(
274+
Effect.gen(function*(_) {
275+
const executor = yield* _(CommandExecutor.CommandExecutor)
276+
const spec = buildDockerLoginSpec(cwd, accountPath, options.image, options.containerPath)
277+
const proc = yield* _(startDockerProcess(executor, spec))
278+
279+
const oauth = yield* _(Deferred.make<ClaudeAuthorizeInfo>())
280+
const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, oauth)))
281+
const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, oauth)))
282+
283+
const first = yield* _(awaitFirstOutcome(proc, oauth))
284+
yield* _(finishClaudeLogin(first, proc))
285+
286+
yield* _(Fiber.join(stdoutFiber))
287+
yield* _(Fiber.join(stderrFiber))
288+
}).pipe(Effect.asVoid)
289+
)

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

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { Effect, Either } from "effect"
99
import type { AuthClaudeLoginCommand, AuthClaudeLogoutCommand, AuthClaudeStatusCommand } from "../core/domain.js"
1010
import { defaultTemplateConfig } from "../core/domain.js"
1111
import { runDockerAuth, runDockerAuthCapture } from "../shell/docker-auth.js"
12-
import { AuthError, CommandFailedError } from "../shell/errors.js"
12+
import type { AuthError } from "../shell/errors.js"
13+
import { CommandFailedError } from "../shell/errors.js"
14+
import { runClaudeOauthLoginWithPrompt } from "./auth-claude-oauth.js"
1315
import { buildDockerAuthSpec, normalizeAccountLabel } from "./auth-helpers.js"
1416
import { migrateLegacyOrchLayout } from "./auth-sync.js"
1517
import { ensureDockerImage } from "./docker-image.js"
@@ -111,13 +113,6 @@ const runClaudeAuthCommand = (
111113
(exitCode) => new CommandFailedError({ command: commandLabel, exitCode })
112114
)
113115

114-
const runClaudeLogin = (
115-
cwd: string,
116-
accountPath: string,
117-
interactive: boolean
118-
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
119-
runClaudeAuthCommand(cwd, accountPath, ["auth", "login"], "claude auth login", interactive)
120-
121116
const runClaudeLogout = (
122117
cwd: string,
123118
accountPath: string
@@ -175,17 +170,14 @@ const decodeClaudeAuthStatus = (raw: string): Effect.Effect<ClaudeAuthStatus, Co
175170
export const authClaudeLogin = (
176171
command: AuthClaudeLoginCommand
177172
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, ClaudeRuntime> => {
178-
const interactive = process.stdin.isTTY && process.stdout.isTTY
179-
if (!interactive) {
180-
return Effect.fail(new AuthError({ message: "Claude auth login requires an interactive TTY." }))
181-
}
182173
const accountLabel = normalizeAccountLabel(command.label, "default")
183-
return Effect.log(
184-
"Claude OAuth: open the URL, then copy the Authentication Code from the browser and paste it here (input is hidden), then press Enter."
185-
).pipe(
186-
Effect.zipRight(withClaudeAuth(command, ({ accountPath, cwd }) => runClaudeLogin(cwd, accountPath, true))),
187-
Effect.zipRight(autoSyncState(`chore(state): auth claude ${accountLabel}`))
188-
)
174+
return withClaudeAuth(command, ({ accountPath, cwd }) =>
175+
runClaudeOauthLoginWithPrompt(cwd, accountPath, {
176+
image: claudeImageName,
177+
containerPath: claudeConfigDir
178+
})).pipe(
179+
Effect.zipRight(autoSyncState(`chore(state): auth claude ${accountLabel}`))
180+
)
189181
}
190182

191183
// CHANGE: show Claude Code auth status for a given label

0 commit comments

Comments
 (0)