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: 8 additions & 0 deletions services/agents-login/src/worker/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export function detectClaudeCodePrompt(buffer: string): boolean {
return /paste\s*code\s*here\s*if\s*prompted\s*>/i.test(stripAnsi(buffer))
}

export function detectClaudeLoggedOutRepl(buffer: string): boolean {
return /(?:run\s+\/login|\?\s*for\s*shortcuts|welcome\s+back)/i.test(stripAnsi(buffer))
}

export function detectClaudeLoginChooser(buffer: string): boolean {
return /(?:select\s+login\s+method|claude\s+account\s+with\s+subscription)/i.test(stripAnsi(buffer))
}

export interface ClaudeRedirectCode {
code: string
state?: string
Expand Down
76 changes: 64 additions & 12 deletions services/agents-login/src/worker/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Logger } from '../shared/log.js'
import { redactString } from '../shared/redact.js'
Expand All @@ -8,6 +8,8 @@ import { isTerminal } from '../shared/types.js'
import { capture, readClaudeCredentialsJson, type CredentialPaths } from './credentials.js'
import {
detectClaudeCodePrompt,
detectClaudeLoggedOutRepl,
detectClaudeLoginChooser,
detectFailure,
detectSuccess,
parseClaude,
Expand Down Expand Up @@ -41,8 +43,8 @@ const DEFAULT_REDIRECT_TIMEOUT_MS = 60_000
const CLAUDE_CREDENTIAL_POLL_MS = 250
const CLAUDE_SUBMIT_ENTER_DELAY_MS = 120
const CLAUDE_SUBMIT_RETRY_DELAY_MS = 3_000
const CLAUDE_ONBOARDING_ENTER_INTERVAL_MS = 1_500
const CLAUDE_ONBOARDING_ENTER_MAX_SENDS = 6
const CLAUDE_ONBOARDING_ENTER_INTERVAL_MS = 1_000
const CLAUDE_ONBOARDING_ENTER_MAX_SENDS = 10
const LOG_LINE_LIMIT = 512
const FAILURE_TAIL_LIMIT = 2_000
const CLAUDE_ONBOARDING_SEED = {
Expand Down Expand Up @@ -71,6 +73,9 @@ export class LoginSession {
private claudeSubmitRetryTimer?: NodeJS.Timeout
private claudeOnboardingEnterTimer?: NodeJS.Timeout
private claudeOnboardingEnterCount = 0
private claudeLoggedOutReplDetected = false
private claudeLoginCommandSent = false
private claudeChooserConfirmed = false
private redirectSubmitted = false
private pendingClaudeCode?: string
private updatedBy = 'unknown'
Expand Down Expand Up @@ -178,15 +183,19 @@ export class LoginSession {

private prepareClaudeLoginHome(): string {
mkdirSync(this.deps.paths.home, { recursive: true })
mkdirSync(join(this.deps.paths.home, '.claude'), { recursive: true })
const dotClaudePath = join(this.deps.paths.home, '.claude.json')
if (!existsSync(dotClaudePath)) {
writeFileSync(dotClaudePath, JSON.stringify(CLAUDE_ONBOARDING_SEED))
this.deps.logger.info('Claude login onboarding seed written', {
sessionId: this.id,
path: '$HOME/.claude.json',
})
}
rmSync(join(this.deps.paths.home, '.claude'), { recursive: true, force: true })
rmSync(dotClaudePath, { force: true })
mkdirSync(join(this.deps.paths.home, '.claude'), { recursive: true })
this.deps.logger.info('Claude login home cleaned', {
sessionId: this.id,
removed: ['$HOME/.claude', '$HOME/.claude.json'],
})
writeFileSync(dotClaudePath, JSON.stringify(CLAUDE_ONBOARDING_SEED))
this.deps.logger.info('Claude login onboarding seed written', {
sessionId: this.id,
path: '$HOME/.claude.json',
})
const managedSettingsPath = join(this.deps.paths.home, 'managed-settings.json')
writeFileSync(managedSettingsPath, JSON.stringify(CLAUDE_MANAGED_SETTINGS))
this.deps.logger.info('Claude login managed settings written', {
Expand Down Expand Up @@ -233,6 +242,7 @@ export class LoginSession {
this.phase !== 'starting' ||
this.authorizeUrl ||
!this.proc ||
this.claudeLoginCommandSent ||
this.claudeOnboardingEnterTimer
) {
return
Expand All @@ -256,7 +266,13 @@ export class LoginSession {
}

private writeClaudeOnboardingEnter(): void {
if (this.provider !== 'claude' || this.phase !== 'starting' || this.authorizeUrl || !this.proc) {
if (
this.provider !== 'claude' ||
this.phase !== 'starting' ||
this.authorizeUrl ||
!this.proc ||
this.claudeLoginCommandSent
) {
return
}
const parsed = parseClaude(this.buffer).authorizeUrl
Expand All @@ -280,6 +296,41 @@ export class LoginSession {
this.scheduleClaudeOnboardingEnter()
}

private handleClaudeLoginNavigation(): void {
if (this.provider !== 'claude' || this.phase !== 'starting' || this.authorizeUrl || !this.proc) {
return
}
if (detectClaudeLoginChooser(this.buffer) && !this.claudeChooserConfirmed) {
this.claudeChooserConfirmed = true
this.clearClaudeOnboardingNavigation()
this.deps.logger.info('Claude login method chooser confirmed', {
sessionId: this.id,
provider: this.provider,
})
this.proc.write('\r')
return
}
if (detectClaudeLoggedOutRepl(this.buffer)) {
if (!this.claudeLoggedOutReplDetected) {
this.claudeLoggedOutReplDetected = true
this.deps.logger.info('Claude logged-out REPL detected', {
sessionId: this.id,
provider: this.provider,
})
}
if (!this.claudeLoginCommandSent) {
this.claudeLoginCommandSent = true
this.clearClaudeOnboardingNavigation()
this.deps.logger.info('Claude /login command written to PTY', {
sessionId: this.id,
provider: this.provider,
})
this.proc.write('/login\r')
}
return
}
}

private clearClaudeOnboardingNavigation(): void {
if (this.claudeOnboardingEnterTimer) {
clearTimeout(this.claudeOnboardingEnterTimer)
Expand Down Expand Up @@ -321,6 +372,7 @@ export class LoginSession {
this.clearClaudeOnboardingNavigation()
}
}
this.handleClaudeLoginNavigation()
this.maybeSubmitPendingClaudeCode()
} else {
if (!this.deviceCode || !this.verificationUrl) {
Expand Down
16 changes: 15 additions & 1 deletion services/agents-login/test/helpers/fakeCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,21 @@ fi
echo "Choose the look for Claude Code"
echo "Dark mode"
read THEME
echo "Login method pre-selected: Subscription Plan (Claude Pro/Max)"
echo "Welcome back"
echo "Not logged in · Run /login"
echo "? for shortcuts"
read LOGIN
if [ "$LOGIN" != "/login" ]; then
echo "error: expected /login, got $LOGIN"
exit 1
fi
echo "Select login method"
echo "Claude account with subscription"
read METHOD
if [ -n "$METHOD" ]; then
echo "error: expected default login-method Enter"
exit 1
fi
echo "Browser didn't open? Use the url below to sign in"
echo "https://claude.com/cai/oauth/authorize?code=true&scope=user%3Aprofile%20user%3Ainference%20user%3Asessions%3Aclaude_code&state=xyz"
echo "Paste code here if prompted >"
Expand Down
14 changes: 14 additions & 0 deletions services/agents-login/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
parseClaudeToken,
parseCodex,
detectClaudeCodePrompt,
detectClaudeLoggedOutRepl,
detectClaudeLoginChooser,
detectSuccess,
detectFailure,
stripAnsi,
Expand Down Expand Up @@ -62,6 +64,18 @@ describe('PTY output parsing', () => {
expect(detectClaudeCodePrompt('Opening browser to sign in...')).toBe(false)
})

it('detects Claude logged-out REPL prompts', () => {
expect(detectClaudeLoggedOutRepl('Not logged in · Run /login')).toBe(true)
expect(detectClaudeLoggedOutRepl('\x1b[2GWelcome back\r\n? for shortcuts')).toBe(true)
expect(detectClaudeLoggedOutRepl('Paste code here if prompted >')).toBe(false)
})

it('detects the Claude login-method chooser', () => {
expect(detectClaudeLoginChooser('Select login method')).toBe(true)
expect(detectClaudeLoginChooser('\x1b[36mClaude account with subscription\x1b[0m')).toBe(true)
expect(detectClaudeLoginChooser('Not logged in · Run /login')).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'),
Expand Down
124 changes: 118 additions & 6 deletions services/agents-login/test/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { LoginSession, SessionManager, type SessionDeps } from '../src/worker/session.js'
Expand Down Expand Up @@ -204,7 +204,7 @@ describe('LoginSession state machine', () => {
JSON.stringify({ forceLoginMethod: 'claudeai' }),
)

await vi.advanceTimersByTimeAsync(1_500)
await vi.advanceTimersByTimeAsync(1_000)
await Promise.resolve()
await Promise.resolve()

Expand All @@ -218,20 +218,132 @@ describe('LoginSession state machine', () => {
}
})

it('does not clobber an existing Claude account file when seeding onboarding', async () => {
const existing = '{"oauthAccount":{"emailAddress":"keep@example.com"}}'
writeFileSync(join(home, '.claude.json'), existing)
it('cleans stale Claude login home before seeding onboarding', async () => {
writeFileSync(join(home, '.claude', '.credentials.json'), '{"stale":true}')
writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{"emailAddress":"stale@example.com"}}')
const { deps: d } = deps(() => [])
const mgr = new SessionManager(d)
const started = mgr.start('claude', 'alice')

expect(readFileSync(join(home, '.claude.json'), 'utf8')).toBe(existing)
expect(existsSync(join(home, '.claude', '.credentials.json'))).toBe(false)
expect(readFileSync(join(home, '.claude.json'), 'utf8')).toBe(
JSON.stringify({
theme: 'dark',
hasCompletedOnboarding: true,
bypassPermissionsModeAccepted: true,
}),
)
expect(readFileSync(join(home, 'managed-settings.json'), 'utf8')).toBe(
JSON.stringify({ forceLoginMethod: 'claudeai' }),
)
mgr.cancel(started.id)
})

it('sends /login once when Claude lands in the logged-out REPL', async () => {
vi.useFakeTimers()
try {
const logs: LogEntry[] = []
const writes: string[] = []
const dataCbs: Array<(chunk: string) => void> = []
const proc = {
onData(cb: (chunk: string) => void) {
dataCbs.push(cb)
},
onExit(_cb: (info: { exitCode: number }) => void) {
// no-op
},
write(data: string) {
writes.push(data)
},
kill() {
// no-op
},
}
const mgr = new SessionManager({
spawner: () => proc,
agentsApi,
lease: new NoopLeaseLock(),
paths: { home, codexHome },
logger: memoryLogger(logs),
ttlMs: 60_000,
})
const started = mgr.start('claude', 'alice')

dataCbs.forEach((cb) => cb('Welcome back\r\nNot logged in · Run /login\r\n? for shortcuts\r\n'))
expect(writes).toEqual(['/login\r'])
expect(logs.some((entry) => entry.msg === 'Claude logged-out REPL detected')).toBe(true)
expect(logs.some((entry) => entry.msg === 'Claude /login command written to PTY')).toBe(true)

dataCbs.forEach((cb) =>
cb(
"Browser didn't open? Use the url below to sign in\r\n" +
'https://claude.com/cai/oauth/authorize?code=true&state=idle-repl\r\n' +
'Paste code here if prompted >',
),
)
expect(mgr.status(started.id)!.phase).toBe('awaiting_url')
expect(mgr.status(started.id)!.authorizeUrl).toContain('state=idle-repl')

await vi.advanceTimersByTimeAsync(5_000)
expect(writes).toEqual(['/login\r'])
mgr.cancel(started.id)
} finally {
vi.useRealTimers()
}
})

it('confirms the Claude login-method chooser with one Enter', async () => {
vi.useFakeTimers()
try {
const logs: LogEntry[] = []
const writes: string[] = []
const dataCbs: Array<(chunk: string) => void> = []
const proc = {
onData(cb: (chunk: string) => void) {
dataCbs.push(cb)
},
onExit(_cb: (info: { exitCode: number }) => void) {
// no-op
},
write(data: string) {
writes.push(data)
},
kill() {
// no-op
},
}
const mgr = new SessionManager({
spawner: () => proc,
agentsApi,
lease: new NoopLeaseLock(),
paths: { home, codexHome },
logger: memoryLogger(logs),
ttlMs: 60_000,
})
const started = mgr.start('claude', 'alice')

dataCbs.forEach((cb) => cb('Select login method\r\nClaude account with subscription\r\n'))
expect(writes).toEqual(['\r'])
expect(logs.some((entry) => entry.msg === 'Claude login method chooser confirmed')).toBe(true)

dataCbs.forEach((cb) =>
cb(
"Browser didn't open? Use the url below to sign in\r\n" +
'https://claude.com/cai/oauth/authorize?code=true&state=chooser\r\n' +
'Paste code here if prompted >',
),
)
expect(mgr.status(started.id)!.phase).toBe('awaiting_url')
expect(mgr.status(started.id)!.authorizeUrl).toContain('state=chooser')

await vi.advanceTimersByTimeAsync(5_000)
expect(writes).toEqual(['\r'])
mgr.cancel(started.id)
} finally {
vi.useRealTimers()
}
})

it('drives the full Claude flow: authorize URL → redirect paste-back → success → agents-api POST', async () => {
const token = 'sk-ant-oat01-FullFlowToken1234567890_-abcXYZ'
const artifacts = claudeArtifacts(token)
Expand Down