diff --git a/README.md b/README.md index 73cdd55..bd2c7f8 100644 --- a/README.md +++ b/README.md @@ -285,14 +285,14 @@ Each row below is a subcommand of `agentscore-pay` — invoke as `agentscore-pay ### Identity commands -`passport login`/`status`/`logout` use the public `POST /v1/sessions/public` endpoint and require **no API key**. The other identity commands below (`reputation`, `assess`, `sessions`, `credentials`, `associate-wallet`) wrap the AgentScore SDK — set `AGENTSCORE_API_KEY`. +`passport login`/`status`/`logout` use AgentScore's buyer-side identity flow and require **no API key**. The other identity commands below (`reputation`, `assess`, `sessions`, `credentials`, `associate-wallet`) wrap the AgentScore SDK — set `AGENTSCORE_API_KEY`. AgentScore Passport is free for buyers, forever. AgentScore monetizes sellers/merchants — buyers and agents-as-buyers never pay us. | Command | Purpose | |---|---| | `passport login` | Verify your identity in browser; saves `operator_token` to `~/.agentscore/passport.json`. After login, every `agentscore-pay ` call auto-attaches `X-Operator-Token` (suppress with `--no-passport`). No API key required. | -| `passport status` | Show stored Passport — token prefix, expiry, expired flag | +| `passport status` | Show stored Passport — token prefix, access + refresh expiry, `silent_refresh_available`, `expired` flag | | `passport logout` | Remove the local file (and revoke remotely if `AGENTSCORE_API_KEY` is set; otherwise local-only) | | `reputation
[--chain c]` | Cached trust reputation lookup (no API key required) | | `assess [--address a \| --operator-token o] [--require-kyc] [--min-age N] [--require-sanctions-clear] [--blocked-jurisdictions cc...] [--allowed-jurisdictions cc...] [--refresh]` | On-the-fly assessment with policy (requires API key) | @@ -314,6 +314,15 @@ AgentScore Passport is free for buyers, forever. AgentScore monetizes sellers/me | `quota_exceeded` | `QuotaExceededError` — account-level cap hit | Do NOT retry; surface to the user with https://agentscore.sh/pricing. Use `assess` response's `quota` field to monitor approach-to-cap proactively | | `network_error` | `RateLimitedError` (per-second cap), `TimeoutError`, or any other transient failure | Retry with backoff per `next_steps.suggestion` | +The `pay ` command additionally throws two non-TTY-only codes when an agent (`--json` / MCP / scripted) hits a Passport-required state — instead of blocking up to an hour on the inline browser-redirect flow, pay surfaces a structured envelope so the agent can route to `passport login`: + +| `code` | Thrown when | Extra | Recovery | +|---|---|---|---| +| `passport_login_required` | Stored Passport's access token expired AND silent refresh did not succeed (revoked, network failure, rate-limited, or no refresh_token because the Passport was minted via cold-start bootstrap) | `previous_token_prefix` | Run `agentscore-pay passport login` interactively (one-time browser click) — mints a fresh 24h access + 90d refresh pair; subsequent calls rotate silently for ~90 days | +| `passport_required_by_merchant` | Merchant returned 403 with bootstrap fields (`verify_url` + `session_id` + `poll_secret`) and the agent has no usable stored Passport | `verify_url`, `session_id`, `poll_secret`, `poll_url`, `order_id` | Recommended: `agentscore-pay passport login` first (mints a portable refresh-bearing Passport that satisfies any AgentScore-gated merchant). Alternative: surface `extra.verify_url` to the user; completing it issues a one-shot 24h token tied to that merchant's session (no refresh_token) | + +Both codes only fire on non-TTY runs. In a human terminal pay continues to drive the inline browser-redirect flow itself. + #### Quota observability `assess` (and the other identity commands when the account has a per-period quota) emits the response's `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` headers as a `quota: { limit, used, reset }` block on the success envelope. Agents monitoring approach-to-cap should warn at 80% and alert at 95%. diff --git a/package.json b/package.json index 92a818d..d52ada1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/pay", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI wallet for one-shell-command agent payments across x402 (Base) and MPP (Tempo, Solana)", "type": "module", "main": "./dist/index.js", diff --git a/src/cli.ts b/src/cli.ts index df43847..1715986 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -691,7 +691,7 @@ export function buildCli() { // ── pay ───────────────────────────────────────────────────────────────────── cli.command('pay', { - description: 'Send an HTTP request and auto-handle the 402 payment round-trip', + description: 'Send an HTTP request and auto-handle the 402 payment round-trip; auto-attaches the stored AgentScore Passport (silently rotated from the 90-day refresh credential)', hint: 'ALWAYS pass --max-spend with a USD ceiling. Run with --dry-run first to confirm rail + cost.', args: z.object({ method: z.string().describe('HTTP method'), @@ -955,11 +955,11 @@ export function buildCli() { // ── passport group (AgentScore identity, browser-redirect login) ──────────── const passport = Cli.create('passport', { description: - 'AgentScore Passport — buyer-side identity (KYC + verified facts). Stores an operator_token locally; auto-attached to merchant requests on settle.', + 'AgentScore Passport — buyer-side identity (KYC + verified facts). Stores a 24h access token + 90d refresh credential locally; auto-attached to merchant requests on settle. Pay rotates the access token silently in the background — the user re-verifies in browser only when the refresh credential also expires (~90 days).', }); passport.command('login', { description: - 'Verify identity in your browser and save the resulting operator_token to ~/.agentscore/passport.json.', + 'Verify identity in your browser and save the resulting credential pair (24h access + 90d refresh) to ~/.agentscore/passport.json. Pay rotates the access token silently for ~90 days before another re-verify is needed.', hint: 'No API key required. Opens a verify URL; pay polls until your KYC completes in browser.', options: z.object({ pollIntervalSeconds: z.coerce.number().optional().describe('Poll cadence (default 5s)'), @@ -994,7 +994,7 @@ export function buildCli() { }, }); passport.command('status', { - description: 'Show stored AgentScore Passport — token prefix, expiry, expired flag.', + description: 'Show stored AgentScore Passport — token prefix, access-token expiry, refresh availability, and days until the user has to re-verify in browser.', options: z.object({}), run(c) { return withCliErrors(async () => { diff --git a/src/commands/agent-guide.ts b/src/commands/agent-guide.ts index 7436fff..98c793b 100644 --- a/src/commands/agent-guide.ts +++ b/src/commands/agent-guide.ts @@ -64,12 +64,14 @@ const GUIDE: AgentGuide = { }, { step: '1. (First run only) Verify identity with `passport login`', - why: 'Required for AgentScore-gated merchants (regulated commerce: age-restricted, jurisdiction-restricted, or compliance-gated services). The agent shares the verify URL with the user; the user completes KYC once in the browser; pay saves the operator_token to ~/.agentscore/passport.json. Every subsequent `agentscore-pay ` call auto-attaches `X-Operator-Token`; no per-call prompting. Tokens are short-lived; pay refreshes them silently and drives inline reauth on hard expiry. Skipping this step is fine for unregulated merchants; pay will run anonymous and the merchant\'s 402 will tell you if identity is required.', + why: 'Required for AgentScore-gated merchants (regulated commerce: age-restricted, jurisdiction-restricted, or compliance-gated services). The agent shares the verify URL with the user; the user completes KYC once in the browser; pay saves the operator_token + a long-lived refresh_token to ~/.agentscore/passport.json. Every subsequent `agentscore-pay ` call auto-attaches `X-Operator-Token`; no per-call prompting. Skipping this step is fine for unregulated merchants; pay will run anonymous and the merchant\'s 402 will tell you if identity is required.', command_example: 'agentscore-pay passport login --json', notes: [ 'No API key required. ~30 seconds in browser. No money needed for this step.', - 'If you skip this and later hit an AgentScore-gated merchant, pay drives the same verify flow inline mid-purchase (cold-start bootstrap) — but the resulting Passport lacks a refresh token and re-verifies after 24h. Doing `passport login` first gets the better long-term UX.', + 'Token lifecycle: access token = 24h (auto-rotated via the refresh_token, which is 90d). Pay refreshes silently on the next call after access expires — no agent action required. Re-verify in browser is needed only when both have expired, i.e. when the agent has been offline for ~90 days.', +'If you skip step 1 and a merchant 403 mid-purchase forces inline bootstrap from a merchant-supplied session (verify_url + session_id + poll_secret in the 403 body), the resulting Passport carries an access token but no refresh_token — that path re-verifies after 24h. Bootstrap-from-stored-expiry (pay falling through to `passport login` after a fully-expired stored Passport) still mints a refresh-bearing pair. Doing `passport login` first up front avoids both edge cases and gets the 90-day silent-refresh UX.', 'Caller-supplied `-H "X-Operator-Token: ..."` always wins over the stored Passport. Use `--no-passport` for explicit-anonymous traffic.', + 'When pay needs to re-verify (refresh failed AND access expired): in a non-TTY context (agent, --json, MCP, scripted) pay throws `code: passport_login_required` with `next_steps.action: passport_login` immediately rather than blocking on a browser flow. Run `agentscore-pay passport login` interactively to mint a fresh pair, then re-run the original command. In a human TTY, pay drives the inline browser-redirect flow itself and prints `Open this URL to renew:` on stderr — surface the URL verbatim if you proxy it; do not fabricate one.', ], }, { @@ -185,9 +187,11 @@ const GUIDE: AgentGuide = { }, { step: 'Inspect / renew the stored Passport with `passport status` / `passport login`', - why: 'After the initial `passport login` (golden-path step 1), most flows are zero-touch — silent refresh keeps the token fresh. Use `passport status` to inspect what\'s saved (token prefix, expiry, expired flag); re-run `passport login` if expired or to re-mint after `passport logout`.', + why: 'After the initial `passport login` (golden-path step 1), most flows are zero-touch — silent refresh keeps the token fresh. Use `passport status` to inspect what\'s saved; re-run `passport login` only when the agent has been offline beyond the refresh window (i.e. when `silent_refresh_available` is false).', command_example: 'agentscore-pay passport status --json', notes: [ + '`passport status` returns `{ authenticated, operator_token_prefix, expires_at, expires_in_days, expired, silent_refresh_available, refresh_expires_at, refresh_expires_in_days }`. The access fields (`expires_*`) are short-lived (~24h) and rotate silently; do not surface "expires in 0 days" to the user as an actionable warning. The refresh fields (`refresh_expires_*`) are the meaningful re-verify horizon — that\'s when the user actually has to do something.', + '`silent_refresh_available: true` means pay will rotate the access token automatically on the next call when it expires; agent has nothing to do. `silent_refresh_available: false` (legacy passport, or merchant-mint cold-start without refresh_token) means the next access expiry forces a verify-URL prompt.', 'Caller-supplied `-H "X-Operator-Token: ..."` always wins over the stored Passport, so existing scripts keep working.', 'Non-AgentScore merchants ignore the header — auto-attach is harmless on those endpoints.', 'Use `--no-passport` on `agentscore-pay ` for explicit-anonymous traffic.', @@ -219,6 +223,22 @@ const GUIDE: AgentGuide = { ], identity_error_recovery: [ + { + cli_code: 'passport_login_required', + thrown_when: + 'Stored AgentScore Passport access token has expired AND silent refresh did not succeed (refresh_token revoked, network failure, rate-limited, or no refresh_token at all because the Passport was minted via a merchant 403 cold-start). Only thrown in non-TTY contexts (--json, MCP, scripted, piped); a human TTY drives the inline browser flow instead.', + next_action: 'passport_login', + recovery: + 'Run `agentscore-pay passport login` interactively (one-time browser click) to mint a fresh access + refresh credential pair, then re-run the original command. The new credential lasts ~90 days before another re-verify is needed. `extra.previous_token_prefix` identifies which stored Passport was rejected, when the agent juggles multiple environments.', + }, + { + cli_code: 'passport_required_by_merchant', + thrown_when: + 'Merchant returned a 403 with bootstrap fields (verify_url + session_id + poll_secret) and the agent has no usable stored Passport. Only thrown in non-TTY contexts; a human TTY drives the inline browser flow instead. Symmetric to passport_login_required but covers the cold-start case where the agent never logged in to begin with.', + next_action: 'passport_login', + recovery: + 'Recommended: run `agentscore-pay passport login` first — mints a portable refresh-bearing Passport that satisfies any AgentScore-gated merchant going forward, no per-merchant re-verify. Alternative: surface `extra.verify_url` to the user verbatim; completing it issues a one-shot 24h token tied to that merchant\'s session (no refresh_token, so the next AgentScore-gated merchant will hit the same flow again).', + }, { cli_code: 'config_error', thrown_when: diff --git a/src/commands/passport.ts b/src/commands/passport.ts index c383766..cdd14d5 100644 --- a/src/commands/passport.ts +++ b/src/commands/passport.ts @@ -21,8 +21,37 @@ export interface PassportLoginInput { export interface PassportLoginOutput { ok: true; operator_token_prefix: string; + /** Access-token expiry (24h). Pay rotates this silently via the refresh_token. */ expires_at: string; + /** Days until the access token expires. After it expires, pay refreshes silently. */ expires_in_days: number; + /** Whether the passport has a refresh_token (i.e. silent refresh is available). */ + silent_refresh_available: boolean; + /** Refresh-token expiry — when the user actually has to re-verify in browser. Absent for legacy / merchant-mint passports. */ + refresh_expires_at?: string; + /** Days until the user has to re-verify in browser. Absent when refresh isn't available. */ + refresh_expires_in_days?: number; +} + +function buildPassportSummary(passport: import('../passport/storage').Passport) { + const now = Date.now(); + const hasRefresh = !!passport.refresh_token && passport.refresh_expires_at != null; + const refreshAlive = hasRefresh && (passport.refresh_expires_at as number) > now; + return { + operator_token_prefix: passport.operator_token.slice(0, 8) + '…', + expires_at: new Date(passport.expires_at).toISOString(), + expires_in_days: expiresInDays(passport, now), + silent_refresh_available: refreshAlive, + ...(hasRefresh + ? { + refresh_expires_at: new Date(passport.refresh_expires_at as number).toISOString(), + refresh_expires_in_days: Math.max( + 0, + Math.floor(((passport.refresh_expires_at as number) - now) / (24 * 60 * 60 * 1000)), + ), + } + : {}), + }; } export async function passportLoginCommand(input: PassportLoginInput = {}): Promise { @@ -36,20 +65,15 @@ export async function passportLoginCommand(input: PassportLoginInput = {}): Prom }); return { ok: true, - operator_token_prefix: passport.operator_token.slice(0, 8) + '…', - expires_at: new Date(passport.expires_at).toISOString(), - expires_in_days: expiresInDays(passport), + ...buildPassportSummary(passport), }; } export type PassportStatusOutput = - | { + | ({ authenticated: true; - operator_token_prefix: string; - expires_at: string; - expires_in_days: number; expired: boolean; - } + } & ReturnType) | { authenticated: false }; export async function passportStatusCommand(): Promise { @@ -57,10 +81,8 @@ export async function passportStatusCommand(): Promise { if (!passport) return { authenticated: false }; return { authenticated: true, - operator_token_prefix: passport.operator_token.slice(0, 8) + '…', - expires_at: new Date(passport.expires_at).toISOString(), - expires_in_days: expiresInDays(passport), expired: isExpired(passport), + ...buildPassportSummary(passport), }; } diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 246851e..8a44904 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -112,6 +112,29 @@ export async function pay(input: PayInput): Promise { // Live path only: drive inline reauth on expired Passport. Dry-run leaves // `kind: 'expired'` visible so the user sees what would have happened. if (passportAttach.kind === 'expired' && !input.dryRun) { + // Non-TTY callers (agents in --json mode, MCP, scripted contexts) shouldn't + // block up to an hour waiting for a human to click a verify URL. Surface a + // structured envelope so the agent can route to `passport login` + // interactively, prompt the user out-of-band, or surface the error to the + // operator. Humans at a terminal still get the inline browser-redirect. + if (!process.stdout.isTTY) { + throw new CliError( + 'passport_login_required', + 'Stored AgentScore Passport access expired and silent refresh did not succeed; this run is non-interactive so pay cannot drive the browser verify flow.', + { + nextSteps: { + action: 'passport_login', + suggestion: + 'Run `agentscore-pay passport login` interactively (one-time browser click) to mint a fresh access + refresh credential, then re-run this command. The new credential lasts ~90 days before another re-verify.', + }, + extra: { + previous_token_prefix: passportAttach.passport + ? passportAttach.passport.operator_token.slice(0, 8) + '…' + : undefined, + }, + }, + ); + } process.stderr.write('Stored Passport has expired — re-verifying (KYC stays valid, this is a one-click renewal)...\n'); let printedVerifyUrl = false; const renewal = await bootstrapFromExpiry({ @@ -280,6 +303,32 @@ export async function pay(input: PayInput): Promise { const callerAlreadyHadIdentity = userHeaderKeysAll.includes('x-operator-token'); const passportAlreadyAttached = passportAttach.kind === 'attached'; if (bootstrapFields && !callerAlreadyHadIdentity && !passportAlreadyAttached && !input.noPassport) { + // Same UX-cliff treatment as the expired-stored-Passport path above: + // non-TTY agents shouldn't block ~1h on the inline browser flow. Surface + // a structured envelope with the merchant-supplied verify_url + session + // fields so the agent can either run `passport login` interactively + // (recommended — mints a refresh-bearing Passport that prevents this + // round-trip on subsequent calls) or proxy the merchant URL out-of-band. + if (!process.stdout.isTTY) { + throw new CliError( + 'passport_required_by_merchant', + 'Merchant requires AgentScore Passport identity verification, and this run is non-interactive so pay cannot drive the browser verify flow.', + { + nextSteps: { + action: 'passport_login', + suggestion: + 'Recommended: run `agentscore-pay passport login` interactively to mint a fresh access + refresh credential, then re-run this command — the new credential lasts ~90 days and prevents this round-trip on subsequent merchants. Alternative: surface the merchant-supplied verify_url to the user; completing it issues a one-shot 24h token tied to this merchant\'s session.', + }, + extra: { + verify_url: bootstrapFields.verify_url, + session_id: bootstrapFields.session_id, + poll_secret: bootstrapFields.poll_secret, + ...(bootstrapFields.poll_url ? { poll_url: bootstrapFields.poll_url } : {}), + ...(bootstrapFields.order_id ? { order_id: bootstrapFields.order_id } : {}), + }, + }, + ); + } process.stderr.write('Merchant requires identity verification — bootstrapping inline...\n'); let printedVerifyUrl = false; const renewal = await bootstrapFromMerchantSession(bootstrapFields, { diff --git a/src/errors.ts b/src/errors.ts index e6441b3..32f7c3d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -25,6 +25,8 @@ export type ErrorCode = | 'passport_verification_failed' | 'passport_verification_timeout' | 'passport_token_expired' + | 'passport_login_required' + | 'passport_required_by_merchant' | 'unknown'; export const EXIT_CODES = { diff --git a/src/passport/attach.ts b/src/passport/attach.ts index 84ac2e8..a8adafa 100644 --- a/src/passport/attach.ts +++ b/src/passport/attach.ts @@ -7,14 +7,27 @@ import { isExpired, loadPassport, type Passport } from './storage'; * X-Operator-Token always wins. */ -const REFRESH_THRESHOLD_MS = 60 * 1000; +/** + * Proactive-refresh trigger window. Fire silent refresh when the access + * token is within this window of expiry, even if it hasn't expired yet — + * gives clock-skew + on-the-wire-latency headroom so a token doesn't + * expire between attach and merchant validation. Distinct from the + * reactive case (access already expired), which always attempts refresh + * when the refresh_token is still valid. + */ +const REFRESH_THRESHOLD_MS = 5 * 60 * 1000; export interface AttachResult { kind: 'attached' | 'expired' | 'absent' | 'opted_out'; passport?: Passport; /** Header value to set as `X-Operator-Token`, when kind === 'attached'. */ operatorToken?: string; - /** True when expires_at - now < 5 days (informational warning, not a block). */ + /** + * Informational warning that the *user* needs to re-verify in browser + * soon. False when a refresh_token is still comfortably valid (pay + * will rotate silently — no user action). True only when access is + * near expiry AND refresh is unavailable or also near expiry. + */ expiringSoon?: boolean; } @@ -46,15 +59,19 @@ export async function attachPassport(input: AttachInput = {}): Promise now); - if (accessNearExpiry && hasUsableRefresh && !input.skipRefresh) { + + // Try silent refresh in two cases: + // - Reactive: access has already expired but refresh_token is still + // valid (the dominant real-world case — agent comes back after the + // 24h access TTL but well within the 90d refresh TTL). + // - Proactive: access within REFRESH_THRESHOLD_MS of expiry; rotates + // before the merchant sees a near-expired token. + if ((accessExpired || accessNearExpiry) && hasUsableRefresh && !input.skipRefresh) { try { passport = await refreshAccessToken({ refreshToken: passport.refresh_token!, @@ -62,11 +79,27 @@ export async function attachPassport(input: AttachInput = {}): Promise SOFT_EXPIRY_WINDOW_MS); + const expiringSoon = + !refreshWillSaveUs && passport.expires_at - now < SOFT_EXPIRY_WINDOW_MS; return { kind: 'attached', passport, diff --git a/tests/passport-attach.test.ts b/tests/passport-attach.test.ts index acd7199..c3d28a2 100644 --- a/tests/passport-attach.test.ts +++ b/tests/passport-attach.test.ts @@ -85,13 +85,53 @@ describe('passport/attach', () => { expect(result.expiringSoon).toBe(false); }); + it('expiringSoon=false even with short-lived access when refresh_token is comfortably valid', async () => { + // Post-silent-refresh-fix: access tokens get rotated to 24h on every + // refresh. If the user-actionable warning fired on every pay call + // (because 24h < 5d), it would be misleading — the user does NOT + // need to re-verify; pay refreshes silently. expiringSoon should + // reflect "user action needed", not just access remaining life. + const now = Date.now(); + await savePassport({ + version: 1, + operator_token: 'opc_short_access_long_refresh', + expires_at: now + 24 * 60 * 60 * 1000, // 24h, well inside 5d window + saved_at: now, + refresh_token: 'prt_test', + refresh_expires_at: now + 90 * 24 * 60 * 60 * 1000, // 90d + }); + const result = await attachPassport({ now }); + expect(result.kind).toBe('attached'); + expect(result.expiringSoon).toBe(false); + }); + + it('expiringSoon=true when access AND refresh both near expiry (user must re-verify)', async () => { + // The case where the warning is genuinely useful: refresh is about + // to expire too, so the user actually needs to passport login again. + const now = Date.now(); + await savePassport({ + version: 1, + operator_token: 'opc_access_near_end', + expires_at: now + 4 * 24 * 60 * 60 * 1000, // 4d + saved_at: now, + refresh_token: 'prt_also_near_end', + refresh_expires_at: now + 3 * 24 * 60 * 60 * 1000, // 3d — within 5d window + }); + const result = await attachPassport({ now }); + expect(result.kind).toBe('attached'); + expect(result.expiringSoon).toBe(true); + }); + describe('silent refresh', () => { function withRefresh(overrides: Partial = {}): Passport { const now = Date.now(); return { version: 1, operator_token: 'opc_about_to_expire', - expires_at: now + 30 * 1000, // 30s left — within REFRESH_THRESHOLD_MS (60s) + // Default: access token within the proactive refresh window + // (REFRESH_THRESHOLD_MS = 5 min). Override per-test to exercise + // the reactive path (negative remaining life) or far-from-expiry. + expires_at: now + 30 * 1000, saved_at: now, refresh_token: 'prt_test_refresh_token', refresh_expires_at: now + 90 * 24 * 60 * 60 * 1000, @@ -99,9 +139,8 @@ describe('passport/attach', () => { }; } - it('silently refreshes when access token is within 60s of expiry and saves the new pair', async () => { - const calls: string[] = []; - const fetchMock = vi.fn(async (url: string | URL) => { + function refreshSuccessFetch(calls: string[]): typeof globalThis.fetch { + return vi.fn(async (url: string | URL) => { calls.push(url.toString()); return new Response( JSON.stringify({ @@ -113,14 +152,20 @@ describe('passport/attach', () => { { status: 200, headers: { 'Content-Type': 'application/json' } }, ); }) as unknown as typeof globalThis.fetch; + } - const stored = withRefresh(); - await savePassport(stored); + // ── Proactive: access still valid but within REFRESH_THRESHOLD_MS ── + + it('proactively refreshes when access is within the threshold of expiry', async () => { + const calls: string[] = []; + const fetchMock = refreshSuccessFetch(calls); + + // 30s remaining is well inside REFRESH_THRESHOLD_MS (5 min). + await savePassport(withRefresh()); const result = await attachPassport({ fetch: fetchMock }); expect(result.kind).toBe('attached'); expect(result.operatorToken).toBe('opc_freshly_minted'); - // Hit /v1/sessions/refresh once. expect(calls.filter((c) => c.endsWith('/v1/sessions/refresh'))).toHaveLength(1); // Disk got the new pair. @@ -129,7 +174,33 @@ describe('passport/attach', () => { expect(reloaded?.refresh_token).toBe('prt_freshly_rotated'); }); - it('does NOT refresh when access token is comfortably away from expiry', async () => { + // ── Reactive: access has already expired but refresh_token is valid ── + // This is the dominant real-world case — agent comes back after the 24h + // access TTL but well within the 90d refresh TTL. Pre-fix, this path + // short-circuited to 'expired' and forced bootstrap reauth. + + it('reactively refreshes when access already expired but refresh_token still valid', async () => { + const calls: string[] = []; + const fetchMock = refreshSuccessFetch(calls); + const now = Date.now(); + + // Access expired 1 hour ago; refresh_token still valid for 90 days. + await savePassport( + withRefresh({ + expires_at: now - 60 * 60 * 1000, + refresh_expires_at: now + 90 * 24 * 60 * 60 * 1000, + }), + ); + + const result = await attachPassport({ fetch: fetchMock, now }); + expect(result.kind).toBe('attached'); + expect(result.operatorToken).toBe('opc_freshly_minted'); + expect(calls.filter((c) => c.endsWith('/v1/sessions/refresh'))).toHaveLength(1); + }); + + // ── No refresh attempted ── + + it('does NOT refresh when access is comfortably away from expiry', async () => { const fetchMock = vi.fn() as unknown as typeof globalThis.fetch; const now = Date.now(); await savePassport(withRefresh({ expires_at: now + 24 * 60 * 60 * 1000 })); @@ -155,33 +226,66 @@ describe('passport/attach', () => { expect((fetchMock as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(0); }); - it('does NOT refresh when the refresh_token itself has expired — surfaces as expired', async () => { + it('does NOT refresh when refresh_token itself has expired (access still nominally valid)', async () => { const fetchMock = vi.fn() as unknown as typeof globalThis.fetch; const now = Date.now(); - await savePassport(withRefresh({ - expires_at: now + 30 * 1000, - refresh_expires_at: now - 1000, - })); + await savePassport( + withRefresh({ + expires_at: now + 30 * 1000, + refresh_expires_at: now - 1000, + }), + ); const result = await attachPassport({ fetch: fetchMock, now }); - // Access token is still nominally valid, but refresh window has closed — - // we still attach (access_expires_at hasn't hit zero yet) without a refresh. - // On the next call when access fully expires, we'll fall through to reauth. + // Access still has 30s — attach uses it. When access expires next + // time, this same hasUsableRefresh=false path falls through to expired. expect(result.kind).toBe('attached'); expect((fetchMock as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(0); }); - it('falls through to expired when refresh fails (revoked / network)', async () => { - const fetchMock = vi.fn(async () => new Response( - JSON.stringify({ error: { code: 'refresh_token_revoked', message: 'Revoked' } }), - { status: 401, headers: { 'Content-Type': 'application/json' } }, - )) as unknown as typeof globalThis.fetch; + // ── Refresh failure paths ── - await savePassport(withRefresh()); + it('proactive refresh failure with still-valid access → uses the existing access (graceful)', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ error: { code: 'refresh_token_revoked', message: 'Revoked' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof globalThis.fetch; + const now = Date.now(); - const result = await attachPassport({ fetch: fetchMock }); + // Access has 30s of life, refresh attempt fails. Better to use the + // still-valid access than to drive bootstrap eagerly on a transient + // refresh failure. + await savePassport(withRefresh({ expires_at: now + 30 * 1000 })); + + const result = await attachPassport({ fetch: fetchMock, now }); + expect(result.kind).toBe('attached'); + expect(result.operatorToken).toBe('opc_about_to_expire'); + }); + + it('reactive refresh failure with already-expired access → returns expired', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ error: { code: 'refresh_token_revoked', message: 'Revoked' } }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof globalThis.fetch; + const now = Date.now(); + + // Access expired AND refresh fails — caller (pay.ts) now drives + // inline bootstrap reauth via the verify-URL flow. + await savePassport( + withRefresh({ + expires_at: now - 60 * 60 * 1000, + refresh_expires_at: now + 90 * 24 * 60 * 60 * 1000, + }), + ); + + const result = await attachPassport({ fetch: fetchMock, now }); expect(result.kind).toBe('expired'); - // Caller (pay.ts) should now drive inline reauth. }); it('honors skipRefresh flag (testing surface)', async () => { diff --git a/tests/pay-passport-expired.test.ts b/tests/pay-passport-expired.test.ts new file mode 100644 index 0000000..ffefc92 --- /dev/null +++ b/tests/pay-passport-expired.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { pay } from '../src/commands/pay'; + +vi.mock('../src/selection', () => ({ + selectRail: vi.fn().mockResolvedValue({ + chain: 'base', + address: '0x1234567890123456789012345678901234567890', + balance_usdc: '5.000000', + }), +})); + +vi.mock('../src/passport/attach', () => ({ + attachPassport: vi.fn().mockResolvedValue({ + kind: 'expired', + passport: { + version: 1, + operator_token: 'opc_expiredtoken_abc123', + expires_at: Date.now() - 1000, + saved_at: Date.now() - 24 * 60 * 60 * 1000, + }, + }), +})); + +vi.mock('../src/passport/bootstrap', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Throw fast on TTY-path so the bootstrap-driven test doesn't poll for an hour. + bootstrapFromExpiry: vi.fn().mockRejectedValue( + Object.assign(new Error('mock bootstrap unavailable'), { code: 'mock_bootstrap_blocked' }), + ), + }; +}); + +// Note: the symmetric merchant-403 cold-start path (passport_required_by_merchant) +// applies the exact same `!process.stdout.isTTY` check immediately before +// `bootstrapFromMerchantSession`. Unit-testing it requires mocking the entire +// x402/MPP request flow (rail-specific clients wrapping fetch) to return a 403 +// with bootstrap fields, which is heavier than the path's structure warrants. +// The structural symmetry with the expired-access tests below + live smoke against +// martin-estate covers the contract. + +function withTTY(value: boolean, fn: () => Promise): Promise { + const origDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true }); + return fn().finally(() => { + if (origDescriptor) { + Object.defineProperty(process.stdout, 'isTTY', origDescriptor); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } + }); +} + +describe('pay — expired passport on non-TTY', () => { + it('throws passport_login_required with action=passport_login when stdout is not a TTY', async () => { + await withTTY(false, async () => { + await expect( + pay({ method: 'POST', url: 'https://m.example/x', maxSpendUsd: 5 }), + ).rejects.toMatchObject({ + code: 'passport_login_required', + nextSteps: { action: 'passport_login' }, + }); + }); + }); + + it('exposes the previous token prefix in extra so the agent can identify which passport was rejected', async () => { + await withTTY(false, async () => { + try { + await pay({ method: 'POST', url: 'https://m.example/x', maxSpendUsd: 5 }); + throw new Error('expected pay() to throw'); + } catch (err) { + expect(err).toMatchObject({ + code: 'passport_login_required', + extra: { previous_token_prefix: 'opc_expi…' }, + }); + } + }); + }); + + it('skips the structured throw and drives inline bootstrap when stdout IS a TTY', async () => { + // On TTY, pay should NOT throw passport_login_required — it falls through to + // bootstrapFromExpiry. That call ultimately fails in the test environment (no + // real /v1/sessions/public), so the resulting error code is anything BUT + // passport_login_required — proving the TTY branch took the bootstrap path. + await withTTY(true, async () => { + const result = await pay({ method: 'POST', url: 'https://m.example/x', maxSpendUsd: 5 }) + .then(() => ({ ok: true, code: undefined as string | undefined })) + .catch((err: { code?: string }) => ({ ok: false, code: err.code })); + expect(result.code).not.toBe('passport_login_required'); + }); + }); + + it('skips the structured throw on dry-run regardless of TTY (dry-run leaves kind: expired visible)', async () => { + await withTTY(false, async () => { + const result = await pay({ + method: 'POST', + url: 'https://m.example/x', + maxSpendUsd: 5, + dryRun: true, + }); + expect(result).toMatchObject({ dry_run: true }); + }); + }); +});