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
2 changes: 1 addition & 1 deletion services/agents-login/src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
22 changes: 14 additions & 8 deletions services/agents-login/src/worker/agentsApiClient.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 {
Expand All @@ -73,11 +73,17 @@ export function providerForApi(provider: Provider): AgentsApiProvider {

export function payloadForApi(provider: Provider, bundle: CredentialBundle): Record<string, string> {
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
Expand Down
73 changes: 59 additions & 14 deletions services/agents-login/src/worker/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,79 @@ async function readUtf8OrUndefined(path: string): Promise<string | undefined> {
}
}

export async function readClaudeCredentialsToken(paths: CredentialPaths): Promise<string | undefined> {
const credentials = await readUtf8OrUndefined(join(paths.home, '.claude', '.credentials.json'))
return credentials === undefined ? undefined : parseClaudeToken(credentials)
export async function readClaudeCredentialsJson(paths: CredentialPaths): Promise<string | undefined> {
return readUtf8OrUndefined(join(paths.home, '.claude', '.credentials.json'))
}

function objectRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}

function findStringField(value: unknown, fieldNames: Set<string>): 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<CredentialBundle> {
const credsPath = join(paths.home, '.claude', '.credentials.json')
const dotClaudePath = join(paths.home, '.claude.json')
const [credentials, dotClaude] = await Promise.all([
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<string, string> = {
schema_version: SCHEMA_VERSION,
updated_at: now().toISOString(),
Expand All @@ -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 }
}
Expand Down
12 changes: 5 additions & 7 deletions services/agents-login/src/worker/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading