Skip to content

feat: add aggregate /.well-known/x402 and external buyer-tool compat - #806

Closed
HananINouman wants to merge 8 commits into
mainfrom
feat/external-buyer-tool-compat
Closed

feat: add aggregate /.well-known/x402 and external buyer-tool compat#806
HananINouman wants to merge 8 commits into
mainfrom
feat/external-buyer-tool-compat

Conversation

@HananINouman

@HananINouman HananINouman commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes Obol Stack storefront offers work with external x402 buyers — especially Merit Systems (AgentCash / x402scan / Poncho) and Bankr — via discovery docs, verifier hardenings, type-specific buy prompts, and flow-22.

Merit discovery (AgentCash / Poncho / x402scan)

Merit products share one discovery convention:

  1. Primary: /openapi.json with per-op x-payment-info
  2. Fallback: /.well-known/x402 for crawlers that don’t parse OpenAPI

This PR adds aggregate public /.well-known/x402, keeps/strengthens OpenAPI x-payment-info, defaults undeclared paid HTTP ops to GET (AgentCash-friendly), and documents both surfaces in skill.md / catalog.

Why AgentCash and Poncho share a flow: both are Merit. Poncho (tryponcho.com) is consumer chat; AgentCash is the micropayment/wallet layer. Same discovery → same 402 → sign → retry. Storefront pills differ mainly by “paste into AgentCash vs Poncho.” Live Base tests: both paid our HTTP offer successfully.

Bankr (live Base mainnet)

Bankr chat / CLI / Apps are different clients. Docs mention discovery-index / Apps allowedHosts, but allowlisting alone does not fix agents: we built a Bankr App with pay:x402 + allowedHosts for our tunnel and bankr.x402.fetch against bounty-radar — payment often verified, then the App failed with rpc timeout ~30s while the agent was still running. HTTP through the same App/chat path usually works (~1s).

Tried HTTP Agent
Chat / Max Mode auto-pay OK Fail / misleading
bankr x402 call Usually OK ~30s 504
Apps bankr.x402.fetch (with allowlist) Can verify rpc timeout after verify
bankr wallet sign + curl ≥180s OK OK (proven)

Two failure modes: (A) voucher (validAfter=now / bad sig → verify reject, usually no charge); (B) timeout/zombie (verify OK, client aborts ~30s; older sellers could still settle → on-chain charge). Seller side: skip settle on disconnect/write error/empty SSE; better facilitator error labels; dual CAIP-2+legacy accepts[]; PAYMENT-SIGNATURE + v=0/1→27/28; structured post-verify upstream errors.

Prompts we publish: HTTP → Bankr chat auto-pay; agent/inference → forbid chat/Apps/x402 call, teach bankr wallet sign + curl --max-time 300 with past validAfter and stream:true.

Storefront / CI / docs

  • Buyer pills: AgentCash · Poncho · Bankr · Another AI (buyprompts/api/services.json)
  • flow-22-external-buyer-compat.sh + generic x402 SDK buyer; release-smoke hook
  • docs/observability.md, CLAUDE.md pitfall 23, buy-x402 skill note

Test plan

  • Roll controller + x402-verifier + public-storefront from this branch
  • GET <tunnel>/.well-known/x402 + OpenAPI x-payment-info on paid ops
  • Storefront pills; HTTP Bankr = chat; agent Bankr = wallet-sign + curl
  • AgentCash + Poncho: pay HTTP (and agent with long timeout)
  • Bankr: HTTP chat OK; agent Apps/chat expected timeout; wallet-sign+curl ≥180s OK
  • Abort mid-agent stream → no settle (no zombie Transfer)
  • go test ./internal/buyprompts/ ./internal/x402/ ./internal/serviceoffercontroller/
  • Optional: flow-22 (rebuild controller first)

Poncho chat, agent offer

Screenshot 2026-08-05 at 9 23 38 PM

Bankr wallet+cursor curl, agent offer

Screenshot 2026-08-05 at 9 24 12 PM

Agentcash(skill/cli/mcp) connected to cursor , agent offer

Screenshot 2026-08-05 at 9 25 38 PM

Bankr chat, http offer

Screenshot 2026-08-05 at 9 26 02 PM Screenshot 2026-08-05 at 9 39 44 PM

HananINouman and others added 2 commits August 2, 2026 20:03
Publish AgentCash/x402scan discovery fallback on the shared storefront,
surface AgentCash/Bankr buy prompts, and gate the path with flow-22
(generic x402 SDK + agent chat-completions) in release-smoke.

Co-authored-by: Cursor <cursoragent@cursor.com>
Skip settle on client disconnect/write errors, classify facilitator rejections,
and teach HTTP buyers Bankr chat auto-pay while agent/inference use wallet-sign
plus long curl after live timeout and voucher failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread internal/x402/forwardauth.go Fixed
Comment thread internal/x402/forwardauth.go Fixed
Comment thread internal/x402/forwardauth.go Fixed
Comment thread internal/x402/forwardauth.go Fixed
Publish type-specific Poncho chat prompts alongside AgentCash/Bankr and
surface them in the storefront pill selector after a successful live pay test.

Co-authored-by: Cursor <cursoragent@cursor.com>
@HananINouman
HananINouman marked this pull request as ready for review August 5, 2026 18:40
Record that a Bankr App with pay:x402 + allowedHosts still hit rpc timeout
on Base mainnet agent offers; drop the Sepolia digression and clarify
HandleProxy settle-skip vs ForwardAuth verify-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@HananINouman
HananINouman requested a review from bussyjd August 5, 2026 18:58
bussyjd added a commit that referenced this pull request Aug 6, 2026
…-rc2

Brings the aggregate /.well-known/x402 discovery doc and external
buyer-tool compat (Merit/AgentCash/Poncho, Bankr) into rc2.

Conflict resolution — both sides were additive, both kept:
- catalog.go: the static-site match/hash now folds in BOTH the widget's
  embedded chat-vendor.js (rc2) and the new x402.json (#806), so a stale
  copy of either re-applies the ConfigMap.
- forwardauth_test.go: kept TestBuildResourceURL_Scheme (rc2) alongside
  #806's brokenPipeWriter settle-skip tests.
- Widget-era test call sites updated for the new wellKnownX402JSON param.

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
bussyjd added a commit that referenced this pull request Aug 6, 2026
…SON param

The #806 merge added a wellKnownX402JSON parameter to
buildStaticSiteConfigMap/staticSiteContentMatches, but four widget-era
call sites in catalog_test.go and hostoffer_test.go were left at the old
arity, so the package failed to build its tests. Pass "" (these tests
do not exercise the aggregate x402 document).

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
bussyjd added a commit that referenced this pull request Aug 6, 2026
@bussyjd bussyjd mentioned this pull request Aug 6, 2026
…njection)

CodeQL flagged four new go/log-injection alerts introduced by this PR in
internal/x402/forwardauth.go: the buyer's payment payload and the
facilitator's reject reason reach log.Printf unescaped, so a crafted
value containing CRLF can forge additional lines in the operator's log
(e.g. a fake "payment settled successfully" entry).

Fix at the producers rather than at each log call, so future call sites
inherit it: paymentPayloadSummary, facilitatorRejectDetail and the
normalize note now return CR/LF-stripped strings, and truncateForLog
sanitizes too (it carries the raw facilitator /verify body).

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
@bussyjd
bussyjd force-pushed the feat/external-buyer-tool-compat branch from 5e15e33 to a5d6972 Compare August 6, 2026 12:48
bussyjd added a commit that referenced this pull request Aug 6, 2026
…-rc2

Brings the aggregate /.well-known/x402 + external buyer-tool compat work
along with its CodeQL go/log-injection fix.

Conflict resolutions (both additive — kept BOTH sides, not one over the
other):
  * catalog.go — the static-site match and content hash now fold in both
    the widget's embedded chat-vendor.js (rc2) and the new x402.json
    (#806). Dropping either side would silently pin a stale copy of that
    asset across controller upgrades via the skip-when-unchanged path.
  * forwardauth_test.go — kept rc2's TestBuildResourceURL_Scheme
    alongside #806's brokenPipeWriter settle-skip tests.

Also updates four widget-era test call sites in catalog_test.go and
hostoffer_test.go for #806's new wellKnownX402JSON parameter (they pass
"" — those tests do not exercise the aggregate x402 document). Without
this the package does not build its tests.

Replaces the unsigned a128bdf/dd9c5a1d/cdee3a22 with a single signed
merge; the resulting tree is byte-identical to cdee3a2.

Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
HananINouman and others added 3 commits August 6, 2026 18:46
…ecks

Drop chatty framing, require AgentCash/Poncho tx receipts, and teach Bankr
agent buyers to reuse the full accepts[] entry to avoid unsupported_scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
defaultPaidMethod flipped `type: http` offers without declared `methods`
from POST to GET. For a root-priced route that makes the advertised
operation unpayable.

renderStaticSite publishes the offer root as exactTo("/", "index.html"),
a GET-scoped Exact match, and its comment states the contract:

    Method-scoped to GET: discovery documents are read-only, and a
    root-priced offer (route pattern "/") advertises POST <origin>/ as
    its paid resource — an unscoped Exact "/" match would shadow that
    POST into the static httpd (501) instead of the payment gate.

So at the offer root GET is served by the landing page and only POST
falls through to the gate. The previous hardcoded POST was one half of
that two-sided contract; changing the advertised method without changing
the routing left discovery pointing at a 200 HTML page that can never
return 402.

Observed live on a deployed offer before this fix:

    GET  https://<offer-host>/  -> 200 text/html
    POST https://<offer-host>/  -> 402

with /.well-known/x402, openapi.json and skill.md all advertising GET.

defaultPaidMethod now takes the paid route's path and returns POST when
that route is root-priced ("", "/" or "/*"), GET otherwise. Sub-path paid
routes are unaffected and keep the GET default that stops OpenAPI and
AgentCash clients POSTing into a GET-only upstream (405), which is what
the GET default was introduced for.

The blast radius is any paid route declared WITHOUT `methods`, not only
offers with no route table at all — primaryPaidMethod checks
len(rt.Methods) > 0. A route table whose paid catch-all omits `methods`
was affected too.

Two existing assertions encoded the old behaviour for the root-priced
case and are updated with the reason. paidmethod_root_test.go adds the
check the route-surface suite was missing: the advertised method must not
be one the static site claims first. The release smoke could not catch
this because the payment gate matches on path only, ignoring method, so a
bare GET probe returns 402 regardless of what discovery advertises.
@bussyjd

bussyjd commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Pre-publish testing of v0.14.0-rc3 found one live defect in this PR — fixed in d359a14

Full pre-publish validation of rc3 (three hosts, live payments on Base Sepolia, and a deployed cluster serving nine paid offers) turned up one genuine user-facing bug originating here, plus two findings that are worth recording but are not this PR's fault.

The defect: defaultPaidMethod makes root-priced type: http offers unpayable-as-advertised

eac5514d flipped type: http offers without declared methods from POST to GET. The rationale — stop OpenAPI/AgentCash clients POSTing into a GET-only upstream and getting a 405 — is sound for sub-path routes. But it collides with the routing layer at the offer root.

render.go publishes the offer root as exactTo("/", "index.html"), a GET-scoped Exact match, and its comment states the contract explicitly:

Method-scoped to GET: discovery documents are read-only, and a root-priced offer (route pattern "/") advertises POST <origin>/ as its paid resource — an unscoped Exact "/" match would shadow that POST into the static httpd (501) instead of the payment gate.

So at the offer root, GET is served by the landing page and only POST reaches the gate. The previous hardcoded POST was one half of a documented two-sided contract; changing the advertised method without changing the routing left discovery pointing at a page that can never return 402.

Measured on a deployed offer before the fix:

GET  https://<offer-host>/                 -> 200 text/html   (landing page)
POST https://<offer-host>/                 -> 402             (the real paid op)
GET  https://<offer-host>/.well-known/x402 -> advertises "GET /"

openapi.json, skill.md and both the per-offer and aggregate /.well-known/x402 were all affected — five call sites.

Blast radius is wider than "offers with no route table". primaryPaidMethod checks len(rt.Methods) > 0, so it is any paid route declared without methods. A live offer with a full 8-entry route table was affected too, because its paid catch-all {"gate":"paid","path":"/*"} omitted methods.

The fix

defaultPaidMethod now takes the paid route's path and returns POST when that route is root-priced ("", "/", "/*"), GET otherwise. Sub-path paid routes are unchanged and keep the GET default this PR introduced them for.

Two existing assertions encoded the old behaviour for the root-priced case and are updated with the reason. paidmethod_root_test.go adds the check the route-surface suite was missing: the advertised method must not be one the static site claims first.

Why the release smoke could not catch this

flow-21-route-surface step 4 probes a paid route declared with no methods using a bare GET and passes — because the payment gate matches on path only, ignoring method. Gating is correct regardless of the advertised method, so the smoke is structurally blind to this class of bug. That is how it reached a tagged RC. The new test closes the gap at the unit level.

Two things that are NOT this PR's fault

  1. Per-offer /.well-known/x402 collapsing from 11 routes to 1. I initially attributed this here; that was wrong. git diff a5bd91e..73eb5222 -- internal/serviceoffercontroller/upstream_openapi.go is empty. The real cause is a sticky-nil cache: upstreamOpenAPICache.refresh records a nil probe result keyed on offer.Generation and then short-circuits every later call for that generation, so one failed 3s probe pins the degraded fallback until the CR is edited. A controller restart empties the cache and gives each offer a single attempt, and since reconcileStaticSite rebuilds the shared ConfigMap from that cache, one nil overwrites the good document cluster-wide. It self-heals, but recurs on every restart. Pre-existing; worth a separate 4-line fix (don't cache a nil probe).

  2. flow-16-sell-agent step 13 402 missing agent metadata. Not a regression. mergeAgentExtras deliberately omits agentModel, and TestMergeAgentExtras_AddsAgentFieldsButNotModel asserts "agentModel must not be surfaced". That is the rc1 agent model-strip; the flow was never updated and has been failing since. Fix the flow, not the verifier.

Validation

  • go build ./..., go vet, and go test ./internal/serviceoffercontroller/... all pass with the fix.
  • Full 22-flow release smoke on a k3d host: 16 of 17 completed flows pass with zero failures; the only failure is the stale flow-16 assertion above.
  • Six on-chain receipts on Base Sepolia (USDC settlement, OBOL Permit2 settlement, Permit2 approval, three ERC-8004 registrations), each independently verified twice via cast receipt and raw eth_getTransactionReceipt, all status 0x1.
  • Both fix(stack): two k3s blockers — loopback Endpoints, and the storefront preview host missing from /etc/hosts #808 fixes confirmed on real hosts: the ollama Endpoints now holds a routable IP instead of 127.0.0.1, and storefront-preview.obol.stack survives repeated hermes rewrites of the managed /etc/hosts block.

All commits on this branch are signed and verified.

bussyjd added a commit that referenced this pull request Aug 7, 2026
Carries d359a14: root-priced type:http offers advertise POST again, so the
advertised paid operation reaches the payment gate instead of the static
index.html. Adds paidmethod_root_test.go.
@bussyjd bussyjd mentioned this pull request Aug 8, 2026
@bussyjd

bussyjd commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Superseded by #811 (integration/v0.14.0-rc3main).

Verified contained, not assumed: git merge-base --is-ancestor origin/feat/external-buyer-tool-compat origin/integration/v0.14.0-rc3 passes, so every commit on this branch is already in the roll-up. The content also shipped in the published v0.14.0-rc3 and was validated on three hosts — a full release smoke on two of them, 11 independently verified on-chain receipts, and a live nine-offer stack upgraded and re-probed end to end.

Closing to keep one path to main. Nothing is lost: if #811 ever needed unwinding, this branch still exists.

@bussyjd bussyjd closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants