From 41e7f2d4611634908edd288ff7d854bbfe1851f6 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Fri, 26 Jun 2026 15:05:08 +0200 Subject: [PATCH] fix(agents-login): use codex login --device-auth and parse its one-time code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex sign-in failed instantly with "CLI reported a login failure". The worker spawned `codex login --device`, but Codex 0.141.0 renamed the flag to `--device-auth`; clap rejected the unknown argument and exited 2, and the worker's failure detector matched the "error:" output. Correcting the flag exposed a second mismatch: 0.141 prints "Enter this one-time code (expires in 15 minutes)" with the code on the following line, so the device-code parser — which expected the code immediately after a "device code"/"code:" keyword — never extracted it. The pattern now spans prose and newlines between the keyword and the code, recognises the "one-time code" wording, and uses a digit lookahead so it locks onto the real code rather than surrounding words. --- services/agents-login/src/index.ts | 8 ++-- services/agents-login/src/worker/parse.ts | 9 +++- services/agents-login/src/worker/session.ts | 49 ++++++++++++++++----- services/agents-login/test/parse.test.ts | 18 ++++++-- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/services/agents-login/src/index.ts b/services/agents-login/src/index.ts index 4d222b4b..73a22d0e 100644 --- a/services/agents-login/src/index.ts +++ b/services/agents-login/src/index.ts @@ -1,10 +1,10 @@ import { runWorker } from './worker/index.js' // The credential-login worker: the only process this image runs. It owns the -// PTY that drives `claude /login` / `codex login --device`, captures the -// credential files, and posts them to agents-api under a Lease. The browser -// UI and its auth live in agents-ui / agents-api, which proxy to this worker -// over the internal token. +// PTY that drives `claude setup-token` / `codex login --device-auth`, captures +// the credential files, and posts them to agents-api. The browser UI and its +// auth live in agents-ui / agents-api, which proxy to this worker over the +// internal token. runWorker().catch((err) => { process.stderr.write(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`) process.exit(1) diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index b7641003..caeb7898 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -45,10 +45,15 @@ export function oscHyperlinkTargets(raw: string): string[] { const CLAUDE_URL_PATTERN = /(https:\/\/(?:claude\.ai|claude\.com|platform\.claude\.com|console\.anthropic\.com)\/[^\s"'<>`]+)/i -// Codex device login prints a verification URL and a user code. +// Codex device login prints a verification URL and a one-time code. Newer +// Codex (`login --device-auth`, 0.141.x) prints "Enter this one-time code +// (expires in 15 minutes)" with the code on the *next* line, so the keyword and +// the code are no longer adjacent; allow prose/newlines between them. The +// digit lookahead keeps the match off pure-word tokens like "expires" that the +// case-insensitive class would otherwise accept. const CODEX_URL_PATTERN = /(https:\/\/[^\s"'<>`]*(?:openai\.com|chatgpt\.com)[^\s"'<>`]*)/i const DEVICE_CODE_PATTERN = - /(?:user\s*code|device\s*code|enter\s*the\s*code|code:)\s*:?\s*([A-Z0-9]{4,}(?:-[A-Z0-9]{4,})?)/i + /(?:one[-\s]?time\s*code|user\s*code|device\s*code|enter\s*the\s*code|code:)[\s\S]{0,80}?(?=[A-Z0-9-]*[0-9])([A-Z0-9]{4,}(?:-[A-Z0-9]{4,})*)/i export interface ClaudeParse { authorizeUrl?: string diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index c8d20fce..6c05edc9 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -36,7 +36,7 @@ function defaultCommand(provider: Provider): { file: string; args: string[] } { // authorize URL and takes a pasted code back, writing the standard credentials. return provider === 'claude' ? { file: 'claude', args: ['setup-token'] } - : { file: 'codex', args: ['login', '--device'] } + : { file: 'codex', args: ['login', '--device-auth'] } } const MAX_BUFFER = 256 * 1024 @@ -115,7 +115,10 @@ export class LoginSession { deviceCode: this.deviceCode, verificationUrl: this.verificationUrl, needsRedirectUrl: - this.provider === 'claude' && this.phase === 'awaiting_url' && !this.redirectSubmitted && !this.pendingClaudeCode, + this.provider === 'claude' && + this.phase === 'awaiting_url' && + !this.redirectSubmitted && + !this.pendingClaudeCode, message: this.message, error: this.error, updatedAt: this.updatedAt, @@ -223,14 +226,23 @@ export class LoginSession { this.verificationUrl = parsed.verificationUrl } if (this.deviceCode && this.phase === 'starting') { - this.setPhase('awaiting_device', 'Enter the device code at the verification URL.', 'Codex device code detected') + this.setPhase( + 'awaiting_device', + 'Enter the device code at the verification URL.', + 'Codex device code detected', + ) } } } - const claudeTokenCaptured = this.provider === 'claude' && this.redirectSubmitted ? parseClaudeToken(this.buffer) : undefined + const claudeTokenCaptured = + this.provider === 'claude' && this.redirectSubmitted ? parseClaudeToken(this.buffer) : undefined if (this.provider === 'claude' && this.redirectSubmitted) { - this.logClaudeTokenParseAttempt('pty-output', claudeTokenCaptured !== undefined, 'PTY output received after redirect') + this.logClaudeTokenParseAttempt( + 'pty-output', + claudeTokenCaptured !== undefined, + 'PTY output received after redirect', + ) } if (claudeTokenCaptured) { void this.finalize(updatedBy, claudeTokenCaptured, 'Claude OAuth token parsed from PTY output') @@ -408,7 +420,9 @@ export class LoginSession { if (isTerminal(this.phase) || this.phase === 'finalizing') { return } - this.fail(`timed out waiting for Claude credential capture after redirect submission; output_tail=${this.outputTail()}`) + this.fail( + `timed out waiting for Claude credential capture after redirect submission; output_tail=${this.outputTail()}`, + ) }, timeoutMs) if (typeof this.redirectTimer.unref === 'function') { this.redirectTimer.unref() @@ -417,7 +431,12 @@ export class LoginSession { } private scheduleClaudeCredentialProbe(delayMs: number): void { - if (this.provider !== 'claude' || !this.redirectSubmitted || isTerminal(this.phase) || this.phase === 'finalizing') { + if ( + this.provider !== 'claude' || + !this.redirectSubmitted || + isTerminal(this.phase) || + this.phase === 'finalizing' + ) { return } if (this.credentialProbeTimer) { @@ -433,7 +452,12 @@ export class LoginSession { } private async probeClaudeCredentialFile(reason: string): Promise { - if (this.provider !== 'claude' || !this.redirectSubmitted || isTerminal(this.phase) || this.phase === 'finalizing') { + if ( + this.provider !== 'claude' || + !this.redirectSubmitted || + isTerminal(this.phase) || + this.phase === 'finalizing' + ) { return } try { @@ -474,7 +498,11 @@ export class LoginSession { this.clearClaudeSubmitTimers() } - private async finalize(updatedBy: string, claudeTokenOverride?: string, reason = 'completion detected'): Promise { + private async finalize( + updatedBy: string, + claudeTokenOverride?: string, + reason = 'completion detected', + ): Promise { if (this.phase === 'finalizing' || isTerminal(this.phase)) { return } @@ -482,7 +510,8 @@ export class LoginSession { try { // For Claude the canonical credential is the OAuth token setup-token // prints to stdout, not a file; parse it from the accumulated buffer. - const claudeToken = this.provider === 'claude' ? (claudeTokenOverride ?? parseClaudeToken(this.buffer)) : undefined + const claudeToken = + this.provider === 'claude' ? (claudeTokenOverride ?? parseClaudeToken(this.buffer)) : undefined this.deps.logger.info('login session finalize starting', { sessionId: this.id, provider: this.provider, diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index 473c6037..c6c86dcd 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -57,9 +57,7 @@ describe('PTY output parsing', () => { }) it('detects the Claude setup-token code prompt', () => { - expect(detectClaudeCodePrompt('\x1b[2GPaste\x1b[8Gcode\x1b[13Ghere\x1b[18Gif\x1b[21Gprompted\x1b[30G>')).toBe( - true, - ) + expect(detectClaudeCodePrompt('\x1b[2GPaste\x1b[8Gcode\x1b[13Ghere\x1b[18Gif\x1b[21Gprompted\x1b[30G>')).toBe(true) expect(detectClaudeCodePrompt('Paste code here if prompted >')).toBe(true) expect(detectClaudeCodePrompt('Opening browser to sign in...')).toBe(false) }) @@ -91,6 +89,20 @@ describe('PTY output parsing', () => { expect(parseCodex('code: AB12CD').deviceCode).toBe('AB12CD') }) + it('parses the Codex 0.141 --device-auth output (one-time code on the next line)', () => { + const buf = [ + 'Follow these steps to sign in with ChatGPT using device code authorization:', + '1. Open this link in your browser and sign in to your account', + ' https://auth.openai.com/codex/device', + '2. Enter this one-time code (expires in 15 minutes)', + ' 8PGY-109RY', + 'Device codes are a common phishing target. Never share this code.', + ].join('\r\n') + const parsed = parseCodex(buf) + expect(parsed.verificationUrl).toBe('https://auth.openai.com/codex/device') + expect(parsed.deviceCode).toBe('8PGY-109RY') + }) + it('detects per-provider success lines', () => { expect(detectSuccess('claude', 'Login successful. You are now logged in.')).toBe(true) expect(detectSuccess('codex', 'Successfully logged in.')).toBe(true)