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
28 changes: 28 additions & 0 deletions services/agents-login/src/worker/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 47 additions & 16 deletions services/agents-login/src/worker/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -198,6 +208,7 @@ export class LoginSession {
)
}
}
this.maybeSubmitPendingClaudeCode()
} else {
if (!this.deviceCode || !this.verificationUrl) {
const parsed = parseCodex(this.buffer)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -259,36 +291,35 @@ 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' }
}
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 }
}

Expand Down
8 changes: 4 additions & 4 deletions services/agents-login/test/helpers/fakeCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion services/agents-login/test/integration.pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
15 changes: 15 additions & 0 deletions services/agents-login/test/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest'
import {
parseClaude,
parseClaudeRedirectCode,
parseClaudeToken,
parseCodex,
detectClaudeCodePrompt,
detectSuccess,
detectFailure,
stripAnsi,
Expand Down Expand Up @@ -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`
Expand Down
84 changes: 69 additions & 15 deletions services/agents-login/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' }],
},
])
Expand All @@ -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/)
})
Expand Down