Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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 <address> [--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) |
Expand All @@ -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 <url>` 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%.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 4 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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)'),
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 23 additions & 3 deletions src/commands/agent-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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 <url>` 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.',
],
},
{
Expand Down Expand Up @@ -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 <url>` for explicit-anonymous traffic.',
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 33 additions & 11 deletions src/commands/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PassportLoginOutput> {
Expand All @@ -36,31 +65,24 @@ 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<typeof buildPassportSummary>)
| { authenticated: false };

export async function passportStatusCommand(): Promise<PassportStatusOutput> {
const passport = await loadPassport();
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),
};
}

Expand Down
Loading
Loading