From 253fbb015688f5b2698e678aecbecf726cd4e588 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:16:50 -0700 Subject: [PATCH 1/9] fix(passport): silent refresh on expired access token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in the silent-refresh path were silently forcing verify-URL prompts after every 24h of agent inactivity, defeating the 90-day refresh_token UX the system was designed for. Bug A (the one users hit). When the access token had expired, the attach handler short-circuited and returned `kind: 'expired'` BEFORE attempting to use the still-valid refresh_token. The caller then drove bootstrap reauth via the verify-URL flow, even though a silent exchange would have worked. Bug B (latent). The proactive refresh window was 60 seconds — unreachably short on a 24h access TTL. Bumped to 5 minutes (covers clock-skew + on-the-wire-latency without over-rotating). Restructured `attachPassport` to evaluate accessExpired vs accessNearExpiry up-front, then attempt refresh in either case when the refresh_token is still valid. Only after refresh has been tried (or skipped) do we surface `kind: 'expired'`, and only when access is genuinely expired post-attempt — the path that drives bootstrap. Refresh failures are now graceful when access is still valid: proactive failure with 30s remaining attaches the existing token rather than driving eager bootstrap. Reactive failure with already- expired access surfaces 'expired' and bootstraps as before. Tests rewritten. Old tests pinned the broken 60s window and the early short-circuit; new tests cover both reactive and proactive paths plus the failure modes (revoked refresh, legacy passport without refresh, refresh_token expired). Net UX after this fix: re-verify needed approximately every 90 days (when refresh_token TTL expires), not every 24h. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/passport/attach.ts | 34 +++++++--- tests/passport-attach.test.ts | 115 +++++++++++++++++++++++++++------- 2 files changed, 117 insertions(+), 32 deletions(-) diff --git a/src/passport/attach.ts b/src/passport/attach.ts index 84ac2e8..10aac55 100644 --- a/src/passport/attach.ts +++ b/src/passport/attach.ts @@ -7,7 +7,15 @@ 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'; @@ -46,15 +54,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,10 +74,16 @@ export async function attachPassport(input: AttachInput = {}): Promise { 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 +102,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 +115,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 +137,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 +189,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 () => { From 8c067a191adc7f7d21f1d179259f51f4fe0d2bc1 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:21:42 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix(passport):=20downstream=20effects=20of?= =?UTF-8?q?=20silent-refresh=20=E2=80=94=20expiringSoon,=20status=20output?= =?UTF-8?q?,=20agent-guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-on changes the silent-refresh fix forced. 1. expiringSoon predicate After silent refresh works, every refresh issues a 24h access token, so `(access_remaining < 5d)` is always true on a freshly-refreshed passport — the misleading `Passport expires soon — run \`passport login\` to renew` warning would print on every pay call. Tightened the predicate to gate on refresh state: expiringSoon=true only when refresh isn't going to bail us out (no refresh_token, or refresh itself within the 5-day window). The warning now fires only when the user genuinely needs to re-verify in browser. 2. passport status output Was reporting only access-token expiry — agents reading `expires_in_days: 0` would think they had to act when actually 89 days of effective life remain via refresh. Extended the output with `silent_refresh_available`, `refresh_expires_at`, `refresh_expires_in_days`. Same shape extension to passport login output (via a shared `buildPassportSummary` helper). 3. agent-guide The auxiliary section on `passport status` now describes the new fields explicitly, plus an unambiguous instruction: do not surface "expires in 0 days" as an actionable warning when silent_refresh_available is true. Step 1 already gained the refresh-token lifecycle clarification in the prior commit on this branch. Tests: two new cases on expiringSoon behavior — false with short-lived access + valid refresh, true when both are within the 5d window. Existing passport-commands tests still pass against the extended status shape (the new fields are additive). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/agent-guide.ts | 10 +++++--- src/commands/passport.ts | 44 ++++++++++++++++++++++++++--------- src/passport/attach.ts | 19 +++++++++++++-- tests/passport-attach.test.ts | 37 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/commands/agent-guide.ts b/src/commands/agent-guide.ts index 7436fff..b85ec52 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 let a merchant 403 trigger the bootstrap mid-purchase (cold-start), the resulting passport is sourced from the merchant-minted session and does NOT carry a refresh_token — that flow re-verifies after 24h. Doing `passport login` first 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 does need to re-verify, you (the agent) will see `code: passport_token_expired` or the inline `Open this URL to renew:` prompt on stderr. Surface the verify URL to the user verbatim; do not fabricate one. The flow polls until the user completes the browser step.', ], }, { @@ -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.', 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/passport/attach.ts b/src/passport/attach.ts index 10aac55..a8adafa 100644 --- a/src/passport/attach.ts +++ b/src/passport/attach.ts @@ -22,7 +22,12 @@ export interface AttachResult { 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; } @@ -84,7 +89,17 @@ 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 d63879f..c3d28a2 100644 --- a/tests/passport-attach.test.ts +++ b/tests/passport-attach.test.ts @@ -85,6 +85,43 @@ 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(); From 5a31a624bf3cf5705221d8f0a006ed36fa5a38aa Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:21:57 -0700 Subject: [PATCH 3/9] chore: bump to 0.1.1 Patch release for the silent-refresh fix + downstream effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From fb4b62da4b1b4010cb128726e95dad36833ba30b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:26:36 -0700 Subject: [PATCH 4/9] docs(readme): passport status row mentions new refresh fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects the extended PassportStatusOutput shape from this release — silent_refresh_available, refresh_expires_at, refresh_expires_in_days. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73cdd55..74959cd 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ AgentScore Passport is free for buyers, forever. AgentScore monetizes sellers/me | 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) | From 179ee54685493700726c19de92eba0d8e8bae930 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:30:26 -0700 Subject: [PATCH 5/9] docs(agent-guide): clarify merchant-bootstrap vs expiry-bootstrap refresh-token issuance --- src/commands/agent-guide.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/agent-guide.ts b/src/commands/agent-guide.ts index b85ec52..8902edb 100644 --- a/src/commands/agent-guide.ts +++ b/src/commands/agent-guide.ts @@ -69,7 +69,7 @@ const GUIDE: AgentGuide = { notes: [ 'No API key required. ~30 seconds in browser. No money needed for this step.', '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 let a merchant 403 trigger the bootstrap mid-purchase (cold-start), the resulting passport is sourced from the merchant-minted session and does NOT carry a refresh_token — that flow re-verifies after 24h. Doing `passport login` first gets the 90-day silent-refresh UX.', +'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 is rooted in a merchant-initiated session and does NOT carry a refresh_token — that flow re-verifies after 24h. Bootstrap-from-stored-expiry (the case where pay just falls through to `passport login` after a fully-expired stored passport) still mints a refresh-bearing pair via `/v1/sessions/public`. 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 does need to re-verify, you (the agent) will see `code: passport_token_expired` or the inline `Open this URL to renew:` prompt on stderr. Surface the verify URL to the user verbatim; do not fabricate one. The flow polls until the user completes the browser step.', ], From 75575ff02a9944b13ea53e6f33ae1b5461cd11bb Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 08:33:32 -0700 Subject: [PATCH 6/9] docs: drop internal endpoint references from public-facing copy --- README.md | 2 +- src/commands/agent-guide.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74959cd..1580ab9 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ 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. diff --git a/src/commands/agent-guide.ts b/src/commands/agent-guide.ts index 8902edb..f957918 100644 --- a/src/commands/agent-guide.ts +++ b/src/commands/agent-guide.ts @@ -69,7 +69,7 @@ const GUIDE: AgentGuide = { notes: [ 'No API key required. ~30 seconds in browser. No money needed for this step.', '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 is rooted in a merchant-initiated session and does NOT carry a refresh_token — that flow re-verifies after 24h. Bootstrap-from-stored-expiry (the case where pay just falls through to `passport login` after a fully-expired stored passport) still mints a refresh-bearing pair via `/v1/sessions/public`. Doing `passport login` first up front avoids both edge cases and gets the 90-day silent-refresh UX.', +'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 does need to re-verify, you (the agent) will see `code: passport_token_expired` or the inline `Open this URL to renew:` prompt on stderr. Surface the verify URL to the user verbatim; do not fabricate one. The flow polls until the user completes the browser step.', ], From 35bca8de9e04952bf411a91e915674c8b89f3d8a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 09:22:33 -0700 Subject: [PATCH 7/9] feat(passport): surface refresh-aware context across all agent surfaces Two changes: 1. New `passport_login_required` envelope: when a stored Passport's access token has expired AND silent refresh did not succeed, non-TTY callers (agents in --json, MCP, scripted contexts) now get a structured { code: passport_login_required, next_steps.action: passport_login } envelope instead of blocking up to an hour on the inline browser-redirect. Humans at a terminal still get the inline bootstrap. Aligns with the API's own /v1/sessions/refresh failure message ("Drive the inline reauth flow to mint a new Passport"). 2. MCP descriptions across the passport group + `pay` now mention the 24h+90d lifecycle and silent rotation, so an agent reading tool docs (rather than `agent-guide`) still gets the picture. agent-guide step 1 + identity_error_recovery catalog updated with the new envelope. Tests: 4 new in pay-passport-expired.test.ts (442 total, +4). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli.ts | 8 +-- src/commands/agent-guide.ts | 10 ++- src/commands/pay.ts | 23 +++++++ src/errors.ts | 1 + tests/pay-passport-expired.test.ts | 97 ++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 tests/pay-passport-expired.test.ts 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 f957918..e5a8024 100644 --- a/src/commands/agent-guide.ts +++ b/src/commands/agent-guide.ts @@ -71,7 +71,7 @@ const GUIDE: AgentGuide = { '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 does need to re-verify, you (the agent) will see `code: passport_token_expired` or the inline `Open this URL to renew:` prompt on stderr. Surface the verify URL to the user verbatim; do not fabricate one. The flow polls until the user completes the browser step.', + '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.', ], }, { @@ -223,6 +223,14 @@ 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: 'config_error', thrown_when: diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 246851e..5b580f4 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({ diff --git a/src/errors.ts b/src/errors.ts index e6441b3..75ffa82 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -25,6 +25,7 @@ export type ErrorCode = | 'passport_verification_failed' | 'passport_verification_timeout' | 'passport_token_expired' + | 'passport_login_required' | 'unknown'; export const EXIT_CODES = { diff --git a/tests/pay-passport-expired.test.ts b/tests/pay-passport-expired.test.ts new file mode 100644 index 0000000..c380ff1 --- /dev/null +++ b/tests/pay-passport-expired.test.ts @@ -0,0 +1,97 @@ +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' }), + ), + }; +}); + +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 }); + }); + }); +}); From 8f819e8ec1edf5c7cbc76deda6e74c396e186137 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 09:30:52 -0700 Subject: [PATCH 8/9] feat(pay): symmetric non-TTY envelope on merchant 403 cold-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same UX-cliff fix as the expired-access path: when a merchant returns 403 with bootstrap fields (verify_url + session_id + poll_secret) and the agent has no usable Passport, non-TTY callers now get a structured { code: passport_required_by_merchant, next_steps.action: passport_login } envelope with the merchant URL in extra, instead of blocking ~1h on the inline browser flow. Humans at a terminal still drive bootstrap inline. Verified shape against the canonical commerce SDK denial body — every node-commerce + python-commerce middleware variant (express, hono, fastify, nextjs, web, fastapi, flask, django, aiohttp, sanic, asgi) routes through the shared denialReasonToBody / denial_reason_to_body which emits verify_url + session_id + poll_secret at the top level. Pay's detectMerchantBootstrap reads top-level fields — matches all 11. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/agent-guide.ts | 8 ++++++++ src/commands/pay.ts | 26 ++++++++++++++++++++++++++ src/errors.ts | 1 + tests/pay-passport-expired.test.ts | 8 ++++++++ 4 files changed, 43 insertions(+) diff --git a/src/commands/agent-guide.ts b/src/commands/agent-guide.ts index e5a8024..98c793b 100644 --- a/src/commands/agent-guide.ts +++ b/src/commands/agent-guide.ts @@ -231,6 +231,14 @@ const GUIDE: AgentGuide = { 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/pay.ts b/src/commands/pay.ts index 5b580f4..8a44904 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -303,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 75ffa82..32f7c3d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -26,6 +26,7 @@ export type ErrorCode = | 'passport_verification_timeout' | 'passport_token_expired' | 'passport_login_required' + | 'passport_required_by_merchant' | 'unknown'; export const EXIT_CODES = { diff --git a/tests/pay-passport-expired.test.ts b/tests/pay-passport-expired.test.ts index c380ff1..ffefc92 100644 --- a/tests/pay-passport-expired.test.ts +++ b/tests/pay-passport-expired.test.ts @@ -32,6 +32,14 @@ vi.mock('../src/passport/bootstrap', async (importOriginal) => { }; }); +// 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 }); From b800025fb9b1bff682454625bacc55b518396572 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 7 May 2026 09:42:37 -0700 Subject: [PATCH 9/9] docs(pay): document passport_login_required + passport_required_by_merchant Both pay-thrown codes (added in this PR) are now in the README's identity error-codes section with extra-field shape and recovery actions. Distinct from the SDK-thrown table since these fire from the `pay ` command itself, not from the SDK identity wrappers. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 1580ab9..bd2c7f8 100644 --- a/README.md +++ b/README.md @@ -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%.