From 9130618d190dd9eda8beb4d999d2c21cd9c3c1d6 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Thu, 25 Jun 2026 17:51:19 +0200 Subject: [PATCH] fix(agents-login): paste the auth code (not the full URL) into setup-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude setup-token sits at 'Paste code here if prompted >' expecting the authorization code, but the worker wrote the entire OAuth callback URL, so the CLI never advanced and capture timed out. - Extract the code from callback URLs (…/oauth/code/callback?code=…&state=…); bare codes pass through unchanged. - Write '\r' only once the paste prompt has appeared; guard against double submission. - Keep the parsed-token / credentials-file completion + bounded timeout->failed behavior. --- services/agents-login/src/worker/parse.ts | 28 +++++++ services/agents-login/src/worker/session.ts | 63 ++++++++++---- services/agents-login/test/helpers/fakeCli.ts | 8 +- .../agents-login/test/integration.pty.test.ts | 5 +- services/agents-login/test/parse.test.ts | 15 ++++ services/agents-login/test/session.test.ts | 84 +++++++++++++++---- 6 files changed, 167 insertions(+), 36 deletions(-) diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index ad909606..a0261df8 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -68,6 +68,34 @@ export function parseClaude(buffer: string): ClaudeParse { return { authorizeUrl: m?.[1]?.trim() } } +export function detectClaudeCodePrompt(buffer: string): boolean { + return /paste\s+code\s+here\s+if\s+prompted\s*>/i.test(stripAnsi(buffer)) +} + +export interface ClaudeRedirectCode { + code: string + state?: string + source: 'url' | 'bare' +} + +export function parseClaudeRedirectCode(input: string): ClaudeRedirectCode | undefined { + const trimmed = input.trim() + if (trimmed.length === 0) { + return undefined + } + try { + const url = new URL(trimmed) + const code = url.searchParams.get('code')?.trim() + if (!code) { + return undefined + } + const state = url.searchParams.get('state')?.trim() || undefined + return { code, state, source: 'url' } + } catch { + return { code: trimmed, source: 'bare' } + } +} + // `claude setup-token`'s actual product is a long-lived OAuth token printed to // stdout ("Your OAuth token (valid for 1 year): sk-ant-oat01-…", meant for // CLAUDE_CODE_OAUTH_TOKEN). It is NOT persisted to a credentials file, so the diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index b66869ca..1e760819 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -4,7 +4,15 @@ import { redactString } from '../shared/redact.js' import type { Provider, SessionPhase, SessionStatus } from '../shared/types.js' import { isTerminal } from '../shared/types.js' import { capture, readClaudeCredentialsToken, type CredentialPaths } from './credentials.js' -import { detectFailure, detectSuccess, parseClaude, parseClaudeToken, parseCodex } from './parse.js' +import { + detectClaudeCodePrompt, + detectFailure, + detectSuccess, + parseClaude, + parseClaudeRedirectCode, + parseClaudeToken, + parseCodex, +} from './parse.js' import type { PtyProcess, PtySpawner } from './pty.js' import type { LeaseLock } from './lease.js' import { payloadForApi, providerForApi, type AgentsApiClient } from './agentsApiClient.js' @@ -53,6 +61,7 @@ export class LoginSession { private redirectTimer?: NodeJS.Timeout private credentialProbeTimer?: NodeJS.Timeout private redirectSubmitted = false + private pendingClaudeCode?: string private updatedBy = 'unknown' constructor( @@ -101,7 +110,8 @@ export class LoginSession { authorizeUrl: this.authorizeUrl, deviceCode: this.deviceCode, verificationUrl: this.verificationUrl, - needsRedirectUrl: this.provider === 'claude' && this.phase === 'awaiting_url' && !this.redirectSubmitted, + needsRedirectUrl: + this.provider === 'claude' && this.phase === 'awaiting_url' && !this.redirectSubmitted && !this.pendingClaudeCode, message: this.message, error: this.error, updatedAt: this.updatedAt, @@ -198,6 +208,7 @@ export class LoginSession { ) } } + this.maybeSubmitPendingClaudeCode() } else { if (!this.deviceCode || !this.verificationUrl) { const parsed = parseCodex(this.buffer) @@ -230,6 +241,27 @@ export class LoginSession { } } + private maybeSubmitPendingClaudeCode(): void { + if (!this.pendingClaudeCode || this.redirectSubmitted || !this.proc || !detectClaudeCodePrompt(this.buffer)) { + return + } + const code = this.pendingClaudeCode + this.pendingClaudeCode = undefined + this.redirectSubmitted = true + this.deps.logger.info('Claude authorization code submitted to PTY', { + sessionId: this.id, + provider: this.provider, + codeBytes: code.length, + }) + this.proc.write(`${code}\r`) + // Stay in awaiting_url so the success line emitted by the CLI after the + // paste-back still drives finalize(); only finalize() flips to finalizing. + this.message = 'Submitted the authorization code; completing login…' + this.touch() + this.startRedirectTimeout() + void this.probeClaudeCredentialFile('redirect submitted') + } + private onExit(exitCode: number): void { if (isTerminal(this.phase) || this.phase === 'finalizing') { return @@ -259,7 +291,7 @@ export class LoginSession { this.fail(`CLI exited with code ${exitCode} before login completed`) } - /** Claude only: feed the post-approval redirect URL to the child's stdin. */ + /** Claude only: feed the post-approval authorization code to the child's stdin. */ submitRedirectUrl(url: string): { ok: boolean; error?: string } { if (this.provider !== 'claude') { return { ok: false, error: 'redirect URL only applies to the Claude flow' } @@ -267,28 +299,27 @@ export class LoginSession { if (this.phase !== 'awaiting_url') { return { ok: false, error: `session is not awaiting a redirect URL (phase=${this.phase})` } } - if (this.redirectSubmitted) { + if (this.redirectSubmitted || this.pendingClaudeCode) { return { ok: false, error: 'authorization code already submitted' } } - const trimmed = url.trim() - // setup-token expects the authorization code copied from the approval page - // (not a URL); accept any non-empty value and let the CLI validate it. - if (trimmed.length === 0) { + const parsed = parseClaudeRedirectCode(url) + if (!parsed) { return { ok: false, error: 'authorization code is required' } } - this.redirectSubmitted = true this.deps.logger.info('Claude redirect submission received', { sessionId: this.id, provider: this.provider, - inputBytes: trimmed.length, + inputBytes: url.trim().length, + codeBytes: parsed.code.length, + inputSource: parsed.source, + statePresent: parsed.state !== undefined, }) - this.proc?.write(trimmed + '\r') - // Stay in awaiting_url so the success line emitted by the CLI after the - // paste-back still drives finalize(); only finalize() flips to finalizing. - this.message = 'Submitted the authorization code; completing login…' + this.pendingClaudeCode = parsed.code + this.message = detectClaudeCodePrompt(this.buffer) + ? 'Submitted the authorization code; completing login…' + : 'Authorization code received; waiting for Claude prompt…' this.touch() - this.startRedirectTimeout() - void this.probeClaudeCredentialFile('redirect submitted') + this.maybeSubmitPendingClaudeCode() return { ok: true } } diff --git a/services/agents-login/test/helpers/fakeCli.ts b/services/agents-login/test/helpers/fakeCli.ts index a440666a..7ee637de 100644 --- a/services/agents-login/test/helpers/fakeCli.ts +++ b/services/agents-login/test/helpers/fakeCli.ts @@ -28,10 +28,10 @@ export function makeFakeCliEnv(): FakeCliEnv { const claude = `#!/usr/bin/env sh echo "Visit the following URL to authorize Claude Code:" echo "https://claude.ai/oauth/authorize?code=abc123&state=xyz" -echo "Paste the redirect URL after approving:" -# block on stdin for the redirect URL -read REDIRECT -echo "received: $REDIRECT" >/dev/null +echo "Paste code here if prompted >" +# block on stdin for the authorization code +read CODE +echo "received: $CODE" >/dev/null echo "Login successful. You are now logged in." mkdir -p "$HOME/.claude" printf '%s' '{"accessToken":"claude-secret-token","refreshToken":"r"}' > "$HOME/.claude/.credentials.json" diff --git a/services/agents-login/test/integration.pty.test.ts b/services/agents-login/test/integration.pty.test.ts index 03b7e684..6a712b0d 100644 --- a/services/agents-login/test/integration.pty.test.ts +++ b/services/agents-login/test/integration.pty.test.ts @@ -69,7 +69,10 @@ describe('real-PTY integration with fake CLIs', () => { const claude = mgr.start('claude', 'alice') await waitFor(() => mgr.status(claude.id)!.phase === 'awaiting_url') expect(mgr.status(claude.id)!.authorizeUrl).toContain('claude.ai/oauth/authorize') - const sub = mgr.submitRedirectUrl(claude.id, 'https://claude.ai/redirect?code=2') + const sub = mgr.submitRedirectUrl( + claude.id, + 'https://platform.claude.com/oauth/code/callback?code=integration-code-2&state=integration-state-2', + ) expect(sub.ok).toBe(true) await waitFor(() => mgr.status(claude.id)!.phase === 'succeeded') expect(posts[0]).toMatchObject({ userId: 'alice', provider: 'CLAUDE' }) diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index 9e022b34..5a639411 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest' import { parseClaude, + parseClaudeRedirectCode, parseClaudeToken, parseCodex, + detectClaudeCodePrompt, detectSuccess, detectFailure, stripAnsi, @@ -54,6 +56,19 @@ describe('PTY output parsing', () => { expect(parseClaude(raw).authorizeUrl).toBe(url) }) + it('detects the Claude setup-token code prompt', () => { + expect(detectClaudeCodePrompt('Paste code here if prompted >')).toBe(true) + expect(detectClaudeCodePrompt('Opening browser to sign in...')).toBe(false) + }) + + it('extracts the Claude authorization code from a callback URL and preserves bare codes', () => { + expect( + parseClaudeRedirectCode('https://platform.claude.com/oauth/code/callback?code=AUTH-CODE-123&state=STATE-456'), + ).toEqual({ code: 'AUTH-CODE-123', state: 'STATE-456', source: 'url' }) + expect(parseClaudeRedirectCode('AUTH-CODE-123')).toEqual({ code: 'AUTH-CODE-123', source: 'bare' }) + expect(parseClaudeRedirectCode('https://platform.claude.com/oauth/code/callback?state=STATE-456')).toBeUndefined() + }) + it('extracts a Codex verification URL wrapped in an OSC 8 hyperlink', () => { const url = 'https://auth.openai.com/device?code=WXYZ-1234' const raw = `Enter the code: WXYZ-1234\r\n\x1b]8;id=dev;${url}\x07${url}\x1b]8;;\x07` diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index 6ee7b8c3..a24e9775 100644 --- a/services/agents-login/test/session.test.ts +++ b/services/agents-login/test/session.test.ts @@ -111,11 +111,11 @@ describe('LoginSession state machine', () => { writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{}}') const token = 'sk-ant-oat01-FullFlowToken1234567890_-abcXYZ' - const { deps: d } = deps(() => [ - { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\n' }, + const { deps: d, instances } = deps(() => [ + { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\nPaste code here if prompted >' }, { type: 'expectStdin', - match: (i) => i.includes('https://claude.ai/redirect'), + match: (i) => i === 'redirect-code-2\r', then: [ { type: 'emit', data: `Login successful.\r\nYour OAuth token: ${token}\r\n` }, { type: 'exit', code: 0 }, @@ -132,20 +132,73 @@ describe('LoginSession state machine', () => { expect(s.authorizeUrl).toContain('claude.ai/oauth/authorize') expect(s.needsRedirectUrl).toBe(true) - const sub = mgr.submitRedirectUrl(started.id, 'https://claude.ai/redirect?code=2') + const sub = mgr.submitRedirectUrl( + started.id, + 'https://platform.claude.com/oauth/code/callback?code=redirect-code-2&state=redirect-state-2', + ) expect(sub.ok).toBe(true) expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'])).toBe('succeeded') + expect(instances[0].writes).toEqual(['redirect-code-2\r']) expect(agentsApi.posts).toEqual([ { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, ]) }) + it('waits for the Claude code prompt before writing the extracted authorization code', async () => { + const writes: string[] = [] + const dataCbs: Array<(chunk: string) => void> = [] + const exitCbs: Array<(info: { exitCode: number }) => void> = [] + const proc = { + onData(cb: (chunk: string) => void) { + dataCbs.push(cb) + }, + onExit(cb: (info: { exitCode: number }) => void) { + exitCbs.push(cb) + }, + write(data: string) { + writes.push(data) + }, + kill() { + // no-op + }, + } + const mgr = new SessionManager({ + spawner: () => proc, + agentsApi, + lease: new NoopLeaseLock(), + paths: { home, codexHome }, + logger: createLogger(), + ttlMs: 60_000, + }) + const started = mgr.start('claude', 'alice') + dataCbs.forEach((cb) => cb('Visit https://claude.com/cai/oauth/authorize?code=true&state=state-1\r\n')) + await tick() + + const sub = mgr.submitRedirectUrl( + started.id, + 'https://platform.claude.com/oauth/code/callback?code=queued-code-123&state=callback-state-456', + ) + expect(sub.ok).toBe(true) + expect(writes).toEqual([]) + expect(mgr.status(started.id)!.needsRedirectUrl).toBe(false) + expect(mgr.submitRedirectUrl(started.id, 'other-code').ok).toBe(false) + + dataCbs.forEach((cb) => cb('Paste code here if prompted >')) + await tick() + + expect(writes).toEqual(['queued-code-123\r']) + expect(exitCbs.length).toBe(1) + }) + it('captures the setup-token OAuth token from stdout when no credentials file is written', async () => { // No .credentials.json is created — setup-token only prints the token. const token = 'sk-ant-oat01-StdoutOnlyToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ - { type: 'emit', data: 'Use the url below\r\nhttps://claude.com/cai/oauth/authorize?code=true\r\n' }, + { + type: 'emit', + data: 'Use the url below\r\nhttps://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >', + }, { type: 'expectStdin', match: () => true, @@ -173,7 +226,7 @@ describe('LoginSession state machine', () => { // exit, so a CLI that printed the token and kept the PTY open never posted. const token = 'sk-ant-oat01-NoExitToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, @@ -195,7 +248,7 @@ describe('LoginSession state machine', () => { it('finalizes Claude from an OSC8-linked setup-token OAuth token after paste-back', async () => { const token = 'sk-ant-oat01-Osc8SessionToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, @@ -217,7 +270,7 @@ describe('LoginSession state machine', () => { it('finalizes Claude from .credentials.json when setup-token prints no token and stays open', async () => { const token = 'sk-ant-oat01-CredentialsFileToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, @@ -247,7 +300,7 @@ describe('LoginSession state machine', () => { const logs: LogEntry[] = [] const { deps: d } = deps( () => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, @@ -274,7 +327,7 @@ describe('LoginSession state machine', () => { it('fails Claude after a bounded post-redirect timeout with redacted output context', async () => { const { deps: d } = deps(() => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, @@ -326,8 +379,8 @@ describe('LoginSession state machine', () => { }) it('rejects an empty authorization code but accepts a non-URL code', async () => { - const { deps: d } = deps(() => [ - { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\n' }, + const { deps: d, instances } = deps(() => [ + { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\nPaste code here if prompted >' }, { type: 'expectStdin', match: () => true, then: [] }, ]) const mgr = new SessionManager(d) @@ -336,6 +389,7 @@ describe('LoginSession state machine', () => { expect(mgr.submitRedirectUrl(started.id, ' ').ok).toBe(false) // setup-token returns a bare code, not a URL — it must be accepted. expect(mgr.submitRedirectUrl(started.id, 'ABCD-1234-token').ok).toBe(true) + expect(instances[0].writes).toEqual(['ABCD-1234-token\r']) }) it('rejects redirect submission for the codex provider', async () => { @@ -430,10 +484,10 @@ describe('LoginSession state machine', () => { const failingAgentsApi = fakeAgentsApi(new Error('agents-api credential ingest failed: 503 unavailable')) const { spawner } = fakeSpawner(() => [ - { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\n' }, + { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\nPaste code here if prompted >' }, { type: 'expectStdin', - match: () => true, + match: (i) => i === 'ingest-failure-code\r', then: [{ type: 'emit', data: 'Login successful.\r\nYour OAuth token: sk-ant-oat01-FailureToken1234567890\r\n' }], }, ]) @@ -447,7 +501,7 @@ describe('LoginSession state machine', () => { }) const started = mgr.start('claude', 'alice') await tick() - mgr.submitRedirectUrl(started.id, 'https://claude.ai/redirect') + mgr.submitRedirectUrl(started.id, 'https://platform.claude.com/oauth/code/callback?code=ingest-failure-code') expect(await waitPhase(mgr, started.id, ['failed', 'succeeded'])).toBe('failed') expect(mgr.status(started.id)!.error).toMatch(/agents-api credential ingest failed/) })