diff --git a/.env.example b/.env.example index d238ba5fc..3b04c7134 100644 --- a/.env.example +++ b/.env.example @@ -938,3 +938,20 @@ TRINITY_DEFAULT_SYSTEM_MANIFEST= # than an error, so mounting the directory is required as well — setting this # alone silently lists nothing. TRINITY_MANIFESTS_DIR= + +# ============================================================ +# NEVERMINED x402 FACILITATOR CONCURRENCY (Optional) +# ============================================================ +# Every payment verify and settle is an outbound call to the Nevermined +# facilitator that runs on the default thread executor, so a slow facilitator +# would otherwise hold backend threads for the whole fleet — and a priced +# agent's public URL needs no credential to make the backend dial out, so +# per-IP rate limiting alone cannot bound it (the bound has to hold across IPs). +# These two knobs are that bound: a fleet-wide ceiling on concurrent facilitator +# calls, and how long a call waits for a free slot before answering "busy, +# retry" rather than queueing without limit. Fleet-wide, not per agent — the +# thread pool is a platform resource. A refused call burns nothing. +# Defaults below are the code defaults; leave them unless the facilitator is +# demonstrably keeping up with more. +NEVERMINED_MAX_INFLIGHT=8 +NEVERMINED_FACILITATOR_WAIT_SECONDS=5.0 diff --git a/docker-compose.hosted.yml b/docker-compose.hosted.yml index 0898b2e70..763ffda7f 100644 --- a/docker-compose.hosted.yml +++ b/docker-compose.hosted.yml @@ -200,6 +200,12 @@ services: # gap here cannot make the feature unreachable — but the env leg still has to # be forwarded or an operator setting it in .env silently does nothing. - A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false} + # Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at + # these defaults whether or not they are wired, so the gap is an inert + # tuning lever rather than a dead feature — but this compose launches + # standalone (no base merge / env_file), so wire them here too (#1056 class). + - NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide + - NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry" # Outbound voice replies via shared TTS (epic #24; Telegram #25). The key # gates the feature (empty ⇒ adapters deliver text). Prod compose launches # standalone (no base merge / env_file), so wire it here too (#1056 class). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 15b692c8c..ccb9502c5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -137,6 +137,12 @@ services: # gap here cannot make the feature unreachable — but the env leg still has to # be forwarded or an operator setting it in .env silently does nothing. - A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false} + # Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at + # these defaults whether or not they are wired, so the gap is an inert + # tuning lever rather than a dead feature — but prod compose launches + # standalone (no base merge / env_file), so wire them here too (#1056 class). + - NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide + - NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry" # Outbound voice replies via shared TTS (epic #24; Telegram #25). The key # gates the feature (empty ⇒ adapters deliver text). Prod compose launches # standalone (no base merge / env_file), so wire it here too (#1056 class). diff --git a/docker-compose.yml b/docker-compose.yml index 01cb58992..78d158dd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,6 +76,12 @@ services: # gap here cannot make the feature unreachable — but the env leg still has to # be forwarded or an operator setting it in .env silently does nothing. - A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false} + # Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at + # these defaults whether or not they are wired, so a gap here is an inert + # tuning lever rather than a dead feature — but this service uses an explicit + # environment list, so forward them or the .env knobs do nothing (#1056 class). + - NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide + - NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry" # Outbound voice replies via shared TTS (epic #24; Telegram #25). The key # gates the feature (empty ⇒ adapters deliver text). Must reach the # container or the .env lever is inert (the #1056/#1039 packaging class). diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 28f3360dc..143f45e3a 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -98,7 +98,34 @@ RUN pip install --no-cache-dir \ # bump this pin with the rest of the file. tzdata==2026.3 \ psutil==6.1.1 \ - payments-py==1.2.1 \ + # payments-py 1.18.0 (abilityai/trinity-enterprise#679, trinity-enterprise#763): + # the A2A x402 flow carries payment IN-BAND in task metadata, which 1.2.1 + # cannot read. tests/requirements-test.txt pins the SAME exact version — + # CI previously ran 1.18.0 against a 1.2.1 image, which is the one + # divergence a dependency guard exists to catch + # (tests/unit/test_ent679_payments_pin_parity.py, the #1891 shape). + payments-py==1.18.0 \ + # 1.18.0's own runtime dependency set, pinned explicitly rather than left to + # pip. `payments_py.payments` imports the a2a package at module load, so any + # one of these failing to import flips NEVERMINED_AVAILABLE to False and both + # payment doors answer 501 with a green build — the failure mode that makes + # ~15 floating transitive packages unacceptable here. Versions are the ones + # the test venv resolved, so image and CI agree; Dependabot bumps them like + # any other pin. The docs/test extras (black, mkdocs*, mike, pytest-asyncio) + # are runtime deps in the sdist's METADATA, not an authoring choice of ours. + a2a-sdk==0.3.26 \ + mcp==1.30.0 \ + python-socketio==5.14.3 \ + pyjwt==2.14.0 \ + jsonschema==4.26.0 \ + websocket-client==1.9.2 \ + helicone-helpers==1.2.1 \ + black==26.5.1 \ + mkdocs==1.6.1 \ + mkdocs-material==9.7.7 \ + "mkdocstrings[python]==0.29.1" \ + mike==2.2.0 \ + pytest-asyncio==1.4.0 \ Pillow==11.1.0 \ # #1536 report export. Both are pure-Python wheels — no system libraries, so # the image build is unchanged beyond these two lines (WeasyPrint was diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index 449f67bc6..a994943c9 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -44,7 +44,7 @@ - `reminders.py` - Agent self-reminders: create/list/cancel (self-gated) (#1296) — see [Agent Self-Reminders](execution.md#agent-self-reminders-1296) - `files.py` - Public download endpoint for outbound agent file sharing (FILES-001) - `agent_rename.py` - Rename endpoint (RENAME-001) -- `a2a.py` - A2A protocol: the authenticated per-agent card (#737) plus the **inbound server** on a separate prefix-less `a2a_server_router` — public `GET /a2a/{name}/.well-known/agent-card.json` (per-IP rate limited) + `POST /a2a/{name}` JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel). Exposure is opt-in per agent (`agent_ownership.a2a_exposed`, default OFF); non-exposed/inaccessible → uniform 404 (Invariant #8). `messageId` dedup is scoped per (agent, caller principal) — the field is peer-controlled and only unique per-client (ent#157). **Also hosts the OUTBOUND client (#736):** `POST /{name}/a2a/call` + `POST /{name}/a2a/task`, a Trinity agent tasking an EXTERNAL A2A agent. The target is never caller-supplied — it is a name resolved through `services/a2a_outbound.py` — and the routes are thin (auth + HTTP error map + audit); orchestration lives in `services/a2a_outbound_service.py`. Default OFF (`A2A_OUTBOUND_ENABLED`), both routes 404 when off +- `a2a.py` - A2A protocol: the authenticated per-agent card (#737) plus the **inbound server** on a separate prefix-less `a2a_server_router` — public `GET /a2a/{name}/.well-known/agent-card.json` (per-IP rate limited) + `POST /a2a/{name}` JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel). Exposure is opt-in per agent (`agent_ownership.a2a_exposed`, default OFF); non-exposed/inaccessible → uniform 404 (Invariant #8). `messageId` dedup is scoped per (agent, caller principal) — the field is peer-controlled and only unique per-client (ent#157). **Also hosts the OUTBOUND client (#736):** `POST /{name}/a2a/call` + `POST /{name}/a2a/task`, a Trinity agent tasking an EXTERNAL A2A agent. The target is never caller-supplied — it is a name resolved through `services/a2a_outbound.py` — and the routes are thin (auth + HTTP error map + audit); orchestration lives in `services/a2a_outbound_service.py`. Default OFF (`A2A_OUTBOUND_ENABLED`), both routes 404 when off. **The inbound door also takes payment (ent#679):** its principal is OPTIONAL (`get_user_or_anonymous`, which degrades on a 401 but RE-RAISES a 403 so a fenced key cannot become a payer), and the handler branches once — a resolved principal gets today's free path byte-identically, an anonymous caller gets today's 401 unless the agent is BOTH exposed and Nevermined-enabled, in which case it gets the paid door's own 402 with `resource.url` on THIS door. The card producer `_card_with_exposed_skills` also declares the price (`a2a_card_service.with_payment_extension`), so both card surfaces state it and an unpriced agent's card is byte-identical - `agent_ssh.py` - SSH access endpoint - `credentials.py` - Credential injection/export/import (CRED-002) - `chat.py` / `chat/` - Agent chat/activity monitoring @@ -91,7 +91,7 @@ *Public Access & Monetization:* - `public_links.py` - Public agent link management - `public.py` - Public chat routes; the turn orchestration moved to `services/public_chat_service.py`, which also owns the per-IP/per-token chat caps and the #311 access-gate predicates (#1028). Also Caddy's unauthenticated on-demand-TLS `ask` gate, `GET /api/public/tls-allowed` (#2380) -- `paid.py` - x402 payment-gated chat (NVM-001) +- `paid.py` - x402 payment-gated chat (NVM-001). The 402/403/verify/settle ORCHESTRATION moved to `services/paid_turn_service.py` (ent#679) so the A2A inbound door runs the identical money logic; this router is now the HTTP shape over it (header read, outcome → response map) and its behaviour is unchanged - `nevermined.py` - Nevermined payment config (NVM-001) - `slack.py` - Slack integration: OAuth, events, multi-agent channel routing, per-agent binding (SLACK-001/002) - `telegram.py` - Telegram bot integration: webhook receiver, bot binding, group config (TELEGRAM-001) @@ -207,7 +207,9 @@ *Integrations:* - `slack_service.py` - Slack API client (OAuth, messaging, verification) (SLACK-001) -- `nevermined_payment_service.py` - x402 payment verification and settlement (NVM-001) +- `nevermined_payment_service.py` - x402 payment verification and settlement (NVM-001). Facilitator calls are bounded fleet-wide by `NEVERMINED_MAX_INFLIGHT` (8) with a `NEVERMINED_FACILITATOR_WAIT_SECONDS` (5.0) wait budget, one semaphore per event loop — verify/settle run on the default thread executor, so an unbounded set of them starves the whole fleet's threads, and the caller needs no credential to trigger one. Knobs documented in [nevermined-payments.md](../feature-flows/nevermined-payments.md#configuration-operator-knobs) +- `paid_turn_service.py` - The ONE x402 paid-turn orchestrator, shared by `routers/paid.py` and the A2A inbound door (ent#679 T3): verify → dedup gate → execute → settle, with the #1018 branches (honest `success_unsettled` on a delivered-but-unsettled turn, a replayed unsettled snapshot re-settling and converging, no settle on a failed/cancelled turn) existing exactly once. Takes every collaborator as a PARAMETER and imports none of them — a service that imported the payment service and the execution bridge would be a second router, and the parameter list is what lets the unit suite drive all ten outcomes with plain stand-ins +- `a2a_payment_gate.py` - The A2A-shaped adapter around `paid_turn_service` (ent#679): `is_priced` (exposure ∧ enabled config ∧ key — deliberately NOT the SDK check, since "takes payment" and "can process one now" are different facts owing a 401 and a 501 respectively), metadata-first token extraction with the deprecated `payment-signature` header as fallback, the 402/403 bodies, and the Task a payer gets back with its x402 metadata. Consults the `a2a_gate` allow-list after verify as `x402:{payer}` and **fails CLOSED** there — the inverse of that seam's authenticated-caller bias, because on this path the payment IS the authorization. Nothing here is edition-aware; the path is reachable only with the entitled exposure flag on - `proactive_message_service.py` - Agent-to-user proactive messaging with rate limiting and audit (#321) - `channel_completion_report.py` - Reports a delegated/background execution's terminal back to its originating channel chat/thread (ent#224 Slack, ent#265 Telegram, ent#457 Workspace): inherited-context-only (never inline turns), binding-agent consent + delivery, effect-guarded at-most-once. **The Workspace leg's consent is by construction, not by flag** — a portal session belongs to exactly one client, so there is no third party for an `allow_proactive` bit to protect, which is why the recipient is read from the SESSION ROW (the platform's own record of whose chat this is) rather than from the execution's inherited stamp alone; delivery is a persisted assistant message and the sidebar's `last_message_at` is touched like any other writer's. **Durable, not immediately visible** — the Workspace does NOT poll its threads (`refreshThreads()` is event-driven; the only interval is the 20s asks poll on a different surface), so a client sitting on the thread sees the report at their next reload or thread switch; an idle history poll is a tracked follow-up. A report landing mid-turn can also be read AS that turn's answer, since the client detects a reply by an assistant-row count delta and this is a second writer of those rows — the honest fix needs a per-row discriminator the table does not carry. `INLINE_CHANNEL_TRIGGERS` gains `"public"` so a Workspace turn's OWN execution is never reported twice — public links and x402 share that trigger and are unaffected, since they stamp no `source_channel_chat_id` and never reach the gate — see [channel-completion-report.md](../feature-flows/channel-completion-report.md) - `channel_history.py` - Persists a delivered proactive **group/channel** broadcast into the channel session (#1649), so the agent has a record of its own outreach. Session keys are derived by driving the channel adapter's own `get_session_identifier()` (never re-implemented — that drifts). **Slack = real recall**: channel sessions are thread-scoped, so a broadcast filed at its own `ts` IS the session an in-thread reply resolves to (needs `slack_service.send_message_detailed()` to return the ts). **Telegram = real recall since ent#600**: group sessions are per chat, so a broadcast lands in the session a participant's reply reads — unless the group's context is off, which records nothing; `purge_telegram_group_history` deletes a group's sessions (chat + forum topics) when its context is switched off. See [integrations.md → Telegram group conversation context](integrations.md#telegram-group-conversation-context-ent600). `#903` shared-thread attribution (`sender_email=None`); persist on confirmed delivery only; fail-soft diff --git a/docs/memory/feature-flows/a2a-inbound-server.md b/docs/memory/feature-flows/a2a-inbound-server.md index dae401346..7d116a630 100644 --- a/docs/memory/feature-flows/a2a-inbound-server.md +++ b/docs/memory/feature-flows/a2a-inbound-server.md @@ -226,3 +226,142 @@ the seam's other hook. so an unadvertised skill is hidden, not unreachable. Anything that constrains what an external caller can actually reach would be a different mechanism (`allowed_tools`/guardrails) with its own threat model. + +## Payment gate (ent#679) + +The door authenticated a Trinity MCP key and nothing else, so a stranger — +including a remote Trinity holding a perfectly good x402 payment token — got +**401** and could never reach a 402, never pay, never be served. Requirement: +`requirements/mcp.md` §32.6. + +**One branch, decided by the principal.** `Depends(get_current_user)` became +`Depends(get_user_or_anonymous)`, a sibling that returns `None` on a **401 only** +and re-raises a **403**. That asymmetry is the point: the connector and +ephemeral-key fences raise 403, and a credential Trinity recognised and then +fenced must not slide onto the payment path and buy the access it was refused. + +``` +principal is not None → TODAY'S PATH, byte-identical: the three gates → dispatch. + No facilitator call, no payment row, no payment metadata. +principal is None → per-IP + per-agent rate limit (before any DB/SDK work) + → not exposed, or not priced → today's 401 bytes + WWW-Authenticate + → priced but SDK absent → 501 + → exposed AND priced → payment path +``` + +The free path is unchanged for internal fleet traffic, owner/shared callers and +subscription tenants — by construction, not by a carve-out. The only agents +whose anonymous answer differs from before are **exposed AND priced**, which +their public well-known card already publishes. + +**Token, metadata first.** `params.message.metadata["x402.payment.payload"]` is +re-encoded to the access token the facilitator consumes; the `payment-signature` +header is the deprecated fallback. Metadata wins when both are present (the +provider SDK's own precedence), so a client migrating between rails cannot have +a stale header decide what it pays with. Re-encoding is signature-safe: the +EIP-712 signature lives *inside* the payload, not over the base64 envelope. +Every malformed shape falls through to the 402 rather than raising — all of it +is caller-controlled input on a route reachable with no credential. + +**No extension handshake.** Trinity speaks the x402 message vocabulary and does +not negotiate. Nothing reads or emits `X-A2A-Extensions`. + +**402/403 come from the paid door's own builders.** One requirements builder, two +doors — a 402 built differently from the later verify is a rejection the caller +cannot act on. `resource.url` names **this** door (`/a2a/{name}`): an x402 token +signs the resource URL, so a token minted against `/api/paid/{name}/chat` cannot +authorize an A2A call. + +**The money logic is not here.** `services/paid_turn_service.py` is shared with +`routers/paid.py`, so the #1018 settle/replay/`success_unsettled` branches exist +once. `services/a2a_payment_gate.py` is only the A2A-shaped adapter: what a token +looks like on this wire, what a refusal looks like, and how an outcome becomes a +Task. Settlement detail: [nevermined-payments.md](nevermined-payments.md). + +**The allow-list seam flips direction here.** `a2a_gate`'s allow-list fails +**open** for an authenticated caller (a restriction layered on auth). For a payer +it is consulted after verify with identity `x402:{payer}` and fails **closed** — +on this path the payment *is* the authorization, so a seam failure must not +void a configured control. + +**A payer can retrieve what it paid for.** `tasks/get` / `tasks/cancel` are +allowed when the token verifies **and** the wallet matches that execution's +payment-log rows. Every mismatch — including payer A polling payer B's existing +task — answers byte-identical `-32001`, so the binding is not an existence +oracle. The binding is written **when the turn's result exists, before the +settle**: `run_paid_turn` logs a `verify` row carrying the execution id as soon +as `execute()` returns, because when only the `settle` / `settle_failed` rows +carried the pair, a payer was locked out of a finished task while its settle was +still running, and for good when a concurrent settle wrote no row. It is **not** +written mid-turn — `execute()` returns at the terminal, so a poll during the +turn reads as not-found and a payer's `tasks/cancel` can only answer "already in +a terminal state". No schema change; those columns were already on the row. + +**A refusal is classified once, rendered twice.** `routers/a2a.py`'s +`_classify_paid_refusal` is the single table, consumed by `message/send` (which +can answer with an HTTP status) and by `message/stream` (which cannot — the +status line is gone by the time the body generator runs). Two properties it +exists to hold: a verify that could not DECIDE — facilitator timeout, SDK error, +saturated concurrency gate — is **our** unavailability, so it answers a JSON-RPC +error with `data.retryable: true` rather than the 403 a remote Trinity reads as +"stop retrying and buy another token", and is logged as a `verify` attempt +rather than a `reject`; and the stream never answers `-32001` for a payment +condition, since that is A2A **TaskNotFound** and tells a client its task +vanished when the truth is "pay" / "not allowed" / "retry". `data.code` +(`payment_rejected` · `verify_unavailable` · `not_allowed` · `in_flight` · +`execution_error`) is the discriminator those four instructions need. The paid +door keeps its own 403 bytes unchanged. + +**Attribution, no schema change.** The payer wallet on the `settle` row, the +execution row (`triggered_by="a2a"` + principal fields) and the platform audit +row (IP + payer) already carry it. No column, no migration. + +**`triggered_by="a2a"` joins `INTERACTIVE_TRIGGERS`.** A remote caller waits +in-line on both the free and the paid path, so both get the caller-went-away +cancel and the claim budget — this changes queue treatment for the **principal** +path too, deliberately, and keeps the `a2a` analytics bucket honest rather than +filing paid A2A calls as REST paid chats. + +### The card states the price + +`_card_with_exposed_skills` is still **the** single card producer (ent#180 +FR-3), and it gains one step: `a2a_card_service.with_payment_extension`. A priced +agent's card declares a `urn:nevermined:payment` extension carrying `agentId`, +`planId`, `credits`, `paymentType` and `paymentInfoUrl` +(`GET /api/paid/{name}/info`, the public "where to buy" document), so an +x402-speaking client mints a token from the card alone and meets the paywall on +its **first** request. + +- The card builder stays pure; the config read lives in the router, like the + skills provider lookup, and uses the no-decrypt `get_nevermined_config` — the + card publishes plan ids, never the API key. +- **An unpriced or disabled agent's card is returned by identity** — byte-identical + to before. Every install that sells nothing is untouched. +- The **official** A2A x402 extension URI is deliberately **not** declared, even + though the provider SDK's own card helper appends it. Declaring an extension + advertises its activation handshake; a generic client would activate it and + then wait for a negotiation that never comes. +- `credits: 0` is a Nevermined **duration** plan (charged by time), declared as + `paymentType: "dynamic"` — `fixed`/0 reads as free and the SDK's own card + validator rejects that shape for a paid plan. +- Fail-open: an unreadable payment config serves the card with no price block + and logs at WARNING. A card route has never 5xx'd, and the gate re-reads the + config and still answers 402, so the only cost is a priced agent briefly + looking free. + +**In an OSS-only build, a configured price block points at a door that 404s.** +Exposure is set only by the entitled provider, so the paid A2A door is not +reachable; the card says what the agent *costs*, not that the door is *open*. +That is the same open-core line the paid chat door has always had — the gate is +OSS mechanism with no new entitlement and no enterprise-submodule code. + +### Payment-gate testing + +`tests/unit/test_ent679_a2a_payment_gate.py` (the gate, over a TestClient), +`tests/unit/test_ent679_paid_turn_service.py` (the shared orchestrator at its own +layer, with the paid door's three existing test files unedited as the behaviour +net) and `tests/unit/test_ent679_a2a_priced_card.py` (the card, both surfaces). + +The real facilitator (verify + settle) and what a duration-plan settle actually +burns are **not** provable from the SDK source and are a live sandbox run before +merge, not a unit test. Stated rather than hidden. diff --git a/docs/memory/feature-flows/nevermined-payments.md b/docs/memory/feature-flows/nevermined-payments.md index 205ed35cc..124d4ec8e 100644 --- a/docs/memory/feature-flows/nevermined-payments.md +++ b/docs/memory/feature-flows/nevermined-payments.md @@ -95,7 +95,10 @@ Shared User (view-only) |------|---------| | `src/backend/db/nevermined.py` | `NeverminedOperations` — config CRUD + payment log | | `src/backend/services/nevermined_payment_service.py` | `NeverminedPaymentService` — SDK verify/settle | -| `src/backend/routers/paid.py` | Public paid endpoint (`/api/paid/`) | +| `src/backend/routers/paid.py` | Public paid endpoint (`/api/paid/`) — the HTTP shape over `paid_turn_service` since ent#679 | +| `src/backend/services/paid_turn_service.py` | **The one paid-turn orchestrator** (ent#679): verify → dedup gate → execute → settle, with the #1018 branches. Shared by the paid door and the A2A inbound door; takes every collaborator as a parameter and imports none | +| `src/backend/services/a2a_payment_gate.py` | The A2A-shaped adapter over it (ent#679) — token extraction, the 402/403 bodies, the payer's Task. Flow: [a2a-inbound-server.md](a2a-inbound-server.md) | +| `src/backend/services/a2a_card_service.py` | `with_payment_extension` — a priced agent's A2A card declares its plan (ent#679) | | `src/backend/routers/nevermined.py` | Admin config endpoints (`/api/nevermined/`), `_require_agent_exists()` guard | | `src/backend/db_models.py` | Pydantic models for config, payment result, payment log | | `src/backend/db/schema.py` | Table definitions | @@ -126,6 +129,7 @@ Shared User (view-only) |--------|------|------|-------------| | `POST` | `/api/paid/{agent_name}/chat` | x402 | Paid chat (402/403/200/409). Accepts `Idempotency-Key` (#1018); settle-fail → `success_unsettled` | | `GET` | `/api/paid/{agent_name}/info` | None | Payment info | +| `POST` | `/a2a/{agent_name}` | x402 **or** Trinity key | The A2A inbound door (ent#679). A resolved Trinity principal runs free, exactly as before; an anonymous caller is charged when the agent is both A2A-exposed and payments-enabled — same 402 bytes, same settle logic, `resource.url` on this door. See `requirements/mcp.md` §32.6 | | `POST` | `/api/nevermined/agents/{name}/config` | JWT (owner) | Configure | | `GET` | `/api/nevermined/agents/{name}/config` | JWT (shared+) | Read config | | `DELETE` | `/api/nevermined/agents/{name}/config` | JWT (owner) | Remove config | @@ -159,9 +163,27 @@ Shared User (view-only) | Server-side settlement retry | 501 | `/api/nevermined/retry-settlement/{log_id}` — token not stored (#1018) | | SDK not installed | 501 | `_check_sdk()` | +## Configuration (operator knobs) + +Both are env-only and read at import, so a change needs a backend restart. + +| Variable | Default | What it bounds | +|----------|---------|----------------| +| `NEVERMINED_MAX_INFLIGHT` | `8` | Fleet-wide ceiling on **concurrent** facilitator calls. Every verify and settle attempt runs on the default thread executor, so a slow facilitator would otherwise hold backend threads for the whole fleet. Deliberately fleet-wide rather than per agent — the thread pool is a platform resource — and a priced agent's public URL needs no credential to make the backend dial out, so per-IP rate limiting cannot supply this bound (it has to hold *across* IPs). | +| `NEVERMINED_FACILITATOR_WAIT_SECONDS` | `5.0` | How long a call waits for a free slot before giving up. Bounded because the caller is holding an HTTP request open: "busy, retry" is an honest answer, an unbounded queue is not. A refused call never reaches the facilitator, so it burns nothing. | + +The gate is one semaphore **per event loop** (`_FACILITATOR_GATES`, keyed weakly +on the running loop): a module-level semaphore would bind whichever loop first +contended on it, which in a test suite is whichever test ran first, while in +production there is one loop per worker and the bound is per worker. + ## Isolation Guarantees -1. All changes are additive — no existing code paths modified +1. All changes are additive — no existing code paths modified. (ent#679 is the + one exception and deliberately behaviour-preserving: `routers/paid.py`'s + orchestration moved into `services/paid_turn_service.py` so the A2A door + could share it rather than grow a second copy of the #1018 branches. The + paid door's three existing test files are the net and were not edited.) 2. Lazy SDK imports — `payments-py` never imported at module level 3. Graceful degradation — 501 if SDK not installed 4. No foreign key constraints to existing tables @@ -177,6 +199,7 @@ Shared User (view-only) | Issue | Change | |-------|--------| | #1018 | **Settlement-ordering / honest status.** Settle-fail → `success_unsettled` (was lying `"success"`); concurrent effect-guard settle → `settle_in_progress:true`; wired `Idempotency-Key` keyed on `(payment-signature ∥ message)` with in-flight-409 / settled-verbatim-replay / unsettled-re-drive-and-converge (`_finalize_settled` + `upgrade_snapshot`); `fail()` on 403/exception/failed paths; stop leaking the body on `failed` executions (keep it on `cancelled`); `/retry-settlement` stub → honest 501. Tier 2 durable stored-credential retry split to a follow-up. | +| ent#679 | **The same paywall on the A2A door.** `paid.py`'s 402/verify/settle orchestration extracted to `services/paid_turn_service.py` (behaviour-preserving) and reused by `POST /a2a/{name}`; metadata-first token carriage with the `payment-signature` header as deprecated fallback; the priced agent's A2A card declares its plan; `credits_per_request` accepts **0** for a duration plan (a negative is still a named 422). No migration. Requirement: `requirements/mcp.md` §32.6. | | #1084 | `settle_payment_once` + `effect_guard` on `payment:{agent_request_id}` (local exactly-once + receipt replay). | | #679 | Cancelled turn must NOT settle (charge-on-cancel money bug). | | NVM-001 | Initial x402 integration (2026-03-04). | diff --git a/docs/memory/requirements/mcp.md b/docs/memory/requirements/mcp.md index e68a979c8..ea8b9c54a 100644 --- a/docs/memory/requirements/mcp.md +++ b/docs/memory/requirements/mcp.md @@ -869,6 +869,99 @@ revision — the kind is a label inside the existing envelope. - **Flow**: `docs/memory/feature-flows/a2a-outbound-call.md` +### 32.6 A2A Inbound Payment Gate — x402 on `POST /a2a/{name}` (ent#679) +- **Status**: 🚧 In Progress +- **Implements**: trinity-enterprise#679 (epic trinity-enterprise#156); stacks on + abilityai/trinity#3185 (the outbound consumer's 402 handling) +- **Description**: §32.2 authenticated the inbound door with a Trinity MCP key + and nothing else, so a stranger — including a remote Trinity holding a valid + x402 payment token — got **401** and could never reach a 402, never pay, and + never be served. Meanwhile the paywall (`public-access.md` §23) lived only on + the bespoke `POST /api/paid/{name}/chat` door, and the card said nothing about + price. This puts the same paywall on the A2A door, in the A2A x402 message + vocabulary, and states the price on the card. +- **FR-1 — One branch, decided by the principal**: the door resolves an + *optional* principal (`dependencies.get_user_or_anonymous`). A resolved + principal takes **today's path, unchanged**: the §32.2 gates, no payment, no + facilitator call, no payment-log row. Only an *anonymous* caller can reach the + payment path, and only for an agent that is BOTH A2A-exposed and + Nevermined-enabled; anything else answers today's 401 bytes. Internal fleet + traffic, owner/shared callers and subscription tenants are therefore + unaffected by construction, not by a carve-out. +- **FR-2 — A refused credential is never downgraded into a payer**: the optional + dependency degrades to anonymous on a **401 only**. A **403** (the connector + and ephemeral-key fences) is re-raised. A credential Trinity recognised and + then fenced must not be able to buy the access it was just refused. +- **FR-3 — "Takes payment" ≠ "can process one"**: the two facts get different + honest answers. No price configured ⇒ 401 (the stranger has no business + here). Priced but the payment SDK is absent ⇒ **501** (the door exists and is + broken). Fusing them would answer "authenticate" to a caller holding a valid + token for an agent whose card advertises a price — telling it to present a + credential that does not exist. +- **FR-4 — 402 parity with the paid door**: a missing or unusable token answers + **HTTP 402** with the paid door's body (`detail`, `payment_required`, + `credits_per_request`) and base64 `payment-required` header, from the **one** + requirements builder both doors share — a 402 built differently from the later + verify is a rejection the caller cannot act on. `resource.url` names **this** + door (`/a2a/{name}`), not the paid one: an x402 token signs the resource URL, + so a token minted against the paid chat door cannot authorize an A2A call. A + rejected token answers **403** with a `reject` log row. +- **FR-5 — Metadata-first token carriage**: the token is read from the A2A + message metadata (`x402.payment.payload`) first and from the deprecated + `payment-signature` header only as a fallback, matching the provider SDK's own + precedence. When both are present the metadata wins, so a client migrating + between rails cannot have a stale header silently decide what it pays with. + Every malformed payload shape falls through to the 402 rather than raising — + all of it is caller-controlled input on a route reachable with no credential. + **No extension activation handshake** is implemented or advertised (§32.5's + standing scope line): Trinity speaks the vocabulary, it does not negotiate. +- **FR-6 — One home for the money logic**: the settle/replay/honest-status + branches (`public-access.md` §23.3, #1018) are **not** duplicated. Both doors + call one orchestrator (`services/paid_turn_service.py`) that takes its + collaborators as parameters, so a delivered-but-unsettled turn is + `success_unsettled` on both rails, the dedup unit is the same + `(token ∥ message)` one, and a replayed unsettled snapshot re-settles and + converges identically. The paid door's behaviour is unchanged. +- **FR-7 — The card states the price (AC2)**: a priced agent's card declares a + payment extension carrying `agentId`, `planId`, `credits`, `paymentType` and a + `paymentInfoUrl` pointing at the public `GET /api/paid/{name}/info` document, + so an x402-speaking client mints a token from the card alone and meets the + paywall on its **first** request. Both card surfaces carry it (§32.4 FR-3's + single producer). An unpriced or disabled agent's card is **byte-identical** + to before. An unreadable payment config fails open (card served, no price + block) — a card route has never 5xx'd, and the gate re-reads the config and + still answers 402. +- **FR-8 — Attribution with no schema change**: a settled call is attributable + from rows that already exist — the payer wallet on the `settle` log row, the + execution row (`triggered_by="a2a"` + its principal fields), and the platform + audit row (source IP + payer). No new column, no migration. +- **FR-9 — The paying path is rate limited before it costs anything**: per-IP + **and** per-agent budgets are enforced ahead of any DB read or facilitator + call. A distributed flood passes every per-IP bucket while still pinning one + agent's facilitator quota, which only the per-agent limit sees. +- **FR-10 — A payer can retrieve what it paid for**: `tasks/get` and + `tasks/cancel` are allowed to a payer whose token verifies **and** whose + wallet matches that execution's payment-log rows. Every mismatch — including + payer A polling payer B's existing task — answers byte-identical "task not + found", so the binding is not an existence oracle. +- **FR-11 — A duration plan is configurable honestly**: `credits_per_request` + accepts **0**. A Nevermined *duration* plan charges by time — Trinity sends no + amount to the facilitator and the plan defines the burn — so the old `>= 1` + floor forced an operator to claim a per-call price nothing would ever charge. + A negative amount is still a named 422. Such a plan's card declares + `paymentType: "dynamic"`, not the contradictory `fixed`/0 that reads as free. +- **FR-12 — Open-core (mechanism in OSS)**: nothing here is edition-aware, the + same shape §23.3's paid door has always had. The path is reachable only when + the §32.2 exposure flag (settable only by the entitled provider) **and** the + OSS Nevermined config are both on, and the enterprise inbound allow-list is + consulted after verify as `x402:{payer}` — **fail-closed** on this path, the + opposite bias to §32.2's authenticated callers, because here the payment is + the authorization. **In an OSS-only build a configured price block on a card + points at a door that answers 404**, because exposure is off: the card says + what the agent costs, not that the door is open. +- **Flow**: `docs/memory/feature-flows/a2a-inbound-server.md`, + `docs/memory/feature-flows/nevermined-payments.md` + --- ## 45. Per-Agent MCP Exposure — Dedicated Dynamic Tools (#846) diff --git a/docs/memory/requirements/public-access.md b/docs/memory/requirements/public-access.md index d94e8a1c6..d32f18bad 100644 --- a/docs/memory/requirements/public-access.md +++ b/docs/memory/requirements/public-access.md @@ -525,6 +525,18 @@ - **Description**: 4 MCP tools for Nevermined management - **Tools**: `configure_nevermined`, `get_nevermined_config`, `toggle_nevermined`, `get_nevermined_payments` +### 23.7 The same paywall on the A2A door (ent#679) — pointer +- **Status**: 🚧 In Progress +- **Home**: `docs/memory/requirements/mcp.md` **§32.6**. The A2A inbound door + (§32.2 there) owns the gate, so the requirement lives with the door and this + is a pointer, not a second description. +- **What changes here**: §23.3's settle / replay / `success_unsettled` logic is + no longer paid-door-only — both doors call one orchestrator + (`services/paid_turn_service.py`), so the #1018 branches have one home. The + paid door's own behaviour is unchanged. `credits_per_request` now accepts + **0** for a Nevermined *duration* plan (charged by time, not per call); a + negative is still a named 422 (§32.6 FR-11). + --- ## 27. Mobile Admin PWA (MOB-001) diff --git a/docs/user-docs/integrations/a2a-protocol.md b/docs/user-docs/integrations/a2a-protocol.md index 54b0e6325..44dbd0718 100644 --- a/docs/user-docs/integrations/a2a-protocol.md +++ b/docs/user-docs/integrations/a2a-protocol.md @@ -133,6 +133,80 @@ By default, any caller authenticated as an owner/shared identity for the agent m --- +## Charge for inbound A2A calls + +If an exposed agent has **Payments** configured (Agent → Payments tab, x402 via +Nevermined), callers without a Trinity key pay to task it over A2A — the same +paywall the paid chat endpoint has always had, now on the A2A door. + +**Who pays, and who doesn't:** + +| Caller | What happens | +|---|---| +| Another agent in your fleet, you, or anyone the agent is shared with (a valid Bearer MCP key) | Runs **free**, exactly as before. Nothing about your internal traffic changes. | +| A subscription tenant | Unaffected. | +| A stranger, on an agent with payments **off** | `401`, exactly as before. | +| A stranger, on an exposed agent with payments **on** | `402 Payment Required` with what to pay; a valid token runs the task and settles. | + +**How an external client pays.** It reads the price off the discovery card, buys +plan credits from Nevermined, and sends the payment in the A2A message's +`metadata` under `x402.payment.payload`. The older `payment-signature` HTTP +header still works as a fallback, and if a client sends both, the one in the +message wins. The reply is a normal A2A Task whose metadata carries the payment +status and a receipt. + +**The card tells a client the price before it calls.** An exposed, priced agent's +`/.well-known/agent-card.json` carries a payment entry under +`capabilities.extensions` with the plan id, the credits per call, and a +`paymentInfoUrl` pointing at `/api/paid/{agent}/info` — so a payment-aware client +can pay on its **first** request instead of being refused once to learn the +price. An agent with no payment config has a byte-identical card to before. + +> **A price on the card does not mean the door is open.** The card says what the +> agent *costs*; A2A exposure is what makes the paid A2A door reachable. On a +> build without the exposure feature, a configured price block points at a door +> that answers `404` — turn exposure on for that agent to open it. + +**Time-based (duration) plans.** Set **Credits per Request** to `0`. A duration +plan charges by time, so Trinity sends no per-call amount and the plan decides +what a call burns; `0` says that honestly rather than claiming a per-call price +nothing will charge. The card then advertises the cost as plan-defined. (A +negative number is rejected.) + +**Retrieving a paid result.** If you hold the task id, the payer can poll +`tasks/get` with the same token — a task is bound to the wallet that paid for +it, and any other caller gets the ordinary "task not found". If your HTTP client +timed out before it read the task id, re-send the identical message with the +same token: that replays the completed result instead of re-running (and +re-charging) the work. + +**Refusals, and what they mean:** + +| Answer | Meaning | +|---|---| +| `401` | No price configured for this agent (or it isn't exposed) — authenticate with a Trinity key. | +| `402` | Pay, then retry. The body and the `payment-required` header say what to buy. | +| `403` | The token was rejected (or your wallet isn't on the agent's inbound allow-list). | +| `429` | Rate limited. The paying path is capped per source address **and** per agent. | +| `501` | Payments are configured but this Trinity install can't process one right now. | + +A refusal that is **our** side being busy rather than a verdict on your token — +a payment checker that timed out, or too many payment checks in flight — is not +a `403`. It comes back as a JSON-RPC error carrying `data.retryable: true`, with +a `data.code` saying which case it is (`verify_unavailable` — we could not +check; `in_flight` — your own identical request is still running). Retry those +with the **same** token; never buy another. `payment_rejected` and `not_allowed` +carry `retryable: false`. On `message/stream` the same classification arrives as +an error event, because a status code cannot be sent once the stream has opened. + +> **Paid calls are logged as money.** Each settled call records the paying wallet, +> the execution it paid for, and the source address. A delivered turn whose +> settlement fails still returns your result and is reported as unsettled rather +> than as a clean success — Trinity never claims it charged you when it didn't, +> or that it delivered for free when it is still reconciling. + +--- + ## Outbound endpoints Register the external A2A endpoints your agent is allowed to call (name + URL + optional credential). Credentials are stored **encrypted and never shown again** — the UI only indicates whether an endpoint has one (`🔒 credentialed`). @@ -328,7 +402,7 @@ That receipt matters: a timed-out `call_a2a_agent` returns `possibly_delivered: ## Behavior & security notes - **Safe by default** — exposure is OFF for every agent until you turn it on; a non-exposed or non-existent agent returns a uniform `404` (no way to enumerate which agents exist). -- **Auth is fail-closed** — every task call validates the Bearer MCP key; a bad/missing token is `401`. +- **Auth is fail-closed** — every task call validates the Bearer MCP key; a bad/missing token is `401`. The one exception is an exposed agent with **payments on**, where a caller with no Trinity key gets `402` instead so it can pay (see [Charge for inbound A2A calls](#charge-for-inbound-a2a-calls)). A credential Trinity recognises and then refuses — a connector-scoped or ephemeral key — stays refused and never falls through to the paying path. - **The front door must reach it** — external clients hit your public URL, not the backend port directly. Trinity proxies `/a2a/` to the backend (nginx in production, the dev proxy locally). Set `PUBLIC_CHAT_URL` so the card's published `url` is reachable from outside your network. - **Stopped agents** still serve a card (from container labels); tasking a stopped/unreachable agent returns a structured JSON-RPC error, never a 5xx. - **Every inbound task is audit-logged** (`source=a2a`, with the caller identity). @@ -347,7 +421,7 @@ That receipt matters: a timed-out `call_a2a_agent` returns `possibly_delivered: | Method | Path | Auth | Purpose | |--------|------|------|---------| | GET | `/a2a/{agent}/.well-known/agent-card.json` | none | Discovery card | -| POST | `/a2a/{agent}` | Bearer MCP key | JSON-RPC task endpoint | +| POST | `/a2a/{agent}` | Bearer MCP key **or** an x402 payment (when the agent is priced) | JSON-RPC task endpoint | ### Outbound routes (calling out) @@ -381,7 +455,7 @@ The two agent routes return `404` while outbound calling is off. The three setti | `-32602` | Invalid params (e.g. no message text) | | `-32001` | Task not found | -Auth failures are transport-level `401`; exposure/allow-list failures are `404`/`403`. +Auth failures are transport-level `401`; exposure/allow-list failures are `404`/`403`. On a priced agent, an unpaid call is `402` and a rejected payment token is `403`. --- diff --git a/src/backend/database.py b/src/backend/database.py index e12548251..402254bc3 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -3686,6 +3686,11 @@ def log_nevermined_payment(self, agent_name, action, success, **kwargs): def get_nevermined_payment_log(self, agent_name, limit=50): return self._nevermined_ops.get_payment_log(agent_name, limit) + def nevermined_payer_owns_execution(self, agent_name, execution_id, subscriber_address): + return self._nevermined_ops.payer_owns_execution( + agent_name, execution_id, subscriber_address + ) + def get_nevermined_settlement_failures(self, limit=50): return self._nevermined_ops.get_settlement_failures(limit) diff --git a/src/backend/db/nevermined.py b/src/backend/db/nevermined.py index d712c5f56..eecfc82ae 100644 --- a/src/backend/db/nevermined.py +++ b/src/backend/db/nevermined.py @@ -8,7 +8,7 @@ import uuid from typing import Optional, List -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select, insert, update, delete, func from .engine import get_engine from .tables import nevermined_agent_config, nevermined_payment_log @@ -247,6 +247,41 @@ def get_payment_log( ).mappings().all() return [self._row_to_payment_log(row) for row in rows] + def payer_owns_execution( + self, agent_name: str, execution_id: str, subscriber_address: str + ) -> bool: + """Did this payer wallet pay for this execution? (ent#679 T5) + + The no-schema payer→task binding the A2A payment path uses to decide + whether an anonymous x402 caller may `tasks/get` / `tasks/cancel` a + task: the settle / settle_failed rows already carry both + ``execution_id`` and ``subscriber_address``, so the join exists without + a new column. + + A targeted query rather than a scan of ``get_payment_log``'s newest 50: + on a busy agent a payer's own row rolls out of that window within + minutes, and the payer would then lose access to the task it paid for + — a correctness bug that only appears under load. + + Matching is case-insensitive because an EVM address is hex and the + facilitator's checksum casing is not guaranteed stable across a verify + and a settle. + """ + if not (agent_name and execution_id and subscriber_address): + return False + with get_engine().connect() as conn: + row = conn.execute( + select(nevermined_payment_log.c.id) + .where(nevermined_payment_log.c.agent_name == agent_name) + .where(nevermined_payment_log.c.execution_id == execution_id) + .where( + func.lower(nevermined_payment_log.c.subscriber_address) + == subscriber_address.lower() + ) + .limit(1) + ).first() + return row is not None + def get_settlement_failures(self, limit: int = 50) -> List[NeverminedPaymentLog]: """Get all failed settlements across all agents (admin view).""" with get_engine().connect() as conn: diff --git a/src/backend/db_models.py b/src/backend/db_models.py index 59d0a860a..fc814160e 100644 --- a/src/backend/db_models.py +++ b/src/backend/db_models.py @@ -1447,8 +1447,15 @@ def validate_environment(cls, v: str) -> str: @field_validator('credits_per_request') @classmethod def validate_credits(cls, v: int) -> int: - if v < 1: - raise ValueError("credits_per_request must be >= 1") + # ent#679 T9: 0 is legal. A Nevermined *duration* plan charges by time, + # not per call, so Trinity sends no amount to the facilitator and the + # burn is whatever the plan defines; `credits_per_request` is display + + # `credits_amount` logging only. The old `>= 1` floor made such a plan + # impossible to configure honestly — the operator had to claim a + # per-call credit price that nothing would ever charge. A NEGATIVE + # amount is still a named 422: it is not a plan shape, it is a typo. + if v < 0: + raise ValueError("credits_per_request must be >= 0") return v @@ -1474,6 +1481,14 @@ class NeverminedPaymentResult(BaseModel): remaining_balance: Optional[str] = None tx_hash: Optional[str] = None error: Optional[str] = None + #: Is this failure OURS rather than the token's (ent#679 E7)? A facilitator + #: timeout, an SDK error or a saturated concurrency gate means we could not + #: decide; a facilitator that answered "invalid" means the token is bad. + #: Only the second should tell a caller to go buy a new one. Defaulted so a + #: stored settle snapshot written before this field replays unchanged + #: (`NeverminedPaymentResult(**snapshot)`), and deliberately NOT part of + #: `_settle_snapshot` — it is about one attempt, not about the receipt. + retryable: bool = False class NeverminedPaymentLog(BaseModel): diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index f059fd853..fcd6ed980 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -844,6 +844,40 @@ async def get_optional_user( return None +async def get_user_or_anonymous( + request: Request, token: str = Depends(oauth2_scheme_optional) +) -> Optional[User]: + """The current user, or None when no Trinity credential was recognised. + + abilityai/trinity-enterprise#679. The sibling of :func:`get_optional_user` + for a route that must serve a caller holding a credential of a DIFFERENT + kind — the A2A inbound door, where an x402 payment token arrives in + `Authorization: Bearer` and is not a Trinity credential at all. Same + delegate-never-reimplement rule: `get_current_user` stays the only place + that decides what a Trinity credential means. + + The difference from `get_optional_user` is the one that matters: **only a + 401 degrades to None.** A 403 is RE-RAISED, so a credential that WAS + recognised and then fenced — a connector key outside its scope, an + ephemeral agent key off its allow-list — keeps its refusal instead of + silently becoming an anonymous caller who may pay its way in. Collapsing + 403 into None here would turn every containment fence in `get_current_user` + into a downgrade to the payment path. + + Like `get_optional_user`, this is only safe on a route that makes its own + authorization decision for the `None` case. `routers/a2a.py::a2a_jsonrpc` + answers today's 401 bytes unless the agent is both A2A-exposed and priced. + """ + if not token: + return None + try: + return await get_current_user(request, token) + except HTTPException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED: + return None + raise + + def _enforce_ephemeral_key_fence(request: Request, agent_name: str) -> None: """Containment fence for ephemeral agents' own keys (trinity-enterprise#69). diff --git a/src/backend/routers/a2a.py b/src/backend/routers/a2a.py index 836e28467..39d843f17 100644 --- a/src/backend/routers/a2a.py +++ b/src/backend/routers/a2a.py @@ -34,24 +34,26 @@ import json import logging import uuid -from typing import Any, Dict, Optional +from typing import Any, Dict, NamedTuple, Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from database import db -from dependencies import AuthorizedAgentByName, get_current_user +from dependencies import AuthorizedAgentByName, get_current_user, get_user_or_anonymous from models import A2ACallRequest, A2ACallResponse, A2ATaskRequest, User from routers.public import _get_client_ip from services import ( a2a_gate, a2a_outbound_service, + a2a_payment_gate, a2a_protocol, idempotency_service, + paid_turn_service, rate_limiter, ) -from services.a2a_card_service import generate_a2a_card +from services.a2a_card_service import generate_a2a_card, with_payment_extension from services.a2a_client import A2ACallError from services.a2a_outbound_service import ( A2AEndpointNotFound, @@ -60,6 +62,11 @@ from services.idempotency_service import EffectInProgressError, EffectUnguardedError from services.agent_auth import agent_httpx_client from services.docker_service import get_agent_container +from services.nevermined_payment_service import ( + NEVERMINED_AVAILABLE, + get_nevermined_payment_service, +) +from services.platform_prompt_service import build_public_channel_caller_prompt from services.platform_audit_service import AuditEventType, platform_audit_service from services.task_execution_service import ( dispatch_and_await_terminal, @@ -172,7 +179,31 @@ def _card_with_exposed_skills( base_url=base_url, ) card["skills"] = a2a_gate.filter_exposed_skills(agent_name, card.get("skills") or []) - return card + return _priced(agent_name, card, base_url) + + +def _priced(agent_name: str, card: dict, base_url: str) -> dict: + """Attach the ent#679 price block when this agent takes payment. + + The config read lives here, not in `a2a_card_service`, so the card builder + stays pure (ent#180's rule for the skills provider, same reason). An + unpriced agent's card is returned unchanged by identity. + + `get_nevermined_config` is the no-decrypt read: the card publishes plan + ids, never the API key. Fail-open and never 5xx — the card must still serve + when the payment config is unreadable, exactly as it serves when the agent + container is unreachable. The honest cost of failing open is that a priced + agent can briefly look free, which costs the operator nothing: the gate + itself reads the config independently and still answers 402. + """ + try: + config = db.get_nevermined_config(agent_name) + except Exception as e: # noqa: BLE001 — defensive: never 5xx the card + logger.warning(f"A2A card: payment config unreadable for {agent_name}: {e}") + return card + return with_payment_extension( + card, config, agent_name=agent_name, base_url=base_url + ) @router.get("/{agent_name}/a2a/agent-card") @@ -238,6 +269,15 @@ async def get_agent_card( A2A_CARD_RATE_LIMIT = 60 # max card fetches per IP A2A_CARD_RATE_WINDOW = 60 # per minute +# The anonymous (paying) branch's budgets live in `services/a2a_payment_gate.py` +# next to the reason they exist — each hit there can cost a 15-second +# facilitator verify. Re-exported here so the limiter calls below read like the +# card route's. +A2A_PAY_RATE_LIMIT = a2a_payment_gate.A2A_PAY_RATE_LIMIT +A2A_PAY_RATE_WINDOW = a2a_payment_gate.A2A_PAY_RATE_WINDOW +A2A_PAY_AGENT_RATE_LIMIT = a2a_payment_gate.A2A_PAY_AGENT_RATE_LIMIT +A2A_PAY_AGENT_RATE_WINDOW = a2a_payment_gate.A2A_PAY_AGENT_RATE_WINDOW + # Cap the JSON-RPC body before parsing it (the #1424 / #1083 shape). nginx caps # at 25m, but :8000 may be reachable directly. _MAX_RPC_BODY_BYTES = a2a_protocol.MAX_RPC_BODY_BYTES @@ -278,8 +318,17 @@ def _text_from_message(message: Dict[str, Any]) -> str: def _task_object(execution_id: str, state: str, *, text: Optional[str] = None, - context_id: Optional[str] = None, error: Optional[str] = None) -> Dict[str, Any]: - """Build an A2A Task object. `state`: submitted|working|completed|failed|canceled.""" + context_id: Optional[str] = None, error: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Build an A2A Task object. `state`: submitted|working|completed|failed|canceled. + + `metadata` (ent#679) rides on `status.message.metadata` — where the x402 A2A + extension puts payment state and where #3185's outbound client reads it + (`status.message.metadata`, falling back to `metadata`). A task carrying + metadata always gets a `status.message`, even with no error text, because + the metadata is the message's only reason to exist on a successful paid + turn: no message, nowhere for the receipt to go. + """ task: Dict[str, Any] = { "id": execution_id, "contextId": context_id or execution_id, @@ -291,12 +340,17 @@ def _task_object(execution_id: str, state: str, *, text: Optional[str] = None, "artifactId": uuid.uuid4().hex, "parts": [{"kind": "text", "text": text}], }] - if error is not None: - task["status"]["message"] = { + if error is not None or metadata: + message: Dict[str, Any] = { "role": "agent", - "parts": [{"kind": "text", "text": error}], + "parts": ( + [{"kind": "text", "text": error}] if error is not None else [] + ), "messageId": uuid.uuid4().hex, } + if metadata: + message["metadata"] = metadata + task["status"]["message"] = message return task @@ -379,6 +433,39 @@ async def a2a_well_known_card(agent_name: str, request: Request): return card +async def _parse_rpc_envelope(request: Request): + """Cap, parse and validate the JSON-RPC envelope → (method, params, rpc_id). + + Returns a `JSONResponse` instead when the envelope is unusable. Shared by + the principal and the anonymous (paying) paths so a malformed request gets + the SAME bytes on both — a stranger must not be able to tell the two paths + apart from a parse error, and a second copy of these four refusals is how + that difference appears later. + + The body cap runs BEFORE the parse (the #1424 / #1083 shape): an uncapped + `await request.json()` lets one caller pin memory. nginx caps at 25m, but + :8000 may be reachable directly. On the paying path it also runs before any + token extraction, so an oversized body never reaches the facilitator. + """ + raw = await request.body() + if len(raw) > _MAX_RPC_BODY_BYTES: + return _rpc_error(None, _RPC_INVALID_REQUEST, "Request body too large") + try: + body = json.loads(raw) + except Exception: + return _rpc_error(None, _RPC_PARSE_ERROR, "Parse error: body is not valid JSON") + + if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str): + return _rpc_error(body.get("id") if isinstance(body, dict) else None, + _RPC_INVALID_REQUEST, "Invalid JSON-RPC 2.0 request") + + params = body.get("params") or {} + rpc_id = body.get("id") + if not isinstance(params, dict): + return _rpc_error(rpc_id, _RPC_INVALID_PARAMS, "params must be an object") + return body["method"], params, rpc_id + + def _authorize_inbound(current_user: User, agent_name: str) -> None: """Exposure + access + allow-list gate for an inbound A2A task. Raises HTTPException(404) for non-exposed/inaccessible (uniform — no enumeration), @@ -416,36 +503,489 @@ async def _run_a2a_task(agent_name: str, text: str, current_user: User): ) +# =========================================================================== +# ent#679 — the x402 payment path (a caller with no Trinity credential) +# =========================================================================== + + +async def _run_a2a_paid_execution(agent_name: str, text: str): + """The execution bridge for a PAYING caller: no Trinity principal. + + `source_user_*` are all None — there is no Trinity identity to attribute + this to, and inventing one would put a stranger's turn on a real user's + name in the execution row and every analytics surface downstream. The + attribution that does exist is the payer wallet on the payment log rows and + the audit row (T4). + + `triggered_by` stays `"a2a"` (T6) rather than borrowing `"paid"`: an A2A + paid call IS an A2A call, and relabelling it would hide it from the a2a + bucket and the `?triggered_by=a2a` filter while making it look like a REST + paid chat. + + The two public-channel settings the paid door applies are applied here for + the same reason it applies them: a paying stranger is a public caller, not + a tenant (#1205 caller prompt, #894 per-agent model override). + """ + return await dispatch_and_await_terminal( + agent_name=agent_name, + message=text, + triggered_by="a2a", + source_user_id=None, + source_user_email=None, + source_mcp_key_id=None, + system_prompt=build_public_channel_caller_prompt(agent_name), + model=db.get_public_channel_model(agent_name), + ) + + +def _not_authenticated() -> JSONResponse: + """Today's 401, byte-identical. + + What a stranger gets for any agent that is not both exposed AND priced — + which is every agent in an OSS build, and every non-priced agent in an + entitled one. The bytes matter: this is the answer the route has always + given, so the gate adds no new signal for an attacker mapping the fleet. + """ + return JSONResponse( + status_code=401, + content={"detail": "Not authenticated"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def _payer_for_task( + agent_name: str, exec_id: str, priced, access_token: Optional[str], base_url: str +) -> bool: + """May this token's payer see/cancel `exec_id`? (T5) + + The binding with no new schema: the payment log rows already carry both + `execution_id` and `subscriber_address`, so "did this wallet pay for this + task" is a query, not a column. Verifying on each poll costs one facilitator + call, which is why the per-IP and per-agent limiters are upstream of here. + + EVERY failure — no token, a token that does not verify, a wallet with no + row for this execution, a row belonging to another agent — returns False, + and the caller then gets the SAME `-32001 Task not found` an unknown id + gets. That uniformity is the point: a differential answer would turn this + into an oracle for "which execution ids exist", and an anonymous caller is + exactly who must not have one. + + Bound as soon as the turn has a RESULT, not only once a settle row lands + (I4): `run_paid_turn` writes a `verify` row carrying the execution id when + `execute()` returns, before the settle starts. The settle rows used to be + the only writers of the pair, so a payer was locked out of a finished task + for as long as the settle ran (three 30-second attempts), and for good when + a concurrent settle wrote no row of its own (`settle_in_progress`). + + NOT bound mid-turn, stated: `execute()` is `dispatch_and_await_terminal`, + which returns at the terminal on push and pull alike, so the execution id + does not exist in `run_paid_turn` while the turn is running. A poll during + the turn reads as not-found, and a payer's `tasks/cancel` can only answer + "already in a terminal state" — the paying caller holds no task id until + the turn ends anyway. Binding earlier needs the execution id minted before + dispatch; that is a change to the dispatch contract, not to this function. + + Residual, stated: a consumer that timed out never received the task id in + the first place, so polling is only available to a client that HAS one. The + payer's own retry of the original `message/send` is what recovers the + artifact in that case — it replays the completed snapshot without + re-executing or re-charging. + """ + if not access_token: + return False + try: + verify = await get_nevermined_payment_service().verify_payment( + nvm_api_key=priced.nvm_api_key, + nvm_environment=priced.config.nvm_environment, + config=priced.config, + access_token=access_token, + base_url=base_url, + endpoint=f"{base_url}/a2a/{agent_name}", + ) + except Exception: # noqa: BLE001 — a verify that blew up is not an entitlement + logger.warning("a2a: verify raised while binding a payer to a task", exc_info=True) + return False + if not verify.success or not verify.payer: + return False + return bool(db.nevermined_payer_owns_execution(agent_name, exec_id, verify.payer)) + + +async def _anonymous_jsonrpc(agent_name: str, request: Request): + """The JSON-RPC door for a caller holding no Trinity credential (ent#679). + + Order is load-bearing and each step is cheaper than the next: + + 1. **Rate limit, per IP and per agent**, before any DB read or SDK call. + One unauthenticated hit on the paying path can cost a 15-second + facilitator verify, so this is the step that keeps a flood from + converting into an agent's whole facilitator quota. The per-agent bucket + exists because a distributed flood passes every per-IP bucket. + 2. **Exposed and priced?** No → today's 401. The gate is invisible to + anyone who could not already read the agent off its published card. + 3. **SDK present?** No → 501, the paid door's answer. Honest rather than + 401: the door exists and cannot take payment right now, and no + credential the caller could obtain would change that. + 4. **Envelope**, capped and parsed — the principal path's exact refusals. + 5. **Token**, in-band first. Absent → 402 with the paid door's bytes. + """ + rate_limiter.enforce( + f"a2a_pay_ip:{_get_client_ip(request)}", + A2A_PAY_RATE_LIMIT, + A2A_PAY_RATE_WINDOW, + detail="Too many unauthenticated A2A requests from this address.", + ) + rate_limiter.enforce( + f"a2a_pay_agent:{agent_name}", + A2A_PAY_AGENT_RATE_LIMIT, + A2A_PAY_AGENT_RATE_WINDOW, + detail="Too many unauthenticated A2A requests for this agent.", + ) + + priced = a2a_payment_gate.is_priced(agent_name, db=db) + if priced is None: + return _not_authenticated() + if not NEVERMINED_AVAILABLE: + return JSONResponse( + status_code=501, + content={"detail": "Nevermined payment integration is not available"}, + ) + + parsed = await _parse_rpc_envelope(request) + if isinstance(parsed, JSONResponse): + return parsed + method, params, rpc_id = parsed + + base_url = str(request.base_url).rstrip("/") + caller_ip = request.client.host if request.client else None + payment_service = get_nevermined_payment_service() + + if method in ("message/send", "message/stream"): + message = params.get("message") + if not isinstance(message, dict): + return _rpc_error(rpc_id, _RPC_INVALID_PARAMS, "params.message is required") + text = _text_from_message(message) + if not text: + return _rpc_error(rpc_id, _RPC_INVALID_PARAMS, "message has no text parts") + + access_token = a2a_payment_gate.extract_token(message, request.headers) + if not access_token: + status_code, body, headers = a2a_payment_gate.payment_required_response( + agent_name, priced.config, + payment_service=payment_service, base_url=base_url, + ) + return JSONResponse(status_code=status_code, content=body, headers=headers) + + if method == "message/stream": + return await _stream_paid_task( + agent_name, text, priced, access_token, base_url, rpc_id, caller_ip, + ) + return await _send_paid_task( + agent_name, text, priced, access_token, base_url, rpc_id, caller_ip, request, + ) + + if method in ("tasks/get", "tasks/cancel"): + exec_id = params.get("id") + if not isinstance(exec_id, str) or not exec_id: + return _rpc_error(rpc_id, _RPC_INVALID_PARAMS, "params.id is required") + access_token = a2a_payment_gate.extract_token(params.get("message"), request.headers) + allowed = await _payer_for_task(agent_name, exec_id, priced, access_token, base_url) + if not allowed: + # Byte-identical to an unknown task id (T5) — no existence oracle. + return _rpc_error(rpc_id, _A2A_TASK_NOT_FOUND, "Task not found") + return await _bound_task_rpc(agent_name, method, exec_id, rpc_id, caller_ip) + + if method == "tasks/resubscribe": + return _rpc_error(rpc_id, _A2A_UNSUPPORTED, + "tasks/resubscribe is not yet supported on this server") + return _rpc_error(rpc_id, _RPC_METHOD_NOT_FOUND, f"Method not found: {method}") + + +class _PaidRefusal(NamedTuple): + """A refusing `PaidTurnOutcome`, classified once for BOTH paid doors. + + `http` is `(status, body)` where the refusal IS an HTTP status on + `message/send` — the paid door's 403 bytes (T3) and the allow-list / + sentinel abort. `message/stream` cannot use it: the status line is long gone + by the time the body generator runs, so the stream renders `code`/`message`/ + `data` instead. One classification, two renderers — the alternative was a + second table in `_stream_paid_task`, which is how it came to answer `-32001` + (A2A **TaskNotFound**) for a payment refusal and to flatten "not allowed" + and "retry" into one `-32603 Task execution failed`. + """ + code: int + message: str + data: Optional[dict] + http: Optional[tuple] = None + + +def _classify_paid_refusal(outcome) -> Optional[_PaidRefusal]: + """None when the outcome IS a task state; the refusal otherwise. + + `data.code` is the discriminator a caller needs: "buy a token", "not + allowed", "retry — we were busy" and "retry — your own duplicate is still + running" are four different instructions, and `data.retryable` says which + of them are worth repeating with the SAME token. + """ + kind = outcome.kind + if kind == paid_turn_service.VERIFY_FAILED: + if getattr(outcome.verify, "retryable", False): + # OUR side could not decide — facilitator timeout, SDK error, or a + # saturated concurrency gate (E7). A 403 here would tell #3209's + # client `payment_rejected`, i.e. stop retrying and go buy another + # token, for what is us being busy. Never a 403, and never -32001: + # this is not a task condition either. + return _PaidRefusal( + _RPC_INTERNAL_ERROR, + "Payment verification could not be completed", + {"code": "verify_unavailable", "retryable": True}, + ) + # A real rejection. The paid door's 403 bytes: #3185's client maps a 403 + # carrying `credential_kind=payment_token` to `payment_rejected`, which + # is what tells a remote Trinity to stop retrying and go buy a token. + return _PaidRefusal( + _RPC_INTERNAL_ERROR, + "Payment verification failed", + {"code": "payment_rejected", "retryable": False}, + http=(403, outcome.payload), + ) + if kind == paid_turn_service.ABORTED: + return _PaidRefusal( + _RPC_INTERNAL_ERROR, + outcome.payload.get("detail") or "Request refused", + {"code": "not_allowed", "retryable": False}, + http=(outcome.status_code, outcome.payload), + ) + if kind == paid_turn_service.IN_FLIGHT: + return _PaidRefusal( + _RPC_INTERNAL_ERROR, + "A duplicate paid request is still being processed", + {"code": "in_flight", "retryable": True}, + ) + if kind == paid_turn_service.EXECUTION_ERROR: + return _PaidRefusal( + _RPC_INTERNAL_ERROR, + "Task execution failed", + {"code": "execution_error", "retryable": False}, + ) + return None + + +def _paid_outcome_refusal(outcome, rpc_id: Any): + """A `PaidTurnOutcome` → the `message/send` answer, or None for "render a Task".""" + refusal = _classify_paid_refusal(outcome) + if refusal is None: + return None + if refusal.http is not None: + status, body = refusal.http + return JSONResponse(status_code=status, content=body) + return _rpc_error(rpc_id, refusal.code, refusal.message, data=refusal.data) + + +async def _send_paid_task(agent_name: str, text: str, priced, access_token: str, + base_url: str, rpc_id: Any, caller_ip: Optional[str], + request: Request): + """`message/send` on the payment path: verify → dedup → execute → settle → Task.""" + try: + outcome = await a2a_payment_gate.run_a2a_paid_turn( + agent_name=agent_name, + priced=priced, + access_token=access_token, + text=text, + base_url=base_url, + execute=lambda: _run_a2a_paid_execution(agent_name, text), + payment_service=get_nevermined_payment_service(), + idem=idempotency_service, + db=db, + ) + except Exception as exc: # noqa: BLE001 — never 5xx; A2A wants a JSON-RPC error + logger.warning("a2a paid message/send failed for %s: %s", agent_name, exc) + return _rpc_error(rpc_id, _RPC_INTERNAL_ERROR, "Task execution failed") + + refusal = _paid_outcome_refusal(outcome, rpc_id) + if refusal is not None: + return refusal + + task = a2a_payment_gate.task_from_paid_payload(outcome, task_builder=_task_object) + await platform_audit_service.log( + event_type=AuditEventType.EXECUTION, event_action="a2a_task", source="a2a", + # No actor_user: there is no Trinity identity behind a paying stranger. + # The payer wallet is the identity, and it goes in the details (T4). + actor_user=None, actor_ip=caller_ip, + target_type="agent", target_id=agent_name, + endpoint=request.scope["path"], + details={ + "execution_id": outcome.execution_id, + "state": (task or {}).get("status", {}).get("state"), + "payer": getattr(outcome.verify, "payer", None), + "settled": outcome.settled, + }, + ) + headers = {"X-Idempotent-Replay": "true"} if outcome.replayed else None + return JSONResponse( + {"jsonrpc": "2.0", "id": rpc_id, "result": task}, headers=headers, + ) + + +async def _stream_paid_task(agent_name: str, text: str, priced, access_token: str, + base_url: str, rpc_id: Any, caller_ip: Optional[str]): + """`message/stream` on the payment path. + + Non-incremental like the principal path (the agent turn is atomic), but + spec-shaped: a `working` status event, then the terminal task carrying the + payment metadata. A refusal that is an HTTP status (402 handled upstream, + 403 here) cannot be expressed mid-stream, so it is emitted as a JSON-RPC + error event — a streaming client has an event-stream parser attached and a + bare JSON body would break it. + + `run_paid_turn` owns the cancellation bookkeeping (#679 E5): a client that + disconnects mid-turn has its claim released before a result exists, and + after one exists the settle is shielded and the claim is completed as + unsettled, so a retry replays the work instead of paying for it twice. + """ + async def _gen(): + working = {"jsonrpc": "2.0", "id": rpc_id, "result": { + "kind": "status-update", + "status": {"state": "working"}, + "final": False, + }} + yield f"data: {json.dumps(working)}\n\n" + try: + outcome = await a2a_payment_gate.run_a2a_paid_turn( + agent_name=agent_name, + priced=priced, + access_token=access_token, + text=text, + base_url=base_url, + execute=lambda: _run_a2a_paid_execution(agent_name, text), + payment_service=get_nevermined_payment_service(), + idem=idempotency_service, + db=db, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("a2a paid message/stream failed for %s: %s", agent_name, exc) + err = {"jsonrpc": "2.0", "id": rpc_id, "error": { + "code": _RPC_INTERNAL_ERROR, "message": "Task execution failed"}} + yield f"data: {json.dumps(err)}\n\n" + return + + refusal = _classify_paid_refusal(outcome) + task = ( + None if refusal is not None + else a2a_payment_gate.task_from_paid_payload(outcome, task_builder=_task_object) + ) + if task is None: + # A payment refusal is not a task state, and the HTTP-shaped ones + # (the 403, the abort) cannot be expressed mid-stream — so every one + # of them goes out as the SAME classified JSON-RPC error + # `message/send` uses, carrying `data.code` + `data.retryable`. + # Deliberately never `-32001`: that is A2A TaskNotFound, and telling + # a client its task vanished when the truth is "pay" / "not allowed" + # / "retry" is a wrong answer it cannot recover from. + err = {"jsonrpc": "2.0", "id": rpc_id, "error": ( + {"code": refusal.code, "message": refusal.message, + "data": refusal.data} + if refusal is not None + # Defensive: an outcome kind that is neither a refusal nor a + # task. Still not -32001. + else {"code": _RPC_INTERNAL_ERROR, "message": "Task execution failed"} + )} + yield f"data: {json.dumps(err)}\n\n" + return + + await platform_audit_service.log( + event_type=AuditEventType.EXECUTION, event_action="a2a_task_stream", source="a2a", + actor_user=None, actor_ip=caller_ip, + target_type="agent", target_id=agent_name, + details={ + "execution_id": outcome.execution_id, + "payer": getattr(outcome.verify, "payer", None), + "settled": outcome.settled, + }, + ) + final = {"jsonrpc": "2.0", "id": rpc_id, "result": {**task, "final": True}} + yield f"data: {json.dumps(final)}\n\n" + + return StreamingResponse(_gen(), media_type="text/event-stream") + + +async def _bound_task_rpc(agent_name: str, method: str, exec_id: str, + rpc_id: Any, caller_ip: Optional[str]): + """`tasks/get` / `tasks/cancel` for a payer already bound to `exec_id` (T5). + + Reads and cancels exactly as the principal path does — the authorization + happened upstream in `_payer_for_task`, and the behaviour a caller gets + after it must not be a second, divergent implementation of the same two + methods. + """ + row = db.get_execution(exec_id) + if not row or _exec_field(row, "agent_name") != agent_name: + return _rpc_error(rpc_id, _A2A_TASK_NOT_FOUND, "Task not found") + status = _exec_field(row, "status") + + if method == "tasks/get": + a2a_state = { + "success": "completed", "failed": "failed", "cancelled": "canceled", + "running": "working", "queued": "submitted", + }.get(status, "working") + return _rpc_result(rpc_id, _task_object( + exec_id, a2a_state, + text=_exec_field(row, "response") if a2a_state == "completed" else None, + error=_exec_field(row, "error") if a2a_state == "failed" else None, + )) + + if status in ("success", "failed", "cancelled"): + return _rpc_error(rpc_id, _A2A_TASK_NOT_CANCELABLE, + "Task is already in a terminal state") + if status == "queued": + cancelled = bool(db.cancel_queued_execution( + exec_id, reason="Cancelled by A2A caller")) + else: + cancelled = bool(await terminate_execution_on_agent(agent_name, exec_id)) + if not cancelled: + return _rpc_error(rpc_id, _A2A_TASK_NOT_CANCELABLE, "Task could not be canceled") + + await platform_audit_service.log( + event_type=AuditEventType.EXECUTION, event_action="a2a_cancel", source="a2a", + actor_user=None, actor_ip=caller_ip, + target_type="agent", target_id=agent_name, details={"execution_id": exec_id}, + ) + return _rpc_result(rpc_id, _task_object(exec_id, "canceled")) + + @a2a_server_router.post("/a2a/{agent_name}") async def a2a_jsonrpc( agent_name: str, request: Request, - current_user: User = Depends(get_current_user), + current_user: Optional[User] = Depends(get_user_or_anonymous), ): - """A2A JSON-RPC 2.0 task endpoint. Bearer = a Trinity MCP API key (validated - by `get_current_user` — fail-closed 401). Methods: message/send, - message/stream (SSE), tasks/get, tasks/cancel.""" - _authorize_inbound(current_user, agent_name) - - # Cap before parsing — an uncapped await request.json() lets one caller pin - # memory. nginx caps at 25m, but :8000 may be reachable directly. - raw = await request.body() - if len(raw) > _MAX_RPC_BODY_BYTES: - return _rpc_error(None, _RPC_INVALID_REQUEST, "Request body too large") - try: - body = json.loads(raw) - except Exception: - return _rpc_error(None, _RPC_PARSE_ERROR, "Parse error: body is not valid JSON") + """A2A JSON-RPC 2.0 task endpoint. Two credentials, one door. + + * **A Trinity MCP API key** → the principal path, unchanged: owner/shared + access + the enterprise allow-list, and the task runs for free. This is + internal fleet traffic and subscription tenants, and nothing below + touches it (AC4). + * **No recognised Trinity credential** → the x402 payment path + (abilityai/trinity-enterprise#679), but ONLY when the agent is both + A2A-exposed and priced. Otherwise the caller gets today's 401 bytes, so + the gate never makes exposure or pricing observable to a stranger who + could not already read it off the published well-known card. + + `get_user_or_anonymous` degrades to `None` on a 401 only. A 403 — a + connector key outside its scope, a fenced ephemeral key — is re-raised, so + a credential Trinity recognised and then REFUSED can never slide onto the + payment path and buy its way in. + """ + if current_user is None: + return await _anonymous_jsonrpc(agent_name, request) - if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str): - return _rpc_error(body.get("id") if isinstance(body, dict) else None, - _RPC_INVALID_REQUEST, "Invalid JSON-RPC 2.0 request") + _authorize_inbound(current_user, agent_name) - method = body["method"] - params = body.get("params") or {} - rpc_id = body.get("id") - if not isinstance(params, dict): - return _rpc_error(rpc_id, _RPC_INVALID_PARAMS, "params must be an object") + parsed = await _parse_rpc_envelope(request) + if isinstance(parsed, JSONResponse): + return parsed + method, params, rpc_id = parsed caller_ip = request.client.host if request.client else None diff --git a/src/backend/routers/paid.py b/src/backend/routers/paid.py index 736358876..85c505ff0 100644 --- a/src/backend/routers/paid.py +++ b/src/backend/routers/paid.py @@ -22,63 +22,12 @@ ) from services.task_execution_service import get_task_execution_service from services.platform_prompt_service import build_public_channel_caller_prompt +from services import paid_turn_service router = APIRouter(prefix="/api/paid", tags=["paid"]) logger = logging.getLogger(__name__) -def _finalize_settled( - *, - agent_name: str, - config, - response, - execution_id: Optional[str], - settle_result, - payer: Optional[str], - idem: idempotency_service.IdempotencyDecision, -) -> dict: - """Shared success finalizer for BOTH the fresh-settle and replay-resettle paths (#1018). - - A client retry that finally settles must still (a) log ``action="settle"`` and - (b) converge the stored trigger snapshot unsettled→settled — otherwise the - settle is never recorded and every replay re-drives settle forever. Kept as one - helper so neither path can drift on the bookkeeping. - """ - db.log_nevermined_payment( - agent_name=agent_name, - action="settle", - success=True, - execution_id=execution_id, - subscriber_address=payer, - credits_amount=config.credits_per_request, - tx_hash=settle_result.tx_hash, - remaining_balance=( - int(settle_result.remaining_balance) - if settle_result.remaining_balance - else None - ), - ) - - settled_payload = { - "response": response, - "execution_id": execution_id, - "status": "success", - "payment": { - "settled": True, - "credits_burned": config.credits_per_request, - "remaining_balance": settle_result.remaining_balance, - "tx_hash": settle_result.tx_hash, - }, - } - - # Converge the stored trigger snapshot (#1018): on the fresh path this completes - # the in-flight claim with the settled snapshot; on the replay-resettle path it - # upgrades a completed-but-unsettled snapshot so a THIRD request replays - # 'settled' and never re-drives settle. No-op when dedup is disabled. - idempotency_service.upgrade_snapshot(idem.scope, idem.key, settled_payload) - return settled_payload - - @router.get("/{agent_name}/info") async def get_paid_agent_info(agent_name: str): """Get agent payment info and requirements. @@ -197,128 +146,18 @@ async def paid_chat( }, ) - # Step 2: Verify payment - verify_result = await payment_service.verify_payment( - nvm_api_key=nvm_api_key, - nvm_environment=config.nvm_environment, - config=config, - access_token=access_token, - base_url=base_url, - ) - - if not verify_result.success: - # Log rejected verification - db.log_nevermined_payment( - agent_name=agent_name, - action="reject", - success=False, - subscriber_address=verify_result.payer, - error=verify_result.error, - ) - return JSONResponse( - status_code=403, - content={ - "detail": "Payment verification failed", - "error": verify_result.error, - }, - ) - - # Log successful verification - db.log_nevermined_payment( - agent_name=agent_name, - action="verify", - success=True, - subscriber_address=verify_result.payer, - ) - - # Idempotency gate (Invariant #18, #1018) — placed AFTER a successful verify so - # a rejected 403 never consumes a key. The key is derived from - # (payment-signature + message); the client `Idempotency-Key` header is accepted - # for contract compliance but intentionally does NOT participate in derivation — - # a divergent header must not fork execution. None (missing token/body) → dedup - # disabled (fail-open), never a constant key. - idem_scope = idempotency_service.make_agent_scope(agent_name) - idem_key = idempotency_service.derive_payment_key( - access_token, request_body.message.encode("utf-8") if request_body.message else None - ) - idem = idempotency_service.begin(idem_scope, idem_key) - - if idem.replay: - if idem.in_flight: - # A concurrent duplicate of the same (token+body) is mid-flight. - return JSONResponse( - status_code=409, - content={"detail": "A duplicate paid request is still being processed."}, - ) - - snapshot = idem.snapshot or {} - payment_snap = snapshot.get("payment") or {} - - if payment_snap.get("settled"): - # Already settled — replay the receipt verbatim, no re-execute/re-settle. - return JSONResponse( - status_code=200, - content=snapshot, - headers={"X-Idempotent-Replay": "true"}, - ) - - # Completed but UNSETTLED — re-drive settle WITHOUT re-running the LLM (the - # trigger key already deduped the execution), then converge the snapshot. This - # is why the unsettled branch stores the claim with complete() (not fail()): - # fail() would re-execute here. NOTE: the re-settle is NOT provider-idempotent — - # Nevermined's agent_request_id is an observability id (fresh per verify) and the - # facilitator burns on every successful settle_permissions call. Re-driving is - # safe here only because the prior settle genuinely did NOT complete; a settle - # that burned on-chain but reported failure would re-burn (at-least-once residual, - # tracked by #1408). The payment:{agent_request_id} effect guard only dedups a - # concurrent settle that reuses the SAME id. - resettle = await payment_service.settle_payment_once( - config=config, - nvm_api_key=nvm_api_key, - nvm_environment=config.nvm_environment, - access_token=access_token, - agent_request_id=verify_result.agent_request_id, - execution_id=snapshot.get("execution_id"), - base_url=base_url, - ) - if resettle.success: - return _finalize_settled( - agent_name=agent_name, - config=config, - response=snapshot.get("response"), - execution_id=snapshot.get("execution_id"), - settle_result=resettle, - payer=verify_result.payer, - idem=idem, - ) - # Still unsettled — replay the stored unsettled snapshot; the claim stays - # 'completed unsettled' so a later retry re-drives settle again. - return JSONResponse( - status_code=200, - content=snapshot, - headers={"X-Idempotent-Replay": "true"}, - ) - - # EXEC-023 (#1672): reject the #1083 dispatch sentinels as a resume target on the - # paid path too — 'dispatched'/'dispatched_async' are never a resumable session, so - # `--resume dispatched_async` would just fail. Scoped slice only: unlike the - # authenticated /task gate, there is no Trinity `current_user` here (an anonymous - # x402 payer, session_id self-asserted), so full payer→session ownership can't be - # enforced without a payer-identity binding that doesn't exist yet — tracked as a - # follow-up, not silently ignored. - if request_body.session_id in ("dispatched", "dispatched_async"): - return JSONResponse( - status_code=400, - content={"detail": "This execution was never assigned a resumable session."}, - ) - - # Step 3: Execute task + # Steps 2-4 (verify → dedup → execute → settle) are the SHARED orchestrator + # (ent#679): the same code the A2A payment gate runs, so the #1018 settle + # branches exist once. Every collaborator is passed from THIS module's + # globals — `db`, `idempotency_service`, the payment service and the execute + # closure — so a test that patches `paid.db` or `paid.idempotency_service` + # still decides what the money path talks to (decision 20). from services.task_execution_service import dispatch_and_await_terminal - try: + async def _execute(): # #3114: on a pull pilot the turn is queued and awaited here; a # resumed session is its conversation key. - exec_result = await dispatch_and_await_terminal( + return await dispatch_and_await_terminal( service=get_task_execution_service(), agent_name=agent_name, message=request_body.message, @@ -331,113 +170,69 @@ async def paid_chat( # #894: per-agent public-channel model override (None → platform default). model=db.get_public_channel_model(agent_name), ) - except Exception as e: - logger.error(f"Task execution failed for paid request on {agent_name}: {e}") - # Nothing dispatched — release the claim so a legitimate retry re-executes. - idempotency_service.fail(idem) - # Don't settle — caller keeps credits - db.log_nevermined_payment( - agent_name=agent_name, - action="verify", - success=True, - subscriber_address=verify_result.payer, - error=f"Execution failed: {e}", - ) - return JSONResponse( - status_code=500, - content={ - "detail": "Task execution failed", - "error": str(e), - "payment": {"settled": False, "reason": "Execution failed — no charge"}, - }, - ) - - if exec_result.status in ("failed", "cancelled"): - # Don't settle — caller keeps credits. #679: a CANCELLED turn must NOT - # settle either — settling on cancel is the charge-on-cancel money bug. - # Release the claim so a retry re-executes (no completed work to replay). - idempotency_service.fail(idem) - is_cancelled = exec_result.status == "cancelled" - content = { - "execution_id": exec_result.execution_id, - "status": "cancelled" if is_cancelled else "failed", - "payment": { - "settled": False, - "reason": ( - "Execution cancelled — no charge" - if is_cancelled - else "Execution failed — no charge" - ), - }, - } - # #1018 hardening: a FAILED execution may hold partial/garbled output; the - # caller paid nothing, so don't leak the body. A CANCELLED turn keeps its - # response (#679 — the user cancelled their own work and may want it). - if is_cancelled: - content["response"] = exec_result.response - return JSONResponse(status_code=200, content=content) - # Record the execution on the claim now that it exists (best-effort). - idempotency_service.attach_execution(idem, exec_result.execution_id) + def _reject_dispatch_sentinel(_verify): + """EXEC-023 (#1672): reject the #1083 dispatch sentinels as a resume target. + + 'dispatched'/'dispatched_async' are never a resumable session, so + `--resume dispatched_async` would just fail. Scoped slice only: unlike the + authenticated /task gate, there is no Trinity `current_user` here (an + anonymous x402 payer, session_id self-asserted), so full payer→session + ownership can't be enforced without a payer-identity binding that doesn't + exist yet — tracked as a follow-up, not silently ignored. Runs where it + has always run: after the dedup gate, before execution. + """ + if request_body.session_id in ("dispatched", "dispatched_async"): + raise paid_turn_service.PaidTurnAbort( + {"detail": "This execution was never assigned a resumable session."}, + status_code=400, + ) - # Step 4: Settle payment (on success only). Effect-scoped guard (#1084) so a - # concurrent settle reusing the SAME agent_request_id is deduped locally. The - # terminal-turn guard above (failed execution → no settle) is the outer layer - # and is preserved. NOTE: agent_request_id is a Nevermined observability id, not - # a provider exactly-once token — this local guard is the only settle dedup, and - # a fresh-id retry's double-settle residual is tracked by #1408. - settle_result = await payment_service.settle_payment_once( + # Idempotency gate (Invariant #18, #1018) — applied by the orchestrator AFTER a + # successful verify so a rejected 403 never consumes a key. The key is derived + # from (payment-signature + message); the client `Idempotency-Key` header is + # accepted for contract compliance but intentionally does NOT participate in + # derivation — a divergent header must not fork execution. None (missing + # token/body) → dedup disabled (fail-open), never a constant key. + turn = await paid_turn_service.run_paid_turn( + agent_name=agent_name, config=config, nvm_api_key=nvm_api_key, - nvm_environment=config.nvm_environment, access_token=access_token, - agent_request_id=verify_result.agent_request_id, - execution_id=exec_result.execution_id, + idem_scope=idempotency_service.make_agent_scope(agent_name), + idem_key=idempotency_service.derive_payment_key( + access_token, + request_body.message.encode("utf-8") if request_body.message else None, + ), + execute=_execute, + pre_execute=_reject_dispatch_sentinel, + payment_service=payment_service, + idem=idempotency_service, + db=db, base_url=base_url, ) - if settle_result.success: - # Logs settle + completes the idempotency claim with the settled snapshot. - return _finalize_settled( - agent_name=agent_name, - config=config, - response=exec_result.response, - execution_id=exec_result.execution_id, - settle_result=settle_result, - payer=verify_result.payer, - idem=idem, - ) - - # Settlement did not complete — the work WAS delivered, so deliver-then-reconcile: - # keep HTTP 200 + the response, but tell the truth with status "success_unsettled" - # (the filed #1018 bug: this used to lie with status "success"). Two sub-cases: - # * concurrent settle in-flight (effect guard) → settle_in_progress, no log - # (the concurrently-running settle logs its own outcome; it completes once). - # * genuine failure after retries → settle_retry_needed + settle_failed log. - settle_in_progress = settle_result.error == "settlement already in progress" - payment_block = {"settled": False, "error": settle_result.error} - if settle_in_progress: - payment_block["settle_in_progress"] = True - else: - payment_block["settle_retry_needed"] = True - db.log_nevermined_payment( - agent_name=agent_name, - action="settle_failed", - success=False, - execution_id=exec_result.execution_id, - subscriber_address=verify_result.payer, - credits_amount=config.credits_per_request, - error=settle_result.error, + # Render the outcome into this door's historical response bodies. The paid + # door's bytes are unchanged on every branch; only the code that produced + # them moved. + if turn.kind in (paid_turn_service.REPLAY_SETTLED, paid_turn_service.REPLAY_UNSETTLED): + return JSONResponse( + status_code=200, + content=turn.payload, + headers={"X-Idempotent-Replay": "true"}, ) - - unsettled_payload = { - "response": exec_result.response, - "execution_id": exec_result.execution_id, - "status": "success_unsettled", - "payment": payment_block, - } - # complete() — NOT fail() — so a client re-POST replays the completed work and - # re-drives settle (idempotent) rather than re-running the LLM (double cost). - # The snapshot stays 'unsettled' until a settle finally succeeds and upgrades it. - idempotency_service.complete(idem, exec_result.execution_id, unsettled_payload) - return unsettled_payload + if turn.kind in ( + paid_turn_service.VERIFY_FAILED, + paid_turn_service.ABORTED, + paid_turn_service.IN_FLIGHT, + paid_turn_service.EXECUTION_ERROR, + ): + return JSONResponse(status_code=turn.status_code, content=turn.payload) + if turn.kind in ( + paid_turn_service.EXECUTION_FAILED, + paid_turn_service.EXECUTION_CANCELLED, + ): + return JSONResponse(status_code=200, content=turn.payload) + # settled (fresh or replay-resettled) and unsettled success: a plain dict, as + # this endpoint has always returned on the success paths. + return turn.payload diff --git a/src/backend/services/a2a_card_service.py b/src/backend/services/a2a_card_service.py index 0c96a31de..8c222cfa8 100644 --- a/src/backend/services/a2a_card_service.py +++ b/src/backend/services/a2a_card_service.py @@ -166,3 +166,110 @@ def generate_a2a_card( card["documentationUrl"] = f"{b}/a2a/{agent_name}/.well-known/agent-card.json" return card + + +# =========================================================================== +# ent#679 — the card states the price +# =========================================================================== + +#: Nevermined's own payment-extension URI, as emitted by the provider SDK's +#: `payments_py.a2a.agent_card.build_payment_agent_card`. We speak that +#: vocabulary verbatim so a payments-py client reads `agentId` / `planId` +#: straight off our card with no Trinity-specific knowledge. +#: +#: The **official A2A x402 extension URI** (`A2A_X402_EXTENSION_URI`, which the +#: SDK's helper also appends) is deliberately NOT declared. Per the A2A +#: extension spec, declaring an extension advertises the activation handshake +#: for it (`X-A2A-Extensions` negotiation), and Trinity runs no handshake — it +#: reads the in-band payment metadata and answers 402. Declaring the URI would +#: promise a protocol we do not implement, which is worse for a generic client +#: than saying nothing: it would activate the extension and then wait. +NEVERMINED_PAYMENT_EXTENSION_URI = "urn:nevermined:payment" + + +def _cost_description(credits: int, plan_id: str) -> str: + """Human-readable price line for the extension's `description`. + + `credits == 0` is a duration/time-based Nevermined plan (ent#679 T9): the + burn is whatever the plan defines and the per-call amount is not a fixed + number, so saying "0 credits per call" would read as free. Say what is + true instead — the plan sets the cost. + """ + if credits <= 0: + return f"Cost per call is set by Nevermined plan {plan_id}" + unit = "credit" if credits == 1 else "credits" + return f"{credits} {unit} per call via Nevermined plan {plan_id}" + + +def with_payment_extension( + card: Dict[str, Any], + pricing: Any, + *, + agent_name: str, + base_url: str = "", +) -> Dict[str, Any]: + """Declare the agent's price on its A2A card (ent#679 AC2). + + A stranger that gets a 402 from `POST /a2a/{name}` can act on it, but it + has to call first to learn there is a price at all. The card is the + discovery document, so the price belongs on the card: an x402-speaking + client can mint a token from `agentId` + `planId` and meet the paywall on + its first request, and a human following `paymentInfoUrl` lands on the + public `GET /api/paid/{name}/info` document that says what to buy. + + **Pure.** No I/O, no edition awareness — the caller does the config read + and decides whether this agent is priced. `pricing` is a Nevermined config + (anything carrying `nvm_agent_id` / `nvm_plan_id` / `credits_per_request` / + `nvm_environment` / `enabled`); `None`, a disabled config, or one missing + its plan/agent ids returns **the card object unchanged, by identity**, so + an unpriced agent's card is byte-identical to before this change. A priced + agent gets a new dict — the input is never mutated. + + The declared `paymentType` follows the credit amount rather than being + hardcoded "fixed": a 0-credit duration plan charges by time, and declaring + `{paymentType: "fixed", credits: 0}` is a contradiction the SDK's own card + validator rejects for a paid plan. + + Note for an OSS reader: this block says what the agent costs, not that the + door is open. The paid A2A door also needs A2A exposure to be ON, which is + the entitled enterprise setter's flag — so in an OSS-only build a + configured price block points at a door that answers 404 (ent#679 T1). + """ + if pricing is None or not getattr(pricing, "enabled", False): + return card + + agent_id = getattr(pricing, "nvm_agent_id", None) + plan_id = getattr(pricing, "nvm_plan_id", None) + if not agent_id or not plan_id: + # A config that cannot tell a client what to buy is worse than silence: + # the client would activate a payment flow with no plan to pay into. + return card + + try: + credits = int(getattr(pricing, "credits_per_request", 0) or 0) + except (TypeError, ValueError): + credits = 0 + + params: Dict[str, Any] = { + "agentId": agent_id, + "planId": plan_id, + "credits": credits, + "paymentType": "fixed" if credits > 0 else "dynamic", + "costDescription": _cost_description(credits, plan_id), + } + environment = getattr(pricing, "nvm_environment", None) + if environment: + params["environment"] = environment + if base_url: + params["paymentInfoUrl"] = f"{base_url.rstrip('/')}/api/paid/{agent_name}/info" + + capabilities = dict(card.get("capabilities") or {}) + extensions = list(capabilities.get("extensions") or []) + extensions.append({ + "uri": NEVERMINED_PAYMENT_EXTENSION_URI, + "description": params["costDescription"], + "required": False, + "params": params, + }) + capabilities["extensions"] = extensions + return {**card, "capabilities": capabilities} diff --git a/src/backend/services/a2a_payment_gate.py b/src/backend/services/a2a_payment_gate.py new file mode 100644 index 000000000..7fdc351f1 --- /dev/null +++ b/src/backend/services/a2a_payment_gate.py @@ -0,0 +1,393 @@ +"""The x402 payment gate on the A2A inbound door (abilityai/trinity-enterprise#679). + +`POST /a2a/{name}` authenticated a Trinity MCP key and nothing else, so a +stranger — including a remote Trinity holding a perfectly good x402 payment +token — got 401 and could never reach the 402 that would let it pay. This +module is the branch that serves that caller: token extraction, the paid door's +own 402/403 bytes, the shared money orchestrator, and the Task the payer gets +back. + +**Mechanism in OSS, reachable only in an entitled build (T1).** Nothing here +is edition-aware. The path is reachable only when BOTH `db.get_a2a_exposed` +(set exclusively by the entitled enterprise setter) and the OSS +`nevermined_agent_config.enabled` are true, which is the same shape the paid +door (`routers/paid.py`, NVM-001) has always had. In an OSS-only build every +agent is non-exposed, so `is_priced` is False everywhere and the anonymous +branch answers today's 401 — and the card's price block, if one is configured, +points at a door that 404s until exposure is on. + +**The money logic is NOT here.** It is `services/paid_turn_service.py`, shared +with the paid door, so the three #1018 settle branches exist once. This module +is the A2A-shaped adapter around it: what a token looks like on this wire, what +a refusal looks like, and how an outcome becomes a Task. + +**The in-band token never reaches the agent.** `extract_token` reads +`message.metadata` and the request header; `run_a2a_paid_turn` passes the +message's TEXT parts to the execution stack and the token only to the +facilitator. Nothing logs the token — `derive_payment_key` stores a SHA-256 of +it, and the log rows carry the payer wallet, which is an identity, not a +credential. +""" +from __future__ import annotations + +import base64 +import json +import logging +import uuid +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, Optional + +from services import a2a_gate, a2a_protocol, paid_turn_service + +logger = logging.getLogger(__name__) + +#: Per-IP budget on the anonymous branch. Each hit can cost a 15-second +#: facilitator verify, which is exactly what an unauthenticated flood would +#: amplify — so the limiter runs before any DB or SDK work. Mirrors the +#: well-known card's limiter (`A2A_CARD_RATE_LIMIT`), which exists for the same +#: reason on the same public surface. +A2A_PAY_RATE_LIMIT = 30 +A2A_PAY_RATE_WINDOW = 60 + +#: Per-agent budget, in addition to the per-IP one. A distributed flood across +#: many source addresses passes every per-IP bucket while still pinning one +#: agent's facilitator quota, and the per-agent limit is the only thing that +#: sees it. Higher than the per-IP limit: it must bound abuse without +#: throttling an agent's legitimate payers to one caller's share. +A2A_PAY_AGENT_RATE_LIMIT = 120 +A2A_PAY_AGENT_RATE_WINDOW = 60 + + +@dataclass +class PricedAgent: + """An exposed, priced agent's payment configuration.""" + + config: Any + nvm_api_key: str + + +def is_priced(agent_name: str, *, db) -> Optional[PricedAgent]: + """The agent's payment config when a stranger may pay to task it, else None. + + Two conditions, both required: + + * A2A exposure is ON (the entitled enterprise setter's flag). A non-exposed + agent answers a uniform 404 to a principal, and today's 401 to a + stranger — the gate must not make exposure observable. + * The OSS Nevermined config exists, is enabled, and carries an API key. + + Deliberately NOT the SDK check. "This agent takes payment" and "this + install can process one right now" are different facts with different + honest answers: the first missing means 401 (the stranger has no business + here), the second missing means 501 (the door exists and is broken). Fusing + them would answer 401 to a caller holding a valid token for an agent whose + card advertises a price — telling it to authenticate when no credential it + could obtain would work. + + Returning the config rather than a bool is deliberate: the caller needs it + for the 402 body anyway, and a second read would let the two answers + disagree between them. + """ + if not db.get_a2a_exposed(agent_name): + return None + config_data = db.get_nevermined_config_with_key(agent_name) + if not config_data: + return None + config = config_data.get("config") + nvm_api_key = config_data.get("nvm_api_key") + if not config or not getattr(config, "enabled", False) or not nvm_api_key: + return None + return PricedAgent(config=config, nvm_api_key=nvm_api_key) + + +def extract_token(message: Any, headers: Any) -> Optional[str]: + """The x402 access token for this request — in-band first, header fallback. + + Ruling 3 (2026-10-03): payments-py 1.18.0 carries payment IN-BAND in the + A2A message metadata (`x402.payment.payload`), and the `payment-signature` + header is a deprecated fallback kept for one release. This is the + provider-side mirror of `payments_py.a2a.inband.extract_inband_token`, + including its precedence (`inband_token or header_token`): when both are + present the metadata wins, so a client migrating between rails cannot have + a stale header silently decide what it pays with. + + Re-encoding the in-band payload into the base64 token the facilitator's + verify/settle APIs consume is byte-safe: the EIP-712 signature lives INSIDE + `payload.authorization` / `payload.signature`, not over the base64 + envelope, which is transport-only (the SDK's own round-trip note). + + Tolerant by construction. Every input is caller-controlled on a route + reachable without a Trinity credential, so a missing, malformed or + unencodable payload means "no in-band payment" → header → 402. It never + raises, and it never 500s a request into a shape the caller cannot act on. + """ + payload = a2a_protocol.payment_payload_from_message(message) + if payload is not None: + token = _encode_payload(payload) + if token: + return token + header = None + if headers is not None: + try: + header = headers.get(a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER) + except Exception: # noqa: BLE001 — a header mapping that misbehaves is "no header" + header = None + return header or None + + +def _encode_payload(payload: Dict[str, Any]) -> Optional[str]: + """`PaymentPayload` dict → the facilitator's base64url access token. + + Delegates to the SDK so there is ONE definition of the encoding on both + sides of the wire. The import is lazy and its failure is not an error + condition here: `is_priced` already required the SDK, so an ImportError on + this line means the gate was called in a configuration that cannot verify + anything — the honest answer is "no in-band token", which falls through to + the header and then to the 402. + """ + try: + from payments_py.x402.token import encode_access_token + + return encode_access_token(payload) + except Exception: # noqa: BLE001 — unencodable payload ⇒ no in-band payment + logger.debug("a2a: in-band x402 payload could not be encoded", exc_info=True) + return None + + +def payment_required_response( + agent_name: str, config, *, payment_service, base_url: str +) -> tuple[int, dict, dict]: + """The 402, byte-identical to the paid door's (T2) → (status, body, headers). + + One builder, two doors: `build_402_response` produces the requirements + document the facilitator will later check the token against, so a 402 built + differently from the verify is a rejection the caller cannot act on. + + `endpoint` is THIS door, not the paid one (#679 E2). An x402 v3 token signs + `resourceUrl` and the facilitator compares origin+path, so a token minted + against `/api/paid/{name}/chat` cannot authorize a call to `/a2a/{name}` — + and a non-Trinity client follows `resource.url` out of the 402 verbatim. + + A builder failure is the paid door's 500 branch: a broken plan config is + ours, not the caller's, and inventing requirements would mint a token + nothing can verify. + """ + try: + payment_required = payment_service.build_402_response( + config, base_url, f"{base_url}/a2a/{agent_name}" + ) + except Exception as e: # noqa: BLE001 + logger.error("Failed to build 402 response for a2a/%s: %s", agent_name, e) + return 500, {"detail": "Failed to build payment requirements"}, {} + + payment_required_b64 = base64.b64encode( + json.dumps(payment_required).encode() + ).decode() + return ( + 402, + { + "detail": "Payment required", + "payment_required": payment_required, + "credits_per_request": config.credits_per_request, + }, + {a2a_protocol.X402_PAYMENT_REQUIRED_HEADER: payment_required_b64}, + ) + + +def payment_caller_allowed(agent_name: str, payer: Optional[str]) -> bool: + """The enterprise inbound allow-list, consulted for a PAYING caller (T7). + + Deliberately NOT `a2a_gate.check_inbound_allowed`, and the difference is the + failure direction. That function fails OPEN because the caller it guards is + already authenticated as an owner/shared identity — the allow-list is an + extra layer over a decision already made. Here there is no such decision: + the payment IS the authorization, so a provider error must refuse rather + than admit an unlisted wallet. Same provider, same empty-list-means-no- + restriction contract, opposite bias, because the thing underneath it is + different. + + The identity is `x402:{payer}` — prefixed so an operator reading a + configured list can tell a wallet from an email, and so a wallet can never + collide with a Trinity identity in the same list. + """ + provider = a2a_gate.get_provider() + if provider is None: + return True + if not payer: + return False + try: + return bool(provider.is_inbound_allowed(agent_name, f"x402:{payer}")) + except Exception: # noqa: BLE001 — fail CLOSED: here the gate IS the authorization + logger.warning( + "[a2a_payment_gate] allow-list provider error for %s; refusing the " + "paying caller (fail-closed)", agent_name, exc_info=True, + ) + return False + + +class AllowlistRefused(Exception): + """The allow-list refused this payer after a successful verify (T7).""" + + +async def run_a2a_paid_turn( + *, + agent_name: str, + priced: PricedAgent, + access_token: str, + text: str, + base_url: str, + execute: Callable[[], Awaitable[Any]], + payment_service, + idem, + db, +) -> paid_turn_service.PaidTurnOutcome: + """One paid A2A turn, through the orchestrator the paid door runs. + + Two A2A-specific decisions, both load-bearing: + + **The dedup scope is `a2a:{agent}:pay:{payer}`** — resolved from the verify + result, so every payer gets a private replay namespace (FR-4). A shared + scope would let one payer's key resolve to another's stored snapshot, which + carries the agent's full response text. + + **The dedup key is `derive_payment_key(token, text)`, not `messageId`.** + #3209's client mints a fresh `uuid4().hex` messageId per call, so a retry + after its 30-second RPC timeout would carry a new id, re-execute the turn + and re-settle it — the payer pays twice for one answer. The (token, text) + pair IS what a retry repeats. Residual, stated not solved: a v3 single-use + token changes per call for SDK clients and defeats any key derived from it. + + The allow-list is consulted AFTER verify (T7) — the payer wallet only exists + once the facilitator has answered — and before execution, as `pre_execute`, + so a refusal lands at exactly the point the paid door's own refusal does and + never consumes a dedup key for work it won't do. + """ + def _check_allowlist(verify_result) -> None: + if payment_caller_allowed(agent_name, verify_result.payer): + return + db.log_nevermined_payment( + agent_name=agent_name, + action="reject", + success=False, + subscriber_address=verify_result.payer, + error="Payer not on the agent's A2A inbound allow-list", + ) + raise paid_turn_service.PaidTurnAbort( + {"detail": "Caller not on the agent's A2A inbound allow-list"}, + status_code=403, + ) + + return await paid_turn_service.run_paid_turn( + agent_name=agent_name, + config=priced.config, + nvm_api_key=priced.nvm_api_key, + access_token=access_token, + idem_scope=lambda verify: f"a2a:{agent_name}:pay:{verify.payer}", + idem_key=idem.derive_payment_key( + access_token, text.encode("utf-8") if text else None + ), + execute=execute, + pre_execute=_check_allowlist, + payment_service=payment_service, + idem=idem, + db=db, + base_url=base_url, + endpoint=f"{base_url}/a2a/{agent_name}", + ) + + +#: Outcome kind → (A2A task state, x402 payment status, error code). The table +#: IS the contract #3209's client reads, so it lives in one place rather than as +#: branches scattered through the router. +#: +#: `payment-verified` on a delivered-but-unsettled turn is the SDK's own +#: "verified, not settled" state, and it is chosen over `payment-failed` +#: because #3209's client parses the task NORMALLY on anything it does not +#: recognise as a refusal — so the artifact the payer paid for survives +#: (#1018's deliver-then-reconcile, carried onto this wire). +_OUTCOME_RENDER = { + paid_turn_service.SETTLED: ("completed", a2a_protocol.X402_STATUS_COMPLETED, None), + paid_turn_service.REPLAY_SETTLED: ("completed", a2a_protocol.X402_STATUS_COMPLETED, None), + paid_turn_service.UNSETTLED: ("completed", a2a_protocol.X402_STATUS_VERIFIED, None), + paid_turn_service.REPLAY_UNSETTLED: ("completed", a2a_protocol.X402_STATUS_VERIFIED, None), + paid_turn_service.EXECUTION_FAILED: ( + "failed", a2a_protocol.X402_STATUS_VERIFIED, "execution_failed"), + paid_turn_service.EXECUTION_CANCELLED: ( + "canceled", a2a_protocol.X402_STATUS_VERIFIED, "execution_cancelled"), +} + + +def task_from_paid_payload( + outcome: paid_turn_service.PaidTurnOutcome, + *, + task_builder: Callable[..., Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """A paid outcome → the A2A Task its payer gets back, or None for a JSON-RPC error. + + Rebuilt FROM `outcome.payload` — the paid door's snapshot dict — on every + answer INCLUDING a replay, which is why the snapshot shape is shared with + the paid door: the replay logic in `paid_turn_service` is then one code path + rather than one per door, and a replayed Task cannot drift from the Task + that was originally served. + + `task_builder` is passed in rather than imported: the router owns + `_task_object` and the artifact shape, and this service must not acquire a + second opinion about what an A2A Task looks like. + + Returns None for the outcomes that are not a Task at all (a verification + failure, an allow-list refusal, an in-flight duplicate, a raised execution) + — the router answers those in their own shapes, which are an HTTP status or + a JSON-RPC error, not a task state. + """ + render = _OUTCOME_RENDER.get(outcome.kind) + if render is None: + return None + state, payment_status, error_code = render + payload = outcome.payload or {} + payment = payload.get("payment") or {} + execution_id = payload.get("execution_id") or outcome.execution_id or uuid.uuid4().hex + + metadata: Dict[str, Any] = {a2a_protocol.X402_STATUS_KEY: payment_status} + if payment_status == a2a_protocol.X402_STATUS_COMPLETED: + # The SDK's `SettleResponse` alias names, so a non-Trinity reader sees + # the spec shape rather than Trinity's internal snapshot keys. + receipt = { + # The payer is on the verify result, not in the snapshot — and the + # replay path re-verifies, so it is present on a replayed receipt + # too (which is why the receipt is not stored in the snapshot). + "payer": getattr(outcome.verify, "payer", None), + "transaction": payment.get("tx_hash"), + "creditsRedeemed": payment.get("credits_burned"), + "remainingBalance": payment.get("remaining_balance"), + } + metadata[a2a_protocol.X402_RECEIPTS_KEY] = [ + {k: v for k, v in receipt.items() if v is not None} + ] + else: + # Verified but not settled, or verified and nothing owed. Name WHY in a + # code the caller can branch on, and say plainly that nothing was + # charged — a payer holding an artifact with no receipt otherwise has + # to guess whether it was billed. + code = error_code or ( + "settle_in_progress" if payment.get("settle_in_progress") + else "settle_retry_needed" + ) + reason = payment.get("reason") or payment.get("error") + if error_code in ("execution_failed", "execution_cancelled") and not reason: + reason = "no charge" + metadata[a2a_protocol.X402_ERROR_KEY] = {"code": code, "reason": reason} + + # A failed turn gets NO artifact (#1018): the output may be partial or + # garbled and the caller was not charged for it. A cancelled turn keeps its + # text (#679) — the caller cancelled its own work and may still want it. + text = payload.get("response") if state in ("completed", "canceled") else None + return task_builder( + execution_id, + state, + # A failed turn's honest text is the orchestrator's own reason + # ("Execution failed — no charge"): the snapshot deliberately carries no + # response on that branch, so this is the only thing to tell the caller. + error=payment.get("reason") if state == "failed" else None, + text=text, + metadata=metadata, + ) diff --git a/src/backend/services/a2a_protocol.py b/src/backend/services/a2a_protocol.py index 8c871b723..4c6f28725 100644 --- a/src/backend/services/a2a_protocol.py +++ b/src/backend/services/a2a_protocol.py @@ -79,6 +79,16 @@ X402_STATUS_REQUIRED = "payment-required" X402_STATUS_FAILED = "payment-failed" X402_STATUS_COMPLETED = "payment-completed" +#: Verified but NOT settled — payments-py 1.18's own state, and the honest +#: answer for Trinity's deliver-then-reconcile branch (#1018): the turn ran and +#: the artifact is attached, but no receipt exists yet. It matters that this is +#: neither `payment-completed` (which would be a receipt we do not have) nor +#: `payment-failed` (on which the outbound client DISCARDS the artifact the +#: payer's turn produced). A client that does not know the value parses the task +#: normally, which is exactly the required behaviour. +X402_STATUS_VERIFIED = "payment-verified" +#: What the provider sends when it accepted no payment and ran nothing. +X402_STATUS_REJECTED = "payment-rejected" #: The HTTP response header a priced peer uses to carry its requirements #: (base64 JSON `X402PaymentRequired`), and the request header carrying the @@ -187,6 +197,41 @@ def decode_payment_token(credential: Optional[str], *, return obj +def payment_payload_from_message(message: Any) -> Optional[Dict[str, Any]]: + """The in-band x402 payment payload on an A2A `message` param, or `None`. + + The provider-side counterpart of what the outbound client WRITES: ruling 3 + makes task metadata the primary rail (`x402.payment.payload`), with the + `payment-signature` header a deprecated fallback. This reads only the rail; + turning the payload into a facilitator-valid access token is the SDK's job + (`payments_py.x402.token.encode_access_token`) and stays out of this module, + which is SDK-free by design — the outbound client imports it and must not + acquire a payments-py dependency. + + Tolerant on purpose: every field here is caller-controlled on an endpoint + reachable without a Trinity credential, so a missing/odd shape means "no + in-band payment" (→ header fallback → 402), never an exception. The shape + check is `PaymentPayload`'s own (`x402Version` int + a `payload` key), the + same predicate `decode_payment_token` applies to the encoded form — one + definition of "is this an x402 payment", so the two directions cannot come + to disagree. + """ + if not isinstance(message, dict): + return None + metadata = message.get("metadata") + if not isinstance(metadata, dict): + return None + payload = metadata.get(X402_PAYLOAD_KEY) + if not isinstance(payload, dict): + return None + version = payload.get("x402Version") + if not isinstance(version, int) or isinstance(version, bool): + return None + if "payload" not in payload: + return None + return payload + + @dataclass(frozen=True) class Dialect: """One protocol generation's wire vocabulary.""" diff --git a/src/backend/services/nevermined_payment_service.py b/src/backend/services/nevermined_payment_service.py index 7c4dbb921..694f90ae1 100644 --- a/src/backend/services/nevermined_payment_service.py +++ b/src/backend/services/nevermined_payment_service.py @@ -6,7 +6,10 @@ """ import asyncio +import contextlib import logging +import os +import weakref from typing import Optional from db_models import NeverminedConfig, NeverminedPaymentResult @@ -15,6 +18,95 @@ logger = logging.getLogger(__name__) +#: Fleet-wide ceiling on CONCURRENT facilitator calls (#679 E8). Every verify +#: (15 s) and settle attempt (3 x 30 s) runs on the default `to_thread` +#: executor, so a slow facilitator otherwise holds backend threads for the +#: whole fleet — and a priced agent's public URL needs no credential to make us +#: dial out. Per-IP rate limiting alone does not bound that, because the bound +#: has to hold across IPs. +NEVERMINED_MAX_INFLIGHT = int(os.getenv("NEVERMINED_MAX_INFLIGHT", "8")) + +#: How long a call waits for a slot before giving up. Bounded rather than +#: unbounded because the caller is holding an HTTP request open: "busy, retry" +#: is an honest answer, a queue that grows without limit is not. +NEVERMINED_FACILITATOR_WAIT_SECONDS = float( + os.getenv("NEVERMINED_FACILITATOR_WAIT_SECONDS", "5.0") +) + +#: One semaphore per event loop. Module-level `asyncio.Semaphore()` would bind +#: the first loop that contends on it, which in a test suite is whichever test +#: ran first; a WeakKeyDictionary keyed on the running loop keeps the bound +#: real in production (one loop per worker) without that cross-loop trap. +_FACILITATOR_GATES: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary() + + +class FacilitatorBusy(Exception): + """No facilitator slot became free within the wait budget.""" + + +def _facilitator_gate() -> asyncio.Semaphore: + loop = asyncio.get_running_loop() + gate = _FACILITATOR_GATES.get(loop) + if gate is None: + gate = asyncio.Semaphore(NEVERMINED_MAX_INFLIGHT) + _FACILITATOR_GATES[loop] = gate + return gate + + +@contextlib.asynccontextmanager +async def facilitator_slot(): + """Hold one of the `NEVERMINED_MAX_INFLIGHT` slots, or raise `FacilitatorBusy`.""" + gate = _facilitator_gate() + try: + await asyncio.wait_for( + gate.acquire(), timeout=NEVERMINED_FACILITATOR_WAIT_SECONDS + ) + except asyncio.TimeoutError: + raise FacilitatorBusy("facilitator concurrency limit reached") from None + try: + yield + finally: + gate.release() + + +def _resolve_endpoint(config: NeverminedConfig, base_url: str, + endpoint: Optional[str]) -> str: + """The x402 `resource` URL a token is minted and verified against (#679 E2). + + Default = the paid chat door, which is what every pre-#679 caller got. The + A2A gate passes its own door instead, because an x402 v3 token signs + `resourceUrl` and the facilitator compares origin+path: a token minted + against the paid URL cannot authorize a call to `/a2a/{name}`, and a + non-Trinity client follows `resource.url` out of the 402 verbatim. + """ + return endpoint or f"{base_url}/api/paid/{config.agent_name}/chat" + + +def _build_payment_required(config: NeverminedConfig, base_url: str, + endpoint: Optional[str]): + """The SDK `X402PaymentRequired` for this agent's plan. + + One home for the three call sites (402 body, verify, settle) that MUST agree: + the facilitator checks the token against this object, so a requirements + document built differently for verify than for the 402 is a rejection the + caller cannot act on. + """ + network_map = { + "sandbox": "eip155:84532", # Base Sepolia testnet + "staging_sandbox": "eip155:84532", + "live": "eip155:8453", # Base mainnet + "staging_live": "eip155:8453", + "custom": "eip155:84532", + } + return build_payment_required( + plan_id=config.nvm_plan_id, + endpoint=_resolve_endpoint(config, base_url, endpoint), + agent_id=config.nvm_agent_id, + http_verb="POST", + network=network_map.get(config.nvm_environment, "eip155:84532"), + ) + + class _SettleNotCompleted(Exception): """Internal control-flow signal (#1084): a non-successful settle. @@ -75,34 +167,18 @@ def _get_payments_client(self, nvm_api_key: str, nvm_environment: str): environment=nvm_environment, )) - def build_402_response(self, config: NeverminedConfig, base_url: str = "") -> dict: + def build_402_response(self, config: NeverminedConfig, base_url: str = "", + endpoint: Optional[str] = None) -> dict: """Build the 402 Payment Required response body. Returns a dict suitable for JSON serialization in the 402 response. + `endpoint` defaults to the paid chat door (see `_resolve_endpoint`); a + caller serving the requirements from a different door passes its own. """ if not NEVERMINED_AVAILABLE: raise RuntimeError("payments-py SDK is not installed") - endpoint = f"{base_url}/api/paid/{config.agent_name}/chat" - - # Determine network from environment - network_map = { - "sandbox": "eip155:84532", # Base Sepolia testnet - "staging_sandbox": "eip155:84532", - "live": "eip155:8453", # Base mainnet - "staging_live": "eip155:8453", - "custom": "eip155:84532", - } - network = network_map.get(config.nvm_environment, "eip155:84532") - - payment_required = build_payment_required( - plan_id=config.nvm_plan_id, - endpoint=endpoint, - agent_id=config.nvm_agent_id, - http_verb="POST", - network=network, - ) - + payment_required = _build_payment_required(config, base_url, endpoint) return payment_required.model_dump(by_alias=True) async def verify_payment( @@ -112,44 +188,35 @@ async def verify_payment( config: NeverminedConfig, access_token: str, base_url: str = "", + endpoint: Optional[str] = None, ) -> NeverminedPaymentResult: """Verify a payment token before processing a request. Does NOT burn credits — only checks validity and balance. - Timeout: 15 seconds. + Timeout: 15 seconds, under the facilitator concurrency bound. + + A failure carries `retryable` (#679 E7): a timeout, an SDK error or a + saturated facilitator gate is OUR side being unable to decide, not the + token being bad. The paid door answers 403 either way (unchanged); the + A2A gate tells a retryable caller to retry instead of telling a human to + go buy another token. """ if not NEVERMINED_AVAILABLE: raise RuntimeError("payments-py SDK is not installed") try: payments = self._get_payments_client(nvm_api_key, nvm_environment) + payment_required = _build_payment_required(config, base_url, endpoint) - endpoint = f"{base_url}/api/paid/{config.agent_name}/chat" - network_map = { - "sandbox": "eip155:84532", - "staging_sandbox": "eip155:84532", - "live": "eip155:8453", - "staging_live": "eip155:8453", - "custom": "eip155:84532", - } - network = network_map.get(config.nvm_environment, "eip155:84532") - - payment_required = build_payment_required( - plan_id=config.nvm_plan_id, - endpoint=endpoint, - agent_id=config.nvm_agent_id, - http_verb="POST", - network=network, - ) - - result = await asyncio.wait_for( - asyncio.to_thread( - payments.facilitator.verify_permissions, - payment_required, - access_token, - ), - timeout=15.0, - ) + async with facilitator_slot(): + result = await asyncio.wait_for( + asyncio.to_thread( + payments.facilitator.verify_permissions, + payment_required, + access_token, + ), + timeout=15.0, + ) return NeverminedPaymentResult( success=result.is_valid, @@ -157,17 +224,29 @@ async def verify_payment( agent_request_id=result.agent_request_id, error=result.invalid_reason if not result.is_valid else None, ) + except FacilitatorBusy: + logger.warning( + f"Nevermined verify declined for agent {config.agent_name}: " + f"{NEVERMINED_MAX_INFLIGHT} facilitator calls already in flight" + ) + return NeverminedPaymentResult( + success=False, + error="Payment verification is busy — retry shortly", + retryable=True, + ) except asyncio.TimeoutError: logger.error(f"Nevermined verify timeout for agent {config.agent_name}") return NeverminedPaymentResult( success=False, error="Payment verification timed out", + retryable=True, ) except Exception as e: logger.error(f"Nevermined verify error for agent {config.agent_name}: {e}") return NeverminedPaymentResult( success=False, error=str(e), + retryable=True, ) async def settle_payment( @@ -178,48 +257,35 @@ async def settle_payment( access_token: str, agent_request_id: Optional[str] = None, base_url: str = "", + endpoint: Optional[str] = None, ) -> NeverminedPaymentResult: """Settle a payment after successful task execution. Burns credits on-chain. Retries up to 3 times with exponential backoff. - Timeout per attempt: 30 seconds. + Timeout per attempt: 30 seconds, under the facilitator concurrency bound + (a saturated gate is one more retryable attempt failure, not a lost + settle — the caller's unsettled-success path re-drives it). """ if not NEVERMINED_AVAILABLE: raise RuntimeError("payments-py SDK is not installed") payments = self._get_payments_client(nvm_api_key, nvm_environment) - - endpoint = f"{base_url}/api/paid/{config.agent_name}/chat" - network_map = { - "sandbox": "eip155:84532", - "staging_sandbox": "eip155:84532", - "live": "eip155:8453", - "staging_live": "eip155:8453", - "custom": "eip155:84532", - } - network = network_map.get(config.nvm_environment, "eip155:84532") - - payment_required = build_payment_required( - plan_id=config.nvm_plan_id, - endpoint=endpoint, - agent_id=config.nvm_agent_id, - http_verb="POST", - network=network, - ) + payment_required = _build_payment_required(config, base_url, endpoint) last_error = None for attempt in range(3): try: - result = await asyncio.wait_for( - asyncio.to_thread( - payments.facilitator.settle_permissions, - payment_required, - access_token, - None, # max_amount - agent_request_id, - ), - timeout=30.0, - ) + async with facilitator_slot(): + result = await asyncio.wait_for( + asyncio.to_thread( + payments.facilitator.settle_permissions, + payment_required, + access_token, + None, # max_amount + agent_request_id, + ), + timeout=30.0, + ) if result.success: return NeverminedPaymentResult( @@ -236,6 +302,13 @@ async def settle_payment( error=result.error_reason, ) + except FacilitatorBusy: + last_error = "facilitator concurrency limit reached" + logger.warning( + f"Nevermined settle declined for agent {config.agent_name} " + f"(attempt {attempt + 1}/3): {NEVERMINED_MAX_INFLIGHT} facilitator " + "calls already in flight" + ) except asyncio.TimeoutError: last_error = "Settlement timed out" logger.warning( @@ -272,6 +345,7 @@ async def settle_payment_once( agent_request_id: Optional[str], execution_id: Optional[str], base_url: str = "", + endpoint: Optional[str] = None, ) -> NeverminedPaymentResult: """Settle at-most-once per local ``agent_request_id`` guard claim (#1084). @@ -322,6 +396,7 @@ async def settle_payment_once( access_token=access_token, agent_request_id=agent_request_id, base_url=base_url, + endpoint=endpoint, ) if not settle_result.success: # Release the claim — only a SUCCESSFUL settle is replayable. diff --git a/src/backend/services/paid_turn_service.py b/src/backend/services/paid_turn_service.py new file mode 100644 index 000000000..2a2dc4547 --- /dev/null +++ b/src/backend/services/paid_turn_service.py @@ -0,0 +1,574 @@ +"""One home for the x402 verify → dedup → execute → settle lifecycle. + +`routers/paid.py` owned this inline (NVM-001, hardened by #1018, #1084 and +#679). The A2A inbound gate (abilityai/trinity-enterprise#679) must run the +IDENTICAL money logic — the same three settle branches, the same +complete-not-fail on an unsettled success, the same replay-resettle with a +snapshot upgrade — so it is extracted here rather than rebuilt from the leaf +helpers. Two copies of those branches is how a door comes to charge on a +cancelled turn again. + +**Collaborators are PARAMETERS, never imports (decision 20).** `idem`, `db`, +`payment_service` and `execute` are passed in by the caller, from the caller's +own module globals. That is not style: three existing test files patch +`paid.db`, `paid.idempotency_service` and `paid.NEVERMINED_AVAILABLE`, and an +extraction that imported those names here would silently detach every one of +those patches — the money path would then be tested in a configuration nobody +runs (the 2026-09-29 guard-seam-swap class). The caller passes what it holds, so +a patch on the caller still decides what this function talks to. + +**What it does NOT own**: loading the config, the 501/404 shapes, HTTP status +codes, the JSON-RPC envelope, or anything about a request. It returns a +:class:`PaidTurnOutcome` and the caller renders it — the paid door into its +historical JSON bodies, the A2A gate into a Task object. A service holding HTTP +concerns is Invariant #1. +""" +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Optional, Union + +logger = logging.getLogger(__name__) + +#: Strong references to in-flight detached settle tasks (C1). The settle must +#: outlive the request that started it, and asyncio holds a running task only +#: weakly. +_PENDING_SETTLES: set = set() + + +class PaidTurnAbort(Exception): + """A caller's `pre_execute` hook refusing the turn after the dedup gate. + + Carries the payload and status the caller wants rendered. It exists so a + door-specific input check (the paid door's #1672 resume-sentinel rejection) + can keep running at exactly the point in the sequence it runs today — + after `begin()`, before `execute()` — without this service knowing what the + check is about. + """ + + def __init__(self, payload: dict, status_code: int = 400) -> None: + self.payload = payload + self.status_code = status_code + super().__init__(payload.get("detail", "paid turn aborted")) + + +#: Outcome kinds. The caller must handle all of them; a new one is a new branch +#: at every door, which is the point of naming them rather than returning a +#: loose dict. +VERIFY_FAILED = "verify_failed" +ABORTED = "aborted" +IN_FLIGHT = "in_flight" +REPLAY_SETTLED = "replay_settled" +REPLAY_UNSETTLED = "replay_unsettled" +SETTLED = "settled" +UNSETTLED = "unsettled" +EXECUTION_ERROR = "execution_error" +EXECUTION_FAILED = "execution_failed" +EXECUTION_CANCELLED = "execution_cancelled" + + +@dataclass +class PaidTurnOutcome: + """What happened, and the payload the caller should render. + + `payload` is the paid door's historical response body in every branch — + including for the A2A gate, which rebuilds its Task FROM that dict. Keeping + one snapshot shape is what lets the replay logic below be a single code + path instead of one per door. + """ + + kind: str + payload: dict = field(default_factory=dict) + status_code: int = 200 + verify: Any = None + settle: Any = None + execution_id: Optional[str] = None + replayed: bool = False + + @property + def settled(self) -> bool: + return bool((self.payload.get("payment") or {}).get("settled")) + + +def _resolve(value: Union[str, Callable[[Any], str], None], verify: Any): + """Allow scope/key to be computed from the verify result. + + The paid door knows its scope up front (`agent:{name}`); the A2A gate's is + namespaced by the PAYER wallet, which only exists once verify has answered. + Taking a callable keeps the per-caller dedup namespace (FR-4) without + verifying twice or moving verify out of this function. + """ + return value(verify) if callable(value) else value + + +def _log_verify_ok(db, agent_name: str, verify: Any, error: Optional[str] = None, + execution_id: Optional[str] = None) -> None: + db.log_nevermined_payment( + agent_name=agent_name, + action="verify", + success=True, + subscriber_address=verify.payer, + **({"error": error} if error is not None else {}), + **({"execution_id": execution_id} if execution_id is not None else {}), + ) + + +def finalize_settled( + *, + agent_name: str, + config, + response, + execution_id: Optional[str], + settle_result, + payer: Optional[str], + idem_decision, + idem, + db, +) -> dict: + """Shared success finalizer for BOTH the fresh-settle and replay-resettle paths (#1018). + + A client retry that finally settles must still (a) log ``action="settle"`` and + (b) converge the stored trigger snapshot unsettled→settled — otherwise the + settle is never recorded and every replay re-drives settle forever. Kept as one + helper so neither path can drift on the bookkeeping. + """ + db.log_nevermined_payment( + agent_name=agent_name, + action="settle", + success=True, + execution_id=execution_id, + subscriber_address=payer, + credits_amount=config.credits_per_request, + tx_hash=settle_result.tx_hash, + remaining_balance=( + int(settle_result.remaining_balance) + if settle_result.remaining_balance + else None + ), + ) + + settled_payload = { + "response": response, + "execution_id": execution_id, + "status": "success", + "payment": { + "settled": True, + "credits_burned": config.credits_per_request, + "remaining_balance": settle_result.remaining_balance, + "tx_hash": settle_result.tx_hash, + }, + } + + # Converge the stored trigger snapshot (#1018): on the fresh path this completes + # the in-flight claim with the settled snapshot; on the replay-resettle path it + # upgrades a completed-but-unsettled snapshot so a THIRD request replays + # 'settled' and never re-drives settle. No-op when dedup is disabled. + idem.upgrade_snapshot(idem_decision.scope, idem_decision.key, settled_payload) + return settled_payload + + +async def run_paid_turn( + *, + agent_name: str, + config, + nvm_api_key: str, + access_token: str, + idem_scope: Union[str, Callable[[Any], str]], + idem_key: Union[Optional[str], Callable[[Any], Optional[str]]], + execute: Callable[[], Awaitable[Any]], + payment_service, + idem, + db, + base_url: str = "", + endpoint: Optional[str] = None, + pre_execute: Optional[Callable[[Any], None]] = None, +) -> PaidTurnOutcome: + """Verify, dedup, execute, settle — once, in this order, for every paid door. + + The ORDER is the invariant, and each step is here because a previous bug put + it here: + + * verify BEFORE the dedup gate, so a rejected token never consumes a key; + * `complete()` — not `fail()` — on a success that could not settle, so a + client retry re-drives settle instead of re-running the LLM (#1018); + * `fail()` on a failed, cancelled or raised execution, so a retry re-executes + and nothing is charged (#679: settling a cancelled turn is the money bug); + * a replayed unsettled snapshot re-drives settle and `upgrade_snapshot`s on + success, so a third attempt replays 'settled' and stops re-settling. + + `execute()` returns an object with `.status`, `.response` and + `.execution_id`. Cancellation is phase-aware (#679 E5): before a result + exists the claim is released; after the turn succeeded the settle AND its + bookkeeping run in a detached, shielded task, so a caller that walked away + cannot cause the LLM work to be repeated or the burn to go unrecorded — see + `_settle_and_record` for why the bookkeeping cannot live in a cancellation + handler (C1). The `CancelledError` is always re-raised — this function + decides the bookkeeping, not whether the request lives. + """ + # --- 1. verify (before any dedup key is consumed) -------------------- + verify_result = await payment_service.verify_payment( + nvm_api_key=nvm_api_key, + nvm_environment=config.nvm_environment, + config=config, + access_token=access_token, + base_url=base_url, + endpoint=endpoint, + ) + + if not verify_result.success: + # A verify that could not DECIDE — facilitator timeout, SDK error, a + # saturated concurrency gate (E7) — is OUR side being unavailable, not a + # rejection of the payer, so it is logged as the verify ATTEMPT it was. + # RELABELLED rather than skipped (I1): an operator reconciling a + # facilitator outage needs to see the attempts, and a `reject` row would + # read as "this wallet was refused" in the payment log and in anything + # that later reports on refusals. + db.log_nevermined_payment( + agent_name=agent_name, + action="verify" if getattr(verify_result, "retryable", False) else "reject", + success=False, + subscriber_address=verify_result.payer, + error=verify_result.error, + ) + return PaidTurnOutcome( + kind=VERIFY_FAILED, + status_code=403, + payload={ + "detail": "Payment verification failed", + "error": verify_result.error, + }, + verify=verify_result, + ) + + _log_verify_ok(db, agent_name, verify_result) + + # --- 2. dedup gate --------------------------------------------------- + scope = _resolve(idem_scope, verify_result) + key = _resolve(idem_key, verify_result) + decision = idem.begin(scope, key) + + if decision.replay: + if decision.in_flight: + return PaidTurnOutcome( + kind=IN_FLIGHT, + status_code=409, + payload={"detail": "A duplicate paid request is still being processed."}, + verify=verify_result, + ) + + snapshot = decision.snapshot or {} + payment_snap = snapshot.get("payment") or {} + + if payment_snap.get("settled"): + # Already settled — replay the receipt verbatim, no re-execute/re-settle. + return PaidTurnOutcome( + kind=REPLAY_SETTLED, + payload=snapshot, + verify=verify_result, + execution_id=snapshot.get("execution_id"), + replayed=True, + ) + + # Completed but UNSETTLED — re-drive settle WITHOUT re-running the LLM (the + # trigger key already deduped the execution), then converge the snapshot. This + # is why the unsettled branch stores the claim with complete() (not fail()): + # fail() would re-execute here. NOTE: the re-settle is NOT provider-idempotent — + # Nevermined's agent_request_id is an observability id (fresh per verify) and the + # facilitator burns on every successful settle_permissions call. Re-driving is + # safe here only because the prior settle genuinely did NOT complete; a settle + # that burned on-chain but reported failure would re-burn (at-least-once residual, + # tracked by #1408). The payment:{agent_request_id} effect guard only dedups a + # concurrent settle that reuses the SAME id. + resettle = await payment_service.settle_payment_once( + config=config, + nvm_api_key=nvm_api_key, + nvm_environment=config.nvm_environment, + access_token=access_token, + agent_request_id=verify_result.agent_request_id, + execution_id=snapshot.get("execution_id"), + base_url=base_url, + endpoint=endpoint, + ) + if resettle.success: + return PaidTurnOutcome( + kind=SETTLED, + payload=finalize_settled( + agent_name=agent_name, + config=config, + response=snapshot.get("response"), + execution_id=snapshot.get("execution_id"), + settle_result=resettle, + payer=verify_result.payer, + idem_decision=decision, + idem=idem, + db=db, + ), + verify=verify_result, + settle=resettle, + execution_id=snapshot.get("execution_id"), + replayed=True, + ) + # Still unsettled — replay the stored unsettled snapshot; the claim stays + # 'completed unsettled' so a later retry re-drives settle again. + return PaidTurnOutcome( + kind=REPLAY_UNSETTLED, + payload=snapshot, + verify=verify_result, + settle=resettle, + execution_id=snapshot.get("execution_id"), + replayed=True, + ) + + # --- 3. door-specific refusal, at its historical position ------------ + if pre_execute is not None: + try: + pre_execute(verify_result) + except PaidTurnAbort as abort: + # Nothing charged, nothing delivered — release the fresh claim (I2). + # Keeping it would answer the refused payer's identical retry + # IN_FLIGHT ("still being processed") instead of the refusal that + # says why, for the key's whole 24 h TTL. + idem.fail(decision) + return PaidTurnOutcome( + kind=ABORTED, + status_code=abort.status_code, + payload=abort.payload, + verify=verify_result, + ) + + # --- 4. execute ------------------------------------------------------ + try: + exec_result = await execute() + except asyncio.CancelledError: + # Nothing was delivered, so release the claim before unwinding — a + # stranded in-flight claim would 409 the payer's own retry for the key's + # whole TTL. + idem.fail(decision) + raise + except Exception as e: + logger.error(f"Task execution failed for paid request on {agent_name}: {e}") + # Nothing dispatched — release the claim so a legitimate retry re-executes. + idem.fail(decision) + # Don't settle — caller keeps credits + _log_verify_ok(db, agent_name, verify_result, error=f"Execution failed: {e}") + return PaidTurnOutcome( + kind=EXECUTION_ERROR, + status_code=500, + payload={ + "detail": "Task execution failed", + "error": str(e), + "payment": {"settled": False, "reason": "Execution failed — no charge"}, + }, + verify=verify_result, + ) + + if exec_result.status in ("failed", "cancelled"): + # Don't settle — caller keeps credits. #679: a CANCELLED turn must NOT + # settle either — settling on cancel is the charge-on-cancel money bug. + # Release the claim so a retry re-executes (no completed work to replay). + idem.fail(decision) + is_cancelled = exec_result.status == "cancelled" + payload = { + "execution_id": exec_result.execution_id, + "status": "cancelled" if is_cancelled else "failed", + "payment": { + "settled": False, + "reason": ( + "Execution cancelled — no charge" + if is_cancelled + else "Execution failed — no charge" + ), + }, + } + # #1018 hardening: a FAILED execution may hold partial/garbled output; the + # caller paid nothing, so don't leak the body. A CANCELLED turn keeps its + # response (#679 — the user cancelled their own work and may want it). + if is_cancelled: + payload["response"] = exec_result.response + return PaidTurnOutcome( + kind=EXECUTION_CANCELLED if is_cancelled else EXECUTION_FAILED, + payload=payload, + verify=verify_result, + execution_id=exec_result.execution_id, + ) + + # Record the execution on the claim now that it exists (best-effort). + idem.attach_execution(decision, exec_result.execution_id) + + # The payer→task binding, written NOW rather than only by the settle rows + # (I4). `db.payer_owns_execution` is what lets a paying stranger reach + # `tasks/get` on the A2A door, and it matches on (agent, execution_id, + # payer) — columns only a `settle` / `settle_failed` row carried, i.e. only + # once a settle had been attempted AND had logged. So a payer could not read + # a finished task while its settle was still running, nor at all when a + # concurrent settle wrote no row (`settle_in_progress`). This row carries + # both columns as soon as `execute()` has returned — which is after the + # turn's terminal, not during it: this function has no execution id while + # the turn runs. No schema change: the columns are already there. + _log_verify_ok(db, agent_name, verify_result, + execution_id=exec_result.execution_id) + + # --- 5. settle (success only) ---------------------------------------- + # Effect-scoped guard (#1084) so a concurrent settle reusing the SAME + # agent_request_id is deduped locally. The terminal-turn guard above (failed + # execution → no settle) is the outer layer and is preserved. NOTE: + # agent_request_id is a Nevermined observability id, not a provider + # exactly-once token — this local guard is the only settle dedup, and a + # fresh-id retry's double-settle residual is tracked by #1408. + # + # Detached + shielded (#679 E5, C1): the work is DONE and the payer owes for + # it. A client that disconnects here must not abort a settle mid-flight — + # that strands the claim in-flight with the money unrecorded, and the retry + # re-runs the LLM. The settle and ALL of its bookkeeping therefore run in the + # detached task below, never in a cancellation handler. + def _record_unsettled(settle_result) -> dict: + """The settle-failed row + the claim, as one step. + + `complete()` — NOT `fail()` — so a client re-POST replays the completed + work and re-drives settle (idempotent) rather than re-running the LLM + (double cost). The snapshot stays 'unsettled' until a settle finally + succeeds and upgrades it. + """ + payload = _unsettled_payload( + agent_name=agent_name, + config=config, + exec_result=exec_result, + settle_result=settle_result, + verify_result=verify_result, + db=db, + ) + idem.complete(decision, exec_result.execution_id, payload) + return payload + + async def _settle_and_record(): + """Settle AND every money record it implies, in ONE detached task. + + The bookkeeping lives here rather than in the awaiting frame's + `except CancelledError` handler because the two cancellation shapes are + not interchangeable (C1). `asyncio.Task.cancel()` is edge-triggered: one + `CancelledError` is delivered, so a handler may await the settle and + then write its rows. Starlette's `StreamingResponse` — the A2A + `message/stream` consumer — runs its body generator inside an anyio task + group and cancels that group's cancel SCOPE on client disconnect, and + anyio cancellation is LEVEL-triggered: every subsequent `await` inside + the cancelled scope raises `CancelledError` again. Such a handler never + reaches its rows, so the facilitator burns credits with no `settle` row + and the claim strands in-flight for the key's whole 24 h TTL — the retry + then re-runs the LLM and re-settles. + + A detached task is not inside that scope, so it completes either way. + Returns `(settle_result, payload, exc)`; `exc` is re-raised by the + awaiting frame if it is still alive, so a settle that RAISES keeps + answering exactly what it answered before (the paid door's 500). + """ + try: + settle_result = await payment_service.settle_payment_once( + config=config, + nvm_api_key=nvm_api_key, + nvm_environment=config.nvm_environment, + access_token=access_token, + agent_request_id=verify_result.agent_request_id, + execution_id=exec_result.execution_id, + base_url=base_url, + endpoint=endpoint, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 — recorded, then handed back + logger.warning( + "Settle raised for %s; completing the claim as unsettled so a " + "retry re-drives it instead of re-running the LLM: %s", + agent_name, exc, + ) + return None, _record_unsettled(None), exc + + if settle_result.success: + # Logs settle + completes the idempotency claim with the settled snapshot. + return settle_result, finalize_settled( + agent_name=agent_name, + config=config, + response=exec_result.response, + execution_id=exec_result.execution_id, + settle_result=settle_result, + payer=verify_result.payer, + idem_decision=decision, + idem=idem, + db=db, + ), None + + return settle_result, _record_unsettled(settle_result), None + + settle_task = asyncio.ensure_future(_settle_and_record()) + # asyncio keeps only a weak reference to a running task, so a settle whose + # awaiter has walked away could be collected mid-flight — which is the same + # lost burn by another route. Hold a strong reference until it finishes. + _PENDING_SETTLES.add(settle_task) + settle_task.add_done_callback(_PENDING_SETTLES.discard) + + try: + settle_result, settle_payload, settle_exc = await asyncio.shield(settle_task) + except asyncio.CancelledError: + # Re-raise ONLY — never await here. The detached task above owns the + # money bookkeeping and finishes it on its own; awaiting it inside a + # level-triggered cancelled scope is precisely what C1 was. + raise + + if settle_exc is not None: + raise settle_exc + + if settle_result.success: + return PaidTurnOutcome( + kind=SETTLED, + payload=settle_payload, + verify=verify_result, + settle=settle_result, + execution_id=exec_result.execution_id, + ) + + return PaidTurnOutcome( + kind=UNSETTLED, + payload=settle_payload, + verify=verify_result, + settle=settle_result, + execution_id=exec_result.execution_id, + ) + + +def _unsettled_payload(*, agent_name: str, config, exec_result, settle_result, + verify_result, db) -> dict: + """The honest body for a delivered turn whose settle did not complete (#1018). + + The work WAS delivered, so deliver-then-reconcile: the caller keeps the + response, but the status says `success_unsettled` (the filed #1018 bug: this + used to lie with status "success"). Two sub-cases: + + * concurrent settle in-flight (effect guard) → `settle_in_progress`, NO log + row — the concurrently-running settle logs its own outcome, once; + * genuine failure after retries → `settle_retry_needed` + a `settle_failed` + log row. + """ + error = getattr(settle_result, "error", "Settlement did not complete") + settle_in_progress = error == "settlement already in progress" + payment_block = {"settled": False, "error": error} + if settle_in_progress: + payment_block["settle_in_progress"] = True + else: + payment_block["settle_retry_needed"] = True + db.log_nevermined_payment( + agent_name=agent_name, + action="settle_failed", + success=False, + execution_id=exec_result.execution_id, + subscriber_address=verify_result.payer, + credits_amount=config.credits_per_request, + error=error, + ) + + return { + "response": exec_result.response, + "execution_id": exec_result.execution_id, + "status": "success_unsettled", + "payment": payment_block, + } diff --git a/src/backend/services/pull_pilot.py b/src/backend/services/pull_pilot.py index 9e9ba9a5b..c5cf26fd9 100644 --- a/src/backend/services/pull_pilot.py +++ b/src/backend/services/pull_pilot.py @@ -135,12 +135,31 @@ def pull_queue_allowance(agent_name: str) -> int: ) -# Triggers with a person waiting on the reply. A pull worker claims these ahead -# of every other queued row (#2842). ⚠️ Adding a human-facing trigger? Add it -# here, or its turns queue behind batch work. +# Triggers with a CALLER waiting in-line on the reply. A pull worker claims +# these ahead of every other queued row (#2842). ⚠️ Adding a human-facing +# trigger? Add it here, or its turns queue behind batch work. +# +# ``a2a`` is here as of abilityai/trinity-enterprise#679 (T6), and it is the one +# member that is ALSO in ``_AUTONOMOUS_TRIGGERS`` — deliberately, because the +# two sets answer different questions and an inbound A2A task answers them +# differently: +# +# * "is a caller blocked on this reply?" — YES. The JSON-RPC request is held +# open for the whole turn (``dispatch_and_await_terminal``), on the principal +# path and the paid path alike. That is what earns the claim priority and the +# claim budget (``_CLAIM_WAITING_TRIGGERS``), so a remote caller's turn is not +# queued behind an agent's batch work until its RPC times out. +# * "is a PERSON on this install reading the reply?" — NO. It goes back over +# the wire as a Task artifact, which is why ``_AUTONOMOUS_TRIGGERS`` keeps it +# (an unresolved skill alerts the operator rather than relying on a human +# seeing the error text). +# +# The membership overlap is therefore the honest encoding, not a mistake; the +# disjointness guard in test_2842_2843_pull_claim_order.py is narrowed to this +# one documented member so a THIRD overlap still fails. INTERACTIVE_TRIGGERS = frozenset( {"manual", "mcp", "chat", "session", "public", "voice", "voip", "room", - "user", "paid", "slack", "telegram", "whatsapp"} + "user", "paid", "a2a", "slack", "telegram", "whatsapp"} ) diff --git a/src/frontend/src/components/NeverminedPanel.vue b/src/frontend/src/components/NeverminedPanel.vue index 9c989404f..799c5e8f7 100644 --- a/src/frontend/src/components/NeverminedPanel.vue +++ b/src/frontend/src/components/NeverminedPanel.vue @@ -139,7 +139,7 @@ @@ -271,7 +271,7 @@ const isFormValid = computed(() => { form.value.nvm_environment && form.value.nvm_agent_id && form.value.nvm_plan_id && - form.value.credits_per_request >= 1 + Number.isFinite(form.value.credits_per_request) && form.value.credits_per_request >= 0 }) // Methods diff --git a/tests/registry.json b/tests/registry.json index 798b3b9df..ca62afa72 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -4115,6 +4115,74 @@ "security" ], "description": "The credential KIND on the outbound A2A endpoint store (#3185 checkpoint B): `credential_kind` rides the credential's existing three write paths rather than adding a fourth (set / leave alone / clear), an omitted kind is INFERRED from the value with the same predicate the client sends on (an x402 payload -> payment_token, anything else -> api_key, fail-safe over junk), an explicit kind wins, a kind alone re-labels a stored secret without re-typing it, a kind with no credential under it is refused, `clear_credentials` drops the label and the single-use flag with the value, kind + clear is refused at the model (422, named reason, no echo) and at the store, `api_key` is stored as the ABSENCE of the key so a relabel leaves a pre-#3185-shaped record, an x402 v3 nonce is flagged `credential_single_use` rather than refused and the flag cannot outlive the token it describes, and the settings PUT/GET report the kind (plus a one-time single-use hint) while the audit row records the label and never the value. Real AES-256-GCM envelope over an in-memory settings row; no Docker, no network." + }, + { + "file": "unit/test_ent679_payments_pin_parity.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "dependencies", + "payments" + ], + "description": "payments-py pin parity + import smoke (ent#679 checkpoint A), the #1891 shape applied to a dependency: docker/backend/Dockerfile and tests/requirements-test.txt must pin payments-py EXACTLY and EQUALLY (a floor in the test requirements is how CI came to exercise 1.18.0 against a 1.2.1 image, trinity-enterprise#763), the pin must be >= 1.18 (below it the in-band A2A metadata rail does not exist), the installed version must equal the pin, every unconditional runtime requirement of payments-py must be constrained in the image (`requests` the one reasoned pre-existing exemption) and the 13 explicitly pinned transitives must equal what the venv resolved; plus the import smoke that is the real point — `payments_py.payments` imports the a2a package at module load, so one unimportable transitive flips NEVERMINED_AVAILABLE to False and both payment doors answer 501 with a green build and no error anywhere but a WARNING. Also pins the facilitator call signatures Trinity passes positionally. No Docker, no network." + }, + { + "file": "unit/test_ent679_payer_owns_execution.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "database", + "payments", + "security" + ], + "description": "The payer\u2192task binding at the layer that implements it (ent#679 I6): `db/nevermined.py::payer_owns_execution` run against a throwaway SQLite carrying the real `nevermined_payment_log`, because every A2A gate test stubs `db.nevermined_payer_owns_execution` and the one new query on the money path \u2014 the one carrying T5's security property \u2014 never executed in CI. Pins that the payer matches their own settled (and settle_failed) task across the facilitator's unstable checksum casing, that another payer / another agent / an unknown execution are all False, that a SQL-shaped wallet is a bound parameter rather than SQL, and that a missing argument fails closed." + }, + { + "file": "unit/test_ent679_paid_turn_service.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "payments", + "reliability" + ], + "description": "The shared x402 paid-turn orchestrator (ent#679 checkpoint A) at its own layer: one test per outcome kind (verify_failed / in_flight / replay_settled / replay_unsettled / settled / unsettled / execution_error / execution_failed / execution_cancelled / aborted) plus the orderings a response body cannot show — verify BEFORE the dedup gate so a rejected token consumes no key, complete()-not-fail() on a delivered-but-unsettled turn so a retry re-drives settle instead of re-running the LLM (#1018), fail() and no settle on a failed/cancelled/raised turn (#679 charge-on-cancel), a replayed unsettled snapshot that re-settles and upgrade_snapshot()s so a third attempt stops re-settling, settle_in_progress logging nothing, phase-aware cancellation (claim released before a result, shielded settle after one), the `endpoint` thread (decision 19, default unchanged) and the payer-derived dedup scope the A2A gate needs. Collaborators are plain stand-ins, which is itself the proof that the service imports none of them (decision 20). routers/paid.py stays covered end-to-end by test_1018_settlement_ordering / test_679_callers / test_3114_pull_route_callers, unedited." + }, + { + "file": "unit/test_ent679_nevermined_service_bounds.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "payments", + "security" + ], + "description": "`endpoint`, `retryable` and the facilitator concurrency bound on nevermined_payment_service (ent#679 checkpoint A): the 402's `resource.url` defaults to the paid chat door and can be bound to another door while the plan in `accepts` stays identical (an x402 v3 token signs resourceUrl, so verify and settle must agree with the 402 the client read — decision 19); a facilitator REJECTION is not retryable while a timeout, an SDK error and a saturated gate are (E7), and `retryable` is deliberately absent from the stored settle snapshot so a pre-field receipt still replays; and the fleet-wide NEVERMINED_MAX_INFLIGHT gate admits up to the limit, refuses beyond it within a bounded wait, occupies no thread when it refuses, burns nothing on a refused settle, and is shared across agents because the thread pool is a platform resource (E8)." + }, + { + "file": "unit/test_ent679_a2a_payment_gate.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "api", + "payments", + "security" + ], + "description": "The x402 payment gate on the A2A inbound door (ent#679 checkpoint B). `dependencies.get_user_or_anonymous` degrades to None on a 401 ONLY and RE-RAISES a 403, so a connector/ephemeral key Trinity recognised and then fenced can never slide onto the payment path and buy the access it was refused. `is_priced` is exposure AND an enabled config AND a key, and deliberately NOT the SDK check — fusing them would answer 401 (\"authenticate\") to a caller holding a valid token for an agent whose card advertises a price. `extract_token` is metadata-first with the deprecated `payment-signature` header as fallback (ruling 3), metadata winning when both are present, and every malformed payload shape falling through rather than raising. The allow-list seam is consulted after verify as `x402:{payer}` and fails CLOSED (T7) — the opposite bias to a2a_gate.check_inbound_allowed, because here the payment IS the authorization. Over a TestClient: today's 401 bytes for anything not exposed-and-priced (uniform with an unknown agent), 501 for a priced agent with no SDK, 402 with the paid door's body + base64 header and `resource.url` on the A2A door (#679 E2), 403 + a reject row on a rejected token with verify BEFORE the dedup gate, the limiter proven to run ahead of any DB read, the settled/unsettled/failed/cancelled Tasks with their x402 metadata (payment-completed with a spec-shaped receipt; payment-verified + a named error code on a delivered-but-unsettled turn, artifact kept per #1018), replay keyed on (token, text) so a fresh messageId does not fork a retry, and per-payer dedup scopes. tasks/get + tasks/cancel are payer-bound via the settle-row join (T5) with EVERY mismatch — including payer A polling payer B's existing task — answering byte-identical -32001. Finally the hard line (AC4): a Trinity principal still runs for free, with no facilitator call, no payment row, no payment metadata, no paying-bucket rate limit and its own attribution. Mutation-proven (limiter dropped / precedence inverted / unsettled claiming payment-completed all go red). No Docker, no live backend, no facilitator." + }, + { + "file": "unit/test_ent679_a2a_priced_card.py", + "feature": "abilityai/trinity-enterprise#679", + "added": "2026-10-03", + "categories": [ + "backend", + "api", + "payments" + ], + "description": "The priced A2A agent card (ent#679 checkpoint C, AC2). A stranger could previously learn an agent's price only by calling and being refused; the card is A2A's discovery document, so a priced agent now declares `agentId` / `planId` / `credits` there and an x402 client meets the paywall on its first request. The hard line first: no config, a DISABLED config, or an enabled one missing its plan or agent id all return the card object BY IDENTITY, so every install that sells nothing is byte-identical and `generate_a2a_card` stays pure with no payment knowledge and no I/O. The declared vocabulary is Nevermined's own `urn:nevermined:payment` (the provider SDK's card helper's URI) and the official Google/Coinbase x402 extension URI is asserted ABSENT — declaring it would advertise an `X-A2A-Extensions` activation handshake Trinity does not run, leaving a generic client waiting for a negotiation that never comes. Both card surfaces are driven over a real TestClient (ent#180 FR-3: the public well-known card and the authenticated per-agent card must not disagree) and `paymentInfoUrl` is proven to be built from the requesting instance's own origin rather than a hardcoded host, omitted entirely when there is no base URL. T9: `credits_per_request = 0` is a Nevermined DURATION plan (charged by time, so Trinity sends no amount and the plan defines the burn) — the model accepts 0, a negative is still a named 422, the default is still 1, and 0 declares `paymentType: dynamic` rather than the contradictory `fixed`/0 that reads as free and that the SDK's own card validator rejects. Fail-open is asserted with its WARNING: an unreadable payment config still serves the card, because a card route has never 5xx'd and the gate reads the config independently and still answers 402. Mutation-proven (router wiring dropped / official URI declared / paymentType hardcoded / credits floor restored each go red). No Docker, no live backend, no facilitator." } ] } diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt index 547ad78d1..cad150789 100644 --- a/tests/requirements-test.txt +++ b/tests/requirements-test.txt @@ -113,7 +113,43 @@ passlib[bcrypt]>=1.7.4 bcrypt>=4.2.0,<5 pydantic-settings>=2.0.0 google-genai>=1.0.0 -payments-py>=1.0.0 +# Exact, and equal to docker/backend/Dockerfile (ent#679): a floor here is +# how CI came to run 1.18.0 against a 1.2.1 image (trinity-enterprise#763). +# Guarded by tests/unit/test_ent679_payments_pin_parity.py. +payments-py==1.18.0 +# payments-py 1.18.0's own unconditional runtime dependency set, pinned here to +# exactly what `docker/backend/Dockerfile` pins. Leaving them to pip is what +# turned this branch red: payments-py declares `pyjwt<3.0.0,>=2.9.0`, nothing in +# this file constrained it, so CI resolved the newest release (2.15.1) against +# an image pinning 2.14.0 — the SAME image-vs-CI divergence the exact +# `payments-py` pin above exists to prevent, one layer down. The other twelve +# were green only because the newest release still happened to equal the image +# pin; the Dockerfile's "versions are the ones the test venv resolved" note was +# true when written and expires the moment any of these publishes a release. +# +# Keep this block equal to the Dockerfile's, name for name and version for +# version: `tests/unit/test_ent679_payments_pin_parity.py` asserts +# image-pin == installed-version for every one of them, and the whole point of +# that test is that CI exercises the SDK stack production ships (an import +# failure in any one of them flips NEVERMINED_AVAILABLE to False and both +# payment doors answer 501 with a green build). Bump both files together. +a2a-sdk==0.3.26 +mcp==1.30.0 +python-socketio==5.14.3 +pyjwt==2.14.0 +jsonschema==4.26.0 +websocket-client==1.9.2 +helicone-helpers==1.2.1 +black==26.5.1 +mkdocs==1.6.1 +mkdocs-material==9.7.7 +mkdocstrings[python]==0.29.1 +mike==2.2.0 +# Exact here, and intentionally narrower than the `pytest-asyncio>=0.24.0` floor +# in this file's test-tooling block above — pip intersects the two to this +# version. payments-py declares it as a runtime requirement, so the parity test +# covers it like the rest of the set. +pytest-asyncio==1.4.0 # tzdata: the IANA database as a pure-Python fallback for `zoneinfo`. The #1771 # timestamp properties draw real IANA zones (`st.timezones()`) deliberately — # DST transitions, quarter-hour offsets and historical LMT offsets are what stop diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py index 9d535597a..af594e603 100644 --- a/tests/unit/_route_census.py +++ b/tests/unit/_route_census.py @@ -611,6 +611,7 @@ def _resolve_endpoint( "routers/public.py::request_verification_code": ("POST /api/public/verify/request", "public-link surface; the link token in the path is the credential (email verification)"), "routers/public.py::tls_allowed": ("GET /api/public/tls-allowed", "unauthenticated by design (reverse-proxy on-demand TLS probe)"), "routers/a2a.py::a2a_well_known_card": ("GET /a2a/{agent_name}/.well-known/agent-card.json", "unauthenticated by design (A2A discovery card)"), + "routers/a2a.py::a2a_jsonrpc": ("POST /a2a/{agent_name}", "ent#679: a Trinity MCP key OR an x402 payment token; decided in-handler (get_user_or_anonymous → 401 unless exposed AND priced)"), "routers/mcp_keys.py::validate_mcp_api_key_http_endpoint": ("POST /api/mcp/validate", "validates the presented MCP key itself (MCP server auth)"), "main.py::health_check": ("GET /health", "unauthenticated by design (health probe)"), "routers/slack.py::handle_slack_event": ("POST /api/public/slack/events", "Slack request signature"), @@ -642,7 +643,7 @@ def _resolve_endpoint( # The exact size of the frozen baseline. Lower it in the same change that # removes an entry; it never goes up. -FROZEN_BASELINE_COUNT = 360 +FROZEN_BASELINE_COUNT = 359 def load_baseline(path: Path = BASELINE_PATH) -> Dict[str, str]: diff --git a/tests/unit/fixtures/human_only_route_baseline.json b/tests/unit/fixtures/human_only_route_baseline.json index 8e370251f..ff077d572 100644 --- a/tests/unit/fixtures/human_only_route_baseline.json +++ b/tests/unit/fixtures/human_only_route_baseline.json @@ -6,7 +6,6 @@ "client_portal/router.py::unblock_agent_client": "DELETE /api/enterprise/client-portal/agents/{agent_name}/clients/{email}/block", "main.py::get_current_user_info": "GET /api/users/me", "main.py::get_version": "GET /api/version", - "routers/a2a.py::a2a_jsonrpc": "POST /a2a/{agent_name}", "routers/a2a.py::call_a2a_agent": "POST /api/agents/{agent_name}/a2a/call", "routers/a2a.py::get_a2a_task": "POST /api/agents/{agent_name}/a2a/task", "routers/a2a.py::get_agent_card": "GET /api/agents/{agent_name}/a2a/agent-card", diff --git a/tests/unit/test_157_a2a_inbound_server.py b/tests/unit/test_157_a2a_inbound_server.py index be9d5145a..3c54f8d03 100644 --- a/tests/unit/test_157_a2a_inbound_server.py +++ b/tests/unit/test_157_a2a_inbound_server.py @@ -87,6 +87,11 @@ def _cancel_queued(eid, reason=None): can_user_access_agent=lambda user, name: name in state["access"], get_execution=lambda eid: state["executions"].get(eid), cancel_queued_execution=_cancel_queued, + # ent#679: the card producer reads the payment config to decide whether + # to declare a price. Nothing in this file is priced — stub it + # explicitly so the "card unchanged" assertions below prove the + # unpriced path rather than the fail-open exception path. + get_nevermined_config=lambda name: None, ) monkeypatch.setattr(a2a, "db", fake_db) @@ -175,7 +180,12 @@ def fail(self, decision): app.include_router(a2a.a2a_server_router) user = types.SimpleNamespace(id=1, username="alice", email="alice@example.com", role="user", agent_name=None, mcp_key_id="k1") - app.dependency_overrides[deps.get_current_user] = lambda: user + # ent#679: the route's dependency is now `get_user_or_anonymous`, which + # CALLS `get_current_user` directly rather than depending on it — so an + # override of the latter is never consulted and every test here would + # silently exercise the anonymous payment branch instead (→ 401, not the + # principal path). Override what the route actually depends on. + app.dependency_overrides[deps.get_user_or_anonymous] = lambda: user return types.SimpleNamespace(http=TestClient(app), state=state, a2a_gate=a2a_gate, user=user) diff --git a/tests/unit/test_2524_fanout_async_join.py b/tests/unit/test_2524_fanout_async_join.py index 5542d75dd..dee3ff13e 100644 --- a/tests/unit/test_2524_fanout_async_join.py +++ b/tests/unit/test_2524_fanout_async_join.py @@ -579,17 +579,24 @@ def test_a_push_dispatch_is_returned_untouched(self, monkeypatch): def test_a_queued_dispatch_waits_and_rebuilds_from_the_row(self, monkeypatch): """The pull path. QUEUED is not an outcome — the row is on the durable - queue and the worker's terminal is the answer.""" + queue and the worker's terminal is the answer. + + The row starts CLAIMED-and-running and only goes terminal inside the + wait, which is the real sequence and the one `a2a` now takes: ent#679 T6 + put `a2a` in `INTERACTIVE_TRIGGERS` (an inbound JSON-RPC caller is + blocked in-line on the reply), so the adapter claim-waits first and then + reads the row once before waiting. A fixture that was ALREADY terminal at + dispatch time therefore short-circuited on that read and never reached + `wait_for_sync_terminal` — a state that cannot precede the wait it is + here to exercise. Every assertion below is unchanged; only the fixture + now models a row the worker has not finished yet. + """ from services.execution_envelope import TaskExecutionResult queued = TaskExecutionResult( execution_id="exec_9", status="queued", response="", ) - db = _AdapterDB(row={ - "status": "success", "response": "answered later", "error": None, - "cost": 0.02, "context_used": 10, "context_max": 200000, - "claude_session_id": "s1", - }) + db = _AdapterDB(row={"status": "running", "response": "", "error": None}) tes, _svc = self._service(monkeypatch, result=queued, db=db) seen = {} @@ -597,6 +604,12 @@ def test_a_queued_dispatch_waits_and_rebuilds_from_the_row(self, monkeypatch): async def _wait(execution_id, timeout): seen["execution_id"] = execution_id seen["timeout"] = timeout + # The worker's terminal lands on the row while the caller waits. + db.row = { + "status": "success", "response": "answered later", "error": None, + "cost": 0.02, "context_used": 10, "context_max": 200000, + "claude_session_id": "s1", + } return None # the poll fallback: "re-read the row" monkeypatch.setattr("services.sync_waiter.wait_for_sync_terminal", _wait) diff --git a/tests/unit/test_2842_2843_pull_claim_order.py b/tests/unit/test_2842_2843_pull_claim_order.py index 35f4e6419..b284ccb98 100644 --- a/tests/unit/test_2842_2843_pull_claim_order.py +++ b/tests/unit/test_2842_2843_pull_claim_order.py @@ -140,11 +140,23 @@ def test_claim_next_task_passes_the_interactive_set(): assert db.claim_next_queued.call_args.kwargs["interactive_triggers"] is INTERACTIVE_TRIGGERS -def test_interactive_and_autonomous_sets_are_disjoint(): +def test_interactive_and_autonomous_sets_overlap_only_where_documented(): + """The two sets answer different questions, so an overlap must be argued for. + + ``INTERACTIVE_TRIGGERS`` = a caller is blocked on the reply (claim priority + + claim budget). ``_AUTONOMOUS_TRIGGERS`` = no PERSON on this install is + reading it (alert the operator instead of relying on them seeing the text). + + ``a2a`` is both, and the only one: an inbound A2A request is held open for + the whole turn while its answer leaves over the wire + (abilityai/trinity-enterprise#679 T6). The assertion is narrowed rather + than deleted so a FOURTH set membership — or a second trigger added to both + without the argument — still fails here. + """ from services.pull_pilot import INTERACTIVE_TRIGGERS from services.task_execution_service import _AUTONOMOUS_TRIGGERS - assert not INTERACTIVE_TRIGGERS & _AUTONOMOUS_TRIGGERS + assert INTERACTIVE_TRIGGERS & _AUTONOMOUS_TRIGGERS == {"a2a"} def test_every_channel_adapter_trigger_is_interactive(): diff --git a/tests/unit/test_3114_pull_route_interactive.py b/tests/unit/test_3114_pull_route_interactive.py index 87ac747cd..28b646a9b 100644 --- a/tests/unit/test_3114_pull_route_interactive.py +++ b/tests/unit/test_3114_pull_route_interactive.py @@ -483,19 +483,51 @@ async def test_caller_going_away_cancels_the_queued_turn(seed_agent, monkeypatch @pytest.mark.asyncio async def test_autonomous_trigger_skips_the_claim_phase(seed_agent, monkeypatch): + """A row nobody is blocked on waits for its terminal, not for a claim. + + Driven with ``schedule``. It used to be driven with ``a2a``, which stopped + being purely autonomous in abilityai/trinity-enterprise#679 (T6) — see the + companion test below. The property under test is unchanged and still has + members; what moved is which trigger demonstrates it. + """ seed_agent(timeout=1) - _row("e1", trigger="a2a") + _row("e1", trigger="schedule") from services import task_execution_service as tes waited = AsyncMock() monkeypatch.setattr("services.sync_waiter.wait_for_sync_terminal", waited) await tes.dispatch_and_await_terminal( - agent_name=AGENT, message="m", triggered_by="a2a", service=_queued_service("e1"), + agent_name=AGENT, message="m", triggered_by="schedule", service=_queued_service("e1"), ) waited.assert_awaited_once() assert _db().get_execution("e1").status == "queued" +@pytest.mark.asyncio +async def test_a2a_takes_the_claim_phase(seed_agent, monkeypatch): + """ent#679 T6: an inbound A2A caller is blocked in-line, so it waits for a claim. + + This is the principal-path effect of adding ``a2a`` to + ``INTERACTIVE_TRIGGERS`` and it is deliberate: the JSON-RPC request is held + open for the whole turn, so a row no worker claims within one agent timeout + must come back FAILED/CAPACITY — the same answer push gives an agent with + no free slot — rather than leaving the caller waiting out its RPC deadline + on a row that was never going to run. + """ + seed_agent(timeout=1) + _row("e1", trigger="a2a") + from services import task_execution_service as tes + + monkeypatch.setattr(tes, "QUEUE_CLAIM_POLL_INTERVAL", 0.05) + waited = AsyncMock(side_effect=AssertionError("must not reach the terminal wait")) + monkeypatch.setattr("services.sync_waiter.wait_for_sync_terminal", waited) + out = await tes.dispatch_and_await_terminal( + agent_name=AGENT, message="m", triggered_by="a2a", service=_queued_service("e1"), + ) + assert out.status == "failed" + assert out.error_code.value == "capacity" + + @pytest.mark.asyncio async def test_terminal_wait_timeout_text_matches_callers(seed_agent, monkeypatch): """public_chat_service and mcp_auth_service map on the substring "timed out".""" diff --git a/tests/unit/test_3185_a2a_payment_outcome.py b/tests/unit/test_3185_a2a_payment_outcome.py index 2e151ba51..03560dfdf 100644 --- a/tests/unit/test_3185_a2a_payment_outcome.py +++ b/tests/unit/test_3185_a2a_payment_outcome.py @@ -434,3 +434,103 @@ def test_the_x402_metadata_keys_are_the_spec_names(): assert a2a_protocol.X402_PAYLOAD_KEY == "x402.payment.payload" assert a2a_protocol.X402_ERROR_KEY == "x402.payment.error" assert a2a_protocol.X402_STATUS_SUBMITTED == "payment-submitted" + + +# --------------------------------------------------------------------------- # +# The PROVIDER side of the same vocabulary (abilityai/trinity-enterprise#679). +# +# `payment_payload_from_message` reads the in-band rail this module's client +# half WRITES, so both live here: a reader and a writer that disagree about +# where the payload sits is exactly the rot the shared-vocabulary module exists +# to prevent. The gate that consumes the reader lands in checkpoint B; what is +# pinned here is the parse, which is the part reachable from an uncredentialed +# caller and must never raise. +# --------------------------------------------------------------------------- # + +def _message_with(payload): + return { + "role": "user", + "parts": [{"kind": "text", "text": "hi"}], + "messageId": "m-1", + "metadata": {a2a_protocol.X402_PAYLOAD_KEY: payload}, + } + + +def test_the_inband_payload_is_read_from_the_messages_metadata(): + payload = {"x402Version": 1, "payload": {"signature": "0xsig"}} + assert a2a_protocol.payment_payload_from_message(_message_with(payload)) == payload + + +def test_a_message_the_client_built_round_trips_through_the_reader(): + """The writer's own output is readable by the reader (one vocabulary).""" + payload = TOKEN_OBJ + message = a2a_protocol.text_message("hi", "m-1") + message["metadata"] = { + a2a_protocol.X402_STATUS_KEY: a2a_protocol.X402_STATUS_SUBMITTED, + a2a_protocol.X402_PAYLOAD_KEY: payload, + } + assert a2a_protocol.payment_payload_from_message(message) == payload + + +@pytest.mark.parametrize("message", [ + None, + "not a message", + {}, # no metadata + {"metadata": "not a dict"}, + {"metadata": {}}, # no payload key + {"metadata": {"x402.payment.payload": "a string"}}, # not an object + {"metadata": {"x402.payment.payload": {"payload": {}}}}, # no version + {"metadata": {"x402.payment.payload": {"x402Version": "1", + "payload": {}}}}, # version not int + {"metadata": {"x402.payment.payload": {"x402Version": True, + "payload": {}}}}, # bool is not int + {"metadata": {"x402.payment.payload": {"x402Version": 1}}}, # no payload key +]) +def test_anything_that_is_not_an_x402_payload_reads_as_absent(message): + """"Absent" — never an exception: this runs before any credential check.""" + assert a2a_protocol.payment_payload_from_message(message) is None + + +def test_the_shape_check_matches_the_encoded_codecs(): + """One definition of "is this an x402 payment", in both encodings. + + A payload the in-band reader accepts must be a payload `decode_payment_token` + accepts once encoded, or a provider and a consumer would disagree about what + a payment IS. + """ + assert a2a_protocol.payment_payload_from_message(_message_with(TOKEN_OBJ)) == TOKEN_OBJ + assert a2a_protocol.decode_payment_token(_b64url(TOKEN_OBJ)) == TOKEN_OBJ + + +def test_verified_is_a_distinct_status_from_completed_and_failed(): + """The delivered-but-unsettled state needs its own word (#1018 honesty). + + `payment-completed` would claim a receipt that does not exist; + `payment-failed` makes the outbound client DISCARD the artifact the payer's + turn produced. + """ + assert a2a_protocol.X402_STATUS_VERIFIED == "payment-verified" + assert len({ + a2a_protocol.X402_STATUS_SUBMITTED, + a2a_protocol.X402_STATUS_REQUIRED, + a2a_protocol.X402_STATUS_FAILED, + a2a_protocol.X402_STATUS_COMPLETED, + a2a_protocol.X402_STATUS_VERIFIED, + a2a_protocol.X402_STATUS_REJECTED, + }) == 6 + + +def test_the_status_vocabulary_matches_the_sdks(): + """payments-py 1.18 is the peer; a status we invent is a status nobody reads.""" + from payments_py.x402.a2a import PaymentStatus + + sdk = {member.value for member in PaymentStatus} + for status in ( + a2a_protocol.X402_STATUS_SUBMITTED, + a2a_protocol.X402_STATUS_REQUIRED, + a2a_protocol.X402_STATUS_FAILED, + a2a_protocol.X402_STATUS_COMPLETED, + a2a_protocol.X402_STATUS_VERIFIED, + a2a_protocol.X402_STATUS_REJECTED, + ): + assert status in sdk, f"{status} is not a payments-py PaymentStatus" diff --git a/tests/unit/test_736_a2a_outbound_call.py b/tests/unit/test_736_a2a_outbound_call.py index 6f4bd37c9..3c86b1dda 100644 --- a/tests/unit/test_736_a2a_outbound_call.py +++ b/tests/unit/test_736_a2a_outbound_call.py @@ -224,6 +224,11 @@ def test_loopback_round_trip_against_trinitys_own_inbound_server(monkeypatch): can_user_access_agent=lambda user, name: True, get_execution=lambda eid: state["executions"].get(eid), cancel_queued_execution=lambda eid, reason=None: False, + # ent#679: the card producer reads the payment config to decide whether + # to declare a price. `remotebot` is not priced here — stub it + # explicitly so the card this loopback fetches is the unpriced one + # rather than the fail-open-on-exception one. + get_nevermined_config=lambda name: None, ) monkeypatch.setattr(a2a, "db", fake_db) monkeypatch.setattr( @@ -279,7 +284,15 @@ async def log(self, **kwargs): remote = FastAPI() remote.include_router(a2a.a2a_server_router) - remote.dependency_overrides[deps.get_current_user] = lambda: types.SimpleNamespace( + # ent#679: the route's dependency is now `get_user_or_anonymous`, which + # CALLS `get_current_user` directly rather than depending on it — so an + # override of the latter is never consulted and this loopback would run down + # the anonymous x402 branch instead of the fleet path it exists to prove. + # Override what the route actually depends on (same fix as test_157's + # `client` fixture). The peer here IS a Trinity principal: #738 federation's + # premise is a Trinity calling a Trinity with an MCP key, and ruling T6/AC4 + # says that path is byte-identical to before the gate. + remote.dependency_overrides[deps.get_user_or_anonymous] = lambda: types.SimpleNamespace( id=2, username="peer", email="peer@example.com", role="user", agent_name=None, mcp_key_id="k2", ) diff --git a/tests/unit/test_ent679_a2a_payment_gate.py b/tests/unit/test_ent679_a2a_payment_gate.py new file mode 100644 index 000000000..4e4821bea --- /dev/null +++ b/tests/unit/test_ent679_a2a_payment_gate.py @@ -0,0 +1,1060 @@ +"""ent#679 checkpoint B — the x402 payment gate on the A2A inbound door. + +What this file proves, driven at the layer each thing lives in: + +* `dependencies.get_user_or_anonymous`: None on 401 ONLY; a 403 is re-raised. +* `a2a_payment_gate.is_priced`: exposure ∧ enabled config ∧ key, each absence. +* `a2a_payment_gate.extract_token`: metadata-first, header fallback, precedence + when both are present, and every malformed-payload shape falling through. +* `a2a_payment_gate.payment_caller_allowed`: fail-CLOSED, `x402:{payer}` identity. +* The router's anonymous branch over a real `TestClient`: today's 401 bytes for + a non-priced agent, 402 parity with the paid door, 403 on a rejected token, + limiter ordering ahead of any DB read, the settled/unsettled/failed Tasks and + their x402 metadata, replay, and payer-bound `tasks/get` / `tasks/cancel` + including payer A polling payer B's task. +* The principal path is untouched (the hard line): a Trinity key still runs the + turn for free, with no facilitator call and no payment log row. +""" +from __future__ import annotations + +import base64 +import json +import sys +import types +from pathlib import Path + +import pytest +from fastapi import HTTPException, status + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import dependencies as deps # noqa: E402 +import routers.a2a as a2a # noqa: E402 +from services import a2a_gate, a2a_payment_gate, a2a_protocol, paid_turn_service # noqa: E402 + +pytestmark = pytest.mark.unit + +AGENT = "bot" +PAYER = "0xPayerAAA" +OTHER_PAYER = "0xPayerBBB" + + +# --------------------------------------------------------------------------- # +# dependencies.get_user_or_anonymous +# --------------------------------------------------------------------------- # +class TestUserOrAnonymous: + """401 degrades, 403 does not. The difference is the whole point.""" + + @pytest.mark.asyncio + async def test_no_token_is_anonymous(self): + assert await deps.get_user_or_anonymous(object(), token="") is None + + @pytest.mark.asyncio + async def test_401_degrades_to_anonymous(self, monkeypatch): + async def _raise(request, token): + raise HTTPException(status_code=401, detail="Not authenticated") + monkeypatch.setattr(deps, "get_current_user", _raise) + assert await deps.get_user_or_anonymous(object(), token="bad") is None + + @pytest.mark.asyncio + async def test_403_is_reraised_not_degraded(self, monkeypatch): + """A fenced connector/ephemeral key must NOT become a payer. + + If this collapsed to None, every containment fence inside + `get_current_user` would turn into a downgrade onto the payment path — + a refused credential buying the access it was refused. + """ + async def _raise(request, token): + raise HTTPException(status_code=403, detail="Connector scope") + monkeypatch.setattr(deps, "get_current_user", _raise) + with pytest.raises(HTTPException) as exc: + await deps.get_user_or_anonymous(object(), token="fenced") + assert exc.value.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.asyncio + async def test_a_valid_key_resolves_to_its_user(self, monkeypatch): + sentinel = types.SimpleNamespace(username="alice") + + async def _ok(request, token): + return sentinel + monkeypatch.setattr(deps, "get_current_user", _ok) + assert await deps.get_user_or_anonymous(object(), token="good") is sentinel + + +# --------------------------------------------------------------------------- # +# is_priced +# --------------------------------------------------------------------------- # +def _config(enabled=True, credits=2): + return types.SimpleNamespace( + agent_name=AGENT, enabled=enabled, credits_per_request=credits, + nvm_environment="sandbox", nvm_plan_id="plan-1", nvm_agent_id="agent-1", + ) + + +def _fake_db(*, exposed=True, enabled=True, key="sandbox:jwt", bindings=None): + cfg = _config(enabled=enabled) + return types.SimpleNamespace( + get_a2a_exposed=lambda name: exposed and name == AGENT, + get_nevermined_config_with_key=lambda name: ( + {"config": cfg, "nvm_api_key": key} if name == AGENT else None + ), + nevermined_payer_owns_execution=lambda a, e, p: (a, e, p) in (bindings or set()), + ) + + +class TestIsPriced: + def test_exposed_and_enabled_is_priced(self): + priced = a2a_payment_gate.is_priced(AGENT, db=_fake_db()) + assert priced is not None and priced.nvm_api_key == "sandbox:jwt" + + def test_not_exposed_is_not_priced(self): + assert a2a_payment_gate.is_priced(AGENT, db=_fake_db(exposed=False)) is None + + def test_config_disabled_is_not_priced(self): + assert a2a_payment_gate.is_priced(AGENT, db=_fake_db(enabled=False)) is None + + def test_missing_key_is_not_priced(self): + assert a2a_payment_gate.is_priced(AGENT, db=_fake_db(key="")) is None + + def test_unknown_agent_is_not_priced(self): + assert a2a_payment_gate.is_priced("ghost", db=_fake_db()) is None + + def test_sdk_absence_is_not_fused_into_is_priced(self): + """"Takes payment" and "can process one now" are different facts. + + Fusing them would answer 401 — "authenticate" — to a caller holding a + valid token for an agent whose card advertises a price, when no + credential it could obtain would work. The router answers 501 for that, + which it can only do if this function does not swallow the case. + """ + import inspect + src = inspect.getsource(a2a_payment_gate.is_priced) + assert "NEVERMINED_AVAILABLE" not in src + assert "sdk_available" not in inspect.signature( + a2a_payment_gate.is_priced).parameters + + +# --------------------------------------------------------------------------- # +# extract_token +# --------------------------------------------------------------------------- # +def _payload(nonce="n1"): + return {"x402Version": 1, "payload": {"signature": "0xsig", "nonce": nonce}} + + +def _encoded(payload): + from payments_py.x402.token import encode_access_token + return encode_access_token(payload) + + +def _message(*, payload=None, text="hi"): + msg = {"parts": [{"kind": "text", "text": text}], "messageId": "m1"} + if payload is not None: + msg["metadata"] = {a2a_protocol.X402_PAYLOAD_KEY: payload} + return msg + + +class TestExtractToken: + def test_metadata_payload_is_re_encoded(self): + token = a2a_payment_gate.extract_token(_message(payload=_payload()), {}) + assert token == _encoded(_payload()) + + def test_header_is_the_fallback(self): + token = a2a_payment_gate.extract_token( + _message(), {a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER: "hdr-token"}) + assert token == "hdr-token" + + def test_metadata_wins_over_header(self): + """The SDK's own precedence (`inband_token or header_token`). + + Header-first would let a stale header silently decide what a client + migrating onto the in-band rail pays with. + """ + token = a2a_payment_gate.extract_token( + _message(payload=_payload()), + {a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER: "hdr-token"}) + assert token == _encoded(_payload()) + + @pytest.mark.parametrize("payload", [ + "not-a-dict", + {"payload": {}}, # no x402Version + {"x402Version": "1", "payload": {}}, # version not an int + {"x402Version": True, "payload": {}}, # bool is not an int here + {"x402Version": 1}, # no payload key + ]) + def test_malformed_payload_falls_through_to_the_header(self, payload): + token = a2a_payment_gate.extract_token( + _message(payload=payload), + {a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER: "hdr-token"}) + assert token == "hdr-token" + + def test_neither_rail_is_no_token(self): + assert a2a_payment_gate.extract_token(_message(), {}) is None + + def test_unencodable_payload_never_raises(self, monkeypatch): + """A payload the SDK cannot encode is "no in-band payment", not a 500.""" + monkeypatch.setattr(a2a_payment_gate, "_encode_payload", lambda p: None) + assert a2a_payment_gate.extract_token(_message(payload=_payload()), {}) is None + + def test_a_hostile_header_mapping_is_no_header(self): + class _Boom: + def get(self, _k): + raise RuntimeError("nope") + assert a2a_payment_gate.extract_token(_message(), _Boom()) is None + + +# --------------------------------------------------------------------------- # +# T7 — the allow-list seam, fail-CLOSED on the payment path +# --------------------------------------------------------------------------- # +class TestPaymentCallerAllowed: + def teardown_method(self): + a2a_gate.clear_provider() + + def test_oss_no_provider_allows(self): + a2a_gate.clear_provider() + assert a2a_payment_gate.payment_caller_allowed(AGENT, PAYER) is True + + def test_identity_is_the_prefixed_wallet(self): + seen = [] + + class _P: + def is_inbound_allowed(self, agent, identity): + seen.append((agent, identity)) + return True + a2a_gate.register_provider(_P()) + assert a2a_payment_gate.payment_caller_allowed(AGENT, PAYER) is True + assert seen == [(AGENT, f"x402:{PAYER}")] + + def test_a_listed_provider_can_refuse(self): + class _P: + def is_inbound_allowed(self, agent, identity): + return False + a2a_gate.register_provider(_P()) + assert a2a_payment_gate.payment_caller_allowed(AGENT, PAYER) is False + + def test_provider_error_fails_closed(self): + """The OPPOSITE bias to `a2a_gate.check_inbound_allowed`, on purpose. + + There the caller is already authenticated as owner/shared, so the + allow-list is an extra layer over a decision already made and an error + must not block them. Here the payment IS the authorization, so an error + must refuse rather than admit an unlisted wallet. + """ + class _P: + def is_inbound_allowed(self, agent, identity): + raise RuntimeError("policy store down") + a2a_gate.register_provider(_P()) + assert a2a_payment_gate.payment_caller_allowed(AGENT, PAYER) is False + + def test_no_payer_fails_closed_when_a_provider_exists(self): + class _P: + def is_inbound_allowed(self, agent, identity): + return True + a2a_gate.register_provider(_P()) + assert a2a_payment_gate.payment_caller_allowed(AGENT, None) is False + + +# --------------------------------------------------------------------------- # +# Router harness — the anonymous branch end to end +# --------------------------------------------------------------------------- # +class _Verify: + def __init__(self, success=True, payer=PAYER, error=None, retryable=False): + self.success, self.payer, self.error = success, payer, error + self.agent_request_id = "req-1" + self.retryable = retryable + + +class _Settle: + def __init__(self, success=True, error=None): + self.success, self.error = success, error + self.tx_hash = "0xtx" if success else None + self.remaining_balance = "41" if success else None + self.credits_redeemed = "2" if success else None + + +class _Dec: + def __init__(self, key=None, scope=None, replay=False, in_flight=False, snapshot=None): + self.key, self.scope = key, scope + self.replay, self.in_flight, self.snapshot = replay, in_flight, snapshot + self.enabled = key is not None + + +class _Idem: + """Enough of `idempotency_service` to exercise replay, keyed as the real one.""" + + def __init__(self): + self.store = {} + self.calls = [] + + @staticmethod + def derive_payment_key(token, body): + import hashlib + if not token: + return None + return hashlib.sha256(token.encode() + b"\x00" + (body or b"")).hexdigest() + + def begin(self, scope, key): + self.calls.append(("begin", scope, key)) + if not key: + return _Dec() + rec = self.store.get((scope, key)) + if rec is not None: + return _Dec(key=key, scope=scope, replay=True, snapshot=rec) + return _Dec(key=key, scope=scope) + + def attach_execution(self, decision, execution_id): + pass + + def complete(self, decision, execution_id, snapshot): + if decision.key and not decision.replay: + self.store[(decision.scope, decision.key)] = snapshot + + def upgrade_snapshot(self, scope, key, snapshot): + if key: + self.store[(scope, key)] = snapshot + + def fail(self, decision): + self.calls.append(("fail", decision.scope, decision.key)) + + +@pytest.fixture() +def client(monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + state = { + "exposed": {AGENT}, + "priced": {AGENT}, + "executions": {}, + "payment_log": [], + "bindings": set(), # (agent, execution_id, payer) + "verify": _Verify(), + "settle": _Settle(), + "exec_status": "success", + "exec_response": "the answer", + "db_reads": [], + "built_402": {"scheme": "exact", "x402Version": 1}, + "endpoints": [], + } + + def _cancel_queued(eid, reason=None): + row = state["executions"].get(eid) + if not row or row.get("status") != "queued": + return False + row["status"] = "cancelled" + return True + + def _get_cfg_with_key(name): + state["db_reads"].append(("config", name)) + if name not in state["priced"]: + return None + return {"config": _config(), "nvm_api_key": "sandbox:jwt"} + + def _exposed(name): + state["db_reads"].append(("exposed", name)) + return name in state["exposed"] + + fake_db = types.SimpleNamespace( + get_a2a_exposed=_exposed, + can_user_access_agent=lambda user, name: name in state["exposed"], + get_execution=lambda eid: state["executions"].get(eid), + cancel_queued_execution=_cancel_queued, + get_nevermined_config_with_key=_get_cfg_with_key, + get_public_channel_model=lambda name: "claude-opus-5", + nevermined_payer_owns_execution=lambda a, e, p: (a, e, p) in state["bindings"], + log_nevermined_payment=lambda **kw: state["payment_log"].append(kw), + ) + monkeypatch.setattr(a2a, "db", fake_db) + + monkeypatch.setattr( + a2a, "get_agent_container", + lambda name: types.SimpleNamespace(status="running", labels={}) + if name in state["exposed"] else None) + + async def _tmpl(name, container): + return {"display_name": name, "capabilities": ["chat"]} + monkeypatch.setattr(a2a, "_fetch_template_data", _tmpl) + + class _Result: + def __init__(self): + self.execution_id = "exec-1" + self.status = state["exec_status"] + self.response = state["exec_response"] + self.error = "boom" if state["exec_status"] == "failed" else None + + async def _adapter(**kwargs): + state["last_dispatch"] = kwargs + if state.get("exec_raises"): + raise RuntimeError("agent unreachable") + return _Result() + monkeypatch.setattr(a2a, "dispatch_and_await_terminal", _adapter) + + async def _terminate(agent, eid): + state["terminated"] = (agent, eid) + return True + monkeypatch.setattr(a2a, "terminate_execution_on_agent", _terminate) + + class _Audit: + async def log(self, **kwargs): + state.setdefault("audit", []).append(kwargs) + monkeypatch.setattr(a2a, "platform_audit_service", _Audit()) + + idem = _Idem() + monkeypatch.setattr(a2a, "idempotency_service", idem) + + class _PaymentService: + def build_402_response(self, config, base_url="", endpoint=None): + state["endpoints"].append(("build", endpoint)) + if state.get("build_402_raises"): + raise RuntimeError("bad plan config") + return state["built_402"] + + async def verify_payment(self, **kw): + state["endpoints"].append(("verify", kw.get("endpoint"))) + state.setdefault("verifies", []).append(kw["access_token"]) + return state["verify"] + + async def settle_payment_once(self, **kw): + state["endpoints"].append(("settle", kw.get("endpoint"))) + state.setdefault("settles", []).append(kw.get("execution_id")) + return state["settle"] + + monkeypatch.setattr(a2a, "get_nevermined_payment_service", lambda: _PaymentService()) + monkeypatch.setattr(a2a, "NEVERMINED_AVAILABLE", True) + monkeypatch.setattr(a2a, "build_public_channel_caller_prompt", lambda name: "PUBLIC") + monkeypatch.setattr(a2a.rate_limiter, "enforce", + lambda *a_, **k_: state.setdefault("limits", []).append(a_[0])) + a2a_gate.clear_provider() + + app = FastAPI() + app.include_router(a2a.a2a_server_router) + # Anonymous by default — this file's subject is the payment branch. + app.dependency_overrides[deps.get_user_or_anonymous] = lambda: None + + return types.SimpleNamespace( + http=TestClient(app), state=state, idem=idem, app=app, deps=deps) + + +def _send(client, *, payload=None, header=None, text="hi", method="message/send", + agent=AGENT, rpc_id=1): + body = {"jsonrpc": "2.0", "id": rpc_id, "method": method, + "params": {"message": _message(payload=payload, text=text)}} + headers = {a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER: header} if header else {} + return client.http.post(f"/a2a/{agent}", json=body, headers=headers) + + +def _task_of(response): + return response.json()["result"] + + +def _payment_meta(task): + return task["status"]["message"]["metadata"] + + +# --------------------------------------------------------------------------- # +# The refusals +# --------------------------------------------------------------------------- # +class TestAnonymousRefusals: + def test_non_priced_agent_gets_todays_401_bytes(self, client): + client.state["priced"].clear() + r = _send(client, header="tok") + assert r.status_code == 401 + assert r.json() == {"detail": "Not authenticated"} + assert r.headers["WWW-Authenticate"] == "Bearer" + + def test_non_exposed_agent_gets_todays_401_bytes(self, client): + client.state["exposed"].clear() + r = _send(client, header="tok") + assert r.status_code == 401 + assert r.json() == {"detail": "Not authenticated"} + + def test_unknown_agent_gets_todays_401_bytes(self, client): + """Uniform with a non-priced agent — no enumeration oracle.""" + r = _send(client, header="tok", agent="ghost") + assert r.status_code == 401 + assert r.json() == {"detail": "Not authenticated"} + + def test_priced_agent_without_the_sdk_is_501_not_401(self, client, monkeypatch): + monkeypatch.setattr(a2a, "NEVERMINED_AVAILABLE", False) + r = _send(client, header="tok") + assert r.status_code == 501 + assert r.json() == { + "detail": "Nevermined payment integration is not available"} + + def test_the_limiter_runs_before_any_db_read(self, client, monkeypatch): + """Ordering IS the mitigation: a flood must not reach the facilitator. + + Driven by making the limiter raise and asserting the DB was never + touched — a limiter placed after `is_priced` would already have paid for + two reads per hit. + """ + def _raise(key, *a_, **k_): + raise HTTPException(status_code=429, detail="slow down") + monkeypatch.setattr(a2a.rate_limiter, "enforce", _raise) + client.state["db_reads"].clear() + r = _send(client, header="tok") + assert r.status_code == 429 + assert client.state["db_reads"] == [] + + def test_both_an_ip_and_an_agent_bucket_are_enforced(self, client): + _send(client, header="tok") + keys = client.state["limits"] + assert any(k.startswith("a2a_pay_ip:") for k in keys) + assert f"a2a_pay_agent:{AGENT}" in keys + + def test_missing_token_is_402_with_the_paid_doors_bytes(self, client): + r = _send(client) + assert r.status_code == 402 + assert r.json() == { + "detail": "Payment required", + "payment_required": client.state["built_402"], + "credits_per_request": 2, + } + decoded = json.loads(base64.b64decode( + r.headers[a2a_protocol.X402_PAYMENT_REQUIRED_HEADER])) + assert decoded == client.state["built_402"] + + def test_the_402_resource_url_is_the_a2a_door(self, client): + """#679 E2: an x402 v3 token signs `resourceUrl`, compared origin+path. + + A 402 quoting the paid door would have the caller mint a token that + cannot authorize `/a2a/{name}` — and a non-Trinity client follows + `resource.url` verbatim. + """ + _send(client) + built = [e for e in client.state["endpoints"] if e[0] == "build"] + assert built and built[0][1].endswith(f"/a2a/{AGENT}") + + def test_a_broken_plan_config_is_a_500_not_an_unusable_402(self, client): + client.state["build_402_raises"] = True + r = _send(client) + assert r.status_code == 500 + assert r.json() == {"detail": "Failed to build payment requirements"} + + def test_a_rejected_token_is_403_with_a_reject_row(self, client): + client.state["verify"] = _Verify(success=False, error="insufficient balance") + r = _send(client, header="bad-token") + assert r.status_code == 403 + assert r.json()["detail"] == "Payment verification failed" + assert r.json()["error"] == "insufficient balance" + actions = [row["action"] for row in client.state["payment_log"]] + assert actions == ["reject"] + + def test_a_retryable_verify_is_a_retryable_error_not_a_403(self, client): + """I1: a facilitator timeout is OUR outage, not the payer's problem. + + `retryable` is set on a timeout, an SDK error and a saturated + concurrency gate (E7) — cases where the facilitator never DECIDED. A 403 + is read by #3209's client as `payment_rejected`, i.e. stop retrying and + go buy another token, which is the wrong instruction and costs the payer + money for our unavailability. So: a JSON-RPC error carrying + `data.retryable`, and never a -32001 (that is A2A TaskNotFound). + """ + client.state["verify"] = _Verify( + success=False, error="facilitator timeout", retryable=True) + r = _send(client, header="tok") + + assert r.status_code == 200 # the error rides in the envelope + err = r.json()["error"] + assert err["code"] == a2a_protocol.RPC_INTERNAL_ERROR + assert err["code"] != a2a_protocol.A2A_TASK_NOT_FOUND + assert err["data"] == {"code": "verify_unavailable", "retryable": True} + + def test_a_retryable_verify_is_logged_as_an_attempt_not_a_rejection(self, client): + """A verify that never decided must not read as "this wallet was refused".""" + client.state["verify"] = _Verify( + success=False, error="facilitator timeout", retryable=True) + _send(client, header="tok") + + rows = client.state["payment_log"] + assert [row["action"] for row in rows] == ["verify"] + assert rows[0]["success"] is False + assert rows[0]["error"] == "facilitator timeout" + + def test_a_rejected_token_still_carries_the_discriminator(self, client): + """A real rejection keeps its 403 bytes (T3) — the paid door's shape.""" + client.state["verify"] = _Verify(success=False, error="expired") + r = _send(client, header="tok") + assert r.status_code == 403 + assert r.json() == {"detail": "Payment verification failed", "error": "expired"} + + def test_verify_runs_before_the_dedup_gate(self, client): + """A rejected token must not consume an idempotency key.""" + client.state["verify"] = _Verify(success=False, error="nope") + _send(client, header="bad-token") + assert [c for c in client.idem.calls if c[0] == "begin"] == [] + + def test_the_body_cap_precedes_token_extraction(self, client, monkeypatch): + monkeypatch.setattr(a2a, "_MAX_RPC_BODY_BYTES", 10) + r = _send(client, header="tok", text="x" * 200) + assert r.status_code == 200 + assert r.json()["error"]["message"] == "Request body too large" + assert "verifies" not in client.state + + @pytest.mark.parametrize("raw,msg", [ + ("not json", "Parse error: body is not valid JSON"), + ('{"jsonrpc":"1.0","method":"message/send"}', "Invalid JSON-RPC 2.0 request"), + ]) + def test_envelope_refusals_match_the_principal_path(self, client, raw, msg): + r = client.http.post(f"/a2a/{AGENT}", content=raw, + headers={"content-type": "application/json"}) + assert r.status_code == 200 + assert r.json()["error"]["message"] == msg + + def test_message_without_text_is_invalid_params(self, client): + body = {"jsonrpc": "2.0", "id": 1, "method": "message/send", + "params": {"message": {"parts": []}}} + r = client.http.post(f"/a2a/{AGENT}", json=body) + assert r.json()["error"]["message"] == "message has no text parts" + + def test_an_unknown_method_is_method_not_found(self, client): + body = {"jsonrpc": "2.0", "id": 1, "method": "tasks/frobnicate", "params": {}} + r = client.http.post(f"/a2a/{AGENT}", json=body) + assert r.json()["error"]["code"] == a2a_protocol.RPC_METHOD_NOT_FOUND + + +# --------------------------------------------------------------------------- # +# T7 on the wire +# --------------------------------------------------------------------------- # +class TestAllowlistOnThePaymentPath: + def teardown_method(self): + a2a_gate.clear_provider() + + def test_an_unlisted_payer_is_403_with_a_reject_row_and_no_execution(self, client): + class _P: + def is_inbound_allowed(self, agent, identity): + return False + a2a_gate.register_provider(_P()) + r = _send(client, header="tok") + assert r.status_code == 403 + assert "allow-list" in r.json()["detail"] + assert "last_dispatch" not in client.state + assert [row["action"] for row in client.state["payment_log"]] == [ + "verify", "reject"] + + def test_the_allowlist_is_consulted_after_verify(self, client): + """The wallet only exists once the facilitator has answered.""" + seen = [] + + class _P: + def is_inbound_allowed(self, agent, identity): + seen.append(identity) + return True + a2a_gate.register_provider(_P()) + _send(client, header="tok") + assert seen == [f"x402:{PAYER}"] + + +# --------------------------------------------------------------------------- # +# The happy paths and the honest-failure paths +# --------------------------------------------------------------------------- # +class TestPaidSend: + def test_a_settled_turn_is_a_completed_task_with_a_receipt(self, client): + r = _send(client, payload=_payload()) + task = _task_of(r) + assert task["status"]["state"] == "completed" + assert task["artifacts"][0]["parts"][0]["text"] == "the answer" + meta = _payment_meta(task) + assert meta[a2a_protocol.X402_STATUS_KEY] == a2a_protocol.X402_STATUS_COMPLETED + receipt = meta[a2a_protocol.X402_RECEIPTS_KEY][0] + assert receipt == { + "payer": PAYER, "transaction": "0xtx", + "creditsRedeemed": 2, "remainingBalance": "41", + } + assert [row["action"] for row in client.state["payment_log"]] == [ + "verify", "verify", "settle"] + + def test_the_turn_runs_with_no_trinity_principal_and_public_channel_settings( + self, client): + _send(client, payload=_payload()) + kw = client.state["last_dispatch"] + assert kw["triggered_by"] == "a2a" + assert kw["source_user_id"] is None + assert kw["source_user_email"] is None + assert kw["source_mcp_key_id"] is None + assert kw["system_prompt"] == "PUBLIC" + assert kw["model"] == "claude-opus-5" + + def test_the_in_band_token_never_reaches_the_agent(self, client): + """The hard line: the credential goes to the facilitator, not the prompt.""" + token = _encoded(_payload()) + _send(client, payload=_payload()) + kw = client.state["last_dispatch"] + assert token not in json.dumps(kw) + assert client.state["verifies"] == [token] + + def test_the_dedup_scope_is_namespaced_by_payer(self, client): + _send(client, payload=_payload()) + scopes = [c[1] for c in client.idem.calls if c[0] == "begin"] + assert scopes == [f"a2a:{AGENT}:pay:{PAYER}"] + + def test_two_payers_do_not_share_a_replay_namespace(self, client): + _send(client, payload=_payload()) + client.state["verify"] = _Verify(payer=OTHER_PAYER) + _send(client, payload=_payload()) + # Same (token, text) → same key, but the scopes differ, so the second + # payer executes its own turn instead of replaying the first's answer. + assert client.state["settles"] == ["exec-1", "exec-1"] + scopes = {c[1] for c in client.idem.calls if c[0] == "begin"} + assert scopes == {f"a2a:{AGENT}:pay:{PAYER}", + f"a2a:{AGENT}:pay:{OTHER_PAYER}"} + + def test_a_retry_of_the_same_token_and_text_replays_without_re_executing( + self, client): + first = _task_of(_send(client, payload=_payload())) + client.state.pop("last_dispatch") + r = _send(client, payload=_payload()) + assert r.headers["X-Idempotent-Replay"] == "true" + assert _task_of(r)["status"]["state"] == "completed" + assert _payment_meta(_task_of(r))[a2a_protocol.X402_STATUS_KEY] == \ + a2a_protocol.X402_STATUS_COMPLETED + # Neither the LLM nor a second settle ran. + assert "last_dispatch" not in client.state + assert client.state["settles"] == ["exec-1"] + assert _task_of(r)["artifacts"][0]["parts"][0]["text"] == \ + first["artifacts"][0]["parts"][0]["text"] + + def test_a_messageid_change_does_not_fork_a_retry(self, client): + """#3209's client mints a fresh uuid4 messageId per call. + + Keying the dedup on messageId would make every retry after its 30 s RPC + timeout a fresh execution AND a fresh settle — the payer pays twice for + one answer. + """ + _send(client, payload=_payload()) + client.state.pop("last_dispatch") + body = {"jsonrpc": "2.0", "id": 9, "method": "message/send", + "params": {"message": { + "parts": [{"kind": "text", "text": "hi"}], + "messageId": "a-totally-different-id", + "metadata": {a2a_protocol.X402_PAYLOAD_KEY: _payload()}, + }}} + r = client.http.post(f"/a2a/{AGENT}", json=body) + assert r.headers.get("X-Idempotent-Replay") == "true" + assert "last_dispatch" not in client.state + + def test_a_delivered_but_unsettled_turn_keeps_its_artifact(self, client): + """#1018 deliver-then-reconcile, carried onto this wire. + + `payment-verified` (not `payment-failed`) because #3209's client parses + a task normally on anything it does not recognise as a refusal — so the + artifact the payer paid for survives instead of being discarded. + """ + client.state["settle"] = _Settle(success=False, error="facilitator 503") + task = _task_of(_send(client, payload=_payload())) + assert task["status"]["state"] == "completed" + assert task["artifacts"][0]["parts"][0]["text"] == "the answer" + meta = _payment_meta(task) + assert meta[a2a_protocol.X402_STATUS_KEY] == a2a_protocol.X402_STATUS_VERIFIED + assert meta[a2a_protocol.X402_ERROR_KEY] == { + "code": "settle_retry_needed", "reason": "facilitator 503"} + assert a2a_protocol.X402_RECEIPTS_KEY not in meta + assert "settle_failed" in [row["action"] for row in client.state["payment_log"]] + + def test_a_concurrent_settle_is_named_as_in_progress(self, client): + client.state["settle"] = _Settle( + success=False, error="settlement already in progress") + meta = _payment_meta(_task_of(_send(client, payload=_payload()))) + assert meta[a2a_protocol.X402_ERROR_KEY]["code"] == "settle_in_progress" + # The concurrently-running settle logs its own outcome, once. + assert "settle_failed" not in [ + row["action"] for row in client.state["payment_log"]] + + def test_a_failed_turn_is_not_charged_and_carries_no_artifact(self, client): + client.state["exec_status"] = "failed" + task = _task_of(_send(client, payload=_payload())) + assert task["status"]["state"] == "failed" + assert "artifacts" not in task + meta = _payment_meta(task) + assert meta[a2a_protocol.X402_STATUS_KEY] == a2a_protocol.X402_STATUS_VERIFIED + assert meta[a2a_protocol.X402_ERROR_KEY]["code"] == "execution_failed" + assert "settles" not in client.state + assert "settle" not in [row["action"] for row in client.state["payment_log"]] + + def test_a_cancelled_turn_keeps_its_text_and_is_not_charged(self, client): + client.state["exec_status"] = "cancelled" + task = _task_of(_send(client, payload=_payload())) + assert task["status"]["state"] == "canceled" + assert task["artifacts"][0]["parts"][0]["text"] == "the answer" + assert _payment_meta(task)[a2a_protocol.X402_ERROR_KEY]["code"] == \ + "execution_cancelled" + assert "settles" not in client.state + + def test_a_raised_execution_is_a_json_rpc_error_and_no_charge(self, client): + client.state["exec_raises"] = True + r = _send(client, payload=_payload()) + assert r.json()["error"]["message"] == "Task execution failed" + assert "settles" not in client.state + assert [c for c in client.idem.calls if c[0] == "fail"] + + def test_the_audit_row_has_the_payer_and_no_actor_user(self, client): + _send(client, payload=_payload()) + row = client.state["audit"][-1] + assert row["actor_user"] is None + assert row["details"]["payer"] == PAYER + assert row["details"]["settled"] is True + + def test_verify_and_settle_are_scoped_to_the_a2a_door(self, client): + _send(client, payload=_payload()) + for kind, endpoint in client.state["endpoints"]: + assert endpoint.endswith(f"/a2a/{AGENT}"), kind + + +class TestPaidStream: + def _events(self, response): + return [json.loads(line[len("data: "):]) + for line in response.text.splitlines() if line.startswith("data: ")] + + def test_stream_emits_working_then_the_paid_task(self, client): + r = _send(client, payload=_payload(), method="message/stream") + assert r.headers["content-type"].startswith("text/event-stream") + events = self._events(r) + assert events[0]["result"]["status"]["state"] == "working" + final = events[-1]["result"] + assert final["final"] is True + assert final["status"]["state"] == "completed" + assert final["status"]["message"]["metadata"][ + a2a_protocol.X402_STATUS_KEY] == a2a_protocol.X402_STATUS_COMPLETED + + def test_a_missing_token_on_stream_is_still_an_http_402(self, client): + """A 402 is an HTTP status and cannot be expressed mid-stream, so it + must be answered before the event stream opens.""" + r = _send(client, method="message/stream") + assert r.status_code == 402 + assert not r.headers["content-type"].startswith("text/event-stream") + + def test_a_rejected_token_on_stream_is_an_sse_error_not_a_task(self, client): + client.state["verify"] = _Verify(success=False, error="nope") + r = _send(client, header="bad", method="message/stream") + events = self._events(r) + assert "error" in events[-1] + assert events[-1]["error"]["message"] == "Payment verification failed" + + def test_the_stream_never_answers_task_not_found_for_a_payment_refusal(self, client): + """I3: -32001 is A2A TaskNotFound and this is not a task condition. + + A streaming client is told its task does not exist when the truth is + "buy a token" — an answer it cannot act on, and one the `message/send` + path never gave. The stream now renders the SAME classification `send` + does, with `data.code` as the discriminator. + """ + client.state["verify"] = _Verify(success=False, error="nope") + err = self._events(_send(client, header="bad", method="message/stream"))[-1]["error"] + + assert err["code"] != a2a_protocol.A2A_TASK_NOT_FOUND + assert err["code"] == a2a_protocol.RPC_INTERNAL_ERROR + assert err["data"] == {"code": "payment_rejected", "retryable": False} + + def test_a_retryable_verify_on_stream_keeps_its_retryable_flag(self, client): + client.state["verify"] = _Verify( + success=False, error="facilitator busy", retryable=True) + err = self._events(_send(client, header="tok", method="message/stream"))[-1]["error"] + + assert err["code"] != a2a_protocol.A2A_TASK_NOT_FOUND + assert err["data"] == {"code": "verify_unavailable", "retryable": True} + + def test_an_unlisted_payer_on_stream_is_named_not_allowed(self, client): + """The allow-list 403 cannot be an HTTP status mid-stream (T7 + I3). + + It used to flatten into the same `-32603 Task execution failed` an + in-flight duplicate got, so a client could not tell "you are not + allowed here" (never retry) from "your own duplicate is still running" + (retry shortly). + """ + class _P: + def is_inbound_allowed(self, agent, identity): + return False + a2a_gate.register_provider(_P()) + try: + err = self._events( + _send(client, header="tok", method="message/stream"))[-1]["error"] + finally: + a2a_gate.clear_provider() + + assert err["code"] != a2a_protocol.A2A_TASK_NOT_FOUND + assert err["data"] == {"code": "not_allowed", "retryable": False} + assert "allow-list" in err["message"] + + +# --------------------------------------------------------------------------- # +# T5 — payer-bound tasks/get and tasks/cancel +# --------------------------------------------------------------------------- # +def _rpc(client, method, exec_id, *, payload=None, header=None, agent=AGENT): + params = {"id": exec_id} + if payload is not None: + params["message"] = _message(payload=payload) + body = {"jsonrpc": "2.0", "id": 7, "method": method, "params": params} + headers = {a2a_protocol.X402_PAYMENT_SIGNATURE_HEADER: header} if header else {} + return client.http.post(f"/a2a/{agent}", json=body, headers=headers) + + +class TestPayerBoundTaskRpc: + def test_a_bound_payer_can_read_its_own_task(self, client): + client.state["executions"]["exec-1"] = { + "agent_name": AGENT, "status": "success", "response": "the answer"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + task = _task_of(_rpc(client, "tasks/get", "exec-1", header="tok")) + assert task["status"]["state"] == "completed" + assert task["artifacts"][0]["parts"][0]["text"] == "the answer" + + def test_payer_a_polling_payer_bs_task_gets_task_not_found(self, client): + """T5's named case. Byte-identical to an unknown id — no oracle. + + The row EXISTS and belongs to this agent; only the wallet differs, so + any answer other than the not-found one would confirm its existence to + a stranger. + """ + client.state["executions"]["exec-1"] = { + "agent_name": AGENT, "status": "success", "response": "B's secret answer"} + client.state["bindings"].add((AGENT, "exec-1", OTHER_PAYER)) + r = _rpc(client, "tasks/get", "exec-1", header="tok") # verify → PAYER + assert r.json()["error"] == { + "code": a2a_protocol.A2A_TASK_NOT_FOUND, "message": "Task not found"} + assert "B's secret answer" not in r.text + + def test_an_unknown_task_id_gets_the_identical_answer(self, client): + r = _rpc(client, "tasks/get", "no-such-exec", header="tok") + assert r.json()["error"] == { + "code": a2a_protocol.A2A_TASK_NOT_FOUND, "message": "Task not found"} + + def test_no_token_gets_the_identical_answer(self, client): + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "success"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + r = _rpc(client, "tasks/get", "exec-1") + assert r.json()["error"]["code"] == a2a_protocol.A2A_TASK_NOT_FOUND + + def test_a_token_that_fails_verify_gets_the_identical_answer(self, client): + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "success"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + client.state["verify"] = _Verify(success=False, error="expired") + r = _rpc(client, "tasks/get", "exec-1", header="tok") + assert r.json()["error"]["code"] == a2a_protocol.A2A_TASK_NOT_FOUND + + def test_a_verify_that_raises_is_not_an_entitlement(self, client, monkeypatch): + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "success"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + + class _Boom: + def build_402_response(self, *a_, **k_): + return {} + + async def verify_payment(self, **kw): + raise RuntimeError("facilitator exploded") + monkeypatch.setattr(a2a, "get_nevermined_payment_service", lambda: _Boom()) + r = _rpc(client, "tasks/get", "exec-1", header="tok") + assert r.json()["error"]["code"] == a2a_protocol.A2A_TASK_NOT_FOUND + + def test_the_token_may_arrive_in_band_on_a_poll_too(self, client): + client.state["executions"]["exec-1"] = { + "agent_name": AGENT, "status": "running"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + task = _task_of(_rpc(client, "tasks/get", "exec-1", payload=_payload())) + assert task["status"]["state"] == "working" + + def test_a_bound_payer_can_cancel_a_running_task(self, client): + """Pins `_bound_task_rpc`'s cancel branch, on a state seeded by hand. + + The paid flow does not produce a binding on a RUNNING row today: + `run_paid_turn` writes it once `execute()` has returned, which is at the + terminal. So this is the handler's behaviour should the binding ever be + written earlier — it must then match the principal path's cancel — and + not evidence that a payer can cancel a turn in flight. + """ + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "running"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + task = _task_of(_rpc(client, "tasks/cancel", "exec-1", header="tok")) + assert task["status"]["state"] == "canceled" + assert client.state["terminated"] == (AGENT, "exec-1") + + def test_cancelling_a_terminal_task_says_so(self, client): + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "success"} + client.state["bindings"].add((AGENT, "exec-1", PAYER)) + r = _rpc(client, "tasks/cancel", "exec-1", header="tok") + assert r.json()["error"]["code"] == a2a_protocol.A2A_TASK_NOT_CANCELABLE + + def test_an_unbound_payer_cannot_cancel(self, client): + client.state["executions"]["exec-1"] = {"agent_name": AGENT, "status": "running"} + client.state["bindings"].add((AGENT, "exec-1", OTHER_PAYER)) + r = _rpc(client, "tasks/cancel", "exec-1", header="tok") + assert r.json()["error"]["code"] == a2a_protocol.A2A_TASK_NOT_FOUND + assert "terminated" not in client.state + + def test_resubscribe_is_still_unsupported(self, client): + body = {"jsonrpc": "2.0", "id": 1, "method": "tasks/resubscribe", "params": {}} + r = client.http.post(f"/a2a/{AGENT}", json=body) + assert r.json()["error"]["code"] == a2a_protocol.A2A_UNSUPPORTED + + +# --------------------------------------------------------------------------- # +# The hard line: the authenticated path is untouched +# --------------------------------------------------------------------------- # +class TestPrincipalPathUnaffected: + """AC4: internal fleet callers and subscription tenants pay nothing and + notice nothing. A payment gate that quietly started charging the fleet + would be the expensive failure, so it is asserted rather than assumed.""" + + @pytest.fixture() + def principal(self, client): + user = types.SimpleNamespace(id=1, username="alice", email="alice@example.com", + role="user", agent_name=None, mcp_key_id="k1") + client.app.dependency_overrides[deps.get_user_or_anonymous] = lambda: user + return client + + def test_a_trinity_key_runs_the_turn_for_free(self, principal): + task = _task_of(_send(principal, payload=_payload())) + assert task["status"]["state"] == "completed" + assert task["artifacts"][0]["parts"][0]["text"] == "the answer" + # No facilitator call, no payment row, no payment metadata. + assert "verifies" not in principal.state + assert principal.state["payment_log"] == [] + assert "message" not in task["status"] + + def test_a_trinity_key_is_not_rate_limited_by_the_paying_buckets(self, principal): + principal.state.setdefault("limits", []).clear() + _send(principal, payload=_payload()) + assert not [k for k in principal.state["limits"] if k.startswith("a2a_pay_")] + + def test_the_principal_keeps_its_own_attribution(self, principal): + _send(principal, payload=_payload()) + kw = principal.state["last_dispatch"] + assert kw["source_user_id"] == 1 + assert kw["source_user_email"] == "alice@example.com" + assert kw["source_mcp_key_id"] == "k1" + # And NOT the public-channel caller prompt the payment path applies. + assert "system_prompt" not in kw + + def test_a_principal_on_a_priced_agent_still_pays_nothing(self, principal): + assert AGENT in principal.state["priced"] + _task_of(_send(principal, payload=_payload())) + assert principal.state["payment_log"] == [] + + def test_a_principal_on_a_non_exposed_agent_still_gets_a_uniform_404( + self, principal): + principal.state["exposed"].clear() + r = _send(principal, payload=_payload()) + assert r.status_code == 404 + assert r.json() == {"detail": "Not found"} + + +# --------------------------------------------------------------------------- # +# Outcome → Task mapping, at the service layer +# --------------------------------------------------------------------------- # +class TestTaskFromPaidPayload: + def test_refusal_kinds_are_not_tasks(self): + for kind in (paid_turn_service.VERIFY_FAILED, paid_turn_service.IN_FLIGHT, + paid_turn_service.ABORTED, paid_turn_service.EXECUTION_ERROR): + outcome = paid_turn_service.PaidTurnOutcome(kind=kind, payload={}) + assert a2a_payment_gate.task_from_paid_payload( + outcome, task_builder=a2a._task_object) is None + + def test_every_non_refusal_outcome_kind_renders(self): + """A new outcome kind must be a visible decision, not a silent None.""" + rendered = set(a2a_payment_gate._OUTCOME_RENDER) + refusals = {paid_turn_service.VERIFY_FAILED, paid_turn_service.IN_FLIGHT, + paid_turn_service.ABORTED, paid_turn_service.EXECUTION_ERROR} + all_kinds = { + v for k, v in vars(paid_turn_service).items() + if k.isupper() and isinstance(v, str) and not k.startswith("_") + } + assert all_kinds - refusals == rendered diff --git a/tests/unit/test_ent679_a2a_priced_card.py b/tests/unit/test_ent679_a2a_priced_card.py new file mode 100644 index 000000000..0b5930703 --- /dev/null +++ b/tests/unit/test_ent679_a2a_priced_card.py @@ -0,0 +1,370 @@ +"""ent#679 checkpoint C — the A2A card states the price (AC2). + +A stranger could meet the 402 from `POST /a2a/{name}` only by calling first and +being refused. The card is A2A's discovery document, so a priced agent now +declares its price there: an x402-speaking client mints a token from `agentId` + +`planId` off the card alone and meets the paywall on its first request. + +What this file proves, each at the layer it lives in: + +1. **The unpriced card is byte-identical** (the hard line). No price config, a + disabled one, or a config missing its ids ⇒ the same card object, by + identity. Every A2A install that does not sell anything is untouched. +2. **The priced card carries the Nevermined vocabulary** and nothing else — the + official Google/Coinbase x402 extension URI is deliberately absent, because + declaring it advertises an activation handshake Trinity does not run. +3. **Both card surfaces carry it** (ent#180 FR-3): the public well-known card + and the authenticated per-agent card go through the one producer, driven here + over a real `TestClient` rather than asserted from source text. +4. **`generate_a2a_card` stays pure** — no payment knowledge leaked into the + builder, no I/O. +5. **T9: 0 credits is legal.** A duration plan charges by time; the model + accepts 0, a negative is still a named 422, and the card declares such a + plan as `dynamic` rather than contradicting itself with `fixed`/0. +6. **Fail-open.** An unreadable payment config serves the card anyway — a card + route must never 5xx (the pre-existing contract for an unreachable agent). +""" +from __future__ import annotations + +import copy +import sys +import types +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import dependencies as deps # noqa: E402 +import routers.a2a as a2a # noqa: E402 +from services import a2a_card_service, a2a_gate # noqa: E402 + +pytestmark = pytest.mark.unit + +AGENT = "bot" + +#: The official A2A x402 extension URI the provider SDK's own card helper +#: appends and this implementation must NOT (plan §3.5): declaring an extension +#: means offering its `X-A2A-Extensions` activation handshake, and Trinity runs +#: no handshake — it reads in-band metadata and answers 402. A client that +#: activated it would wait for a negotiation that never comes. +OFFICIAL_X402_URI = ( + "https://github.com/google-agentic-commerce/a2a-x402/blob/main/spec/v0.2" +) + +TEMPLATE = { + "display_name": "Bot", + "description": "a bot", + "capabilities": ["chat", "research"], + "use_cases": ["ask it things"], +} + + +def _config(*, enabled=True, credits=2, plan="plan-1", agent_id="agent-1", + environment="sandbox"): + return types.SimpleNamespace( + agent_name=AGENT, + enabled=enabled, + credits_per_request=credits, + nvm_plan_id=plan, + nvm_agent_id=agent_id, + nvm_environment=environment, + ) + + +def _base_card(): + return a2a_card_service.generate_a2a_card( + agent_name=AGENT, template_data=TEMPLATE, base_url="http://t.example" + ) + + +def _extensions(card): + return (card.get("capabilities") or {}).get("extensions") or [] + + +def _payment_ext(card): + exts = [ + e for e in _extensions(card) + if e.get("uri") == a2a_card_service.NEVERMINED_PAYMENT_EXTENSION_URI + ] + assert len(exts) == 1, f"expected exactly one payment extension, got {exts}" + return exts[0] + + +# --------------------------------------------------------------------------- # +# 1. The unpriced card is byte-identical +# --------------------------------------------------------------------------- # +class TestUnpricedCardIsUnchanged: + """The hard line. An install that sells nothing must not see a byte move.""" + + def test_no_config_returns_the_same_object(self): + card = _base_card() + out = a2a_card_service.with_payment_extension( + card, None, agent_name=AGENT, base_url="http://t.example" + ) + # Identity, not equality: don't even rebuild the dict. + assert out is card + assert "extensions" not in (card.get("capabilities") or {}) + + def test_disabled_config_returns_the_same_object(self): + card = _base_card() + out = a2a_card_service.with_payment_extension( + card, _config(enabled=False), agent_name=AGENT, base_url="http://t.example" + ) + assert out is card + + @pytest.mark.parametrize("missing", ["plan", "agent_id"]) + def test_a_config_that_cannot_say_what_to_buy_is_silent(self, missing): + """An enabled config with no plan (or no agent) id would activate a + payment flow a client cannot complete. Silence beats a dead end.""" + card = _base_card() + cfg = _config(**{missing: ""}) + out = a2a_card_service.with_payment_extension( + card, cfg, agent_name=AGENT, base_url="http://t.example" + ) + assert out is card + + def test_the_input_card_is_never_mutated_when_priced(self): + card = _base_card() + before = copy.deepcopy(card) + a2a_card_service.with_payment_extension( + card, _config(), agent_name=AGENT, base_url="http://t.example" + ) + assert card == before + + +# --------------------------------------------------------------------------- # +# 2. The priced card's vocabulary +# --------------------------------------------------------------------------- # +class TestPricedCardShape: + def test_params_carry_what_a_client_needs_to_pay(self): + card = a2a_card_service.with_payment_extension( + _base_card(), _config(credits=3), agent_name=AGENT, + base_url="http://t.example", + ) + ext = _payment_ext(card) + assert ext["required"] is False + assert ext["params"] == { + "agentId": "agent-1", + "planId": "plan-1", + "credits": 3, + "paymentType": "fixed", + "costDescription": "3 credits per call via Nevermined plan plan-1", + "environment": "sandbox", + "paymentInfoUrl": "http://t.example/api/paid/bot/info", + } + assert ext["description"] == ext["params"]["costDescription"] + + def test_one_credit_is_singular(self): + card = a2a_card_service.with_payment_extension( + _base_card(), _config(credits=1), agent_name=AGENT, base_url="http://x" + ) + assert _payment_ext(card)["params"]["costDescription"] == ( + "1 credit per call via Nevermined plan plan-1" + ) + + def test_the_official_x402_extension_uri_is_not_declared(self): + """We speak the payment vocabulary; we do not offer the handshake.""" + card = a2a_card_service.with_payment_extension( + _base_card(), _config(), agent_name=AGENT, base_url="http://x" + ) + assert all(e.get("uri") != OFFICIAL_X402_URI for e in _extensions(card)) + + def test_payment_info_url_is_omitted_without_a_base_url(self): + """`generate_a2a_card` omits `url` when it has no base; a relative + "where to buy" pointer would be worse than none.""" + card = a2a_card_service.with_payment_extension( + _base_card(), _config(), agent_name=AGENT, base_url="" + ) + assert "paymentInfoUrl" not in _payment_ext(card)["params"] + + def test_an_existing_extension_is_preserved(self): + card = _base_card() + card["capabilities"]["extensions"] = [{"uri": "urn:other"}] + out = a2a_card_service.with_payment_extension( + card, _config(), agent_name=AGENT, base_url="http://x" + ) + assert [e["uri"] for e in _extensions(out)] == [ + "urn:other", a2a_card_service.NEVERMINED_PAYMENT_EXTENSION_URI, + ] + + def test_the_rest_of_the_card_is_untouched(self): + base = _base_card() + out = a2a_card_service.with_payment_extension( + base, _config(), agent_name=AGENT, base_url="http://t.example" + ) + for key, value in base.items(): + if key == "capabilities": + continue + assert out[key] == value + # The pre-existing capability flags survive alongside `extensions`. + assert out["capabilities"]["streaming"] is True + assert out["capabilities"]["pushNotifications"] is False + + +# --------------------------------------------------------------------------- # +# 3. T9 — 0 credits is a duration plan, not free +# --------------------------------------------------------------------------- # +class TestZeroCredits: + def test_the_model_accepts_zero(self): + """A Nevermined duration plan charges by time, so the per-call credit + amount is honestly 0. The old `>= 1` floor forced the operator to + claim a per-call price nothing would ever charge.""" + from db_models import NeverminedConfigCreate + + cfg = NeverminedConfigCreate( + nvm_api_key="sandbox:jwt", nvm_agent_id="a", nvm_plan_id="p", + credits_per_request=0, + ) + assert cfg.credits_per_request == 0 + + def test_the_model_still_rejects_a_negative(self): + import pydantic + from db_models import NeverminedConfigCreate + + with pytest.raises(pydantic.ValidationError) as exc: + NeverminedConfigCreate( + nvm_api_key="sandbox:jwt", nvm_agent_id="a", nvm_plan_id="p", + credits_per_request=-1, + ) + assert "credits_per_request must be >= 0" in str(exc.value) + + def test_the_model_default_is_still_one(self): + from db_models import NeverminedConfigCreate + + cfg = NeverminedConfigCreate( + nvm_api_key="sandbox:jwt", nvm_agent_id="a", nvm_plan_id="p", + ) + assert cfg.credits_per_request == 1 + + def test_zero_declares_a_dynamic_cost_not_a_fixed_zero(self): + """`{paymentType: "fixed", credits: 0}` is a contradiction — it reads as + free. The provider SDK's own card validator rejects exactly that shape + for a paid plan.""" + card = a2a_card_service.with_payment_extension( + _base_card(), _config(credits=0), agent_name=AGENT, base_url="http://x" + ) + params = _payment_ext(card)["params"] + assert params["paymentType"] == "dynamic" + assert params["credits"] == 0 + assert params["costDescription"] == ( + "Cost per call is set by Nevermined plan plan-1" + ) + + def test_an_unreadable_credit_amount_does_not_break_the_card(self): + card = a2a_card_service.with_payment_extension( + _base_card(), _config(credits=None), agent_name=AGENT, base_url="http://x" + ) + assert _payment_ext(card)["params"]["credits"] == 0 + + +# --------------------------------------------------------------------------- # +# 4. `generate_a2a_card` stays pure +# --------------------------------------------------------------------------- # +class TestBuilderStaysPure: + def test_the_pure_builder_declares_no_extensions(self): + card = _base_card() + assert "extensions" not in card["capabilities"] + assert "urn:nevermined:payment" not in str(card) + + +# --------------------------------------------------------------------------- # +# 5. Both card surfaces, over the real routes +# --------------------------------------------------------------------------- # +@pytest.fixture() +def cards(monkeypatch): + """Both card routes on a TestClient, with the payment config switchable.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + state = {"config": None, "raises": False} + + def _get_config(name): + if state["raises"]: + raise RuntimeError("encryption service down") + return state["config"] if name == AGENT else None + + monkeypatch.setattr(a2a, "db", types.SimpleNamespace( + get_a2a_exposed=lambda name: name == AGENT, + get_nevermined_config=_get_config, + )) + monkeypatch.setattr( + a2a, "get_agent_container", + lambda name: types.SimpleNamespace(status="running", labels={}) + if name == AGENT else None) + + async def _tmpl(name, container): + return dict(TEMPLATE) + monkeypatch.setattr(a2a, "_fetch_template_data", _tmpl) + monkeypatch.setattr(a2a.rate_limiter, "enforce", lambda *a_, **k_: None) + a2a_gate.clear_provider() + + app = FastAPI() + app.include_router(a2a.a2a_server_router) # public well-known card + # The authenticated per-agent card resolves its agent via a path + # dependency; override it to the same agent so both surfaces are + # comparable. + app.include_router(a2a.router) # authenticated per-agent card + app.dependency_overrides[deps.get_authorized_agent_by_name] = lambda: AGENT + + return types.SimpleNamespace(http=TestClient(app), state=state) + + +def _well_known(cards): + r = cards.http.get(f"/a2a/{AGENT}/.well-known/agent-card.json") + assert r.status_code == 200, r.text + return r.json() + + +def _authenticated(cards): + r = cards.http.get(f"/api/agents/{AGENT}/a2a/agent-card") + assert r.status_code == 200, r.text + return r.json() + + +class TestBothSurfaces: + def test_neither_surface_prices_an_unconfigured_agent(self, cards): + for card in (_well_known(cards), _authenticated(cards)): + assert "extensions" not in (card.get("capabilities") or {}) + + def test_both_surfaces_carry_the_price_block(self, cards): + cards.state["config"] = _config(credits=5) + wk, auth = _well_known(cards), _authenticated(cards) + for card in (wk, auth): + params = _payment_ext(card)["params"] + assert params["planId"] == "plan-1" + assert params["credits"] == 5 + # ent#180 FR-3: the two surfaces must not disagree about the agent. + assert _payment_ext(wk) == _payment_ext(auth) + + def test_a_disabled_config_prices_neither_surface(self, cards): + cards.state["config"] = _config(enabled=False) + for card in (_well_known(cards), _authenticated(cards)): + assert "extensions" not in (card.get("capabilities") or {}) + + def test_the_price_block_points_at_this_instance(self, cards): + cards.state["config"] = _config() + card = _well_known(cards) + info_url = _payment_ext(card)["params"]["paymentInfoUrl"] + assert info_url.endswith(f"/api/paid/{AGENT}/info") + # Same origin the card's own `url` is built from — a client following + # either one reaches this instance, not a hardcoded host. + assert info_url.startswith(card["url"].rsplit("/a2a/", 1)[0]) + + +# --------------------------------------------------------------------------- # +# 6. Fail-open +# --------------------------------------------------------------------------- # +class TestFailOpen: + def test_an_unreadable_payment_config_still_serves_the_card(self, cards, caplog): + """A card route has never 5xx'd — an unreachable agent falls back to + Docker labels. An unreadable payment config gets the same treatment: + the gate reads the config itself and still answers 402, so the only + cost of failing open here is a priced agent briefly looking free.""" + cards.state["raises"] = True + with caplog.at_level("WARNING"): + card = _well_known(cards) + assert "extensions" not in (card.get("capabilities") or {}) + assert any("payment config unreadable" in r.message for r in caplog.records) diff --git a/tests/unit/test_ent679_nevermined_service_bounds.py b/tests/unit/test_ent679_nevermined_service_bounds.py new file mode 100644 index 000000000..4aa4edc8c --- /dev/null +++ b/tests/unit/test_ent679_nevermined_service_bounds.py @@ -0,0 +1,257 @@ +"""`endpoint`, `retryable` and the facilitator bound (abilityai/trinity-enterprise#679). + +Three properties of `services/nevermined_payment_service.py` that the A2A +payment gate depends on and that nothing previously exercised: + +1. **`endpoint`** (decision 19). The 402's `resource.url` was hard-coded to the + paid chat door. An x402 v3 token signs `resourceUrl` and the facilitator + compares origin+path, so a token minted from the A2A door's 402 cannot + authorize a call verified against the paid URL — and a non-Trinity client + follows `resource.url` verbatim. The default is unchanged, which is the half + that keeps the paid door byte-identical. +2. **`retryable`** (E7). A facilitator timeout is Trinity failing to decide, not + the caller's token being bad. Only the second should make a client go buy + another one. +3. **The concurrency bound** (E8). Every facilitator call occupies a thread for + 15-97 s and a priced agent's door needs no credential to make us dial out, so + the in-flight count is bounded fleet-wide and a saturated gate answers + "busy, retryable" instead of queueing without limit. +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src" / "backend")) + +from services import nevermined_payment_service as nps # noqa: E402 + +pytestmark = pytest.mark.asyncio + +PAID_URL = "http://localhost/api/paid/agent-a/chat" +A2A_URL = "http://localhost/a2a/agent-a" + + +def _config(): + return SimpleNamespace( + agent_name="agent-a", nvm_plan_id="plan-1", nvm_agent_id="did:nv:1", + nvm_environment="sandbox", credits_per_request=1, enabled=True, + ) + + +class _Facilitator: + """Stands in for `payments.facilitator` — records what it was handed.""" + + def __init__(self, *, verify=None, settle=None): + self._verify = verify + self._settle = settle + self.verify_args = [] + self.settle_args = [] + + def verify_permissions(self, payment_required, access_token, *rest): + self.verify_args.append(payment_required) + if isinstance(self._verify, BaseException): + raise self._verify + return self._verify + + def settle_permissions(self, payment_required, access_token, max_amount=None, + agent_request_id=None): + self.settle_args.append(payment_required) + if isinstance(self._settle, BaseException): + raise self._settle + return self._settle + + +def _service(facilitator): + svc = nps.NeverminedPaymentService() + svc._get_payments_client = lambda *a, **k: SimpleNamespace(facilitator=facilitator) + return svc + + +def _verify_reply(valid=True): + return SimpleNamespace(is_valid=valid, payer="0xpayer", agent_request_id="areq-1", + invalid_reason=None if valid else "nope") + + +@pytest.fixture(autouse=True) +def _fresh_gate(monkeypatch): + """A clean, per-loop semaphore for every test in this file.""" + monkeypatch.setattr(nps, "_FACILITATOR_GATES", type(nps._FACILITATOR_GATES)()) + yield + + +# --------------------------------------------------------------------------- +# 1. endpoint +# --------------------------------------------------------------------------- + +async def test_the_402_resource_url_defaults_to_the_paid_door(): + body = nps.NeverminedPaymentService().build_402_response(_config(), "http://localhost") + assert body["resource"]["url"] == PAID_URL + + +async def test_the_402_resource_url_can_be_bound_to_another_door(): + body = nps.NeverminedPaymentService().build_402_response( + _config(), "http://localhost", endpoint=A2A_URL, + ) + assert body["resource"]["url"] == A2A_URL + # Everything else about the requirements is the SAME plan — "same + # requirements as the paid door" means same plan, a different resource. + paid = nps.NeverminedPaymentService().build_402_response(_config(), "http://localhost") + assert body["accepts"] == paid["accepts"] + + +async def test_verify_is_bound_to_the_endpoint_it_was_given(): + facilitator = _Facilitator(verify=_verify_reply()) + result = await _service(facilitator).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", base_url="http://localhost", endpoint=A2A_URL, + ) + assert result.success is True + assert facilitator.verify_args[0].resource.url == A2A_URL + + +async def test_verify_without_an_endpoint_still_uses_the_paid_door(): + facilitator = _Facilitator(verify=_verify_reply()) + await _service(facilitator).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", base_url="http://localhost", + ) + assert facilitator.verify_args[0].resource.url == PAID_URL + + +async def test_settle_is_bound_to_the_same_endpoint_as_verify(): + """A settle against a different resource than the verify would be rejected.""" + facilitator = _Facilitator(settle=SimpleNamespace( + success=True, payer="0xpayer", credits_redeemed="1", remaining_balance="9", + transaction="0xtx", error_reason=None, + )) + result = await _service(facilitator).settle_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", base_url="http://localhost", endpoint=A2A_URL, + ) + assert result.success is True + assert facilitator.settle_args[0].resource.url == A2A_URL + + +# --------------------------------------------------------------------------- +# 2. retryable +# --------------------------------------------------------------------------- + +async def test_a_rejected_token_is_not_retryable(): + """The facilitator DECIDED: this token is bad. Buying another is the fix.""" + result = await _service(_Facilitator(verify=_verify_reply(valid=False))).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", + ) + assert result.success is False + assert result.retryable is False + assert result.error == "nope" + + +async def test_a_verify_timeout_is_retryable(): + """`asyncio.wait_for`'s own exception, raised from the thread it wraps.""" + result = await _service(_Facilitator(verify=asyncio.TimeoutError())).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", + ) + assert result.success is False + assert result.retryable is True + assert result.error == "Payment verification timed out" + + +async def test_an_sdk_error_is_retryable(): + result = await _service(_Facilitator(verify=RuntimeError("socket reset"))).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", + ) + assert result.success is False + assert result.retryable is True + + +async def test_retryable_is_not_part_of_a_stored_settle_receipt(): + """A snapshot written before this field must still replay (#1084).""" + receipt = nps.NeverminedPaymentResult(success=True, tx_hash="0xtx", retryable=True) + snapshot = nps._settle_snapshot(receipt) + assert "retryable" not in snapshot + assert nps.NeverminedPaymentResult(**snapshot).retryable is False + + +# --------------------------------------------------------------------------- +# 3. the facilitator concurrency bound +# --------------------------------------------------------------------------- + +async def test_the_gate_admits_up_to_the_limit_and_then_refuses(monkeypatch): + monkeypatch.setattr(nps, "NEVERMINED_MAX_INFLIGHT", 1) + monkeypatch.setattr(nps, "NEVERMINED_FACILITATOR_WAIT_SECONDS", 0.05) + + async with nps.facilitator_slot(): + with pytest.raises(nps.FacilitatorBusy): + async with nps.facilitator_slot(): + pytest.fail("the second slot must not be granted") + + # Released again once the first call finishes. + async with nps.facilitator_slot(): + pass + + +async def test_a_saturated_gate_makes_verify_busy_and_retryable_without_dialling(monkeypatch): + monkeypatch.setattr(nps, "NEVERMINED_MAX_INFLIGHT", 1) + monkeypatch.setattr(nps, "NEVERMINED_FACILITATOR_WAIT_SECONDS", 0.05) + facilitator = _Facilitator(verify=_verify_reply()) + + async with nps.facilitator_slot(): + result = await _service(facilitator).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", + ) + + assert result.success is False + assert result.retryable is True + assert "busy" in result.error + assert facilitator.verify_args == [], "no thread may be occupied when refused" + + +async def test_a_saturated_gate_makes_settle_fail_retryably_rather_than_burn(monkeypatch): + monkeypatch.setattr(nps, "NEVERMINED_MAX_INFLIGHT", 1) + monkeypatch.setattr(nps, "NEVERMINED_FACILITATOR_WAIT_SECONDS", 0.01) + # Skip the 1 s + 2 s retry backoff; the branch under test is the refusal, + # not the waiting. Captured first — patching `asyncio.sleep` with a lambda + # that calls `asyncio.sleep` is infinite recursion. + real_sleep = asyncio.sleep + monkeypatch.setattr(asyncio, "sleep", lambda *_a, **_k: real_sleep(0)) + facilitator = _Facilitator(settle=SimpleNamespace( + success=True, payer="0xp", credits_redeemed="1", remaining_balance="9", + transaction="0xtx", error_reason=None, + )) + + async with nps.facilitator_slot(): + result = await _service(facilitator).settle_payment( + nvm_api_key="k", nvm_environment="sandbox", config=_config(), + access_token="tok", + ) + + assert result.success is False + assert facilitator.settle_args == [] # nothing burned + assert "concurrency limit" in result.error # named, not a generic failure + + +async def test_the_bound_is_fleet_wide_not_per_agent(monkeypatch): + """One semaphore for every agent: the thread pool is a platform resource.""" + monkeypatch.setattr(nps, "NEVERMINED_MAX_INFLIGHT", 1) + monkeypatch.setattr(nps, "NEVERMINED_FACILITATOR_WAIT_SECONDS", 0.05) + other = SimpleNamespace( + agent_name="agent-b", nvm_plan_id="plan-2", nvm_agent_id="did:nv:2", + nvm_environment="sandbox", credits_per_request=1, enabled=True, + ) + facilitator = _Facilitator(verify=_verify_reply()) + + async with nps.facilitator_slot(): + result = await _service(facilitator).verify_payment( + nvm_api_key="k", nvm_environment="sandbox", config=other, + access_token="tok", + ) + assert result.retryable is True diff --git a/tests/unit/test_ent679_paid_turn_service.py b/tests/unit/test_ent679_paid_turn_service.py new file mode 100644 index 000000000..35343fa31 --- /dev/null +++ b/tests/unit/test_ent679_paid_turn_service.py @@ -0,0 +1,769 @@ +"""The shared paid-turn orchestrator (abilityai/trinity-enterprise#679). + +`services/paid_turn_service.run_paid_turn` is the extracted x402 money +lifecycle that BOTH the paid chat door and the A2A payment gate run. The three +existing paid-door files (`test_1018_settlement_ordering`, `test_679_callers`, +`test_3114_pull_route_callers`) remain the end-to-end net over `routers/paid.py` +and are deliberately unedited; this file drives the service at ITS own layer, +one test per outcome kind, plus the orderings a renderer cannot observe: + +* verify runs BEFORE the dedup gate, so a rejected token consumes no key; +* a success that could not settle is stored with `complete()`, never `fail()`; +* a failed/cancelled/raised turn is `fail()`ed and never settles; +* a client that disconnects mid-settle does not cause the LLM work to re-run. +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from types import SimpleNamespace + +import anyio +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src" / "backend")) + +from services import paid_turn_service as pts # noqa: E402 + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# Collaborator stand-ins. Every one of these is a PARAMETER of run_paid_turn — +# the service imports none of them (decision 20), which is what this file's +# ability to drive it with plain objects demonstrates. +# --------------------------------------------------------------------------- + +class FakeIdem: + """The `idempotency_service` module surface the orchestrator uses.""" + + def __init__(self, decision=None): + self.decision = decision or SimpleNamespace( + replay=False, in_flight=False, snapshot=None, scope="sc", key="ky", + ) + self.begin_calls = [] + self.completed = [] + self.failed = [] + self.attached = [] + self.upgrades = [] + self.order = [] + + def begin(self, scope, key): + self.begin_calls.append((scope, key)) + self.order.append("begin") + return self.decision + + def complete(self, decision, execution_id, snapshot): + self.completed.append((execution_id, snapshot)) + self.order.append("complete") + + def fail(self, decision): + self.failed.append(decision) + self.order.append("fail") + + def attach_execution(self, decision, execution_id): + self.attached.append(execution_id) + + def upgrade_snapshot(self, scope, key, snapshot): + self.upgrades.append((scope, key, snapshot)) + self.order.append("upgrade") + + +class FakeDb: + def __init__(self, order=None): + self.logs = [] + self.order = order if order is not None else [] + + def log_nevermined_payment(self, **kwargs): + self.logs.append(kwargs) + + def actions(self): + return [log["action"] for log in self.logs] + + +class FakePaymentService: + def __init__(self, *, verify=None, settle=None, order=None): + self._verify = verify or _verify_ok() + self._settle = settle + self.verify_calls = [] + self.settle_calls = [] + self.order = order if order is not None else [] + + async def verify_payment(self, **kwargs): + self.verify_calls.append(kwargs) + self.order.append("verify") + if callable(self._verify): + return self._verify(**kwargs) + return self._verify + + async def settle_payment_once(self, **kwargs): + self.settle_calls.append(kwargs) + self.order.append("settle") + if callable(self._settle): + return await self._settle(**kwargs) + return self._settle + + +def _config(credits=1): + return SimpleNamespace( + agent_name="agent-a", enabled=True, nvm_environment="sandbox", + nvm_plan_id="plan-1", nvm_agent_id="did:nv:1", credits_per_request=credits, + ) + + +def _verify_ok(payer="0xpayer", agent_request_id="areq-1"): + return SimpleNamespace(success=True, payer=payer, + agent_request_id=agent_request_id, error=None, + retryable=False) + + +def _verify_bad(error="bad token", retryable=False): + return SimpleNamespace(success=False, payer=None, agent_request_id=None, + error=error, retryable=retryable) + + +def _settle_ok(tx="0xtx", remaining="9"): + return SimpleNamespace(success=True, payer="0xpayer", tx_hash=tx, + credits_redeemed="1", remaining_balance=remaining, + error=None, retryable=False) + + +def _settle_bad(error="facilitator exploded"): + return SimpleNamespace(success=False, payer="0xpayer", tx_hash=None, + credits_redeemed=None, remaining_balance=None, + error=error, retryable=True) + + +def _exec(status="completed", response="the answer", execution_id="exec-1"): + return SimpleNamespace(status=status, response=response, execution_id=execution_id) + + +async def _drive(*, payment_service=None, idem=None, db=None, execute=None, + config=None, **kwargs): + order = [] + db = db or FakeDb(order) + payment_service = payment_service or FakePaymentService(settle=_settle_ok(), order=order) + idem = idem or FakeIdem() + idem.order = order + + async def _default_execute(): + order.append("execute") + return _exec() + + outcome = await pts.run_paid_turn( + agent_name="agent-a", + config=config or _config(), + nvm_api_key="nvm-key", + access_token="tok-1", + idem_scope="agent:agent-a", + idem_key="key-1", + execute=execute or _default_execute, + payment_service=payment_service, + idem=idem, + db=db, + base_url="http://localhost", + **kwargs, + ) + return outcome, SimpleNamespace(idem=idem, db=db, payments=payment_service, + order=order) + + +async def _until(predicate, timeout: float = 2.0): + """Give the detached settle task its turns, bounded. + + The scope-cancelled turn has already unwound when the test resumes, so the + settle's own bookkeeping lands on a later loop iteration. Polling the + observable record (rather than reaching for the task object) keeps the + assertion about behaviour. + """ + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if predicate(): + return + await asyncio.sleep(0.01) + + +# --------------------------------------------------------------------------- +# 1. verify_failed — 403 shape, reject row, and NO key consumed +# --------------------------------------------------------------------------- + +async def test_verify_failure_rejects_and_never_consumes_a_key(): + payments = FakePaymentService(verify=_verify_bad("expired")) + outcome, ctx = await _drive(payment_service=payments) + + assert outcome.kind == pts.VERIFY_FAILED + assert outcome.status_code == 403 + assert outcome.payload == { + "detail": "Payment verification failed", "error": "expired", + } + assert ctx.db.actions() == ["reject"] + # THE ordering invariant: begin() is never reached, so a rejected token + # cannot burn the idempotency key a legitimate retry needs. + assert ctx.idem.begin_calls == [] + assert payments.settle_calls == [] + + +async def test_verify_runs_before_the_dedup_gate_on_the_success_path(): + _, ctx = await _drive() + assert ctx.order.index("verify") < ctx.order.index("begin") + + +# --------------------------------------------------------------------------- +# 2. in-flight duplicate +# --------------------------------------------------------------------------- + +async def test_in_flight_duplicate_is_409_and_does_not_execute(): + idem = FakeIdem(SimpleNamespace(replay=True, in_flight=True, snapshot=None, + scope="sc", key="ky")) + executed = [] + + async def _execute(): + executed.append(1) + return _exec() + + outcome, ctx = await _drive(idem=idem, execute=_execute) + assert outcome.kind == pts.IN_FLIGHT + assert outcome.status_code == 409 + assert executed == [] + assert ctx.payments.settle_calls == [] + + +# --------------------------------------------------------------------------- +# 3. replay of a SETTLED snapshot — verbatim, no re-execute, no re-settle +# --------------------------------------------------------------------------- + +async def test_settled_replay_is_verbatim_and_burns_nothing(): + snapshot = { + "response": "stored", "execution_id": "exec-9", "status": "success", + "payment": {"settled": True, "credits_burned": 1, "tx_hash": "0xold"}, + } + idem = FakeIdem(SimpleNamespace(replay=True, in_flight=False, + snapshot=snapshot, scope="sc", key="ky")) + executed = [] + + async def _execute(): + executed.append(1) + return _exec() + + outcome, ctx = await _drive(idem=idem, execute=_execute) + assert outcome.kind == pts.REPLAY_SETTLED + assert outcome.payload is snapshot + assert outcome.replayed is True + assert outcome.settled is True + assert executed == [] + assert ctx.payments.settle_calls == [] # no second burn + assert "settle" not in ctx.db.actions() + + +# --------------------------------------------------------------------------- +# 4/5. replay of an UNSETTLED snapshot — re-settle, converge or stay honest +# --------------------------------------------------------------------------- + +def _unsettled_snapshot(): + return { + "response": "stored", "execution_id": "exec-9", + "status": "success_unsettled", + "payment": {"settled": False, "error": "boom", "settle_retry_needed": True}, + } + + +async def test_unsettled_replay_resettles_and_upgrades_the_snapshot(): + idem = FakeIdem(SimpleNamespace(replay=True, in_flight=False, + snapshot=_unsettled_snapshot(), + scope="sc", key="ky")) + executed = [] + + async def _execute(): + executed.append(1) + return _exec() + + outcome, ctx = await _drive(idem=idem, execute=_execute) + + assert outcome.kind == pts.SETTLED + assert outcome.replayed is True + assert executed == [] # the LLM does NOT re-run + assert len(ctx.payments.settle_calls) == 1 + assert outcome.payload["status"] == "success" + assert outcome.payload["response"] == "stored" # the stored work, not a new turn + assert outcome.payload["payment"]["settled"] is True + assert "settle" in ctx.db.actions() + # The convergence that stops a third request re-settling forever. + assert ctx.idem.upgrades and ctx.idem.upgrades[-1][2]["payment"]["settled"] is True + + +async def test_unsettled_replay_that_still_cannot_settle_replays_honestly(): + snapshot = _unsettled_snapshot() + idem = FakeIdem(SimpleNamespace(replay=True, in_flight=False, snapshot=snapshot, + scope="sc", key="ky")) + payments = FakePaymentService(settle=_settle_bad()) + outcome, ctx = await _drive(idem=idem, payment_service=payments) + + assert outcome.kind == pts.REPLAY_UNSETTLED + assert outcome.payload is snapshot # unchanged, still unsettled + assert outcome.settled is False + assert ctx.idem.upgrades == [] # nothing to converge + assert ctx.idem.completed == [] and ctx.idem.failed == [] + + +# --------------------------------------------------------------------------- +# 6. fresh settled success +# --------------------------------------------------------------------------- + +async def test_settled_success_logs_the_burn_and_completes_the_claim(): + outcome, ctx = await _drive() + + assert outcome.kind == pts.SETTLED + assert outcome.replayed is False + assert outcome.execution_id == "exec-1" + assert outcome.payload == { + "response": "the answer", + "execution_id": "exec-1", + "status": "success", + "payment": { + "settled": True, "credits_burned": 1, + "remaining_balance": "9", "tx_hash": "0xtx", + }, + } + # Two verify rows: the attempt, then the payer→task binding written once + # the turn's result exists (I4) — the second is what makes tasks/get + # reachable before, and without, a settle row. + assert ctx.db.actions() == ["verify", "verify", "settle"] + assert ctx.idem.attached == ["exec-1"] + assert ctx.idem.upgrades[-1][2] is outcome.payload + # Settle strictly after the turn ran. + assert ctx.order.index("execute") < ctx.order.index("settle") + + +async def test_the_payer_task_binding_is_written_when_the_execution_exists(): + """I4: `payer_owns_execution` must be true BEFORE the settle, not because of it. + + The binding is a log row carrying both `execution_id` and + `subscriber_address`, and only `settle` / `settle_failed` rows carried both + — so a payer could not `tasks/get` a finished task while its settle was + still running. This row is written once `execute()` has returned, which is + the earliest `run_paid_turn` holds an execution id. That is after the + turn's terminal: the binding is not a mid-turn one. + """ + _, ctx = await _drive() + + bound = [log for log in ctx.db.logs + if log["action"] == "verify" and log.get("execution_id")] + assert len(bound) == 1 + assert bound[0]["execution_id"] == "exec-1" + assert bound[0]["subscriber_address"] == "0xpayer" + assert bound[0]["success"] is True + # Before the settle that used to be the only writer of the pair. + assert ctx.db.logs.index(bound[0]) < \ + next(i for i, log in enumerate(ctx.db.logs) if log["action"] == "settle") + + +async def test_the_payer_task_binding_does_not_depend_on_a_settle_row(): + """I4, the case that locked a payer out for good. + + A concurrent settle (`settle_in_progress`) writes no log row of its own, so + with the settle rows as the only writers of (execution_id, payer) this turn + had no binding at all — the payer held a delivered task it could never + `tasks/get`. + """ + payments = FakePaymentService(settle=_settle_bad("settlement already in progress")) + _, ctx = await _drive(payment_service=payments) + + assert not [log for log in ctx.db.logs + if log["action"] in ("settle", "settle_failed")] + bound = [log for log in ctx.db.logs if log.get("execution_id")] + assert [(log["action"], log["execution_id"], log["subscriber_address"]) + for log in bound] == [("verify", "exec-1", "0xpayer")] + + +async def test_no_payer_task_binding_exists_while_the_turn_is_running(): + """The binding is NOT a mid-turn one, and nothing here may claim it is. + + `run_paid_turn` learns the execution id from `execute()`'s return value, so + while the turn runs there is no row a poll or a cancel could match. The + A2A door's `tasks/get` answers not-found in that window by construction. + """ + seen = {} + db = FakeDb() + + async def _execute(): + seen["rows_with_an_execution"] = [ + log for log in db.logs if log.get("execution_id")] + return _exec() + + await _drive(db=db, execute=_execute) + + assert seen["rows_with_an_execution"] == [] + assert [log["execution_id"] for log in db.logs if log.get("execution_id")] + + +async def test_a_retryable_verify_is_logged_as_an_attempt_not_a_rejection(): + """I1: a verify that never DECIDED is not a rejection of the payer.""" + payments = FakePaymentService(verify=_verify_bad("timeout", retryable=True)) + outcome, ctx = await _drive(payment_service=payments) + + assert outcome.kind == pts.VERIFY_FAILED + assert ctx.db.actions() == ["verify"], "a `reject` row would read as refused" + assert ctx.db.logs[0]["success"] is False + assert ctx.db.logs[0]["error"] == "timeout" + # Still consumes no key — the ordering invariant is untouched. + assert ctx.idem.begin_calls == [] + + +async def test_a_real_rejection_is_still_logged_as_a_reject(): + payments = FakePaymentService(verify=_verify_bad("expired", retryable=False)) + _, ctx = await _drive(payment_service=payments) + assert ctx.db.actions() == ["reject"] + + +async def test_settle_receives_the_verify_agent_request_id(): + """#1084: the settle effect guard keys on the id verify handed back.""" + _, ctx = await _drive() + assert ctx.payments.settle_calls[0]["agent_request_id"] == "areq-1" + + +# --------------------------------------------------------------------------- +# 7/8. delivered but unsettled — THE #1018 branch +# --------------------------------------------------------------------------- + +async def test_failed_settle_keeps_the_work_tells_the_truth_and_completes(): + payments = FakePaymentService(settle=_settle_bad("chain down")) + outcome, ctx = await _drive(payment_service=payments) + + assert outcome.kind == pts.UNSETTLED + assert outcome.payload["status"] == "success_unsettled" # not a lie + assert outcome.payload["response"] == "the answer" # delivered anyway + assert outcome.payload["payment"] == { + "settled": False, "error": "chain down", "settle_retry_needed": True, + } + assert ctx.db.actions() == ["verify", "verify", "settle_failed"] + # complete(), NOT fail() — fail() would re-run the LLM on the client's retry. + assert ctx.idem.completed and ctx.idem.failed == [] + assert ctx.idem.completed[0][1] is outcome.payload + + +async def test_concurrent_settle_in_progress_is_not_logged_as_a_failure(): + """The effect guard's in-progress result: the other settle logs its own row.""" + payments = FakePaymentService(settle=_settle_bad("settlement already in progress")) + outcome, ctx = await _drive(payment_service=payments) + + assert outcome.kind == pts.UNSETTLED + assert outcome.payload["payment"]["settle_in_progress"] is True + assert "settle_retry_needed" not in outcome.payload["payment"] + assert ctx.db.actions() == ["verify", "verify"] # no settle_failed row + assert ctx.idem.completed and ctx.idem.failed == [] + + +# --------------------------------------------------------------------------- +# 9/10/11. the three no-charge terminals +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("status,kind", [ + ("failed", pts.EXECUTION_FAILED), + ("cancelled", pts.EXECUTION_CANCELLED), +]) +async def test_failed_or_cancelled_turn_never_settles(status, kind): + async def _execute(): + return _exec(status=status, response="partial work") + + outcome, ctx = await _drive(execute=_execute) + + assert outcome.kind == kind + assert ctx.payments.settle_calls == [] # the #679 money bug + assert outcome.payload["payment"]["settled"] is False + assert ctx.idem.failed and ctx.idem.completed == [] + assert "settle" not in ctx.db.actions() + + +async def test_failed_turn_withholds_the_body_but_cancelled_keeps_it(): + """#1018 vs #679: garbled output is withheld; the payer's own cancel is not.""" + async def _failed(): + return _exec(status="failed", response="garbled") + + async def _cancelled(): + return _exec(status="cancelled", response="partial work") + + failed, _ = await _drive(execute=_failed) + cancelled, _ = await _drive(execute=_cancelled) + + assert "response" not in failed.payload + assert cancelled.payload["response"] == "partial work" + + +async def test_execution_exception_releases_the_claim_and_charges_nothing(): + async def _boom(): + raise RuntimeError("dispatch exploded") + + outcome, ctx = await _drive(execute=_boom) + + assert outcome.kind == pts.EXECUTION_ERROR + assert outcome.status_code == 500 + assert outcome.payload["payment"] == { + "settled": False, "reason": "Execution failed — no charge", + } + assert ctx.payments.settle_calls == [] + assert ctx.idem.failed and ctx.idem.completed == [] + # The verify row is annotated with why nothing was charged. + assert ctx.db.logs[-1]["action"] == "verify" + assert "dispatch exploded" in ctx.db.logs[-1]["error"] + + +# --------------------------------------------------------------------------- +# 12/13. cancellation is phase-aware (#679 E5) +# --------------------------------------------------------------------------- + +async def test_cancellation_before_a_result_releases_the_claim(): + async def _execute(): + raise asyncio.CancelledError() + + idem = FakeIdem() + with pytest.raises(asyncio.CancelledError): + await _drive(execute=_execute, idem=idem) + + assert idem.failed, "a stranded in-flight claim 409s the payer's own retry" + assert idem.completed == [] + + +async def test_disconnect_during_settle_still_settles_and_records_it(): + """The work is done and owed for: a client walking away must not abort settle. + + Without the shield the settle is cancelled mid-flight, the claim stays + in-flight with the money unrecorded, and the payer's retry pays for a second + LLM run. + """ + settle_started = asyncio.Event() + release = asyncio.Event() + + async def _slow_settle(**kwargs): + settle_started.set() + await release.wait() + return _settle_ok() + + idem = FakeIdem() + db = FakeDb() + payments = FakePaymentService(settle=_slow_settle) + + task = asyncio.create_task(_drive(payment_service=payments, idem=idem, db=db)) + await asyncio.wait_for(settle_started.wait(), timeout=2) + task.cancel() + await asyncio.sleep(0) + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + # The settle and its bookkeeping are detached (C1), so join on the record. + await _until(lambda: db.actions() == ["verify", "verify", "settle"]) + + assert len(payments.settle_calls) == 1 + assert db.actions() == ["verify", "verify", "settle"] # the burn IS recorded + assert idem.upgrades, "the claim must converge to settled, not stay in-flight" + assert idem.upgrades[-1][2]["payment"]["settled"] is True + + +async def test_disconnect_during_a_settle_that_fails_persists_the_unsettled_work(): + settle_started = asyncio.Event() + release = asyncio.Event() + + async def _slow_bad_settle(**kwargs): + settle_started.set() + await release.wait() + return _settle_bad("chain down") + + idem = FakeIdem() + db = FakeDb() + payments = FakePaymentService(settle=_slow_bad_settle) + + task = asyncio.create_task(_drive(payment_service=payments, idem=idem, db=db)) + await asyncio.wait_for(settle_started.wait(), timeout=2) + task.cancel() + await asyncio.sleep(0) + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + await _until(lambda: bool(idem.completed)) + + # complete(), not fail(): the retry re-drives settle, it does not re-run the LLM. + assert idem.completed and idem.failed == [] + assert idem.completed[0][1]["status"] == "success_unsettled" + + +# --------------------------------------------------------------------------- +# 13b. the cancellation shape the real consumer produces (C1) +# --------------------------------------------------------------------------- + +async def test_cancel_scope_during_settle_still_records_the_burn(): + """The two tests above cancel the TASK; Starlette cancels a SCOPE. + + `asyncio.Task.cancel()` is edge-triggered: one `CancelledError` is delivered + and every later `await` in the handler proceeds normally. `StreamingResponse` + runs its body generator inside an anyio task group and cancels that group's + **cancel scope** on client disconnect, and anyio cancellation is + LEVEL-triggered — every subsequent `await` inside the cancelled scope raises + `CancelledError` again. So a recovery handler that awaits the settle before + writing its rows never reaches them: the facilitator burns credits, no + `settle` row is written, and the claim stays in-flight for the key's whole + 24 h TTL (C1, abilityai/trinity-enterprise#679). + + The settle's bookkeeping therefore has to live in the DETACHED task, which is + not inside the cancelled scope. This test is the probe for that: it cancels + the scope, not the task. + """ + settle_started = asyncio.Event() + release = asyncio.Event() + + async def _slow_settle(**kwargs): + settle_started.set() + await release.wait() + return _settle_ok() + + idem = FakeIdem() + db = FakeDb() + payments = FakePaymentService(settle=_slow_settle) + + async def _turn(): + await _drive(payment_service=payments, idem=idem, db=db) + + async with anyio.create_task_group() as tg: + tg.start_soon(_turn) + await asyncio.wait_for(settle_started.wait(), timeout=2) + tg.cancel_scope.cancel() + release.set() + + # The settle outlives the cancelled scope and owns its own bookkeeping. + await _until(lambda: db.actions() == ["verify", "verify", "settle"]) + + assert len(payments.settle_calls) == 1 + assert db.actions() == ["verify", "verify", "settle"], \ + "the burn must still be recorded" + assert idem.upgrades, "the claim must converge to settled, not stay in-flight" + assert idem.upgrades[-1][2]["payment"]["settled"] is True + + +async def test_cancel_scope_during_a_settle_that_fails_persists_the_unsettled_work(): + """Same scope-level cancellation, settle-failed branch. + + `complete()` — not `fail()` — so the payer's retry replays the delivered work + and re-drives settle instead of paying for a second LLM run. + """ + settle_started = asyncio.Event() + release = asyncio.Event() + + async def _slow_bad_settle(**kwargs): + settle_started.set() + await release.wait() + return _settle_bad("chain down") + + idem = FakeIdem() + db = FakeDb() + payments = FakePaymentService(settle=_slow_bad_settle) + + async def _turn(): + await _drive(payment_service=payments, idem=idem, db=db) + + async with anyio.create_task_group() as tg: + tg.start_soon(_turn) + await asyncio.wait_for(settle_started.wait(), timeout=2) + tg.cancel_scope.cancel() + release.set() + + await _until(lambda: db.actions() == ["verify", "verify", "settle_failed"]) + + assert db.actions() == ["verify", "verify", "settle_failed"] + assert idem.completed and idem.failed == [] + assert idem.completed[0][1]["status"] == "success_unsettled" + + +# --------------------------------------------------------------------------- +# 14. the `endpoint` thread (decision 19) +# --------------------------------------------------------------------------- + +async def test_endpoint_is_threaded_to_both_facilitator_calls(): + """An x402 v3 token signs `resourceUrl`, so verify and settle must agree.""" + _, ctx = await _drive(endpoint="http://localhost/a2a/agent-a") + assert ctx.payments.verify_calls[0]["endpoint"] == "http://localhost/a2a/agent-a" + assert ctx.payments.settle_calls[0]["endpoint"] == "http://localhost/a2a/agent-a" + + +async def test_endpoint_defaults_to_none_so_the_paid_door_is_unchanged(): + _, ctx = await _drive() + assert ctx.payments.verify_calls[0]["endpoint"] is None + assert ctx.payments.settle_calls[0]["endpoint"] is None + + +# --------------------------------------------------------------------------- +# 15/16. the two injection seams the A2A gate needs +# --------------------------------------------------------------------------- + +async def test_pre_execute_abort_short_circuits_after_the_gate_before_the_turn(): + executed = [] + + async def _execute(): + executed.append(1) + return _exec() + + def _refuse(verify): + raise pts.PaidTurnAbort({"detail": "nope"}, status_code=400) + + idem = FakeIdem() + outcome, ctx = await _drive(execute=_execute, idem=idem, pre_execute=_refuse) + + assert outcome.kind == pts.ABORTED + assert outcome.status_code == 400 + assert outcome.payload == {"detail": "nope"} + assert executed == [] + assert idem.begin_calls, "the refusal runs AFTER the dedup gate, as it does today" + assert ctx.payments.settle_calls == [] + + +async def test_pre_execute_abort_releases_the_fresh_claim(): + """I2: a refusal that keeps the claim 409s the payer's own retry for 24 h. + + `begin()` has already run when `pre_execute` refuses, so without a release + the identical retry is answered IN_FLIGHT ("a duplicate paid request is + still being processed") instead of the refusal that says why — for the key's + whole TTL. Nothing was charged and nothing was delivered, so the claim must + be released, exactly as the cancelled/failed/raised execution branches do. + """ + def _refuse(verify): + raise pts.PaidTurnAbort({"detail": "wallet not allowed"}, status_code=403) + + idem = FakeIdem() + outcome, _ = await _drive(idem=idem, pre_execute=_refuse) + + assert outcome.kind == pts.ABORTED + assert idem.failed, "the refused payer's retry must reach the refusal, not a 409" + assert idem.completed == [] + + +async def test_scope_may_be_derived_from_the_payer(monkeypatch): + """The A2A gate namespaces its dedup scope by payer wallet (FR-4). + + Only verify knows the payer, so the scope has to be resolvable afterwards — + without verifying twice. + """ + seen = {} + + async def _execute(): + return _exec() + + idem = FakeIdem() + payments = FakePaymentService(verify=_verify_ok(payer="0xcafe"), + settle=_settle_ok()) + await pts.run_paid_turn( + agent_name="agent-a", + config=_config(), + nvm_api_key="k", + access_token="tok", + idem_scope=lambda verify: f"a2a:agent-a:pay:{verify.payer}", + idem_key="key-1", + execute=_execute, + payment_service=payments, + idem=idem, + db=FakeDb(), + ) + seen["scope"], seen["key"] = idem.begin_calls[0] + assert seen["scope"] == "a2a:agent-a:pay:0xcafe" + assert seen["key"] == "key-1" diff --git a/tests/unit/test_ent679_payer_owns_execution.py b/tests/unit/test_ent679_payer_owns_execution.py new file mode 100644 index 000000000..55cf09d9f --- /dev/null +++ b/tests/unit/test_ent679_payer_owns_execution.py @@ -0,0 +1,123 @@ +"""The payer→task binding, against the real SQL (abilityai/trinity-enterprise#679 I6). + +`db/nevermined.py::payer_owns_execution` is the whole of T5: it decides whether +an anonymous x402 caller may `tasks/get` / `tasks/cancel` a task on the A2A +payment door. Every gate test stubs `db.nevermined_payer_owns_execution`, so the +`select … where lower(subscriber_address) == …` never ran in CI — the one new +query on the money path, and the one carrying a security property, was covered +only by its callers' stand-in. + +This file runs the method itself against a throwaway SQLite carrying the real +`nevermined_payment_log` table, in the shape of the other `db/` tests. The +properties pinned are the ones an authorization predicate is judged on: + +* it matches the payer's own row, and matches it across the facilitator's + checksum casing (a verify and a settle are not guaranteed to agree on it); +* it is False for another payer, another agent, and an unknown execution — the + three ways one payer could reach another's task; +* the payer string is a BOUND parameter, so a SQL-shaped wallet is just a wallet + that matches nothing; +* missing arguments answer False rather than matching anything (fail closed). +""" +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.unit + +AGENT = "priced-agent" +EXEC = "exec-abc123" +PAYER = "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01" + + +@pytest.fixture() +def nevermined_db(tmp_path, monkeypatch): + """A throwaway SQLite carrying the real payment-log table.""" + db_file = tmp_path / "trinity-ent679-i6.db" + monkeypatch.setenv("TRINITY_DB_PATH", str(db_file)) + + import db.connection as conn_mod + monkeypatch.setattr(conn_mod, "DB_PATH", str(db_file)) + + from db.engine import get_engine + from db.tables import metadata, nevermined_payment_log + + metadata.create_all(get_engine(), tables=[nevermined_payment_log]) + yield get_engine() + + +def _log_row(engine, *, agent, execution_id, payer, action="settle", row_id=None): + from sqlalchemy import insert + from db.tables import nevermined_payment_log + + with engine.begin() as conn: + conn.execute(insert(nevermined_payment_log).values( + id=row_id or f"{agent}:{execution_id}:{payer}:{action}", + agent_name=agent, + execution_id=execution_id, + action=action, + subscriber_address=payer, + credits_amount=1, + success=1, + created_at="2026-10-03T00:00:00Z", + )) + + +def _ops(): + from db.nevermined import NeverminedOperations + return NeverminedOperations() + + +@pytest.mark.parametrize("agent,execution_id,payer,expected,why", [ + (AGENT, EXEC, PAYER, True, "the payer's own settled task"), + (AGENT, EXEC, PAYER.lower(), True, "checksum casing is not stable across verify/settle"), + (AGENT, EXEC, "0x" + "9" * 40, False, "another wallet must not reach this task"), + ("other-agent", EXEC, PAYER, False, "the binding is scoped to one agent"), + (AGENT, "exec-nope", PAYER, False, "an execution this payer never paid for"), + (AGENT, EXEC, "' OR '1'='1", False, "the wallet is a bound parameter, not SQL"), +]) +def test_payer_owns_execution_binding(nevermined_db, agent, execution_id, payer, + expected, why): + _log_row(nevermined_db, agent=AGENT, execution_id=EXEC, payer=PAYER) + + assert _ops().payer_owns_execution(agent, execution_id, payer) is expected, why + + +def test_missing_arguments_fail_closed(nevermined_db): + """An empty wallet/agent/execution is not "match anything" — it is no.""" + _log_row(nevermined_db, agent=AGENT, execution_id=EXEC, payer=PAYER) + ops = _ops() + + assert ops.payer_owns_execution("", EXEC, PAYER) is False + assert ops.payer_owns_execution(AGENT, "", PAYER) is False + assert ops.payer_owns_execution(AGENT, EXEC, "") is False + + +def test_a_settle_failed_row_also_binds_the_payer(nevermined_db): + """A delivered-but-unsettled turn still owes the payer its task. + + `settle_failed` carries both columns, so the payer who was served but whose + burn did not complete keeps access to the task — otherwise the one caller + with a reason to poll is the one locked out. + """ + _log_row(nevermined_db, agent=AGENT, execution_id=EXEC, payer=PAYER, + action="settle_failed") + + assert _ops().payer_owns_execution(AGENT, EXEC, PAYER) is True + + +def test_a_verify_row_carrying_the_execution_binds_without_a_settle_row(nevermined_db): + """The I4 row, read by the predicate that consumes it. + + `payer_owns_execution` is action-agnostic by design: it asks "did this + wallet pay for this execution", and the `verify` row `run_paid_turn` writes + once the turn's result exists answers that on its own — before the settle + has logged anything, and when a concurrent settle logs nothing at all. + """ + _log_row(nevermined_db, agent=AGENT, execution_id=EXEC, payer=PAYER, + action="verify") + + assert _ops().payer_owns_execution(AGENT, EXEC, PAYER) is True + # Still scoped: the row binds THIS payer to THIS agent's execution only. + assert _ops().payer_owns_execution(AGENT, EXEC, "0x" + "9" * 40) is False + assert _ops().payer_owns_execution("other-agent", EXEC, PAYER) is False diff --git a/tests/unit/test_ent679_payments_pin_parity.py b/tests/unit/test_ent679_payments_pin_parity.py new file mode 100644 index 000000000..8e8c40cd4 --- /dev/null +++ b/tests/unit/test_ent679_payments_pin_parity.py @@ -0,0 +1,213 @@ +"""payments-py pin parity + import smoke (abilityai/trinity-enterprise#679). + +The #1891 shape, applied to a Python dependency instead of a Python version. +``tests/requirements-test.txt`` carried a FLOOR (``payments-py>=1.0.0``) while +``docker/backend/Dockerfile`` pinned ``1.2.1``, so CI exercised 1.18.0 against a +1.2.1 image — a divergence that, by construction, cannot catch an incompatible +SDK call (trinity-enterprise#763). Both are now exact and equal, and this file +is what keeps them that way. + +The import smoke is the second half and is not decoration: +``payments_py.payments`` imports the a2a package at module load, so ONE missing +transitive dependency flips ``NEVERMINED_AVAILABLE`` to False and both payment +doors answer 501 — with a green build and no error anywhere except a WARNING +log. That is why the 1.18.0 runtime set is pinned explicitly in the Dockerfile +(T8) and why those pins are compared against what is actually importable here. +""" +from __future__ import annotations + +import importlib.metadata as md +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +DOCKERFILE = REPO / "docker" / "backend" / "Dockerfile" +REQUIREMENTS = REPO / "tests" / "requirements-test.txt" + +#: The 1.18.0 runtime dependency set pinned explicitly in the backend image. +#: Each name must be pinned exactly in the Dockerfile AND resolve to that same +#: version in the environment running this test. +TRANSITIVE_PINS = ( + "a2a-sdk", + "mcp", + "python-socketio", + "pyjwt", + "jsonschema", + "websocket-client", + "helicone-helpers", + "black", + "mkdocs", + "mkdocs-material", + "mkdocstrings", + "mike", + "pytest-asyncio", +) + +#: Already floating in the backend image BEFORE this bump — `docker`, `twilio` +#: and `google-genai` each pull `requests`, so payments-py does not newly expose +#: it. Listed with its reason so a later reader can tell reviewed from +#: overlooked; it is not a licence to add more. +PRE_EXISTING_FLOATERS = frozenset({"requests"}) + + +def _dockerfile_pin(package: str) -> str | None: + """The exact version ``package`` is pinned to in the backend Dockerfile. + + Tolerates the extras form (``mkdocstrings[python]==0.29.1``) and the quoting + the file uses for any requirement containing a bracket. + """ + pattern = re.compile( + r'^\s*"?' + re.escape(package) + r'(?:\[[^\]]+\])?==([0-9][^"\s\\]*)"?\s*\\?\s*$', + re.IGNORECASE | re.MULTILINE, + ) + match = pattern.search(DOCKERFILE.read_text()) + return match.group(1) if match else None + + +def _dockerfile_declares(package: str) -> bool: + """Is ``package`` constrained in the image at all (exactly OR as a range)? + + Weaker than :func:`_dockerfile_pin` on purpose: the "nothing floats" + coverage check cares only that pip is not free to pick, while the + per-package parity check below demands an exact pin. + """ + pattern = re.compile( + r'^\s*"?' + re.escape(package) + r'(?:\[[^\]]+\])?\s*[=<>!]', + re.IGNORECASE | re.MULTILINE, + ) + return pattern.search(DOCKERFILE.read_text()) is not None + + +def _requirements_pin(package: str) -> str | None: + pattern = re.compile( + r"^" + re.escape(package) + r"(?:\[[^\]]+\])?==([0-9][^\s;]*)\s*$", + re.IGNORECASE | re.MULTILINE, + ) + match = pattern.search(REQUIREMENTS.read_text()) + return match.group(1) if match else None + + +# --------------------------------------------------------------------------- +# 1. The two pins agree, and both are exact +# --------------------------------------------------------------------------- + +def test_payments_py_pin_is_exact_in_both_files(): + image = _dockerfile_pin("payments-py") + tests = _requirements_pin("payments-py") + assert image is not None, "payments-py is not pinned exactly in docker/backend/Dockerfile" + assert tests is not None, ( + "payments-py is not pinned exactly in tests/requirements-test.txt — a floor " + "(>=) is what let CI run a different SDK than the image (#763)" + ) + assert image == tests, ( + f"payments-py pin divergence: image {image} vs tests {tests}. CI must exercise " + "the version production ships." + ) + + +def test_payments_py_installed_version_matches_the_pin(): + """The environment running the suite IS the pinned version.""" + assert md.version("payments-py") == _dockerfile_pin("payments-py") + + +def test_payments_py_is_at_least_the_inband_release(): + """1.18.0 is the floor the in-band metadata flow needs (ruling 3). + + Below it, ``payments_py.a2a.inband`` does not exist and the A2A x402 rail is + header-only. + """ + pin = _dockerfile_pin("payments-py") + major, minor = (int(part) for part in pin.split(".")[:2]) + assert (major, minor) >= (1, 18), f"payments-py {pin} predates the in-band A2A flow" + + +# --------------------------------------------------------------------------- +# 2. The transitive runtime set is pinned, and pinned to what is importable +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("package", TRANSITIVE_PINS) +def test_transitive_runtime_dependency_is_pinned_to_the_installed_version(package): + pinned = _dockerfile_pin(package) + assert pinned is not None, ( + f"{package} is a RUNTIME dependency of payments-py but is not pinned in " + "docker/backend/Dockerfile — the image would float it (F6)" + ) + assert pinned == md.version(package), ( + f"{package}: image pins {pinned}, this environment resolved " + f"{md.version(package)}. Image and CI must agree." + ) + + +def test_pinned_set_covers_every_unconditional_runtime_requirement(): + """A future payments-py gaining a dependency must not float it silently. + + Only unconditional requirements are in scope — an ``extra ==`` marker is + opt-in and Trinity installs no extras. + """ + unpinned = [] + for raw in md.requires("payments-py") or []: + if "extra ==" in raw: + continue + name = re.split(r"[\s(\[<>=!;]", raw.strip(), maxsplit=1)[0] + if not _dockerfile_declares(name) and name.lower() not in PRE_EXISTING_FLOATERS: + unpinned.append(name) + assert unpinned == [], ( + f"payments-py runtime dependencies are unconstrained in the backend image: " + f"{unpinned}. Pin them (T8) — an import failure in any one of them turns both " + "payment doors into a silent 501." + ) + + +# --------------------------------------------------------------------------- +# 3. Import smoke — the SDK actually loads under this pin set +# --------------------------------------------------------------------------- + +def test_payments_py_imports_and_nevermined_is_available(): + """The 501-with-a-green-build failure mode, caught at its own layer. + + ``NEVERMINED_AVAILABLE`` is computed by a try/except ImportError at module + import, so this executes the exact expression production depends on. + """ + from services.nevermined_payment_service import NEVERMINED_AVAILABLE + + assert NEVERMINED_AVAILABLE is True, ( + "payments_py failed to import under the pinned dependency set — both payment " + "doors would answer 501. Check the Dockerfile transitive pins." + ) + + +def test_inband_and_x402_modules_are_importable(): + """The 1.18.0 surfaces the A2A gate is built on (ruling 3).""" + from payments_py.a2a.inband import extract_inband_token + from payments_py.x402.token import encode_access_token + from payments_py.x402.helpers import build_payment_required + + assert callable(extract_inband_token) + assert callable(encode_access_token) + assert callable(build_payment_required) + + +def test_facilitator_call_signatures_are_positional_compatible(): + """1.18.0 keeps the argument ORDER the 1.2.1 call sites pass positionally. + + ``settle_permissions`` is called with four positional arguments + (``payment_required, access_token, max_amount, agent_request_id``) in + ``nevermined_payment_service``; a reordered signature upstream would be a + silent mis-binding, not an error. + """ + import inspect + + from payments_py.x402.facilitator_api import FacilitatorAPI + + verify = list(inspect.signature(FacilitatorAPI.verify_permissions).parameters) + settle = list(inspect.signature(FacilitatorAPI.settle_permissions).parameters) + assert verify[:3] == ["self", "payment_required", "x402_access_token"] + assert settle[:5] == [ + "self", + "payment_required", + "x402_access_token", + "max_amount", + "agent_request_id", + ]