From a18775e51dbb5806d192c506fadc52f4981c4233 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Fri, 26 Jun 2026 16:21:06 +0200 Subject: [PATCH 1/2] fix(agents-login): capture a full user:profile-scoped Claude credential `claude setup-token` mints an inferenceOnly token (scope user:inference only, no user:profile), so runner Claude sessions could not resolve the account profile and showed "Claude API" instead of the subscription. Switch the Claude capture from `setup-token` to the interactive "Claude account with subscription" (claudeai) login, which mints the full-scope credential (user:profile + user:inference + refresh) and writes ~/.claude/.credentials.json + ~/.claude.json (oauthAccount): - Launch bare `claude`; seed onboarding flags in ~/.claude.json (only when absent) and write a managed-settings.json with forceLoginMethod=claudeai via CLAUDE_CODE_MANAGED_SETTINGS_PATH so the subscription method is pre-selected. - Bounded Enter-through navigation past the theme/intro screens until the authorize URL appears (reuses the existing URL-parse + code-paste path). - Capture the verbatim credentials_json + extracted oauthAccount (account_json); post both to agents-api (oauth_token kept optional for back-compat). Completion now keys off the credentials file, not a stdout token. Validated live that the claudeai authorize URL carries user:profile. Pairs with the agents-repo change that injects credentials_json + account_json into runners. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/agents-login/src/index.ts | 2 +- .../src/worker/agentsApiClient.ts | 22 +- .../agents-login/src/worker/credentials.ts | 73 ++++-- services/agents-login/src/worker/parse.ts | 12 +- services/agents-login/src/worker/session.ts | 153 ++++++++++--- .../agents-login/test/agentsApiClient.test.ts | 12 +- .../agents-login/test/credentials.test.ts | 37 ++-- services/agents-login/test/helpers/fakeCli.ts | 20 +- services/agents-login/test/helpers/fakePty.ts | 3 + .../agents-login/test/integration.pty.test.ts | 5 +- services/agents-login/test/parse.test.ts | 13 +- services/agents-login/test/session.test.ts | 207 ++++++++++++++---- 12 files changed, 424 insertions(+), 135 deletions(-) diff --git a/services/agents-login/src/index.ts b/services/agents-login/src/index.ts index 73a22d0e..55e82e78 100644 --- a/services/agents-login/src/index.ts +++ b/services/agents-login/src/index.ts @@ -1,7 +1,7 @@ import { runWorker } from './worker/index.js' // The credential-login worker: the only process this image runs. It owns the -// PTY that drives `claude setup-token` / `codex login --device-auth`, captures +// PTY that drives interactive `claude` / `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. diff --git a/services/agents-login/src/worker/agentsApiClient.ts b/services/agents-login/src/worker/agentsApiClient.ts index 767320d3..30b854aa 100644 --- a/services/agents-login/src/worker/agentsApiClient.ts +++ b/services/agents-login/src/worker/agentsApiClient.ts @@ -1,7 +1,7 @@ import type { Provider } from '../shared/types.js' import type { Logger } from '../shared/log.js' import { redactString } from '../shared/redact.js' -import type { CredentialBundle } from './credentials.js' +import { extractClaudeOauthToken, type CredentialBundle } from './credentials.js' export type AgentsApiProvider = 'CLAUDE' | 'CODEX' @@ -57,9 +57,9 @@ export class AgentsApiClient { ok: res.ok, provider: req.provider, }) - if (!res.ok) { - throw new Error(`agents-api credential ingest failed: ${res.status} ${redactString(await safeText(res))}`) - } + if (!res.ok) { + throw new Error(`agents-api credential ingest failed: ${res.status} ${redactString(await safeText(res))}`) + } } storedStatus(): StoredCredentialStatus { @@ -73,11 +73,17 @@ export function providerForApi(provider: Provider): AgentsApiProvider { export function payloadForApi(provider: Provider, bundle: CredentialBundle): Record { if (provider === 'claude') { - const oauthToken = bundle.data.oauth_token - if (!oauthToken) { - throw new Error('no Claude OAuth token captured') + const credentialsJson = bundle.data.credentials_json + if (!credentialsJson) { + throw new Error('no Claude credentials_json captured') + } + const accountJson = bundle.data.account_json + const oauthToken = bundle.data.oauth_token ?? extractClaudeOauthToken(credentialsJson) + return { + credentials_json: credentialsJson, + ...(accountJson ? { account_json: accountJson } : {}), + ...(oauthToken ? { oauth_token: oauthToken } : {}), } - return { oauth_token: oauthToken } } const authJson = bundle.data.auth_json diff --git a/services/agents-login/src/worker/credentials.ts b/services/agents-login/src/worker/credentials.ts index bc3f629c..6ea40909 100644 --- a/services/agents-login/src/worker/credentials.ts +++ b/services/agents-login/src/worker/credentials.ts @@ -27,27 +27,68 @@ async function readUtf8OrUndefined(path: string): Promise { } } -export async function readClaudeCredentialsToken(paths: CredentialPaths): Promise { - const credentials = await readUtf8OrUndefined(join(paths.home, '.claude', '.credentials.json')) - return credentials === undefined ? undefined : parseClaudeToken(credentials) +export async function readClaudeCredentialsJson(paths: CredentialPaths): Promise { + return readUtf8OrUndefined(join(paths.home, '.claude', '.credentials.json')) +} + +function objectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function findStringField(value: unknown, fieldNames: Set): string | undefined { + const record = objectRecord(value) + if (!record) { + return undefined + } + for (const [key, fieldValue] of Object.entries(record)) { + if (fieldNames.has(key) && typeof fieldValue === 'string' && fieldValue.length > 0) { + return fieldValue + } + } + for (const fieldValue of Object.values(record)) { + const nested = findStringField(fieldValue, fieldNames) + if (nested) { + return nested + } + } + return undefined +} + +export function extractClaudeOauthToken(credentialsJson: string): string | undefined { + const tokenFromRaw = parseClaudeToken(credentialsJson) + if (tokenFromRaw) { + return tokenFromRaw + } + try { + return findStringField(JSON.parse(credentialsJson), new Set(['accessToken', 'oauth_token'])) + } catch { + return undefined + } +} + +function extractClaudeAccountJson(dotClaudeJson: string): string | undefined { + const parsed = objectRecord(JSON.parse(dotClaudeJson)) + if (!parsed || parsed.oauthAccount === undefined) { + return undefined + } + return JSON.stringify(parsed.oauthAccount) } /** - * Capture the Claude credential from a completed `setup-token` run. + * Capture the Claude credential from a completed subscription login. * - * `setup-token`'s product is a long-lived OAuth token printed to stdout (for - * CLAUDE_CODE_OAUTH_TOKEN); it does NOT write a credentials file. The token is - * therefore the canonical credential and is parsed from the PTY output by the - * caller. The interactive `claude login` flow instead leaves + * The interactive `claude` first-run login writes * `$HOME/.claude/.credentials.json` and `$HOME/.claude.json` (a SIBLING of - * .claude/), so those are captured opportunistically when present. Capture - * fails only when neither a token nor a credentials file is available. + * .claude/). The full subscription credential is the canonical artifact; a + * parsed token is kept only as an optional back-compat payload field. */ export async function captureClaude( paths: CredentialPaths, updatedBy: string, now: () => Date = () => new Date(), - oauthToken?: string, + _oauthToken?: string, ): Promise { const credsPath = join(paths.home, '.claude', '.credentials.json') const dotClaudePath = join(paths.home, '.claude.json') @@ -55,10 +96,10 @@ export async function captureClaude( readUtf8OrUndefined(credsPath), readUtf8OrUndefined(dotClaudePath), ]) - if (!oauthToken && credentials === undefined) { - throw new Error('no Claude credential captured: setup-token printed no token and no .credentials.json was written') + if (credentials === undefined) { + throw new Error('no Claude credential captured: .credentials.json was not written') } - const capturedToken = oauthToken ?? (credentials === undefined ? undefined : parseClaudeToken(credentials)) + const capturedToken = extractClaudeOauthToken(credentials) const data: Record = { schema_version: SCHEMA_VERSION, updated_at: now().toISOString(), @@ -72,6 +113,10 @@ export async function captureClaude( } if (dotClaude !== undefined) { data.claude_json = dotClaude + const accountJson = extractClaudeAccountJson(dotClaude) + if (accountJson !== undefined) { + data.account_json = accountJson + } } return { data } } diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index caeb7898..10004a5a 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -39,7 +39,7 @@ export function oscHyperlinkTargets(raw: string): string[] { return targets } -// `claude setup-token` prints an OAuth authorize URL. The host has moved across +// Claude's OAuth flows print an authorize URL. The host has moved across // claude.ai / claude.com / platform.claude.com / console.anthropic.com over CLI // versions (2.1.x emits claude.com), so accept all of them. const CLAUDE_URL_PATTERN = @@ -101,11 +101,9 @@ export function parseClaudeRedirectCode(input: string): ClaudeRedirectCode | und } } -// `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 -// token must be captured from the PTY output. The PTY is 400 cols wide, so the -// ~100-char token is emitted on one line and bounded by whitespace. +// Legacy Claude setup-token output included a long-lived OAuth token on stdout. +// The subscription login now uses .credentials.json as the canonical artifact, +// but this parser still supports optional back-compat payload fields. const CLAUDE_TOKEN_PATTERN = /(sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{20,})/ export function parseClaudeToken(buffer: string): string | undefined { @@ -142,7 +140,7 @@ export function parseCodex(buffer: string): CodexParse { // The CLIs print an unambiguous success line once the login completes. const CLAUDE_SUCCESS = - /(login\s*success|successfully\s*logged\s*in|you\s*are\s*now\s*logged\s*in|authenticated|token\s*(?:saved|created|stored)|setup\s*complete)/i + /(login\s*success|logged\s*in\s*as|successfully\s*logged\s*in|you\s*are\s*now\s*logged\s*in|authenticated|token\s*(?:saved|created|stored)|setup\s*complete)/i const CODEX_SUCCESS = /(login\s*success|successfully\s*logged\s*in|signed\s*in|authentication\s*complete)/i export function detectSuccess(provider: Provider, buffer: string): boolean { diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index 6c05edc9..de80ad16 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import type { Logger } from '../shared/log.js' 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 { capture, readClaudeCredentialsJson, type CredentialPaths } from './credentials.js' import { detectClaudeCodePrompt, detectFailure, @@ -31,12 +33,7 @@ export interface SessionDeps { } function defaultCommand(provider: Provider): { file: string; args: string[] } { - // `claude /login` is an interactive REPL slash command ("not available in this - // environment"); `claude setup-token` is the headless OAuth flow that prints an - // 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-auth'] } + return provider === 'claude' ? { file: 'claude', args: [] } : { file: 'codex', args: ['login', '--device-auth'] } } const MAX_BUFFER = 256 * 1024 @@ -44,8 +41,16 @@ 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 LOG_LINE_LIMIT = 512 const FAILURE_TAIL_LIMIT = 2_000 +const CLAUDE_ONBOARDING_SEED = { + theme: 'dark', + hasCompletedOnboarding: true, + bypassPermissionsModeAccepted: true, +} +const CLAUDE_MANAGED_SETTINGS = { forceLoginMethod: 'claudeai' } export class LoginSession { readonly id = randomUUID() @@ -64,6 +69,8 @@ export class LoginSession { private credentialProbeTimer?: NodeJS.Timeout private claudeSubmitEnterTimer?: NodeJS.Timeout private claudeSubmitRetryTimer?: NodeJS.Timeout + private claudeOnboardingEnterTimer?: NodeJS.Timeout + private claudeOnboardingEnterCount = 0 private redirectSubmitted = false private pendingClaudeCode?: string private updatedBy = 'unknown' @@ -129,6 +136,20 @@ export class LoginSession { start(updatedBy: string): void { this.updatedBy = updatedBy const cmd = (this.deps.command ?? defaultCommand)(this.provider) + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: this.deps.paths.home, + CODEX_HOME: this.deps.paths.codexHome, + } + if (this.provider === 'claude') { + try { + env.CLAUDE_CODE_MANAGED_SETTINGS_PATH = this.prepareClaudeLoginHome() + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + this.fail(`failed to prepare Claude login home: ${reason}`) + return + } + } this.deps.logger.info('login session starting', { sessionId: this.id, provider: this.provider, @@ -136,11 +157,6 @@ export class LoginSession { command: cmd.file, args: cmd.args, }) - const env = { - ...process.env, - HOME: this.deps.paths.home, - CODEX_HOME: this.deps.paths.codexHome, - } this.proc = this.deps.spawner(cmd.file, cmd.args, { cwd: this.deps.paths.home, env, @@ -155,6 +171,30 @@ export class LoginSession { if (typeof this.timer.unref === 'function') { this.timer.unref() } + if (this.provider === 'claude') { + this.startClaudeOnboardingNavigation() + } + } + + 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', + }) + } + 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', { + sessionId: this.id, + path: '$HOME/managed-settings.json', + forceLoginMethod: 'claudeai', + }) + return managedSettingsPath } private append(chunk: string): void { @@ -174,7 +214,7 @@ export class LoginSession { } private logClaudeTokenParseAttempt(source: string, matched: boolean, reason: string): void { - this.deps.logger.info('Claude setup-token parse attempt', { + this.deps.logger.info('Claude OAuth token parse attempt', { sessionId: this.id, source, matched, @@ -182,6 +222,71 @@ export class LoginSession { }) } + private startClaudeOnboardingNavigation(): void { + this.claudeOnboardingEnterCount = 0 + this.scheduleClaudeOnboardingEnter() + } + + private scheduleClaudeOnboardingEnter(): void { + if ( + this.provider !== 'claude' || + this.phase !== 'starting' || + this.authorizeUrl || + !this.proc || + this.claudeOnboardingEnterTimer + ) { + return + } + if (this.claudeOnboardingEnterCount >= CLAUDE_ONBOARDING_ENTER_MAX_SENDS) { + this.deps.logger.info('Claude onboarding Enter navigation stopped', { + sessionId: this.id, + provider: this.provider, + reason: 'max sends reached before authorize URL', + sends: this.claudeOnboardingEnterCount, + }) + return + } + this.claudeOnboardingEnterTimer = setTimeout(() => { + this.claudeOnboardingEnterTimer = undefined + this.writeClaudeOnboardingEnter() + }, CLAUDE_ONBOARDING_ENTER_INTERVAL_MS) + if (typeof this.claudeOnboardingEnterTimer.unref === 'function') { + this.claudeOnboardingEnterTimer.unref() + } + } + + private writeClaudeOnboardingEnter(): void { + if (this.provider !== 'claude' || this.phase !== 'starting' || this.authorizeUrl || !this.proc) { + return + } + const parsed = parseClaude(this.buffer).authorizeUrl + if (parsed) { + this.authorizeUrl = parsed + this.setPhase( + 'awaiting_url', + 'Open the authorize URL, approve, then paste the authorization code.', + 'Claude authorize URL detected during onboarding navigation', + ) + return + } + this.claudeOnboardingEnterCount += 1 + this.deps.logger.info('Claude onboarding Enter written to PTY', { + sessionId: this.id, + provider: this.provider, + send: this.claudeOnboardingEnterCount, + maxSends: CLAUDE_ONBOARDING_ENTER_MAX_SENDS, + }) + this.proc.write('\r') + this.scheduleClaudeOnboardingEnter() + } + + private clearClaudeOnboardingNavigation(): void { + if (this.claudeOnboardingEnterTimer) { + clearTimeout(this.claudeOnboardingEnterTimer) + this.claudeOnboardingEnterTimer = undefined + } + } + private onData(chunk: string, updatedBy: string): void { this.append(chunk) @@ -190,7 +295,7 @@ export class LoginSession { } if (this.provider === 'claude') { - this.deps.logger.info('Claude setup-token output scanned', { + this.deps.logger.info('Claude login output scanned', { sessionId: this.id, phase: this.phase, bytes: chunk.length, @@ -213,6 +318,7 @@ export class LoginSession { 'Open the authorize URL, approve, then paste the authorization code.', 'Claude authorize URL detected', ) + this.clearClaudeOnboardingNavigation() } } this.maybeSubmitPendingClaudeCode() @@ -244,12 +350,8 @@ export class LoginSession { 'PTY output received after redirect', ) } - if (claudeTokenCaptured) { - void this.finalize(updatedBy, claudeTokenCaptured, 'Claude OAuth token parsed from PTY output') - return - } if (detectSuccess(this.provider, this.buffer)) { - void this.finalize(updatedBy, undefined, 'CLI success output detected') + void this.finalize(updatedBy, claudeTokenCaptured, 'CLI success output detected') return } if (this.provider === 'claude' && this.redirectSubmitted) { @@ -298,7 +400,7 @@ export class LoginSession { redirectSubmitted: this.redirectSubmitted, phase: this.phase, }) - // A clean exit after the operator submitted the code (Claude setup-token) or + // A clean exit after the operator submitted the code (Claude login) or // after the device prompt was shown and approved (Codex) means the CLI wrote // its credentials — capture them even if no success line was matched. const completed = @@ -461,15 +563,15 @@ export class LoginSession { return } try { - const token = await readClaudeCredentialsToken(this.deps.paths) + const credentialsJson = await readClaudeCredentialsJson(this.deps.paths) this.deps.logger.info('Claude credential file parse attempt', { sessionId: this.id, source: '$HOME/.claude/.credentials.json', - matched: token !== undefined, + matched: credentialsJson !== undefined, reason, }) - if (token) { - await this.finalize(this.updatedBy, token, 'Claude OAuth token parsed from credentials file') + if (credentialsJson !== undefined) { + await this.finalize(this.updatedBy, undefined, 'Claude credentials file detected') return } } catch (err) { @@ -496,6 +598,7 @@ export class LoginSession { this.credentialProbeTimer = undefined } this.clearClaudeSubmitTimers() + this.clearClaudeOnboardingNavigation() } private async finalize( @@ -508,8 +611,6 @@ export class LoginSession { } 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 this.deps.logger.info('login session finalize starting', { diff --git a/services/agents-login/test/agentsApiClient.test.ts b/services/agents-login/test/agentsApiClient.test.ts index fa694cc1..1b6f0445 100644 --- a/services/agents-login/test/agentsApiClient.test.ts +++ b/services/agents-login/test/agentsApiClient.test.ts @@ -91,11 +91,17 @@ describe('AgentsApiClient', () => { }) it('extracts only the required provider payload fields', () => { + const credentialsJson = '{"accessToken":"sk-ant-oat01-AgentPayloadToken1234567890"}' + const accountJson = '{"emailAddress":"alice@example.com"}' expect( payloadForApi('claude', { - data: { oauth_token: 'tok', credentials_json: '{}', updated_by: 'alice' }, + data: { credentials_json: credentialsJson, account_json: accountJson, updated_by: 'alice' }, }), - ).toEqual({ oauth_token: 'tok' }) + ).toEqual({ + credentials_json: credentialsJson, + account_json: accountJson, + oauth_token: 'sk-ant-oat01-AgentPayloadToken1234567890', + }) expect( payloadForApi('codex', { @@ -105,7 +111,7 @@ describe('AgentsApiClient', () => { }) it('rejects incomplete provider payloads before posting', () => { - expect(() => payloadForApi('claude', { data: { credentials_json: '{}' } })).toThrow(/Claude OAuth token/) + expect(() => payloadForApi('claude', { data: { account_json: '{}' } })).toThrow(/Claude credentials_json/) expect(() => payloadForApi('codex', { data: { auth_json: '{}' } })).toThrow(/Codex credential/) }) diff --git a/services/agents-login/test/credentials.test.ts b/services/agents-login/test/credentials.test.ts index da3a8f45..20a92eb8 100644 --- a/services/agents-login/test/credentials.test.ts +++ b/services/agents-login/test/credentials.test.ts @@ -22,39 +22,40 @@ describe('credential capture', () => { rmSync(root, { recursive: true, force: true }) }) - it('captures Claude credentials including the .claude.json SIBLING path', async () => { + it('captures Claude credentials and extracts oauthAccount from the .claude.json sibling path', async () => { // .credentials.json lives inside .claude/, but .claude.json is a sibling. - writeFileSync(join(home, '.claude', '.credentials.json'), '{"accessToken":"a"}') - writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{}}') + const credentialsJson = + '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-CaptureToken1234567890","refreshToken":"r","scopes":["user:profile","user:inference"],"subscriptionType":"max"}}' + const accountJson = '{"emailAddress":"alice@example.com","accountUuid":"acct-1"}' + writeFileSync(join(home, '.claude', '.credentials.json'), credentialsJson) + writeFileSync(join(home, '.claude.json'), `{"installMethod":"global","oauthAccount":${accountJson}}`) const bundle = await captureClaude({ home, codexHome }, 'alice@example.com', fixedNow) - expect(bundle.data['credentials_json']).toBe('{"accessToken":"a"}') - expect(bundle.data['claude_json']).toBe('{"oauthAccount":{}}') + expect(bundle.data['credentials_json']).toBe(credentialsJson) + expect(bundle.data['claude_json']).toBe(`{"installMethod":"global","oauthAccount":${accountJson}}`) + expect(bundle.data['account_json']).toBe(accountJson) + expect(bundle.data.oauth_token).toBe('sk-ant-oat01-CaptureToken1234567890') expect(bundle.data.schema_version).toBe(SCHEMA_VERSION) expect(bundle.data.updated_by).toBe('alice@example.com') expect(bundle.data.updated_at).toBe('2026-06-21T00:00:00.000Z') }) - it('captures the setup-token OAuth token from stdout when no files are written', async () => { - // `claude setup-token` prints the token and persists nothing; the token is - // the canonical credential. + it('rejects stdout-only Claude OAuth tokens without the full credentials file', async () => { const token = 'sk-ant-oat01-abcDEF123456_-789ghiJKLmnop' - const bundle = await captureClaude({ home, codexHome }, 'alice', fixedNow, token) - expect(bundle.data.oauth_token).toBe(token) - expect(bundle.data['credentials_json']).toBeUndefined() - expect(bundle.data['claude_json']).toBeUndefined() - expect(bundle.data.updated_by).toBe('alice') + await expect(captureClaude({ home, codexHome }, 'alice', fixedNow, token)).rejects.toThrow( + /credentials\.json was not written/, + ) }) - it('captures the token alongside files when both are present', async () => { - writeFileSync(join(home, '.claude', '.credentials.json'), '{"accessToken":"a"}') + it('parses the optional back-compat token from credentials_json', async () => { + writeFileSync(join(home, '.claude', '.credentials.json'), '{"accessToken":"sk-ant-oat01-zzzzzzzzzzzzzzzzzzzz"}') writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{}}') - const bundle = await captureClaude({ home, codexHome }, 'alice', fixedNow, 'sk-ant-oat01-zzzzzzzzzzzzzzzzzzzz') + const bundle = await captureClaude({ home, codexHome }, 'alice', fixedNow) expect(bundle.data.oauth_token).toBe('sk-ant-oat01-zzzzzzzzzzzzzzzzzzzz') - expect(bundle.data['credentials_json']).toBe('{"accessToken":"a"}') + expect(bundle.data['credentials_json']).toBe('{"accessToken":"sk-ant-oat01-zzzzzzzzzzzzzzzzzzzz"}') }) - it('fails when neither a token nor a credentials file is available', async () => { + it('fails when no credentials file is available', async () => { await expect(captureClaude({ home, codexHome }, 'x', fixedNow)).rejects.toThrow(/no Claude credential/) }) diff --git a/services/agents-login/test/helpers/fakeCli.ts b/services/agents-login/test/helpers/fakeCli.ts index 7ee637de..306a320e 100644 --- a/services/agents-login/test/helpers/fakeCli.ts +++ b/services/agents-login/test/helpers/fakeCli.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' /** * Materialise fake `claude` and `codex` executables (POSIX shell scripts) in a * temp dir. They emit the same authorize-URL / device-code lines the real CLIs - * print, perform the redirect-URL stdin handshake (Claude), and write the four + * print, perform the redirect-URL stdin handshake (Claude), and write the * credential files into HOME / CODEX_HOME — so the worker can be driven by a * real PTY with no network and no real CLIs. */ @@ -26,16 +26,24 @@ export function makeFakeCliEnv(): FakeCliEnv { mkdirSync(codexHome, { recursive: true }) 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" +if [ ! -f "$CLAUDE_CODE_MANAGED_SETTINGS_PATH" ]; then + echo "error: missing managed settings" + exit 1 +fi +echo "Choose the look for Claude Code" +echo "Dark mode" +read THEME +echo "Login method pre-selected: Subscription Plan (Claude Pro/Max)" +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 >" # 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" -printf '%s' '{"installMethod":"global","oauthAccount":{"emailAddress":"x@y"}}' > "$HOME/.claude.json" +printf '%s' '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-claudeSubscriptionToken1234567890","refreshToken":"r","scopes":["user:profile","user:inference","user:sessions:claude_code"],"subscriptionType":"max"}}' > "$HOME/.claude/.credentials.json" +printf '%s' '{"installMethod":"global","oauthAccount":{"emailAddress":"x@y","accountUuid":"acct-1"}}' > "$HOME/.claude.json" +echo "Logged in as x@y" exit 0 ` diff --git a/services/agents-login/test/helpers/fakePty.ts b/services/agents-login/test/helpers/fakePty.ts index 2bc8a4b0..d3be0ec1 100644 --- a/services/agents-login/test/helpers/fakePty.ts +++ b/services/agents-login/test/helpers/fakePty.ts @@ -8,6 +8,7 @@ import type { PtyProcess, PtySpawner } from '../../src/worker/pty.js' */ export type Action = | { type: 'emit'; data: string } + | { type: 'effect'; run: () => void } | { type: 'expectStdin'; match: (input: string) => boolean; then: Action[] } | { type: 'exit'; code: number } @@ -30,6 +31,8 @@ export class FakePty implements PtyProcess { const a = actions[i] if (a.type === 'emit') { this.dataCbs.forEach((cb) => cb(a.data)) + } else if (a.type === 'effect') { + a.run() } else if (a.type === 'exit') { this.exitCbs.forEach((cb) => cb({ exitCode: a.code })) return diff --git a/services/agents-login/test/integration.pty.test.ts b/services/agents-login/test/integration.pty.test.ts index 6a712b0d..556eb35f 100644 --- a/services/agents-login/test/integration.pty.test.ts +++ b/services/agents-login/test/integration.pty.test.ts @@ -68,7 +68,7 @@ describe('real-PTY integration with fake CLIs', () => { // Claude: wait for the authorize URL, paste the redirect, expect success. 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') + expect(mgr.status(claude.id)!.authorizeUrl).toContain('claude.com/cai/oauth/authorize') const sub = mgr.submitRedirectUrl( claude.id, 'https://platform.claude.com/oauth/code/callback?code=integration-code-2&state=integration-state-2', @@ -77,6 +77,9 @@ describe('real-PTY integration with fake CLIs', () => { await waitFor(() => mgr.status(claude.id)!.phase === 'succeeded') expect(posts[0]).toMatchObject({ userId: 'alice', provider: 'CLAUDE' }) expect(posts[0].payload.oauth_token).toContain('sk-ant-oat01') + expect(JSON.parse(posts[0].payload.credentials_json).claudeAiOauth.scopes).toContain('user:profile') + expect(JSON.parse(posts[0].payload.credentials_json).claudeAiOauth.subscriptionType).toBe('max') + expect(JSON.parse(posts[0].payload.account_json).emailAddress).toBe('x@y') // Codex: device flow, no paste-back. const codex = mgr.start('codex', 'alice') diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index c6c86dcd..b81f4376 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -26,7 +26,7 @@ describe('PTY output parsing', () => { expect(parseClaude(buf).authorizeUrl).toBe('https://console.anthropic.com/oauth/authorize?x=1') }) - it('matches the claude.com host that setup-token emits', () => { + it('matches the claude.com host that Claude OAuth emits', () => { const buf = 'Use the url below to sign in\r\nhttps://claude.com/oauth/authorize?code=true&client_id=x&state=y\r\n' expect(parseClaude(buf).authorizeUrl).toBe('https://claude.com/oauth/authorize?code=true&client_id=x&state=y') }) @@ -36,7 +36,7 @@ describe('PTY output parsing', () => { }) it('extracts the clean target from an OSC 8 hyperlink, not the fused display copy', () => { - // `claude setup-token` prints the authorize URL as an OSC 8 hyperlink + // Claude can print the authorize URL as an OSC 8 hyperlink // (ESC ] 8 ; id ; URL BEL ESC ] 8 ; ; BEL). The framing // bytes used to be dropped as stray control chars, fusing the real URL to // the visible copy and corrupting the `state` param -> claude.com @@ -56,7 +56,7 @@ describe('PTY output parsing', () => { expect(parseClaude(raw).authorizeUrl).toBe(url) }) - it('detects the Claude setup-token code prompt', () => { + it('detects the Claude manual-flow code prompt', () => { 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) @@ -105,11 +105,12 @@ describe('PTY output parsing', () => { it('detects per-provider success lines', () => { expect(detectSuccess('claude', 'Login successful. You are now logged in.')).toBe(true) + expect(detectSuccess('claude', 'Logged in as alice@example.com')).toBe(true) expect(detectSuccess('codex', 'Successfully logged in.')).toBe(true) expect(detectSuccess('claude', 'still working')).toBe(false) }) - it('parses the setup-token OAuth token from stdout, bounded by whitespace', () => { + it('parses a legacy Claude OAuth token from stdout, bounded by whitespace', () => { const token = 'sk-ant-oat01-Dx3Q7rrCNsYQi3h2HLUKZK4-KDiCJ_XCKEcSYx0X3H-O0' const buf = `\x1b[32m✓\x1b[0m Long-lived authentication token created successfully!\r\n` + @@ -117,12 +118,12 @@ describe('PTY output parsing', () => { expect(parseClaudeToken(buf)).toBe(token) }) - it('parses a bare setup-token OAuth token line', () => { + it('parses a bare legacy Claude OAuth token line', () => { const token = 'sk-ant-oat01-BareLineToken1234567890_-abcXYZ' expect(parseClaudeToken(`${token}\r\n`)).toBe(token) }) - it('parses the setup-token OAuth token from an OSC 8 hyperlink target', () => { + it('parses a legacy Claude OAuth token from an OSC 8 hyperlink target', () => { const token = 'sk-ant-oat01-Osc8TokenTarget1234567890_-abcXYZ' const raw = `Your OAuth token: \x1b]8;;${token}\x07open token\x1b]8;;\x07\r\n` expect(parseClaudeToken(raw)).toBe(token) diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index 478e8418..bdd983c4 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 } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { 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' @@ -36,6 +36,13 @@ interface LogEntry { fields?: Record } +interface ClaudeArtifacts { + token: string + credentialsJson: string + accountJson: string + claudeJson: string +} + function memoryLogger(entries: LogEntry[]): Logger { return { info: (msg, fields) => entries.push({ level: 'info', msg, fields }), @@ -45,7 +52,10 @@ function memoryLogger(entries: LogEntry[]): Logger { } } -function fakeAgentsApi(fail?: Error): { postCredentials: (req: PostedCredential) => Promise; posts: PostedCredential[] } { +function fakeAgentsApi(fail?: Error): { + postCredentials: (req: PostedCredential) => Promise + posts: PostedCredential[] +} { const posts: PostedCredential[] = [] return { posts, @@ -58,6 +68,39 @@ function fakeAgentsApi(fail?: Error): { postCredentials: (req: PostedCredential) } } +function claudeArtifacts(token = 'sk-ant-oat01-SubscriptionToken1234567890_-abcXYZ'): ClaudeArtifacts { + const account = { emailAddress: 'alice@example.com', accountUuid: 'acct-1' } + const credentials = { + claudeAiOauth: { + accessToken: token, + refreshToken: 'refresh-token', + scopes: ['user:profile', 'user:inference', 'user:sessions:claude_code'], + subscriptionType: 'max', + }, + } + return { + token, + credentialsJson: JSON.stringify(credentials), + accountJson: JSON.stringify(account), + claudeJson: JSON.stringify({ installMethod: 'global', oauthAccount: account }), + } +} + +function writeClaudeArtifacts(homeDir: string, token?: string): ClaudeArtifacts { + const artifacts = claudeArtifacts(token) + writeFileSync(join(homeDir, '.claude', '.credentials.json'), artifacts.credentialsJson) + writeFileSync(join(homeDir, '.claude.json'), artifacts.claudeJson) + return artifacts +} + +function claudePayload(artifacts: ClaudeArtifacts): Record { + return { + credentials_json: artifacts.credentialsJson, + account_json: artifacts.accountJson, + oauth_token: artifacts.token, + } +} + // Poll until the session reaches one of the expected phases (handles the async // finalize that awaits credential capture + the lease + the agents-api POST). async function waitPhase( @@ -117,10 +160,81 @@ describe('LoginSession state machine', () => { } } + it('seeds Claude subscription login settings and drives bounded onboarding Enter-through', async () => { + vi.useFakeTimers() + try { + const logs: LogEntry[] = [] + const { deps: d, instances } = deps( + (file, args) => { + expect(file).toBe('claude') + expect(args).toEqual([]) + return [ + { type: 'emit', data: 'Choose the look for Claude Code\r\nDark mode\r\n' }, + { + type: 'expectStdin', + match: (i) => i === '\r', + then: [ + { + type: 'emit', + data: + 'Login method pre-selected: Subscription Plan (Claude Pro/Max)\r\n' + + "Browser didn't open? Use the url below to sign in\r\n" + + 'https://claude.com/cai/oauth/authorize?code=true&state=nav\r\n' + + 'Paste code here if prompted >', + }, + ], + }, + ] + }, + new NoopLeaseLock(), + memoryLogger(logs), + ) + const mgr = new SessionManager(d) + const started = mgr.start('claude', 'alice') + await Promise.resolve() + + 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' }), + ) + + await vi.advanceTimersByTimeAsync(1_500) + await Promise.resolve() + await Promise.resolve() + + expect(instances[0].writes).toEqual(['\r']) + expect(mgr.status(started.id)!.phase).toBe('awaiting_url') + expect(mgr.status(started.id)!.authorizeUrl).toContain('claude.com/cai/oauth/authorize') + expect(logs.some((entry) => entry.msg === 'Claude onboarding Enter written to PTY')).toBe(true) + mgr.cancel(started.id) + } finally { + vi.useRealTimers() + } + }) + + 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) + 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(readFileSync(join(home, 'managed-settings.json'), 'utf8')).toBe( + JSON.stringify({ forceLoginMethod: 'claudeai' }), + ) + mgr.cancel(started.id) + }) + it('drives the full Claude flow: authorize URL → redirect paste-back → success → agents-api POST', async () => { - writeFileSync(join(home, '.claude', '.credentials.json'), '{"accessToken":"secret"}') - writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{}}') const token = 'sk-ant-oat01-FullFlowToken1234567890_-abcXYZ' + const artifacts = claudeArtifacts(token) const { deps: d, instances } = deps(() => [ { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\nPaste code here if prompted >' }, @@ -132,7 +246,8 @@ describe('LoginSession state machine', () => { type: 'expectStdin', match: (i) => i === '\r', then: [ - { type: 'emit', data: `Login successful.\r\nYour OAuth token: ${token}\r\n` }, + { type: 'effect', run: () => writeClaudeArtifacts(home, token) }, + { type: 'emit', data: 'Logged in as alice@example.com\r\n' }, { type: 'exit', code: 0 }, ], }, @@ -157,13 +272,12 @@ describe('LoginSession state machine', () => { 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 } }, - ]) + expect(agentsApi.posts).toEqual([{ userId: 'alice', provider: 'CLAUDE', payload: claudePayload(artifacts) }]) }) it('writes the Claude authorization code and submit Enter as distinct PTY writes before finalizing', async () => { const token = 'sk-ant-oat01-SeparateEnterToken1234567890_-abcXYZ' + const artifacts = claudeArtifacts(token) const { deps: d, instances } = deps(() => [ { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { @@ -173,7 +287,10 @@ describe('LoginSession state machine', () => { { type: 'expectStdin', match: (i) => i === '\r', - then: [{ type: 'emit', data: `Your OAuth token: ${token}\r\n` }], + then: [ + { type: 'effect', run: () => writeClaudeArtifacts(home, token) }, + { type: 'emit', data: 'Credential file was updated.\r\n' }, + ], }, ], }, @@ -186,9 +303,7 @@ describe('LoginSession state machine', () => { expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 1000)).toBe('succeeded') expect(instances[0].writes).toEqual(['bulk-paste-sensitive-code', '\r']) - expect(agentsApi.posts).toEqual([ - { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, - ]) + expect(agentsApi.posts).toEqual([{ userId: 'alice', provider: 'CLAUDE', payload: claudePayload(artifacts) }]) }) it('waits for the Claude code prompt before writing the extracted authorization code', async () => { @@ -275,8 +390,7 @@ describe('LoginSession state machine', () => { expect(logs.some((entry) => entry.level === 'error' && entry.msg.includes('PTY process handle missing'))).toBe(true) }) - 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. + it('fails Claude capture when only a stdout OAuth token is available', async () => { const token = 'sk-ant-oat01-StdoutOnlyToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ { @@ -299,15 +413,12 @@ describe('LoginSession state machine', () => { const started = mgr.start('claude', 'alice') await tick() expect(mgr.submitRedirectUrl(started.id, 'paste-code-xyz').ok).toBe(true) - expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'])).toBe('succeeded') - expect(agentsApi.posts).toEqual([ - { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, - ]) + expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'])).toBe('failed') + expect(mgr.status(started.id)!.error).toMatch(/credentials\.json was not written/) + expect(agentsApi.posts).toEqual([]) }) - it('finalizes Claude promptly when setup-token prints the OAuth token after paste-back but stays open', async () => { - // Production regression: the worker waited for a success phrase or process - // exit, so a CLI that printed the token and kept the PTY open never posted. + it('does not finalize Claude from a stdout OAuth token while waiting for credential files', async () => { const token = 'sk-ant-oat01-NoExitToken1234567890_-abcXYZ' const { deps: d } = deps(() => [ { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, @@ -317,19 +428,19 @@ describe('LoginSession state machine', () => { then: [{ type: 'emit', data: `CLAUDE_CODE_OAUTH_TOKEN=${token}\r\n` }], }, ]) + d.redirectTimeoutMs = 20 const mgr = new SessionManager(d) const started = mgr.start('claude', 'alice') await tick() expect(mgr.submitRedirectUrl(started.id, 'paste-code-xyz').ok).toBe(true) - expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 250)).toBe('succeeded') - expect(agentsApi.posts).toEqual([ - { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, - ]) + expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 500)).toBe('failed') + expect(mgr.status(started.id)!.error).toMatch(/timed out waiting for Claude credential capture/) + expect(agentsApi.posts).toEqual([]) }) - it('finalizes Claude from an OSC8-linked setup-token OAuth token after paste-back', async () => { + it('requires credential files even when an OSC8-linked OAuth token is printed 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\nPaste code here if prompted >' }, @@ -339,20 +450,20 @@ describe('LoginSession state machine', () => { then: [{ type: 'emit', data: `Token: \x1b]8;;${token}\x07copy token\x1b]8;;\x07\r\n` }], }, ]) + d.redirectTimeoutMs = 20 const mgr = new SessionManager(d) const started = mgr.start('claude', 'alice') await tick() expect(mgr.submitRedirectUrl(started.id, 'paste-code-xyz').ok).toBe(true) - expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 250)).toBe('succeeded') - expect(agentsApi.posts).toEqual([ - { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, - ]) + expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 500)).toBe('failed') + expect(agentsApi.posts).toEqual([]) }) - it('finalizes Claude from .credentials.json when setup-token prints no token and stays open', async () => { + it('finalizes Claude from credential files when interactive login prints no token and stays open', async () => { const token = 'sk-ant-oat01-CredentialsFileToken1234567890_-abcXYZ' + const artifacts = claudeArtifacts(token) const { deps: d } = deps(() => [ { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, { @@ -370,25 +481,30 @@ describe('LoginSession state machine', () => { const started = mgr.start('claude', 'alice') await tick() - writeFileSync(join(home, '.claude', '.credentials.json'), JSON.stringify({ oauth_token: token })) + writeClaudeArtifacts(home, token) expect(mgr.submitRedirectUrl(started.id, 'paste-code-xyz').ok).toBe(true) expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'], 500)).toBe('succeeded') - expect(agentsApi.posts).toEqual([ - { userId: 'alice', provider: 'CLAUDE', payload: { oauth_token: token } }, - ]) + expect(agentsApi.posts).toEqual([{ userId: 'alice', provider: 'CLAUDE', payload: claudePayload(artifacts) }]) }) - it('redacts setup-token output and records lifecycle log decisions without logging the token', async () => { + it('redacts Claude login output and records lifecycle log decisions without logging the token', async () => { const token = 'sk-ant-oat01-LogRedactionToken1234567890_-abcXYZ' + const artifacts = claudeArtifacts(token) const logs: LogEntry[] = [] const { deps: d } = deps( () => [ - { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >' }, + { + type: 'emit', + data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\nPaste code here if prompted >', + }, { type: 'expectStdin', match: () => true, - then: [{ type: 'emit', data: `Your OAuth token: ${token}\r\n` }], + then: [ + { type: 'effect', run: () => writeClaudeArtifacts(home, token) }, + { type: 'emit', data: `Your OAuth token: ${token}\r\nLogged in as alice@example.com\r\n` }, + ], }, ], new NoopLeaseLock(), @@ -404,9 +520,10 @@ describe('LoginSession state machine', () => { const serialized = JSON.stringify(logs) expect(serialized).not.toContain(token) expect(serialized).toContain('«redacted»') - expect(logs.some((entry) => entry.msg === 'Claude setup-token output scanned')).toBe(true) - expect(logs.some((entry) => entry.msg === 'Claude setup-token parse attempt')).toBe(true) + expect(logs.some((entry) => entry.msg === 'Claude login output scanned')).toBe(true) + expect(logs.some((entry) => entry.msg === 'Claude OAuth token parse attempt')).toBe(true) expect(logs.some((entry) => entry.msg === 'login session phase transition')).toBe(true) + expect(agentsApi.posts[0]).toEqual({ userId: 'alice', provider: 'CLAUDE', payload: claudePayload(artifacts) }) }) it('fails Claude after a bounded post-redirect timeout with redacted output context', async () => { @@ -471,7 +588,7 @@ describe('LoginSession state machine', () => { const started = mgr.start('claude', 'alice') await tick() expect(mgr.submitRedirectUrl(started.id, ' ').ok).toBe(false) - // setup-token returns a bare code, not a URL — it must be accepted. + // Claude's manual flow returns a bare code, not a URL — it must be accepted. expect(mgr.submitRedirectUrl(started.id, 'ABCD-1234-token').ok).toBe(true) expect(await waitForWrites(() => instances[0].writes, 2)).toEqual(['ABCD-1234-token', '\r']) expect(mgr.cancel(started.id).ok).toBe(true) @@ -564,9 +681,8 @@ describe('LoginSession state machine', () => { }) it('surfaces an agents-api ingest failure as a failed session', async () => { - writeFileSync(join(home, '.claude', '.credentials.json'), '{}') - writeFileSync(join(home, '.claude.json'), '{}') const failingAgentsApi = fakeAgentsApi(new Error('agents-api credential ingest failed: 503 unavailable')) + const token = 'sk-ant-oat01-FailureToken1234567890' const { spawner } = fakeSpawner(() => [ { type: 'emit', data: 'Visit https://claude.ai/oauth/authorize?code=1\r\nPaste code here if prompted >' }, @@ -578,7 +694,8 @@ describe('LoginSession state machine', () => { type: 'expectStdin', match: (i) => i === '\r', then: [ - { type: 'emit', data: 'Login successful.\r\nYour OAuth token: sk-ant-oat01-FailureToken1234567890\r\n' }, + { type: 'effect', run: () => writeClaudeArtifacts(home, token) }, + { type: 'emit', data: 'Logged in as alice@example.com\r\n' }, ], }, ], From 28d9fbe6597e389b6c7ca2c752adcecf332d0cfc Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Fri, 26 Jun 2026 16:27:12 +0200 Subject: [PATCH 2/2] test(agents-login): cover new Claude credential helpers to satisfy branch threshold The full-credential capture added branches in credentials.ts (extractClaudeOauthToken / findStringField / extractClaudeAccountJson) that dropped global branch coverage to 79.57% (threshold 80%). Add focused tests for: token extraction from a nested accessToken field and from invalid JSON, and account_json presence/absence based on whether ~/.claude.json carries oauthAccount. Branch coverage now 83.37%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-login/test/agentsApiClient.test.ts | 8 ++++ .../agents-login/test/credentials.test.ts | 40 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/services/agents-login/test/agentsApiClient.test.ts b/services/agents-login/test/agentsApiClient.test.ts index 1b6f0445..85a48aa7 100644 --- a/services/agents-login/test/agentsApiClient.test.ts +++ b/services/agents-login/test/agentsApiClient.test.ts @@ -110,6 +110,14 @@ describe('AgentsApiClient', () => { ).toEqual({ auth_json: '{}', config_toml: 'model="x"' }) }) + it('omits optional Claude payload fields when account and token are unavailable', () => { + expect( + payloadForApi('claude', { + data: { credentials_json: '{"claudeAiOauth":{"refreshToken":"refresh-token"}}' }, + }), + ).toEqual({ credentials_json: '{"claudeAiOauth":{"refreshToken":"refresh-token"}}' }) + }) + it('rejects incomplete provider payloads before posting', () => { expect(() => payloadForApi('claude', { data: { account_json: '{}' } })).toThrow(/Claude credentials_json/) expect(() => payloadForApi('codex', { data: { auth_json: '{}' } })).toThrow(/Codex credential/) diff --git a/services/agents-login/test/credentials.test.ts b/services/agents-login/test/credentials.test.ts index 20a92eb8..a6990eb1 100644 --- a/services/agents-login/test/credentials.test.ts +++ b/services/agents-login/test/credentials.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { capture, captureClaude, captureCodex, SCHEMA_VERSION } from '../src/worker/credentials.js' +import { + capture, + captureClaude, + captureCodex, + extractClaudeOauthToken, + SCHEMA_VERSION, +} from '../src/worker/credentials.js' describe('credential capture', () => { let root: string @@ -55,6 +61,38 @@ describe('credential capture', () => { expect(bundle.data['credentials_json']).toBe('{"accessToken":"sk-ant-oat01-zzzzzzzzzzzzzzzzzzzz"}') }) + it('extracts a nested Claude OAuth accessToken when credentials_json has no raw sk-ant token', () => { + expect( + extractClaudeOauthToken( + JSON.stringify({ + profiles: { default: true }, + claudeAiOauth: { + refreshToken: 'refresh-token', + accessToken: 'oauth-access-token-from-json', + }, + }), + ), + ).toBe('oauth-access-token-from-json') + }) + + it('returns undefined when credentials_json cannot be parsed as JSON', () => { + expect(extractClaudeOauthToken('{"claudeAiOauth":')).toBeUndefined() + }) + + it('omits account_json when .claude.json has no oauthAccount', async () => { + const credentialsJson = '{"claudeAiOauth":{"refreshToken":"refresh-token"}}' + const dotClaudeJson = '{"installMethod":"global"}' + writeFileSync(join(home, '.claude', '.credentials.json'), credentialsJson) + writeFileSync(join(home, '.claude.json'), dotClaudeJson) + + const bundle = await captureClaude({ home, codexHome }, 'alice', fixedNow) + + expect(bundle.data['credentials_json']).toBe(credentialsJson) + expect(bundle.data['claude_json']).toBe(dotClaudeJson) + expect(bundle.data).not.toHaveProperty('account_json') + expect(bundle.data).not.toHaveProperty('oauth_token') + }) + it('fails when no credentials file is available', async () => { await expect(captureClaude({ home, codexHome }, 'x', fixedNow)).rejects.toThrow(/no Claude credential/) })