From 7ca59123d0fd67b592530ba7c3fdd90070dacbb3 Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Sat, 27 Jun 2026 03:53:06 +0200 Subject: [PATCH] fix(agents-login): wait for a populated .credentials.json before capturing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of runners stuck on "Not logged in" / API billing after a successful sign-in: the worker finalized capture the instant ~/.claude/.credentials.json EXISTED, but Claude creates the file and writes claudeAiOauth.accessToken a moment later. The probe (and the success-line path) won the race and captured an empty `{}`, which was stored and injected verbatim — so the runner had no token (the profile/account_json won its race and looked fine, masking the problem). - Add claudeCredentialIsPopulated() (type guard): the credential counts only when an OAuth token can be extracted (claudeAiOauth.accessToken / nested / sk-ant-oat). - probeClaudeCredentialFile finalizes only when populated; an existing-but-empty {} keeps it polling until the token lands. - The "Logged in as" success line no longer finalizes Claude directly (it could precede the token write); it defers to the populated-credential probe. - captureClaude throws unless the credential is populated, so an empty {} can never be stored from any path. Tests: unit + capture coverage for the empty-{} race; existing tests updated to the populated-credential contract. 104 pass, branch coverage 84.42%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agents-login/src/worker/credentials.ts | 17 ++++++++++++-- services/agents-login/src/worker/session.ts | 22 ++++++++++++++----- .../agents-login/test/credentials.test.ts | 15 ++++++++++--- services/agents-login/test/session.test.ts | 2 +- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/services/agents-login/src/worker/credentials.ts b/services/agents-login/src/worker/credentials.ts index 6ea40909..9cd925dc 100644 --- a/services/agents-login/src/worker/credentials.ts +++ b/services/agents-login/src/worker/credentials.ts @@ -31,6 +31,17 @@ export async function readClaudeCredentialsJson(paths: CredentialPaths): Promise return readUtf8OrUndefined(join(paths.home, '.claude', '.credentials.json')) } +/** + * True only when `.credentials.json` actually carries an OAuth token. Claude + * creates the file and populates `claudeAiOauth.accessToken` a moment later, so + * a probe that fires on mere file existence captures an empty `{}` (the runner + * then has no token and shows "Not logged in" / API billing). Gate capture on + * this so the worker waits for the populated credential. + */ +export function claudeCredentialIsPopulated(content: string | undefined): content is string { + return content !== undefined && extractClaudeOauthToken(content) !== undefined +} + function objectRecord(value: unknown): Record | undefined { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -96,8 +107,10 @@ export async function captureClaude( readUtf8OrUndefined(credsPath), readUtf8OrUndefined(dotClaudePath), ]) - if (credentials === undefined) { - throw new Error('no Claude credential captured: .credentials.json was not written') + if (!claudeCredentialIsPopulated(credentials)) { + throw new Error( + 'no Claude credential captured: .credentials.json missing claudeAiOauth.accessToken (not written yet)', + ) } const capturedToken = extractClaudeOauthToken(credentials) const data: Record = { diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index eceffb16..1a66a45e 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -5,7 +5,7 @@ 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, readClaudeCredentialsJson, type CredentialPaths } from './credentials.js' +import { capture, claudeCredentialIsPopulated, readClaudeCredentialsJson, type CredentialPaths } from './credentials.js' import { detectClaudeCodePrompt, detectClaudeLoggedOutRepl, @@ -408,7 +408,15 @@ export class LoginSession { ) } if (detectSuccess(this.provider, this.buffer)) { - void this.finalize(updatedBy, claudeTokenCaptured, 'CLI success output detected') + // For Claude's subscription login the success line ("Logged in as") can + // appear before Claude finishes writing the token into .credentials.json. + // Finalizing here would capture an empty {} (race). Defer to the credential + // probe, which only finalizes once the file carries claudeAiOauth.accessToken. + if (this.provider === 'claude' && !claudeTokenCaptured) { + void this.probeClaudeCredentialFile('CLI success output detected') + } else { + void this.finalize(updatedBy, claudeTokenCaptured, 'CLI success output detected') + } return } if (this.provider === 'claude' && this.redirectSubmitted) { @@ -621,14 +629,18 @@ export class LoginSession { } try { const credentialsJson = await readClaudeCredentialsJson(this.deps.paths) + const populated = claudeCredentialIsPopulated(credentialsJson) this.deps.logger.info('Claude credential file parse attempt', { sessionId: this.id, source: '$HOME/.claude/.credentials.json', - matched: credentialsJson !== undefined, + exists: credentialsJson !== undefined, + populated, reason, }) - if (credentialsJson !== undefined) { - await this.finalize(this.updatedBy, undefined, 'Claude credentials file detected') + // Only finalize once the token is actually written. An existing-but-empty + // file ({} during Claude's create-then-populate) keeps us polling. + if (populated) { + await this.finalize(this.updatedBy, undefined, 'Claude credentials file populated') return } } catch (err) { diff --git a/services/agents-login/test/credentials.test.ts b/services/agents-login/test/credentials.test.ts index a6990eb1..ca0585a5 100644 --- a/services/agents-login/test/credentials.test.ts +++ b/services/agents-login/test/credentials.test.ts @@ -49,10 +49,18 @@ describe('credential capture', () => { it('rejects stdout-only Claude OAuth tokens without the full credentials file', async () => { const token = 'sk-ant-oat01-abcDEF123456_-789ghiJKLmnop' await expect(captureClaude({ home, codexHome }, 'alice', fixedNow, token)).rejects.toThrow( - /credentials\.json was not written/, + /no Claude credential captured/, ) }) + it('rejects an empty {} credentials file (created-but-not-yet-populated race)', async () => { + // Claude creates .credentials.json then writes the token a moment later; + // capturing the empty {} left the runner stuck on "Not logged in" / API billing. + writeFileSync(join(home, '.claude', '.credentials.json'), '{}') + writeFileSync(join(home, '.claude.json'), '{"oauthAccount":{"billingType":"stripe_subscription"}}') + await expect(captureClaude({ home, codexHome }, 'alice', fixedNow)).rejects.toThrow(/no Claude credential captured/) + }) + 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":{}}') @@ -80,7 +88,8 @@ describe('credential capture', () => { }) it('omits account_json when .claude.json has no oauthAccount', async () => { - const credentialsJson = '{"claudeAiOauth":{"refreshToken":"refresh-token"}}' + const credentialsJson = + '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-PopulatedToken1234567890","refreshToken":"refresh-token"}}' const dotClaudeJson = '{"installMethod":"global"}' writeFileSync(join(home, '.claude', '.credentials.json'), credentialsJson) writeFileSync(join(home, '.claude.json'), dotClaudeJson) @@ -90,7 +99,7 @@ describe('credential capture', () => { 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') + expect(bundle.data.oauth_token).toBe('sk-ant-oat01-PopulatedToken1234567890') }) it('fails when no credentials file is available', async () => { diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index 16f9e8f8..3602e27f 100644 --- a/services/agents-login/test/session.test.ts +++ b/services/agents-login/test/session.test.ts @@ -540,7 +540,7 @@ describe('LoginSession state machine', () => { await tick() expect(mgr.submitRedirectUrl(started.id, 'paste-code-xyz').ok).toBe(true) expect(await waitPhase(mgr, started.id, ['succeeded', 'failed'])).toBe('failed') - expect(mgr.status(started.id)!.error).toMatch(/credentials\.json was not written/) + expect(mgr.status(started.id)!.error).toMatch(/no Claude credential captured/) expect(agentsApi.posts).toEqual([]) })