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
17 changes: 15 additions & 2 deletions services/agents-login/src/worker/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
Expand Down Expand Up @@ -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<string, string> = {
Expand Down
22 changes: 17 additions & 5 deletions services/agents-login/src/worker/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 12 additions & 3 deletions services/agents-login/test/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":{}}')
Expand Down Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion services/agents-login/test/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
})

Expand Down