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
23 changes: 20 additions & 3 deletions services/agents-login/src/worker/agentsApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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'

export interface AgentsApiClientOptions {
baseUrl: string
bearer: string
logger?: Logger
}

export interface CredentialIngestRequest {
Expand All @@ -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<void> {
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: {
Expand All @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions services/agents-login/src/worker/credentials.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -26,6 +27,11 @@ 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)
}

/**
* Capture the Claude credential from a completed `setup-token` run.
*
Expand All @@ -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<string, string> = {
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
Expand Down
3 changes: 2 additions & 1 deletion services/agents-login/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export async function runWorker(): Promise<void> {
const agentsApi = new AgentsApiClient({
baseUrl: cfg.agentsApiInternalUrl,
bearer: cfg.agentsApiInternalBearer,
logger,
})

const lease = new K8sLeaseLock({
Expand All @@ -22,7 +23,7 @@ export async function runWorker(): Promise<void> {
saTokenPath: cfg.saTokenPath,
})

const spawner = await createNodePtySpawner()
const spawner = await createNodePtySpawner(logger)

const sessions = new SessionManager({
spawner,
Expand Down
8 changes: 7 additions & 1 deletion services/agents-login/src/worker/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
}

Expand Down
26 changes: 22 additions & 4 deletions services/agents-login/src/worker/pty.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<PtySpawner> {
export async function createNodePtySpawner(logger?: Logger): Promise<PtySpawner> {
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,
Expand All @@ -31,9 +39,19 @@ export async function createNodePtySpawner(): Promise<PtySpawner> {
})
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)
},
}
}
}
26 changes: 26 additions & 0 deletions services/agents-login/src/worker/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}
})
Expand All @@ -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))
})

Expand All @@ -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)
})

Expand Down
Loading