Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions services/agents-login/src/index.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
9 changes: 7 additions & 2 deletions services/agents-login/src/worker/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 39 additions & 10 deletions services/agents-login/src/worker/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand All @@ -433,7 +452,12 @@ export class LoginSession {
}

private async probeClaudeCredentialFile(reason: string): Promise<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
}
try {
Expand Down Expand Up @@ -474,15 +498,20 @@ export class LoginSession {
this.clearClaudeSubmitTimers()
}

private async finalize(updatedBy: string, claudeTokenOverride?: string, reason = 'completion detected'): Promise<void> {
private async finalize(
updatedBy: string,
claudeTokenOverride?: string,
reason = 'completion detected',
): Promise<void> {
if (this.phase === 'finalizing' || isTerminal(this.phase)) {
return
}
this.setPhase('finalizing', 'Capturing credentials…', reason)
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,
Expand Down
18 changes: 15 additions & 3 deletions services/agents-login/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
Expand Down