From 48ad540a634350955a64d68659a2652c38a41cab Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Fri, 26 Jun 2026 17:15:45 +0200 Subject: [PATCH] fix(agents-login): make Claude subscription login reach the authorize URL reliably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude login sessions hung at phase "starting" forever. On claude 2.1.186, stale state in the worker's persistent login HOME makes the CLI land on the idle logged-out REPL ("Not logged in · Run /login") after the trust prompt instead of auto-launching the claudeai login wizard. The blind Enter-through then only submitted empty REPL prompts and never sent /login, so the authorize URL never appeared. - Start each Claude login from a clean home: remove stale ~/.claude and ~/.claude.json, then always (re)write the onboarding seed + the forceLoginMethod=claudeai managed-settings. A clean home reliably reaches the authorize URL (confirmed live against 2.1.186 in-cluster). - Add a one-shot /login fallback: when the logged-out REPL is detected, send `/login` (and stop the blind Enter-through). Add a one-shot Enter for the "Select login method" chooser in case forceLoginMethod does not auto-select. - Raise the onboarding Enter-through to 10 sends at 1000ms (screen count varies). Codex path is unchanged. (Codex login fails separately due to broken IPv6 egress to auth.openai.com — tracked independently.) Co-Authored-By: Claude Opus 4.8 (1M context) --- services/agents-login/src/worker/parse.ts | 8 ++ services/agents-login/src/worker/session.ts | 76 +++++++++-- services/agents-login/test/helpers/fakeCli.ts | 16 ++- services/agents-login/test/parse.test.ts | 14 ++ services/agents-login/test/session.test.ts | 124 +++++++++++++++++- 5 files changed, 219 insertions(+), 19 deletions(-) diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index 10004a5a..3d50ed63 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -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 diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index de80ad16..c81d89d2 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -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' @@ -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, @@ -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 = { @@ -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' @@ -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', { @@ -233,6 +242,7 @@ export class LoginSession { this.phase !== 'starting' || this.authorizeUrl || !this.proc || + this.claudeLoginCommandSent || this.claudeOnboardingEnterTimer ) { return @@ -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 @@ -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) @@ -321,6 +372,7 @@ export class LoginSession { this.clearClaudeOnboardingNavigation() } } + this.handleClaudeLoginNavigation() this.maybeSubmitPendingClaudeCode() } else { if (!this.deviceCode || !this.verificationUrl) { diff --git a/services/agents-login/test/helpers/fakeCli.ts b/services/agents-login/test/helpers/fakeCli.ts index 306a320e..9169bd56 100644 --- a/services/agents-login/test/helpers/fakeCli.ts +++ b/services/agents-login/test/helpers/fakeCli.ts @@ -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 >" diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index b81f4376..f12d6b60 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -5,6 +5,8 @@ import { parseClaudeToken, parseCodex, detectClaudeCodePrompt, + detectClaudeLoggedOutRepl, + detectClaudeLoginChooser, detectSuccess, detectFailure, stripAnsi, @@ -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'), diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index bdd983c4..e48c5e40 100644 --- a/services/agents-login/test/session.test.ts +++ b/services/agents-login/test/session.test.ts @@ -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' @@ -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() @@ -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)