diff --git a/bootstrap/src/cli.ts b/bootstrap/src/cli.ts index bd1a914..c7d8431 100644 --- a/bootstrap/src/cli.ts +++ b/bootstrap/src/cli.ts @@ -24,7 +24,20 @@ import { import type { KeyAlgorithm, KeyBackend, AAuthPublicJwk } from '@aauth/local-keys' import { listSkills, getSkill } from './skills.js' import { bootstrapWithPS } from './bootstrap-ps.js' -import { buildLogEmitter } from './log.js' +import { buildLogEmitter, type LogMode } from './log.js' + +// --log → pretty narrative; --jsonl (alias --ndjson) → NDJSON; neither → silent. +function pickLogMode(flags: Record): LogMode | undefined { + const log = flags.log === 'true' + const jsonl = flags.jsonl === 'true' || flags.ndjson === 'true' + if (log && jsonl) { + console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' })) + process.exit(1) + } + if (log) return 'pretty' + if (jsonl) return 'jsonl' + return undefined +} import { createHash } from 'node:crypto' import { createRequire } from 'node:module' @@ -67,7 +80,7 @@ function parseArgs(args: string[]) { // === Commands === function cmdDiscover(flags: Record) { - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) onEvent?.({ step: 'backend_discovery', phase: 'start' }) const backends = discoverBackends() onEvent?.({ step: 'backend_discovery', phase: 'done', backends: backends.map(b => b.backend) }) @@ -79,7 +92,7 @@ async function cmdGenerate(flags: Record) { const algorithm = (flags.algorithm || (backend === 'software' ? 'EdDSA' : 'ES256')) as KeyAlgorithm const agentUrl = flags.agent const kid = generateKid() - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) const driver = getBackend(backend) const deviceLabel = driver.getDeviceLabel() @@ -138,7 +151,7 @@ async function cmdGenerate(flags: Record) { async function cmdSignToken(flags: Record) { const agentUrl = flags.agent const lifetime = parseInt(flags.lifetime || '3600', 10) - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) if (!agentUrl) { console.error(JSON.stringify({ error: '--agent required' })) @@ -244,7 +257,7 @@ function cmdConfig() { } function cmdShow(flags: Record = {}) { - const onEvent = buildLogEmitter(flags.log === 'true') + const onEvent = buildLogEmitter(pickLogMode(flags)) console.log('@aauth/bootstrap — set up an agent identity for AAuth') console.log('') @@ -404,8 +417,9 @@ async function runBootstrapPS(flags: Record) { } } - const logEnabled = flags.log === 'true' - const onEvent = buildLogEmitter(logEnabled) + const logMode = pickLogMode(flags) + const onEvent = buildLogEmitter(logMode) + const logEnabled = logMode !== undefined if (logEnabled) { onEvent?.({ step: 'bootstrap_started', phase: 'info', agentUrl, personServerUrl }) diff --git a/bootstrap/src/log.ts b/bootstrap/src/log.ts index 0819876..72faa92 100644 --- a/bootstrap/src/log.ts +++ b/bootstrap/src/log.ts @@ -1,7 +1,3 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { writeFileSync, mkdirSync } from 'node:fs' - export interface BootstrapEvent { step: string phase: 'start' | 'done' | 'info' @@ -10,8 +6,6 @@ export interface BootstrapEvent { export type OnBootstrapEvent = (event: BootstrapEvent) => void -const MARKER_PATH = join(homedir(), '.aauth', '.tldr-shown') - // ── ANSI styling (TTY only, respects NO_COLOR) ──────────────────────────────── const IS_TTY = process.stderr.isTTY === true || process.env.AAUTH_FORCE_PRETTY === '1' const COLOR_ENABLED = IS_TTY && !process.env.NO_COLOR @@ -28,48 +22,6 @@ const c = { const RULE = '─'.repeat(80) const section = (title: string) => `${c.dim('─── ')}${c.bold(title)} ${c.dim(RULE.slice(title.length + 5))}` -// ── TL;DR block (shown once at the top of bootstrap --ps --log) ─────────────── -function renderTldr(): string { - return [ - section('What is AAuth?'), - '', - 'AAuth gives every agent its own cryptographic identity. The agent signs every', - 'HTTP request with a private key only it holds; resources verify the signature', - 'and decide whether to authorize. A Person Server represents the user and', - 'grants the agent permission to act on their behalf — no pre-registration, no', - 'shared secrets.', - '', - 'Protocol parties:', - '', - ` ${c.cyan('AGENT')} this CLI on your device. Identifies via an Ed25519 keypair`, - ' generated locally — the private key never leaves the OS keychain.', - ` ${c.green('RESOURCE')} the API the agent wants to call.`, - ` ${c.magenta('PERSON SERVER')} represents the user. Holds identity, decides authorization,`, - ' issues auth_tokens the resource will trust.', - ` ${c.dim('ACCESS SERVER (out of scope for this demo) policy engine that guards')}`, - ` ${c.dim('resources in federated mode.')}`, - '', - 'The user (you) approves consent in a browser the first time the PS sees', - 'this agent.', - '', - 'The flow:', - '', - ` ${c.dim('one-time')} ${c.cyan('AGENT')} generates keypair on this device`, - ` ${c.cyan('AGENT')} registers a Person Server it will delegate consent to`, - ` ${c.dim('per call')} ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — first time needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser, first time only)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - `${c.dim('Key properties: agent identity without pre-registration · proof-of-possession')}`, - `${c.dim('on every request · user consent at the Person Server, never at the resource.')}`, - '', - `${c.dim("You're about to run the one-time setup.")}`, - '', - '', - ].join('\n') -} - // ── Step 0 card builder (accumulates from events) ───────────────────────────── interface Step0State { agentUrl?: string @@ -104,8 +56,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { // Sub-bullet 1: keypair if (hasNewKey && state.publicJwk && state.kid) { - lines.push(' • Generate Ed25519 keypair on this device — the private key stays in the OS') - lines.push(' keychain and never leaves. The public key thumbprint is the agent\'s identity.') + lines.push(...bulletWrap(describe('key_generation', 'start', { algorithm: state.algorithm ?? 'Ed25519' }) ?? '')) lines.push('') if (state.agentUrl) lines.push(` ${c.bold('agent')} ${state.agentUrl}`) if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid}`) @@ -113,8 +64,7 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { if (state.jkt) lines.push(` ${c.bold('jkt')} ${state.jkt}`) lines.push('') } else if (state.agentUrl) { - lines.push(' • Use the existing keypair on this device — no new key generated.') - lines.push(' The public key thumbprint below is this agent\'s identity.') + lines.push(...bulletWrap(describe('key_info', 'info') ?? '')) lines.push('') lines.push(` ${c.bold('agent')} ${state.agentUrl}`) if (state.kid) lines.push(` ${c.bold('kid')} ${state.kid} ${c.dim('(current)')}`) @@ -125,14 +75,14 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { // Sub-bullet 2: PS metadata if (state.metadataUrl) { - lines.push(' • Fetch Person Server metadata to confirm it\'s reachable and well-formed.') + lines.push(...bulletWrap(describe('ps_metadata_request', 'start') ?? '')) lines.push('') const url = new URL(state.metadataUrl) lines.push(` ${c.bold('GET')} ${url.pathname} HTTP/1.1`) lines.push(` ${c.bold('Host:')} ${url.host}`) lines.push('') const statusColor = state.metadataStatus && state.metadataStatus < 300 ? c.green : c.red - lines.push(` ${c.dim('←')} HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`) + lines.push(` ← HTTP/1.1 ${statusColor(String(state.metadataStatus ?? '?'))} ${state.metadataStatus === 200 ? 'OK' : ''}`) lines.push(` ${c.bold('Content-Type:')} application/json`) if (state.metadataBody) { const body = JSON.stringify(state.metadataBody, null, 2).split('\n').map(l => ` ${l}`).join('\n') @@ -142,25 +92,21 @@ function renderStep0(state: Step0State, hasNewKey: boolean): string { } if (state.personServerUrl) { - lines.push(` ${c.green('✓')} Bootstrap complete. The agent will bind to a user on its first authorized request.`) + const desc = describe('bootstrap_complete', 'info') ?? '' + const wrapped = wrap(desc, 76) + wrapped.forEach((line, i) => { + lines.push(i === 0 ? ` ${c.green('✓')} ${line}` : ` ${line}`) + }) lines.push('') } return lines.join('\n') } -// ── Marker file ────────────────────────────────────────────────────────────── -function writeTldrMarker(): void { - try { - mkdirSync(join(homedir(), '.aauth'), { recursive: true }) - writeFileSync(MARKER_PATH, new Date().toISOString(), 'utf8') - } catch { - // Non-fatal — marker is purely a UX hint - } -} - // ── Public API ──────────────────────────────────────────────────────────────── -const narrations: Record string | undefined> = { +type EventDescriber = (e: BootstrapEvent) => string | undefined + +const narrations: Record = { backend_discovery: (e) => e.phase === 'start' ? 'Discovering available key backends on this machine' : `Found ${(e.backends as unknown[] | undefined)?.length ?? 0} backend(s)`, @@ -177,54 +123,89 @@ const narrations: Record string | undefined> = { sign_token: (e) => e.phase === 'start' ? `Signing agent_token` : 'Agent token signed', } +// Long-form per-step prose explaining what's happening at the protocol level. +// Single source of truth for both pretty (renderStep0) and JSON (--jsonl) +// consumers — same map approach as fetch/src/log.ts uses for the per-call flow. +const descriptions: Record = { + key_info: () => + "Use the existing keypair on this device — no new key generated. The public key thumbprint below is this agent's identity.", + key_generation: (e) => e.phase === 'start' + ? `Generate ${e.algorithm ?? 'Ed25519'} keypair on this device — the private key stays in the OS keychain and never leaves. The public key thumbprint is the agent's identity.` + : undefined, + ps_metadata_request: (e) => e.phase === 'start' + ? "Fetch Person Server metadata to confirm it's reachable and well-formed." + : undefined, + bootstrap_complete: () => + "Bootstrap complete. The agent will bind to a user on its first authorized request.", +} + function formatNdjson(event: BootstrapEvent): string { const narration = narrations[event.step]?.(event) - const line = narration ? { ...event, narration } : event + const description = descriptions[event.step]?.(event) + const line: Record = { ...event } + if (narration) line.narration = narration + if (description) line.description = description return JSON.stringify(line) + '\n' } +// Word-wrap a paragraph at `width` columns for terminal rendering. +function wrap(text: string, width = 78): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let cur = '' + for (const w of words) { + if (cur.length === 0) cur = w + else if (cur.length + 1 + w.length <= width) cur += ' ' + w + else { lines.push(cur); cur = w } + } + if (cur) lines.push(cur) + return lines +} + +// Render a paragraph as a bullet with hanging-indent continuation lines. +// " • first line of paragraph..." +// " continuation..." +function bulletWrap(text: string, width = 76): string[] { + return wrap(text, width).map((line, i) => i === 0 ? ` • ${line}` : ` ${line}`) +} + +// Look up a description for a synthetic event shape — used by renderStep0 +// so both pretty and JSON consumers read from the same map. +function describe(step: string, phase: 'start' | 'done' | 'info', extra?: Record): string | undefined { + const e: BootstrapEvent = { step, phase, ...(extra ?? {}) } + return descriptions[step]?.(e) +} + /** * Build a stream-aware bootstrap event handler. * - * When stderr is a TTY: prints TL;DR + Step 0 grouped card, then writes a - * marker file so a subsequent `fetch --log` can suppress its own TL;DR. - * - * When stderr is piped: emits NDJSON (one line per event) as before. + * mode='pretty': prints Step 0 grouped card on stderr. + * mode='jsonl': emits each event as one JSON object per line on stderr. + * mode=undefined: returns undefined (no logging). */ -export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined { - if (!enabled) return undefined +export type LogMode = 'pretty' | 'jsonl' - const pretty = IS_TTY +export function buildLogEmitter(mode: LogMode | undefined): OnBootstrapEvent | undefined { + if (!mode) return undefined - if (!pretty) { - // Piped — keep NDJSON shape for programmatic consumers. + if (mode === 'jsonl') { return (event: BootstrapEvent) => { process.stderr.write(formatNdjson(event)) } } - // TTY: collect events, render TL;DR once, then render Step 0 on completion. - let printedTldr = false + // TTY: collect events, render Step 0 on completion. const state: Step0State = {} let hasNewKey = false let rendered = false - function maybePrintTldr() { - if (!printedTldr) { - process.stderr.write(renderTldr()) - printedTldr = true - } - } - function finalize() { if (rendered) return rendered = true process.stderr.write(renderStep0(state, hasNewKey)) - writeTldrMarker() } return (event: BootstrapEvent) => { - maybePrintTldr() switch (event.step) { case 'bootstrap_started': state.agentUrl = event.agentUrl as string | undefined @@ -270,5 +251,5 @@ export function buildLogEmitter(enabled: boolean): OnBootstrapEvent | undefined export function logEvent(enabled: boolean, event: BootstrapEvent): void { if (!enabled) return - buildLogEmitter(true)?.(event) + buildLogEmitter('jsonl')?.(event) } diff --git a/fetch/src/args.ts b/fetch/src/args.ts index 91a9fac..6b69318 100644 --- a/fetch/src/args.ts +++ b/fetch/src/args.ts @@ -40,6 +40,7 @@ export interface FetchArgs { verbose: boolean debug: boolean log: boolean + jsonl: boolean } function usage(): never { @@ -88,7 +89,10 @@ Interaction: Output: -v, --verbose Show headers + status on stderr --debug Show all requests/responses with headers on stderr - --log Narrate each AAuth protocol step on stderr (JSONL) + --log Narrate each AAuth protocol step on stderr (human-readable) + --jsonl, --ndjson Emit each AAuth protocol step on stderr as one JSON object + per line. Each event carries 'narration' (one-line) and + 'description' (paragraph) fields. Mutually exclusive with --log. `) process.exit(1) } @@ -109,6 +113,7 @@ export function parseArgs(argv: string[]): FetchArgs { verbose: false, debug: false, log: false, + jsonl: false, } for (let i = 0; i < args.length; i++) { @@ -215,6 +220,10 @@ export function parseArgs(argv: string[]): FetchArgs { case '--log': result.log = true break + case '--jsonl': + case '--ndjson': + result.jsonl = true + break default: if (args[i].startsWith('-')) { @@ -234,5 +243,12 @@ export function parseArgs(argv: string[]): FetchArgs { result.signingKey = result.signingKey ?? process.env.AAUTH_SIGNING_KEY result.personServer = result.personServer ?? process.env.AAUTH_PERSON_SERVER + // --log and --jsonl are mutually exclusive — they emit the same protocol + // events in different formats. Pick one. + if (result.log && result.jsonl) { + console.error(JSON.stringify({ error: '--log and --jsonl are mutually exclusive (same events, different formats). Pick one.' })) + process.exit(1) + } + return result } diff --git a/fetch/src/handlers.ts b/fetch/src/handlers.ts index d477f2a..76b6301 100644 --- a/fetch/src/handlers.ts +++ b/fetch/src/handlers.ts @@ -9,7 +9,15 @@ import { } from '@aauth/mcp-agent' import type { GetKeyMaterial, KeyMaterial, FetchLike, Capability, OnEvent } from '@aauth/mcp-agent' import open from 'open' -import { buildLogEmitter } from './log.js' +import { buildLogEmitter, type LogMode } from './log.js' + +// --log → pretty narrative; --jsonl → NDJSON; neither → silent. The args +// parser already enforces mutual exclusion between the two. +function pickLogMode(args: { log?: boolean; jsonl?: boolean }): LogMode | undefined { + if (args.log) return 'pretty' + if (args.jsonl) return 'jsonl' + return undefined +} /** * Filter response headers to AAuth-relevant set for --log events. Mirrors the @@ -105,7 +113,7 @@ export function buildRequestInit(args: { method: string; data?: string; headers: export async function handleAuthorize( args: { url: string; agentUrl?: string; operations?: string; scope?: string; - browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; + browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; jsonl?: boolean; loginHint?: string; domainHint?: string; tenant?: string; justification?: string; capabilities?: string[]; forceConsent?: boolean; }, @@ -114,7 +122,7 @@ export async function handleAuthorize( ): Promise { const shouldOpenBrowser = args.browser ?? true const capabilities = args.capabilities as Capability[] | undefined - const log = buildLogEmitter(args.log ?? false, { url: args.url, agentUrl: args.agentUrl, personServer }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url, agentUrl: args.agentUrl, personServer }) const onEvent: OnEvent | undefined = log?.onEvent const keyMaterial = await getKeyMaterial() @@ -289,7 +297,7 @@ export async function handleAuthorize( * Pre-authed mode: use provided auth token + signing key. */ export async function handlePreAuthed( - args: { url: string; method: string; authToken: string; signingKey: string; verbose: boolean; log?: boolean; data?: string; headers: string[] }, + args: { url: string; method: string; authToken: string; signingKey: string; verbose: boolean; log?: boolean; jsonl?: boolean; data?: string; headers: string[] }, init: RequestInit, ): Promise { let signingKey: JsonWebKey @@ -301,7 +309,7 @@ export async function handlePreAuthed( return } - const log = buildLogEmitter(args.log ?? false, { url: args.url }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url }) const onEvent: OnEvent | undefined = log?.onEvent const getKeyMaterial: GetKeyMaterial = async () => ({ signingKey, @@ -333,11 +341,11 @@ export async function handlePreAuthed( * --agent-only mode: sign with agent token, don't handle 401. */ export async function handleAgentOnly( - args: { url: string; verbose: boolean; log?: boolean }, + args: { url: string; verbose: boolean; log?: boolean; jsonl?: boolean }, init: RequestInit, getKeyMaterial: GetKeyMaterial, ): Promise { - const log = buildLogEmitter(args.log ?? false, { url: args.url }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url }) const onEvent: OnEvent | undefined = log?.onEvent const signedFetch = createSignedFetch(getKeyMaterial) @@ -368,7 +376,7 @@ export async function handleAgentOnly( */ export async function handleFullFlow( args: { - url: string; agentUrl?: string; browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; + url: string; agentUrl?: string; browser?: boolean; nonInteractive: boolean; verbose: boolean; debug?: boolean; log?: boolean; jsonl?: boolean; loginHint?: string; domainHint?: string; tenant?: string; justification?: string; capabilities?: string[]; forceConsent?: boolean; }, @@ -377,7 +385,7 @@ export async function handleFullFlow( personServer: string | undefined, ): Promise { const shouldOpenBrowser = args.browser ?? true - const log = buildLogEmitter(args.log ?? false, { url: args.url, agentUrl: args.agentUrl, personServer }) + const log = buildLogEmitter(pickLogMode(args), { url: args.url, agentUrl: args.agentUrl, personServer }) const onEvent: OnEvent | undefined = log?.onEvent // Pin key material so the same ephemeral key is used for the initial request, diff --git a/fetch/src/log.ts b/fetch/src/log.ts index 6c977b3..db25655 100644 --- a/fetch/src/log.ts +++ b/fetch/src/log.ts @@ -1,6 +1,3 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { readFileSync, existsSync } from 'node:fs' import type { AAuthEvent, OnEvent } from '@aauth/mcp-agent' import { readConfig, readKeychain } from '@aauth/local-keys' import { createHash } from 'node:crypto' @@ -21,78 +18,8 @@ const c = { const RULE = '─'.repeat(80) const section = (title: string) => `${c.dim('─── ')}${c.bold(title)} ${c.dim(RULE.slice(title.length + 5))}` -// ── Marker file (written by bootstrap --log, read here) ─────────────────────── -const MARKER_PATH = join(homedir(), '.aauth', '.tldr-shown') -const MARKER_FRESH_MS = 5 * 60 * 1000 - -function isMarkerFresh(): boolean { - try { - if (!existsSync(MARKER_PATH)) return false - const ts = readFileSync(MARKER_PATH, 'utf8').trim() - const when = Date.parse(ts) - if (Number.isNaN(when)) return false - return Date.now() - when < MARKER_FRESH_MS - } catch { - return false - } -} - // ── Header / preamble blocks ────────────────────────────────────────────────── -function renderTldr(): string { - return [ - section('What is AAuth?'), - '', - 'AAuth gives every agent its own cryptographic identity. The agent signs every', - 'HTTP request with a private key only it holds; resources verify the signature', - 'and decide whether to authorize. A Person Server represents the user and', - 'grants the agent permission to act on their behalf — no pre-registration, no', - 'shared secrets.', - '', - 'Protocol parties:', - '', - ` ${c.cyan('AGENT')} this CLI on your device. Identifies via an Ed25519 keypair`, - ' generated locally — the private key never leaves the OS keychain.', - ` ${c.green('RESOURCE')} the API the agent wants to call.`, - ` ${c.magenta('PERSON SERVER')} represents the user. Holds identity, decides authorization,`, - ' issues auth_tokens the resource will trust.', - ` ${c.dim('ACCESS SERVER (out of scope for this demo) policy engine that guards')}`, - ` ${c.dim('resources in federated mode.')}`, - '', - 'The user (you) approves consent in a browser the first time the PS sees', - 'this agent.', - '', - 'The flow:', - '', - ` ${c.dim('one-time')} ${c.cyan('AGENT')} generates keypair on this device`, - ` ${c.cyan('AGENT')} registers a Person Server it will delegate consent to`, - ` ${c.dim('per call')} ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — first time needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser, first time only)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - `${c.dim('Key properties: agent identity without pre-registration · proof-of-possession')}`, - `${c.dim('on every request · user consent at the Person Server, never at the resource.')}`, - '', - '', - ].join('\n') -} - -function renderCondensedFlow(): string { - return [ - `${c.bold('AAuth · per-call flow')}`, - '', - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (401: who are you?)`, - ` ${c.cyan('AGENT')} ─▶ ${c.magenta('PERSON SERVER')} (token exchange — needs consent)`, - ` ${c.yellow('user')} ─▶ ${c.magenta('PERSON SERVER')} (approve in browser)`, - ` ${c.cyan('AGENT')} ─▶ ${c.green('RESOURCE')} (200: data)`, - '', - c.dim('(Actors and one-time setup: see `npx @aauth/bootstrap --ps --log`.)'), - '', - '', - ].join('\n') -} - function computeJkt(jwk: Record): string { const kty = jwk.kty as string const crv = jwk.crv as string @@ -211,7 +138,7 @@ function stepHeader(n: number, from: Actor, to: Actor, subtitle: string): string function statusLine(status: number): string { const tint = status >= 200 && status < 300 ? c.green : status >= 400 ? c.red : c.yellow const label = STATUS_LABELS[status] ?? '' - return ` ${c.dim('←')} HTTP/1.1 ${tint(String(status))} ${tint(label)}` + return ` ← HTTP/1.1 ${tint(String(status))} ${tint(label)}` } const STATUS_LABELS: Record = { @@ -275,36 +202,135 @@ function compactJson(v: unknown): string { return `{ ${inner} }` } -function renderRequestHeaders(method: string, url: string, hasBody: boolean): string[] { +// Header-Case formatter: "signature-input" → "Signature-Input" +function headerCase(name: string): string { + return name.split('-').map(p => p ? p[0].toUpperCase() + p.slice(1) : '').join('-') +} + +// Truncate the Signature header's base64 value: sig=:: → sig=:<16 chars>…: +function truncateSig(value: string): string { + return value.replace(/=:([^:]+):/g, (_, b64) => + b64.length > 16 ? `=:${b64.slice(0, 16)}…:` : `=:${b64}:`) +} + +// Truncate the JWT inside Signature-Key: sig=jwt;jwt="" +function truncateSigKey(value: string): string { + return value.replace(/jwt="([^"]+)"/g, (_, jwt) => + jwt.length > 24 ? `jwt="${jwt.slice(0, 24)}…"` : `jwt="${jwt}"`) +} + +// Pretty-print a request/response body. JSON gets parsed + indented; long +// JWT-looking string values inside are truncated. Non-JSON returns as-is +// with simple length truncation. +function formatBody(body: string): string { + try { + const parsed = JSON.parse(body) + const pretty = JSON.stringify(parsed, null, 2) + // Truncate JWT-shaped values (>= 40 chars, alphanum/-/_) so the body stays scannable. + return pretty.replace(/("[\w\-.]+":\s*)"([a-zA-Z0-9_\-.]{40,})"/g, (_m, key, val) => + `${key}"${val.slice(0, 32)}…"`) + } catch { + return body.length > 200 ? `${body.slice(0, 200)}…` : body + } +} + +// Canonical ordering for the AAuth-relevant request headers we know about. +// Anything not in this list is appended after, in insertion order. +const REQUEST_HEADER_ORDER = [ + 'content-type', + 'content-length', + 'content-digest', + 'prefer', + 'authorization', + 'aauth-capabilities', + 'aauth-mission', + 'signature-input', + 'signature', + 'signature-key', +] + +function renderRequestHeaders( + method: string, + url: string, + headers: Record | undefined, + body?: string, +): string[] { const u = new URL(url) const path = u.pathname + u.search - const components = hasBody - ? '("@method" "@authority" "@path" "content-digest" "signature-key")' - : '("@method" "@authority" "@path" "signature-key")' - const created = Math.floor(Date.now() / 1000) const lines: string[] = [] lines.push(` ${c.bold(method)} ${path} HTTP/1.1`) lines.push(` ${c.bold('Host:')} ${u.host}`) - if (hasBody) { - lines.push(` ${c.bold('Content-Type:')} application/json`) - } else { - lines.push(` ${c.bold('Content-Type:')} application/json`) + + if (!headers) { + // Fallback for events without captured headers (shouldn't happen post-Layer-2). + lines.push(` ${c.dim('(signed-request headers not captured for this exchange)')}`) + return lines + } + + const seen = new Set(['host']) + for (const name of REQUEST_HEADER_ORDER) { + const value = headers[name] + if (value === undefined) continue + seen.add(name) + const display = + name === 'signature' ? truncateSig(value) + : name === 'signature-key' ? truncateSigKey(value) + : value + lines.push(` ${c.bold(headerCase(name) + ':')} ${display}`) + } + // Trailing pass for any headers we didn't anticipate (HTTP/2 implementations + // sometimes add extras like accept-encoding). + for (const [k, v] of Object.entries(headers)) { + if (seen.has(k)) continue + lines.push(` ${c.bold(headerCase(k) + ':')} ${v}`) + } + if (body) { + lines.push('') + const formatted = formatBody(body) + lines.push(...formatted.split('\n').map(l => ` ${l}`)) } - lines.push(` ${c.bold('Signature-Input:')} sig=${components};created=${created}`) - lines.push(` ${c.bold('Signature:')} sig=:${c.dim('')}:`) - lines.push(` ${c.bold('Signature-Key:')} sig=jwt;jwt="${c.dim('')}"`) return lines } // ── Collected event state ───────────────────────────────────────────────────── -interface Step1Data { url?: string; method?: string; agentToken?: Record } -interface Step2Data { status?: number; resourceToken?: Record; aauthRequirement?: string } -interface Step3Data { url?: string; status?: number; body?: Record } -interface Step4Data { url?: string; status?: number; agentToken?: Record; aauthRequirement?: string; location?: string } +interface Step1Data { + url?: string + method?: string + agentToken?: Record + requestHeaders?: Record +} +interface Step2Data { + status?: number + resourceToken?: Record + aauthRequirement?: string + responseBody?: string +} +interface Step3Data { + url?: string + status?: number + body?: Record + requestHeaders?: Record + responseBody?: string +} +interface Step4Data { + url?: string + status?: number + agentToken?: Record + aauthRequirement?: string + location?: string + requestHeaders?: Record + requestBody?: string + responseBody?: string +} interface Step5UserData { interactionUrl?: string; code?: string; resolvedAt?: number; startedAt?: number } interface Step6TokenData { authToken?: Record; expiresIn?: number } -interface Step8Data { url?: string; method?: string; authToken?: Record } +interface Step8Data { + url?: string + method?: string + authToken?: Record + requestHeaders?: Record +} interface Step9Data { status?: number } interface FlowState { @@ -329,18 +355,23 @@ function newState(): FlowState { // ── Render the flow ─────────────────────────────────────────────────────────── +function pushDescription(out: string[], step: string, phase: 'start' | 'done' | 'info', status?: number): void { + const text = describe(step, phase, status) + if (text) { + out.push(...wrap(text)) + out.push('') + } +} + function renderColdFlow(s: FlowState): string { const out: string[] = [] // Step 1: AGENT → RESOURCE out.push(stepHeader(1, 'AGENT', 'RESOURCE', "sign with agent's key")) out.push('') - out.push("We sign an HTTP request with the agent's keypair and call the resource. The") - out.push('Signature-Key header carries the agent_token so the resource can verify the') - out.push("signature against the agent's public key.") - out.push('') + pushDescription(out, 'signed_request', 'start') if (s.step1.url) { - out.push(...renderRequestHeaders(s.step1.method ?? 'GET', s.step1.url, false)) + out.push(...renderRequestHeaders(s.step1.method ?? 'GET', s.step1.url, s.step1.requestHeaders)) out.push('') out.push(renderJwtBlock('agent_token (decoded JWT in Signature-Key)', s.step1.agentToken)) } @@ -348,10 +379,7 @@ function renderColdFlow(s: FlowState): string { // Step 2: RESOURCE → AGENT (401 + resource_token) out.push(stepHeader(2, 'RESOURCE', 'AGENT', '401 with capability')) out.push('') - out.push('The resource verified the signature, but the agent isn\'t carrying an') - out.push('auth_token for this call. It mints a resource_token bound to the agent\'s') - out.push("public-key thumbprint and tells us to exchange it at the PS.") - out.push('') + pushDescription(out, 'signed_request', 'done', 401) out.push(statusLine(s.step2.status ?? 401)) out.push(` ${c.bold('Content-Type:')} application/json`) if (s.step2.aauthRequirement) { @@ -363,9 +391,7 @@ function renderColdFlow(s: FlowState): string { // Step 3: AGENT → PS (discovery) out.push(stepHeader(3, 'AGENT', 'PERSON SERVER', 'discover token endpoint')) out.push('') - out.push("We fetch the PS's well-known metadata to find the token endpoint we'll POST") - out.push("the resource_token to.") - out.push('') + pushDescription(out, 'ps_metadata_request', 'start') if (s.step3.url) { const u = new URL(s.step3.url) out.push(` ${c.bold('GET')} ${u.pathname} HTTP/1.1`) @@ -383,24 +409,18 @@ function renderColdFlow(s: FlowState): string { // Step 4: AGENT → PS (token exchange) out.push(stepHeader(4, 'AGENT', 'PERSON SERVER', 'exchange resource_token')) out.push('') - out.push('We POST the resource_token to the token endpoint we just discovered, signed') - out.push('with the same agent key as Step 1.') - out.push('') + pushDescription(out, 'ps_token_request', 'start') if (s.step4.url) { - out.push(...renderRequestHeaders('POST', s.step4.url, true)) - out.push('') - out.push(` ${c.dim('{ resource_token: "…", capabilities: ["interaction"]' + (s.step4.url ? '' : '') + ' }')}`) + out.push(...renderRequestHeaders('POST', s.step4.url, s.step4.requestHeaders, s.step4.requestBody)) out.push('') + out.push(renderJwtBlock('agent_token (decoded JWT in Signature-Key)', s.step4.agentToken ?? s.step1.agentToken)) } if (s.consentRequired) { // Step 5: PS → AGENT (202 consent required) out.push(stepHeader(5, 'PERSON SERVER', 'AGENT', '202, consent required')) out.push('') - out.push('The PS recognised the agent and the resource_token, but the user has not yet') - out.push('consented to this agent acting on their behalf. It deferred — returning a URL') - out.push("and a short code the user must approve, plus a Location to long-poll.") - out.push('') + pushDescription(out, 'ps_token_request', 'done', 202) out.push(statusLine(202)) out.push(` ${c.bold('Content-Type:')} application/json; charset=utf-8`) if (s.step4.aauthRequirement) { @@ -414,10 +434,7 @@ function renderColdFlow(s: FlowState): string { // Step 6: USER → PS (approve in browser) out.push(stepHeader(6, 'user', 'PERSON SERVER', 'approve in browser')) out.push('') - out.push('The user opens the consent screen, signs in to the Person Server, sees what') - out.push('scopes this agent is requesting, and approves. AAuth\'s consent always') - out.push('happens at the user\'s PS — never at the resource and never at the agent.') - out.push('') + pushDescription(out, 'consent_prompt', 'info') if (s.step5.interactionUrl) { out.push(` ${c.bold('→ Open')} ${s.step5.interactionUrl}`) } @@ -434,30 +451,22 @@ function renderColdFlow(s: FlowState): string { // Step 7: PS → AGENT (auth_token issued) out.push(stepHeader(7, 'PERSON SERVER', 'AGENT', 'issues auth_token')) out.push('') - out.push('The long-poll from Step 6 resolves with the auth_token. It\'s bound to the') - out.push('agent\'s key, scoped only to what the user granted, and carries identity') - out.push('claims released by the openid + profile scopes (if requested).') - out.push('') + pushDescription(out, 'auth_token_received', 'info') out.push(statusLine(200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') out.push(renderJwtBlock('auth_token (decoded)', s.step6.authToken, { - cnf: 'same key as Step 1', scope: 'identity scopes consumed as claims', })) } else { // Warm path: PS returns 200 directly with auth_token out.push(stepHeader(5, 'PERSON SERVER', 'AGENT', '200 with auth_token')) out.push('') - out.push('The PS recognised the agent and saw that the user has already consented to') - out.push('this agent + scope combination. It issued an auth_token directly — no 202,') - out.push('no consent screen, no long-poll.') - out.push('') + pushDescription(out, 'ps_token_request', 'done', 200) out.push(statusLine(200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') out.push(renderJwtBlock('auth_token (decoded)', s.step6.authToken, { - cnf: 'same key as Step 1', scope: 'identity scopes consumed as claims', })) } @@ -466,12 +475,11 @@ function renderColdFlow(s: FlowState): string { const retryStepNum = s.consentRequired ? 8 : 6 out.push(stepHeader(retryStepNum, 'AGENT', 'RESOURCE', 'retry with auth_token')) out.push('') - out.push('Same signature scheme as Step 1, but the Signature-Key now carries the') - out.push('auth_token instead of the agent_token.') - out.push('') + pushDescription(out, 'retry_with_auth_token', 'start') if (s.step8.url) { - out.push(...renderRequestHeaders(s.step8.method ?? 'GET', s.step8.url, false)) - out.push(` ${c.dim(' ← the auth_token from the previous step')}`) + out.push(...renderRequestHeaders(s.step8.method ?? 'GET', s.step8.url, s.step8.requestHeaders)) + out.push('') + out.push(renderJwtBlock('auth_token (decoded JWT in Signature-Key)', s.step8.authToken ?? s.step6.authToken)) out.push('') } @@ -479,10 +487,7 @@ function renderColdFlow(s: FlowState): string { const finalStepNum = s.consentRequired ? 9 : 7 out.push(stepHeader(finalStepNum, 'RESOURCE', 'AGENT', '200 with data')) out.push('') - out.push("The resource verifies the HTTP signature against the auth_token's cnf.jwk,") - out.push("confirms the PS issued the token (using a cached copy of the PS's JWKS),") - out.push('checks the scope covers this endpoint, and returns the data.') - out.push('') + pushDescription(out, 'retry_with_auth_token', 'done', 200) out.push(statusLine(s.step9.status ?? 200)) out.push(` ${c.bold('Content-Type:')} application/json`) out.push('') @@ -510,7 +515,9 @@ function renderCloser(consentRequired: boolean): string { // ── Public API ──────────────────────────────────────────────────────────────── -const narrations: Record string | undefined> = { +type EventDescriber = (e: AAuthEvent) => string | undefined + +const narrations: Record = { signed_request: (e) => e.phase === 'start' ? `Agent → Resource: ${e.method ?? 'GET'} ${e.url} (HTTP-signed)` : `Resource responded ${e.status}`, @@ -531,35 +538,94 @@ const narrations: Record string | undefined> = { : `Resource responded ${e.status}`, } +// Long-form per-step prose explaining what's happening at the protocol level. +// Single source of truth for both --log (pretty CLI rendering) and --jsonl +// (machine-readable events). Each function returns a paragraph (no embedded +// newlines); the CLI word-wraps for terminal display, JSON consumers can +// reflow as they see fit. Disambiguated by phase/status when one step has +// multiple narrative outcomes (e.g., ps_token_request:done is 200 for warm +// path, 202 for cold). +const descriptions: Record = { + signed_request: (e) => e.phase === 'start' + ? "We sign an HTTP request with the agent's keypair and call the resource. The Signature-Key header carries the agent_token so the resource can verify the signature against the agent's public key." + : e.status === 401 + ? "The resource verified the signature, but the agent isn't carrying an auth_token for this call. It mints a resource_token bound to the agent's public-key thumbprint and tells us to exchange it at the Person Server." + : undefined, + ps_metadata_request: (e) => e.phase === 'start' + ? "We fetch the Person Server's well-known metadata to find the token endpoint we'll POST the resource_token to." + : undefined, + ps_token_request: (e) => e.phase === 'start' + ? 'We POST the resource_token to the token endpoint we just discovered, signed with the same agent key as the initial call.' + : e.status === 202 + ? "The PS recognised the agent and the resource_token, but the user has not yet consented to this agent acting on their behalf. It deferred — returning a URL and a short code the user must approve, plus a Location to long-poll." + : e.status === 200 + ? "The PS recognised the agent and saw that the user has already consented to this agent + scope combination. It issued an auth_token directly — no 202, no consent screen, no long-poll." + : undefined, + consent_prompt: () => + "The user opens the consent screen, signs in to the Person Server, sees what scopes this agent is requesting, and approves. AAuth's consent always happens at the user's PS — never at the resource and never at the agent.", + auth_token_received: () => + "The PS issued an auth_token. It's bound to the agent's key, scoped only to what the user granted, and carries identity claims released by the openid + profile scopes (if requested).", + retry_with_auth_token: (e) => e.phase === 'start' + ? 'Same signature scheme as the initial call, but the Signature-Key now carries the auth_token instead of the agent_token.' + : e.status === 200 + ? "The resource verifies the HTTP signature against the auth_token's cnf.jwk, confirms the PS issued the token (using a cached copy of the PS's JWKS), checks the scope covers this endpoint, and returns the data." + : undefined, +} + function formatNdjson(event: AAuthEvent): string { const narration = narrations[event.step]?.(event) - const line = narration ? { ...event, narration } : event + const description = descriptions[event.step]?.(event) + const line: Record = { ...event } + if (narration) line.narration = narration + if (description) line.description = description return JSON.stringify(line) + '\n' } +// Word-wrap a paragraph at `width` columns for terminal rendering. +function wrap(text: string, width = 78): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let cur = '' + for (const w of words) { + if (cur.length === 0) cur = w + else if (cur.length + 1 + w.length <= width) cur += ' ' + w + else { lines.push(cur); cur = w } + } + if (cur) lines.push(cur) + return lines +} + +// Look up a description for a synthetic event shape — used by the CLI +// pretty-printer so both --log and --jsonl read from the same map. +function describe(step: string, phase: 'start' | 'done' | 'info', status?: number): string | undefined { + const e: AAuthEvent = { step, phase, ...(status !== undefined ? { status } : {}) } + return descriptions[step]?.(e) +} + export interface LogHandle { onEvent: OnEvent finish: () => void } +export type LogMode = 'pretty' | 'jsonl' + /** * Build a stream-aware fetch event handler. * - * TTY mode: collects events, renders TL;DR + Already-set-up (if marker stale) - * or condensed flow (if marker fresh) at first event, then renders 7/9-step - * cards + closer at finish(). - * - * Piped mode: emits NDJSON one line per event (current behavior preserved). + * mode='pretty': collects events, renders the human-readable step cards + * (Already-set-up + This-call + numbered steps + closer) + * mode='jsonl': emits each event as one JSON object per line on stderr. + * Each line carries `narration` (short) and `description` + * (paragraph) fields so machine + AI consumers get the + * same prose --log shows. */ export function buildLogEmitter( - enabled: boolean, + mode: LogMode | undefined, context: { url?: string; agentUrl?: string; personServer?: string } = {}, ): LogHandle | undefined { - if (!enabled) return undefined - - const pretty = IS_TTY + if (!mode) return undefined - if (!pretty) { + if (mode === 'jsonl') { return { onEvent: (event: AAuthEvent) => process.stderr.write(formatNdjson(event)), finish: () => {}, @@ -573,12 +639,7 @@ export function buildLogEmitter( function printPreamble() { if (preamblePrinted) return preamblePrinted = true - if (isMarkerFresh()) { - process.stderr.write(renderCondensedFlow()) - } else { - process.stderr.write(renderTldr()) - process.stderr.write(renderAlreadySetUp(context.agentUrl)) - } + process.stderr.write(renderAlreadySetUp(context.agentUrl)) if (context.url) { process.stderr.write(renderThisCall(context.url, context.agentUrl, context.personServer)) } @@ -594,10 +655,15 @@ export function buildLogEmitter( state.step1.agentToken = event.agent_token as Record | undefined } else if (event.phase === 'done') { state.step2.status = event.status as number | undefined - const headers = (event.response as Record | undefined)?.headers as Record | undefined + state.step1.requestHeaders = event.request_headers as Record | undefined + const response = event.response as Record | undefined + const headers = response?.headers as Record | undefined if (headers) { state.step2.aauthRequirement = headers['aauth-requirement'] } + if (typeof response?.body === 'string') { + state.step2.responseBody = response.body + } } break case 'challenge_received': @@ -608,6 +674,11 @@ export function buildLogEmitter( state.step3.url = event.url as string | undefined } else if (event.phase === 'done') { state.step3.status = event.status as number | undefined + state.step3.requestHeaders = event.request_headers as Record | undefined + const response = event.response as Record | undefined + if (typeof response?.body === 'string') { + try { state.step3.body = JSON.parse(response.body) } catch { state.step3.responseBody = response.body } + } } break case 'ps_token_request': @@ -616,11 +687,17 @@ export function buildLogEmitter( state.step4.agentToken = event.agent_token as Record | undefined } else if (event.phase === 'done') { state.step4.status = event.status as number | undefined - const headers = (event.response as Record | undefined)?.headers as Record | undefined + state.step4.requestHeaders = event.request_headers as Record | undefined + state.step4.requestBody = event.request_body as string | undefined + const response = event.response as Record | undefined + const headers = response?.headers as Record | undefined if (headers) { state.step4.aauthRequirement = headers['aauth-requirement'] state.step4.location = headers['location'] } + if (typeof response?.body === 'string') { + state.step4.responseBody = response.body + } } break case 'ps_consent_pending': @@ -645,6 +722,7 @@ export function buildLogEmitter( state.step8.authToken = event.auth_token as Record | undefined } else if (event.phase === 'done') { state.step9.status = event.status as number | undefined + state.step8.requestHeaders = event.request_headers as Record | undefined } break } @@ -664,5 +742,5 @@ export function buildLogEmitter( export function logEvent(enabled: boolean, event: AAuthEvent): void { if (!enabled) return - buildLogEmitter(true)?.onEvent(event) + buildLogEmitter('jsonl')?.onEvent(event) } diff --git a/mcp-agent/package.json b/mcp-agent/package.json index 35237e2..e695202 100644 --- a/mcp-agent/package.json +++ b/mcp-agent/package.json @@ -32,7 +32,7 @@ "directory": "mcp-agent" }, "dependencies": { - "@hellocoop/httpsig": "^1.1.3" + "@hellocoop/httpsig": "^1.6.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/mcp-agent/src/aauth-fetch.ts b/mcp-agent/src/aauth-fetch.ts index ea1942c..2114412 100644 --- a/mcp-agent/src/aauth-fetch.ts +++ b/mcp-agent/src/aauth-fetch.ts @@ -4,8 +4,8 @@ import { parseAAuthHeader, buildCapabilitiesHeader, buildMissionHeader } from '. import { exchangeToken } from './token-exchange.js' import { pollDeferred } from './deferred.js' import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey } from './log-helpers.js' -import type { GetKeyMaterial, FetchLike, OnEvent } from './types.js' +import { summarizeResponseHeaders, decodeSignatureKey, captureSentFromHttpsig, peekResponseBody } from './log-helpers.js' +import type { GetKeyMaterial, FetchLike, OnEvent, CapturedSent } from './types.js' import type { Capability, AAuthMission } from './aauth-header.js' export interface AAuthFetchOptions { @@ -57,7 +57,15 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { prompt, } = options - const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, mission }) + // Shared mutable holder for the latest signed-request capture. signed-fetch + // and the fetchWith* helpers populate `.latest` via onSigned; the next + // emitted :done event (here, in token-exchange, or in deferred) reads it. + // Wide explicit type so TS doesn't narrow `.latest` to never after we set + // it to undefined before an awaited call that mutates it via callback. + const sentTracker: { latest: CapturedSent | undefined } = { latest: undefined } + const onSigned = onEvent ? (sent: CapturedSent) => { sentTracker.latest = sent } : undefined + + const signedFetch = createSignedFetch(getKeyMaterial, { capabilities, mission, onSigned }) const tokenCache = new Map() const accessCache = new Map() @@ -69,7 +77,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { const cached = findCachedToken(tokenCache, resourceOrigin) if (cached) { // Use cached auth token — sign with auth token instead of agent token - const response = await fetchWithAuthToken(url, init, cached.authToken, getKeyMaterial) + const response = await fetchWithAuthToken(url, init, cached.authToken, getKeyMaterial, onSigned) // If the cached token is rejected, fall through to challenge flow if (response.status !== 401) { cacheAccessToken(accessCache, resourceOrigin, response) @@ -82,7 +90,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { // Check cache for an opaque AAuth-Access token (two-party mode) const cachedAccess = accessCache.get(resourceOrigin) if (cachedAccess) { - const response = await fetchWithAccessToken(url, init, cachedAccess.token, getKeyMaterial) + const response = await fetchWithAccessToken(url, init, cachedAccess.token, getKeyMaterial, onSigned) if (response.status !== 401) { cacheAccessToken(accessCache, resourceOrigin, response) return handleResourceInteraction(response, signedFetch, onInteraction, onClarification) @@ -103,11 +111,16 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { }) } const response = await signedFetch(url, init) + const responseBody = response.status === 401 ? await peekResponseBody(response) : undefined onEvent?.({ step: 'signed_request', phase: 'done', status: response.status, - response: { headers: summarizeResponseHeaders(response.headers) }, + request_headers: sentTracker.latest?.headers, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(responseBody !== undefined ? { body: responseBody } : {}), + }, }) // 200: success — check for AAuth-Access token @@ -152,6 +165,7 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { onClarification, onEvent, getKeyMaterial, + sentTracker, }) // Cache the auth token @@ -170,12 +184,13 @@ export function createAAuthFetch(options: AAuthFetchOptions): FetchLike { auth_token: decodeJwtPayload(result.authToken), }) const retryResponse = await fetchWithAuthToken( - url, init, result.authToken, getKeyMaterial, + url, init, result.authToken, getKeyMaterial, onSigned, ) onEvent?.({ step: 'retry_with_auth_token', phase: 'done', status: retryResponse.status, + request_headers: sentTracker.latest?.headers, response: { headers: summarizeResponseHeaders(retryResponse.headers) }, }) cacheAccessToken(accessCache, resourceOrigin, retryResponse) @@ -247,14 +262,24 @@ async function fetchWithAuthToken( init: RequestInit | undefined, authToken: string, getKeyMaterial: GetKeyMaterial, + onSigned?: (sent: CapturedSent) => void, ): Promise { const { signingKey } = await getKeyMaterial() - const response = await httpSigFetch(url, { + if (onSigned) { + const { response, sent } = await httpSigFetch(url, { + ...init, + signingKey, + signatureKey: { type: 'jwt', jwt: authToken }, + returnSent: true, + }) + onSigned(captureSentFromHttpsig(sent)) + return response + } + return await httpSigFetch(url, { ...init, signingKey, signatureKey: { type: 'jwt', jwt: authToken }, }) - return response as Response } /** @@ -266,6 +291,7 @@ async function fetchWithAccessToken( init: RequestInit | undefined, accessToken: string, getKeyMaterial: GetKeyMaterial, + onSigned?: (sent: CapturedSent) => void, ): Promise { const { signingKey, signatureKey } = await getKeyMaterial() const headers = new Headers(init?.headers) @@ -273,13 +299,23 @@ async function fetchWithAccessToken( const httpSigKey = signatureKey.type === 'jkt-jwt' ? { type: 'jwt' as const, jwt: signatureKey.jwt } : signatureKey - const response = await httpSigFetch(url, { + if (onSigned) { + const { response, sent } = await httpSigFetch(url, { + ...init, + headers, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + onSigned(captureSentFromHttpsig(sent)) + return response + } + return await httpSigFetch(url, { ...init, headers, signingKey, signatureKey: httpSigKey, }) - return response as Response } /** diff --git a/mcp-agent/src/deferred.ts b/mcp-agent/src/deferred.ts index f3895d1..e127d3f 100644 --- a/mcp-agent/src/deferred.ts +++ b/mcp-agent/src/deferred.ts @@ -1,6 +1,6 @@ import { parseAAuthHeader } from './aauth-header.js' import { summarizeResponseHeaders } from './log-helpers.js' -import type { FetchLike, OnEvent } from './types.js' +import type { FetchLike, OnEvent, CapturedSent } from './types.js' export interface DeferredOptions { signedFetch: FetchLike @@ -11,6 +11,8 @@ export interface DeferredOptions { onClarification?: (question: string) => Promise onEvent?: OnEvent maxPollDuration?: number // total timeout in seconds, default 300 + /** Shared sent-request tracker; see TokenExchangeOptions. */ + sentTracker?: { latest?: CapturedSent } } export interface AAuthError { @@ -46,6 +48,7 @@ export async function pollDeferred(options: DeferredOptions): Promise = {} + sent.headers.forEach((value, key) => { headers[key] = value }) + return { + method: sent.method, + url: sent.url, + headers, + body: typeof sent.body === 'string' ? sent.body : undefined, + } +} + +/** + * Read a Response's body as text without consuming it for downstream + * consumers. Uses Response.clone() so the caller can still read body + * afterwards. Returns undefined if the body can't be read as text. + */ +export async function peekResponseBody(response: Response): Promise { + try { + return await response.clone().text() + } catch { + return undefined + } +} diff --git a/mcp-agent/src/signed-fetch.ts b/mcp-agent/src/signed-fetch.ts index 008d4a2..0c43a78 100644 --- a/mcp-agent/src/signed-fetch.ts +++ b/mcp-agent/src/signed-fetch.ts @@ -1,11 +1,37 @@ import { fetch as httpSigFetch } from '@hellocoop/httpsig' +import type { SentRequest } from '@hellocoop/httpsig' import { buildCapabilitiesHeader, buildMissionHeader } from './aauth-header.js' -import type { GetKeyMaterial, FetchLike } from './types.js' +import type { GetKeyMaterial, FetchLike, CapturedSent } from './types.js' import type { Capability, AAuthMission } from './aauth-header.js' export interface SignedFetchOptions { capabilities?: Capability[] mission?: AAuthMission + /** + * Called synchronously after each signed request returns, with the actual + * on-the-wire headers + body. Used by the AAuth flow to capture the + * signed request data for --log rendering. + */ + onSigned?: (sent: CapturedSent) => void +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { out[key] = value }) + return out +} + +function captureSent(sent: SentRequest): CapturedSent { + let body: string | undefined + if (typeof sent.body === 'string') { + body = sent.body + } + return { + method: sent.method, + url: sent.url, + headers: headersToRecord(sent.headers), + body, + } } export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: SignedFetchOptions): FetchLike { @@ -18,6 +44,8 @@ export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: Sign ? { type: 'jwt' as const, jwt: signatureKey.jwt } : signatureKey + const wantSent = !!options?.onSigned + if (hasExtraHeaders) { const headers = new Headers(init?.headers) if (options?.capabilities?.length) { @@ -26,20 +54,39 @@ export function createSignedFetch(getKeyMaterial: GetKeyMaterial, options?: Sign if (options?.mission) { headers.set('aauth-mission', buildMissionHeader(options.mission)) } - const response = await httpSigFetch(url, { + if (wantSent) { + const { response, sent } = await httpSigFetch(url, { + ...init, + headers, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + options!.onSigned!(captureSent(sent)) + return response + } + return await httpSigFetch(url, { ...init, headers, signingKey, signatureKey: httpSigKey, }) - return response as Response } - const response = await httpSigFetch(url, { + if (wantSent) { + const { response, sent } = await httpSigFetch(url, { + ...init, + signingKey, + signatureKey: httpSigKey, + returnSent: true, + }) + options!.onSigned!(captureSent(sent)) + return response + } + return await httpSigFetch(url, { ...init, signingKey, signatureKey: httpSigKey, }) - return response as Response } } diff --git a/mcp-agent/src/token-exchange.ts b/mcp-agent/src/token-exchange.ts index 3efaaa9..0957d42 100644 --- a/mcp-agent/src/token-exchange.ts +++ b/mcp-agent/src/token-exchange.ts @@ -1,9 +1,9 @@ -import type { FetchLike, GetKeyMaterial, OnEvent } from './types.js' +import type { FetchLike, GetKeyMaterial, OnEvent, CapturedSent } from './types.js' import { pollDeferred } from './deferred.js' import type { AAuthError } from './deferred.js' import { parseAAuthHeader } from './aauth-header.js' import { decodeJwtPayload } from './decode-jwt.js' -import { summarizeResponseHeaders, decodeSignatureKey } from './log-helpers.js' +import { summarizeResponseHeaders, decodeSignatureKey, peekResponseBody } from './log-helpers.js' export class TokenExchangeError extends Error { constructor( @@ -38,6 +38,12 @@ export interface TokenExchangeOptions { * outgoing-token visibility under --log. */ getKeyMaterial?: GetKeyMaterial + /** + * Optional: mutable holder that signed-fetch's onSigned callback updates + * after each signed request. exchangeToken reads `.latest` after its own + * signedFetch calls to attach request_headers to :done events. + */ + sentTracker?: { latest?: CapturedSent } } export interface TokenExchangeResult { @@ -74,10 +80,11 @@ export async function exchangeToken(options: TokenExchangeOptions): Promise { const metadataUrl = `${authServerUrl.replace(/\/$/, '')}/.well-known/aauth-person.json` if (onEvent) { @@ -192,11 +209,17 @@ async function fetchMetadata( onEvent({ step: 'ps_metadata_request', phase: 'start', url: metadataUrl, agent_token: agentToken }) } const response = await signedFetch(metadataUrl, { method: 'GET' }) + // Peek body so the rendered card can show the discovered endpoints. + const responseBody = response.ok ? await peekResponseBody(response) : undefined onEvent?.({ step: 'ps_metadata_request', phase: 'done', status: response.status, - response: { headers: summarizeResponseHeaders(response.headers) }, + request_headers: sentTracker?.latest?.headers, + response: { + headers: summarizeResponseHeaders(response.headers), + ...(responseBody !== undefined ? { body: responseBody } : {}), + }, }) if (!response.ok) { diff --git a/mcp-agent/src/types.ts b/mcp-agent/src/types.ts index d5f8677..cbcf326 100644 --- a/mcp-agent/src/types.ts +++ b/mcp-agent/src/types.ts @@ -24,7 +24,27 @@ export type FetchLike = (url: string | URL, init?: RequestInit) => Promise + /** Raw request body (POST/PUT bodies). String form for readability. */ + request_body?: string [key: string]: unknown } export type OnEvent = (event: AAuthEvent) => void + +/** + * The signed request that was sent. Mirrors @hellocoop/httpsig's SentRequest + * but uses a plain Record for headers so it survives JSON serialisation. + */ +export interface CapturedSent { + method: string + url: string + headers: Record + body?: string +} diff --git a/package-lock.json b/package-lock.json index c03cdd0..ece7c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,7 @@ "version": "0.11.3", "license": "MIT", "dependencies": { - "@hellocoop/httpsig": "^1.1.3" + "@hellocoop/httpsig": "^1.6.0" }, "devDependencies": { "@types/node": "^20.0.0", @@ -666,9 +666,9 @@ } }, "node_modules/@hellocoop/httpsig": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-1.5.1.tgz", - "integrity": "sha512-ChC9ZGWS73gj62MlInaYVqvspUvDzzQZkzLDt7ybLrk8opipwrvALpETElgN1NF+EtKG8YPDgV7CMdNRT6MrBg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@hellocoop/httpsig/-/httpsig-1.6.0.tgz", + "integrity": "sha512-KKD1pyoZy9KlgrSkTzurnZaHCcXPt2DvZCxckf/SoNQ4RRgt0RttFOYD6BEB1z1R809G7E6CVJl6kWZyytr1BA==", "license": "MIT", "engines": { "node": ">=18" @@ -802,6 +802,197 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", @@ -3237,197 +3428,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/@napi-rs/keyring-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", - "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-freebsd-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", - "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", - "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", - "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", - "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", - "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-gnu": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", - "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-musl": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", - "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-arm64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", - "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-ia32-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", - "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-x64-msvc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", - "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } }