diff --git a/services/agents-login/src/worker/agentsApiClient.ts b/services/agents-login/src/worker/agentsApiClient.ts index c78273ca..767320d3 100644 --- a/services/agents-login/src/worker/agentsApiClient.ts +++ b/services/agents-login/src/worker/agentsApiClient.ts @@ -1,4 +1,6 @@ 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' export type AgentsApiProvider = 'CLAUDE' | 'CODEX' @@ -6,6 +8,7 @@ export type AgentsApiProvider = 'CLAUDE' | 'CODEX' export interface AgentsApiClientOptions { baseUrl: string bearer: string + logger?: Logger } export interface CredentialIngestRequest { @@ -27,11 +30,19 @@ export class AgentsApiClient { ) { this.endpoint = new URL('/api/v1/internal/credentials', opts.baseUrl).toString() this.bearer = opts.bearer + this.logger = opts.logger } private readonly bearer: string + private readonly logger?: Logger async postCredentials(req: CredentialIngestRequest): Promise { + this.logger?.info('agents-api credential ingest POST starting', { + url: this.endpoint, + userId: req.userId, + provider: req.provider, + payloadKeys: Object.keys(req.payload), + }) const res = await this.fetchImpl(this.endpoint, { method: 'POST', headers: { @@ -40,9 +51,15 @@ export class AgentsApiClient { }, body: JSON.stringify(req), }) - if (!res.ok) { - throw new Error(`agents-api credential ingest failed: ${res.status} ${await safeText(res)}`) - } + this.logger?.info('agents-api credential ingest POST completed', { + url: this.endpoint, + status: res.status, + ok: res.ok, + provider: req.provider, + }) + if (!res.ok) { + throw new Error(`agents-api credential ingest failed: ${res.status} ${redactString(await safeText(res))}`) + } } storedStatus(): StoredCredentialStatus { diff --git a/services/agents-login/src/worker/credentials.ts b/services/agents-login/src/worker/credentials.ts index 8675c928..bc3f629c 100644 --- a/services/agents-login/src/worker/credentials.ts +++ b/services/agents-login/src/worker/credentials.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { Provider } from '../shared/types.js' +import { parseClaudeToken } from './parse.js' export const SCHEMA_VERSION = '1' @@ -26,6 +27,11 @@ 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) +} + /** * Capture the Claude credential from a completed `setup-token` run. * @@ -52,13 +58,14 @@ export async function captureClaude( if (!oauthToken && credentials === undefined) { throw new Error('no Claude credential captured: setup-token printed no token and no .credentials.json was written') } + const capturedToken = oauthToken ?? (credentials === undefined ? undefined : parseClaudeToken(credentials)) const data: Record = { schema_version: SCHEMA_VERSION, updated_at: now().toISOString(), updated_by: updatedBy, } - if (oauthToken) { - data.oauth_token = oauthToken + if (capturedToken) { + data.oauth_token = capturedToken } if (credentials !== undefined) { data.credentials_json = credentials diff --git a/services/agents-login/src/worker/index.ts b/services/agents-login/src/worker/index.ts index 5505a829..6d5cf1da 100644 --- a/services/agents-login/src/worker/index.ts +++ b/services/agents-login/src/worker/index.ts @@ -13,6 +13,7 @@ export async function runWorker(): Promise { const agentsApi = new AgentsApiClient({ baseUrl: cfg.agentsApiInternalUrl, bearer: cfg.agentsApiInternalBearer, + logger, }) const lease = new K8sLeaseLock({ @@ -22,7 +23,7 @@ export async function runWorker(): Promise { saTokenPath: cfg.saTokenPath, }) - const spawner = await createNodePtySpawner() + const spawner = await createNodePtySpawner(logger) const sessions = new SessionManager({ spawner, diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index 4463e989..ad909606 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -26,7 +26,7 @@ export function stripAnsi(input: string): string { } // OSC 8 hyperlink targets, in emission order, from the raw (pre-strip) buffer. -function oscHyperlinkTargets(raw: string): string[] { +export function oscHyperlinkTargets(raw: string): string[] { const targets: string[] = [] OSC8_HYPERLINK.lastIndex = 0 let m: RegExpExecArray | null @@ -76,6 +76,12 @@ export function parseClaude(buffer: string): ClaudeParse { const CLAUDE_TOKEN_PATTERN = /(sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{20,})/ export function parseClaudeToken(buffer: string): string | undefined { + for (const uri of oscHyperlinkTargets(buffer)) { + const matched = uri.match(CLAUDE_TOKEN_PATTERN)?.[1] + if (matched) { + return matched + } + } return stripAnsi(buffer).match(CLAUDE_TOKEN_PATTERN)?.[1] } diff --git a/services/agents-login/src/worker/pty.ts b/services/agents-login/src/worker/pty.ts index 22fcc0d4..5a5b05df 100644 --- a/services/agents-login/src/worker/pty.ts +++ b/services/agents-login/src/worker/pty.ts @@ -1,6 +1,7 @@ // Thin abstraction over a pseudo-terminal child process so the session logic // can be tested without the native node-pty addon. The real implementation // lazily loads node-pty; tests inject a fake PtySpawner. +import type { Logger } from '../shared/log.js' export interface PtyProcess { onData(cb: (chunk: string) => void): void @@ -19,9 +20,16 @@ export interface PtySpawnOptions { export type PtySpawner = (file: string, args: string[], options: PtySpawnOptions) => PtyProcess /** Real node-pty backed spawner. Loaded lazily so test runs need no addon. */ -export async function createNodePtySpawner(): Promise { +export async function createNodePtySpawner(logger?: Logger): Promise { const pty = await import('node-pty') return (file, args, options) => { + logger?.info('worker PTY spawn starting', { + file, + args, + cwd: options.cwd, + cols: options.cols ?? 120, + rows: options.rows ?? 40, + }) const proc = pty.spawn(file, args, { name: 'xterm-color', cols: options.cols ?? 120, @@ -31,9 +39,19 @@ export async function createNodePtySpawner(): Promise { }) return { onData: (cb) => proc.onData(cb), - onExit: (cb) => proc.onExit(cb), - write: (data) => proc.write(data), - kill: (signal) => proc.kill(signal), + onExit: (cb) => + proc.onExit((info) => { + logger?.info('worker PTY exited', { file, exitCode: info.exitCode, signal: info.signal }) + cb(info) + }), + write: (data) => { + logger?.info('worker PTY stdin write', { file, bytes: data.length }) + proc.write(data) + }, + kill: (signal) => { + logger?.info('worker PTY kill requested', { file, signal: signal ?? 'default' }) + proc.kill(signal) + }, } } } diff --git a/services/agents-login/src/worker/server.ts b/services/agents-login/src/worker/server.ts index 51ec4094..5b146248 100644 --- a/services/agents-login/src/worker/server.ts +++ b/services/agents-login/src/worker/server.ts @@ -71,14 +71,27 @@ export function buildWorkerServer(deps: WorkerServerDeps): FastifyInstance { app.post('/sessions', async (req, reply) => { const body = (req.body ?? {}) as { provider?: unknown; updatedBy?: unknown } + deps.logger.info('worker session start request received', { + provider: typeof body.provider === 'string' ? body.provider : 'invalid', + updatedBy: typeof body.updatedBy === 'string' && body.updatedBy ? body.updatedBy : 'unknown', + }) if (!isProvider(body.provider)) { return reply.code(400).send({ error: 'provider must be "claude" or "codex"' }) } const updatedBy = typeof body.updatedBy === 'string' && body.updatedBy ? body.updatedBy : 'unknown' try { const status = deps.sessions.start(body.provider, updatedBy) + deps.logger.info('worker session start request completed', { + sessionId: status.id, + provider: status.provider, + phase: status.phase, + }) return reply.code(201).send(safeStatus(status)) } catch (err) { + deps.logger.warn('worker session start request failed', { + provider: body.provider, + error: err instanceof Error ? err.message : 'cannot start session', + }) return reply.code(409).send({ error: err instanceof Error ? err.message : 'cannot start session' }) } }) @@ -87,8 +100,14 @@ export function buildWorkerServer(deps: WorkerServerDeps): FastifyInstance { const { id } = req.params as { id: string } const status = deps.sessions.status(id) if (!status) { + deps.logger.warn('worker session status request missed', { sessionId: id }) return reply.code(404).send({ error: 'no matching session' }) } + deps.logger.info('worker session status request completed', { + sessionId: id, + provider: status.provider, + phase: status.phase, + }) return reply.send(safeStatus(status)) }) @@ -98,16 +117,23 @@ export function buildWorkerServer(deps: WorkerServerDeps): FastifyInstance { if (typeof body.url !== 'string') { return reply.code(400).send({ error: 'url is required' }) } + deps.logger.info('worker redirect submission request received', { + sessionId: id, + inputBytes: body.url.length, + }) const result = deps.sessions.submitRedirectUrl(id, body.url) if (!result.ok) { + deps.logger.warn('worker redirect submission request rejected', { sessionId: id, error: result.error }) return reply.code(400).send({ error: result.error }) } + deps.logger.info('worker redirect submission request completed', { sessionId: id }) return reply.send({ ok: true }) }) app.post('/sessions/:id/cancel', async (req, reply) => { const { id } = req.params as { id: string } const result = deps.sessions.cancel(id) + deps.logger.info('worker cancel request completed', { sessionId: id, ok: result.ok }) return reply.code(result.ok ? 200 : 404).send(result) }) diff --git a/services/agents-login/src/worker/session.ts b/services/agents-login/src/worker/session.ts index e2689f8a..b66869ca 100644 --- a/services/agents-login/src/worker/session.ts +++ b/services/agents-login/src/worker/session.ts @@ -1,8 +1,9 @@ import { randomUUID } from 'node:crypto' 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, type CredentialPaths } from './credentials.js' +import { capture, readClaudeCredentialsToken, type CredentialPaths } from './credentials.js' import { detectFailure, detectSuccess, parseClaude, parseClaudeToken, parseCodex } from './parse.js' import type { PtyProcess, PtySpawner } from './pty.js' import type { LeaseLock } from './lease.js' @@ -15,6 +16,7 @@ export interface SessionDeps { paths: CredentialPaths logger: Logger ttlMs: number + redirectTimeoutMs?: number now?: () => Date // override the launched command for tests / alternate CLIs command?: (provider: Provider) => { file: string; args: string[] } @@ -30,6 +32,10 @@ function defaultCommand(provider: Provider): { file: string; args: string[] } { } const MAX_BUFFER = 256 * 1024 +const DEFAULT_REDIRECT_TIMEOUT_MS = 60_000 +const CLAUDE_CREDENTIAL_POLL_MS = 250 +const LOG_LINE_LIMIT = 512 +const FAILURE_TAIL_LIMIT = 2_000 export class LoginSession { readonly id = randomUUID() @@ -44,6 +50,8 @@ export class LoginSession { private buffer = '' private proc?: PtyProcess private timer?: NodeJS.Timeout + private redirectTimer?: NodeJS.Timeout + private credentialProbeTimer?: NodeJS.Timeout private redirectSubmitted = false private updatedBy = 'unknown' @@ -67,12 +75,22 @@ export class LoginSession { this.updatedAt = this.nowIso() } - private setPhase(phase: SessionPhase, message?: string): void { + private setPhase(phase: SessionPhase, message?: string, reason = 'unspecified'): void { + const previous = this.phase this.phase = phase if (message !== undefined) { this.message = message } this.touch() + if (previous !== phase) { + this.deps.logger.info('login session phase transition', { + sessionId: this.id, + provider: this.provider, + from: previous, + to: phase, + reason, + }) + } } status(): SessionStatus { @@ -94,6 +112,13 @@ export class LoginSession { start(updatedBy: string): void { this.updatedBy = updatedBy const cmd = (this.deps.command ?? defaultCommand)(this.provider) + this.deps.logger.info('login session starting', { + sessionId: this.id, + provider: this.provider, + updatedBy, + command: cmd.file, + args: cmd.args, + }) const env = { ...process.env, HOME: this.deps.paths.home, @@ -122,6 +147,24 @@ export class LoginSession { } } + private outputTail(limit = FAILURE_TAIL_LIMIT): string { + return redactString(this.buffer.slice(Math.max(0, this.buffer.length - limit))) + } + + private scanLines(chunk: string): string[] { + const lines = chunk.split(/\r?\n/).map((line) => redactString(line.trim())) + return lines.filter((line) => line.length > 0).map((line) => line.slice(0, LOG_LINE_LIMIT)) + } + + private logClaudeTokenParseAttempt(source: string, matched: boolean, reason: string): void { + this.deps.logger.info('Claude setup-token parse attempt', { + sessionId: this.id, + source, + matched, + reason, + }) + } + private onData(chunk: string, updatedBy: string): void { this.append(chunk) @@ -129,6 +172,15 @@ export class LoginSession { return } + if (this.provider === 'claude') { + this.deps.logger.info('Claude setup-token output scanned', { + sessionId: this.id, + phase: this.phase, + bytes: chunk.length, + lines: this.scanLines(chunk), + }) + } + if (detectFailure(this.buffer) && (this.phase === 'starting' || this.redirectSubmitted)) { this.fail('CLI reported a login failure') return @@ -139,7 +191,11 @@ export class LoginSession { const { authorizeUrl } = parseClaude(this.buffer) if (authorizeUrl) { this.authorizeUrl = authorizeUrl - this.setPhase('awaiting_url', 'Open the authorize URL, approve, then paste the authorization code.') + this.setPhase( + 'awaiting_url', + 'Open the authorize URL, approve, then paste the authorization code.', + 'Claude authorize URL detected', + ) } } } else { @@ -152,14 +208,25 @@ export class LoginSession { this.verificationUrl = parsed.verificationUrl } if (this.deviceCode && this.phase === 'starting') { - this.setPhase('awaiting_device', 'Enter the device code at the verification URL.') + this.setPhase('awaiting_device', 'Enter the device code at the verification URL.', 'Codex device code detected') } } } - const claudeTokenCaptured = this.provider === 'claude' && this.redirectSubmitted && parseClaudeToken(this.buffer) - if (claudeTokenCaptured || detectSuccess(this.provider, this.buffer)) { - void this.finalize(updatedBy) + const claudeTokenCaptured = this.provider === 'claude' && this.redirectSubmitted ? parseClaudeToken(this.buffer) : undefined + if (this.provider === 'claude' && this.redirectSubmitted) { + this.logClaudeTokenParseAttempt('pty-output', claudeTokenCaptured !== undefined, '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') + return + } + if (this.provider === 'claude' && this.redirectSubmitted) { + void this.probeClaudeCredentialFile('PTY output received after redirect') } } @@ -167,6 +234,13 @@ export class LoginSession { if (isTerminal(this.phase) || this.phase === 'finalizing') { return } + this.deps.logger.info('login session CLI exited', { + sessionId: this.id, + provider: this.provider, + exitCode, + redirectSubmitted: this.redirectSubmitted, + phase: this.phase, + }) // A clean exit after the operator submitted the code (Claude setup-token) 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. @@ -175,7 +249,11 @@ export class LoginSession { this.redirectSubmitted || (this.provider === 'codex' && this.phase === 'awaiting_device') if (exitCode === 0 && completed) { - void this.finalize(this.updatedBy) + const claudeToken = this.provider === 'claude' ? parseClaudeToken(this.buffer) : undefined + if (this.provider === 'claude') { + this.logClaudeTokenParseAttempt('pty-output', claudeToken !== undefined, 'CLI exited cleanly after login flow') + } + void this.finalize(this.updatedBy, claudeToken, 'CLI exited cleanly after login flow') return } this.fail(`CLI exited with code ${exitCode} before login completed`) @@ -199,51 +277,150 @@ export class LoginSession { return { ok: false, error: 'authorization code is required' } } this.redirectSubmitted = true + this.deps.logger.info('Claude redirect submission received', { + sessionId: this.id, + provider: this.provider, + inputBytes: trimmed.length, + }) this.proc?.write(trimmed + '\r') // Stay in awaiting_url so the success line emitted by the CLI after the // paste-back still drives finalize(); only finalize() flips to finalizing. this.message = 'Submitted the authorization code; completing login…' this.touch() + this.startRedirectTimeout() + void this.probeClaudeCredentialFile('redirect submitted') return { ok: true } } + private startRedirectTimeout(): void { + const timeoutMs = this.deps.redirectTimeoutMs ?? DEFAULT_REDIRECT_TIMEOUT_MS + if (this.redirectTimer) { + clearTimeout(this.redirectTimer) + } + this.redirectTimer = setTimeout(() => { + if (isTerminal(this.phase) || this.phase === 'finalizing') { + return + } + this.fail(`timed out waiting for Claude credential capture after redirect submission; output_tail=${this.outputTail()}`) + }, timeoutMs) + if (typeof this.redirectTimer.unref === 'function') { + this.redirectTimer.unref() + } + this.scheduleClaudeCredentialProbe(Math.min(CLAUDE_CREDENTIAL_POLL_MS, Math.max(10, Math.floor(timeoutMs / 2)))) + } + + private scheduleClaudeCredentialProbe(delayMs: number): void { + if (this.provider !== 'claude' || !this.redirectSubmitted || isTerminal(this.phase) || this.phase === 'finalizing') { + return + } + if (this.credentialProbeTimer) { + clearTimeout(this.credentialProbeTimer) + this.credentialProbeTimer = undefined + } + this.credentialProbeTimer = setTimeout(() => { + void this.probeClaudeCredentialFile('credential file poll') + }, delayMs) + if (typeof this.credentialProbeTimer.unref === 'function') { + this.credentialProbeTimer.unref() + } + } + + private async probeClaudeCredentialFile(reason: string): Promise { + if (this.provider !== 'claude' || !this.redirectSubmitted || isTerminal(this.phase) || this.phase === 'finalizing') { + return + } + try { + const token = await readClaudeCredentialsToken(this.deps.paths) + this.deps.logger.info('Claude credential file parse attempt', { + sessionId: this.id, + source: '$HOME/.claude/.credentials.json', + matched: token !== undefined, + reason, + }) + if (token) { + await this.finalize(this.updatedBy, token, 'Claude OAuth token parsed from credentials file') + return + } + } catch (err) { + this.deps.logger.warn('Claude credential file parse attempt failed', { + sessionId: this.id, + reason, + error: err instanceof Error ? err.message : String(err), + }) + } + this.scheduleClaudeCredentialProbe(CLAUDE_CREDENTIAL_POLL_MS) + } + private clearTimer(): void { if (this.timer) { clearTimeout(this.timer) this.timer = undefined } + if (this.redirectTimer) { + clearTimeout(this.redirectTimer) + this.redirectTimer = undefined + } + if (this.credentialProbeTimer) { + clearTimeout(this.credentialProbeTimer) + this.credentialProbeTimer = undefined + } } - private async finalize(updatedBy: string): Promise { + private async finalize(updatedBy: string, claudeTokenOverride?: string, reason = 'completion detected'): Promise { if (this.phase === 'finalizing' || isTerminal(this.phase)) { return } - this.setPhase('finalizing', 'Capturing credentials…') + 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' ? parseClaudeToken(this.buffer) : undefined + const claudeToken = this.provider === 'claude' ? (claudeTokenOverride ?? parseClaudeToken(this.buffer)) : undefined + this.deps.logger.info('login session finalize starting', { + sessionId: this.id, + provider: this.provider, + reason, + claudeTokenMatched: this.provider === 'claude' ? claudeToken !== undefined : undefined, + }) const bundle = await capture(this.provider, this.deps.paths, updatedBy, this.now, claudeToken) + this.deps.logger.info('login session credential capture completed', { + sessionId: this.id, + provider: this.provider, + dataKeys: Object.keys(bundle.data), + }) const provider = providerForApi(this.provider) const payload = payloadForApi(this.provider, bundle) await this.deps.lease.withLock(async () => { + this.deps.logger.info('login session posting credentials to agents-api', { + sessionId: this.id, + provider: this.provider, + apiProvider: provider, + payloadKeys: Object.keys(payload), + }) await this.deps.agentsApi.postCredentials({ userId: updatedBy, provider, payload }) }) this.clearTimer() this.proc?.kill() - this.setPhase('succeeded', 'Credentials written.') + this.setPhase('succeeded', 'Credentials written.', 'credentials captured and posted to agents-api') this.deps.logger.info('login session succeeded', { sessionId: this.id, provider: this.provider }) } catch (err) { - this.fail(err instanceof Error ? err.message : 'failed to finalize login') + const error = redactString(err instanceof Error ? err.message : 'failed to finalize login') + this.deps.logger.warn('login session finalize failed', { + sessionId: this.id, + provider: this.provider, + reason, + error, + }) + this.fail(error) } } private fail(reason: string): void { + const safeReason = redactString(reason) this.clearTimer() - this.error = reason - this.setPhase('failed') + this.error = safeReason + this.setPhase('failed', undefined, safeReason) this.proc?.kill() - this.deps.logger.warn('login session failed', { sessionId: this.id, provider: this.provider }) + this.deps.logger.warn('login session failed', { sessionId: this.id, provider: this.provider, reason: safeReason }) } cancel(reason = 'cancelled by operator'): void { @@ -252,7 +429,7 @@ export class LoginSession { } this.clearTimer() this.message = reason - this.setPhase('cancelled') + this.setPhase('cancelled', undefined, reason) this.proc?.kill() } } diff --git a/services/agents-login/test/agentsApiClient.test.ts b/services/agents-login/test/agentsApiClient.test.ts index 5652ca2b..fa694cc1 100644 --- a/services/agents-login/test/agentsApiClient.test.ts +++ b/services/agents-login/test/agentsApiClient.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest' import { AgentsApiClient, payloadForApi, providerForApi } from '../src/worker/agentsApiClient.js' +import type { Logger } from '../src/shared/log.js' + +function testLogger(lines: Array<{ msg: string; fields?: Record }>): Logger { + return { + info: (msg, fields) => lines.push({ msg, fields }), + warn: (msg, fields) => lines.push({ msg, fields }), + error: (msg, fields) => lines.push({ msg, fields }), + debug: (msg, fields) => lines.push({ msg, fields }), + } +} describe('AgentsApiClient', () => { it('posts credentials to the internal ingest endpoint', async () => { @@ -39,6 +49,42 @@ describe('AgentsApiClient', () => { ).rejects.toThrow(/503 nope/) }) + it('logs the ingest POST URL and response status without payload values', async () => { + const lines: Array<{ msg: string; fields?: Record }> = [] + const client = new AgentsApiClient( + { baseUrl: 'http://agents-api.local:8082', bearer: 'secret-bearer', logger: testLogger(lines) }, + async () => new Response(null, { status: 204 }), + ) + + await client.postCredentials({ + userId: 'alice', + provider: 'CLAUDE', + payload: { oauth_token: 'sk-ant-oat01-ShouldNotAppear1234567890' }, + }) + + expect(lines).toEqual([ + { + msg: 'agents-api credential ingest POST starting', + fields: { + url: 'http://agents-api.local:8082/api/v1/internal/credentials', + userId: 'alice', + provider: 'CLAUDE', + payloadKeys: ['oauth_token'], + }, + }, + { + msg: 'agents-api credential ingest POST completed', + fields: { + url: 'http://agents-api.local:8082/api/v1/internal/credentials', + status: 204, + ok: true, + provider: 'CLAUDE', + }, + }, + ]) + expect(JSON.stringify(lines)).not.toContain('ShouldNotAppear') + }) + it('maps provider names to the uppercase API enum', () => { expect(providerForApi('claude')).toBe('CLAUDE') expect(providerForApi('codex')).toBe('CODEX') diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index 1c326dff..9e022b34 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -87,6 +87,17 @@ describe('PTY output parsing', () => { expect(parseClaudeToken(buf)).toBe(token) }) + it('parses a bare setup-token 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', () => { + 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) + }) + it('returns undefined when no token was printed', () => { expect(parseClaudeToken('Visit https://claude.com/cai/oauth/authorize?code=true')).toBeUndefined() }) diff --git a/services/agents-login/test/session.test.ts b/services/agents-login/test/session.test.ts index 51a5a9c9..6ee7b8c3 100644 --- a/services/agents-login/test/session.test.ts +++ b/services/agents-login/test/session.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionManager, type SessionDeps } from '../src/worker/session.js' import { NoopLeaseLock } from '../src/worker/lease.js' -import { createLogger } from '../src/shared/log.js' +import { createLogger, type Logger } from '../src/shared/log.js' import { fakeSpawner, type Action } from './helpers/fakePty.js' async function tick(times = 8): Promise { @@ -19,6 +19,21 @@ interface PostedCredential { payload: Record } +interface LogEntry { + level: 'info' | 'warn' | 'error' | 'debug' + msg: string + fields?: Record +} + +function memoryLogger(entries: LogEntry[]): Logger { + return { + info: (msg, fields) => entries.push({ level: 'info', msg, fields }), + warn: (msg, fields) => entries.push({ level: 'warn', msg, fields }), + error: (msg, fields) => entries.push({ level: 'error', msg, fields }), + debug: (msg, fields) => entries.push({ level: 'debug', msg, fields }), + } +} + function fakeAgentsApi(fail?: Error): { postCredentials: (req: PostedCredential) => Promise; posts: PostedCredential[] } { const posts: PostedCredential[] = [] return { @@ -72,6 +87,7 @@ describe('LoginSession state machine', () => { function deps( scriptFor: (file: string, args: string[]) => Action[], lease = new NoopLeaseLock(), + logger: Logger = createLogger(), ): { deps: SessionDeps instances: ReturnType['instances'] @@ -83,7 +99,7 @@ describe('LoginSession state machine', () => { agentsApi, lease, paths: { home, codexHome }, - logger: createLogger(), + logger, ttlMs: 60_000, }, instances, @@ -176,6 +192,108 @@ describe('LoginSession state machine', () => { ]) }) + it('finalizes Claude from an OSC8-linked setup-token OAuth token 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\n' }, + { + type: 'expectStdin', + match: () => true, + then: [{ type: 'emit', data: `Token: \x1b]8;;${token}\x07copy token\x1b]8;;\x07\r\n` }], + }, + ]) + 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 } }, + ]) + }) + + it('finalizes Claude from .credentials.json when setup-token prints no token and stays open', async () => { + const token = 'sk-ant-oat01-CredentialsFileToken1234567890_-abcXYZ' + const { deps: d } = deps(() => [ + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { + type: 'expectStdin', + match: () => true, + then: [ + { + type: 'emit', + data: 'Credential file was updated.\r\n', + }, + ], + }, + ]) + const mgr = new SessionManager(d) + const started = mgr.start('claude', 'alice') + await tick() + + writeFileSync(join(home, '.claude', '.credentials.json'), JSON.stringify({ oauth_token: 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 } }, + ]) + }) + + it('redacts setup-token output and records lifecycle log decisions without logging the token', async () => { + const token = 'sk-ant-oat01-LogRedactionToken1234567890_-abcXYZ' + const logs: LogEntry[] = [] + const { deps: d } = deps( + () => [ + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { + type: 'expectStdin', + match: () => true, + then: [{ type: 'emit', data: `Your OAuth token: ${token}\r\n` }], + }, + ], + new NoopLeaseLock(), + memoryLogger(logs), + ) + 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'], 500)).toBe('succeeded') + + 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 === 'login session phase transition')).toBe(true) + }) + + it('fails Claude after a bounded post-redirect timeout with redacted output context', async () => { + const { deps: d } = deps(() => [ + { type: 'emit', data: 'Open https://claude.com/cai/oauth/authorize?code=true\r\n' }, + { + type: 'expectStdin', + match: () => true, + then: [{ type: 'emit', data: 'still waiting for opaque-secret-timeout-value-12345678901234567890\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'], 500)).toBe('failed') + expect(mgr.status(started.id)!.error).toMatch(/timed out waiting for Claude credential capture/) + expect(mgr.status(started.id)!.error).toContain('«redacted»') + expect(agentsApi.posts).toEqual([]) + }) + it('drives the Codex device flow with no paste-back', async () => { writeFileSync(join(codexHome, 'auth.json'), '{"tokens":{}}') writeFileSync(join(codexHome, 'config.toml'), 'model="x"\n')