Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f77eb81
fix(network): stop reth liveness probe from killing consistency recovery
bussyjd Jul 14, 2026
d419620
fix(x402): default 402 challenge resource.url to https on public hosts
bussyjd Jul 14, 2026
440c59f
chore(deps): update dependency kubernetes-sigs/gateway-api to v1.6.1
github-actions[bot] Jul 16, 2026
a04c05d
fix(ui): honor OBOL_NONINTERACTIVE on a real TTY
bussyjd Jul 18, 2026
f5c880b
fix(agentcrd): strip server-managed metadata before ResumeAll re-apply
bussyjd Jul 18, 2026
c70634a
fix(serviceoffercontroller): align well-known/x402 resource path with…
bussyjd Jul 18, 2026
58c7db2
fix(stack): don't treat own cluster's ports as conflicts under --force
bussyjd Jul 18, 2026
8427f10
fix(tunnel): make hostname add idempotent for already-bound hosts
bussyjd Jul 18, 2026
8908241
chore(deps): update cloudflare/cloudflared docker tag to v2026.7.2
github-actions[bot] Jul 20, 2026
c3afc6e
chore(deps): update dependency @scalar/api-reference to v1.62.9
github-actions[bot] Jul 20, 2026
c632485
chore(deps): update ethereum el/cl client updates
github-actions[bot] Jul 20, 2026
947c843
chore(deps): update obolup.sh dependency updates
github-actions[bot] Jul 20, 2026
aa74352
feat(x402): auth-capture unlock gate + fee revenue metrics
bussyjd Jul 20, 2026
5d9a849
Merge remote-tracking branch 'origin/fix/reth-liveness-recovery' into…
bussyjd Jul 20, 2026
7a3964e
Merge remote-tracking branch 'origin/renovate/kubernetes-sigs-gateway…
bussyjd Jul 20, 2026
bb1f94f
Merge remote-tracking branch 'origin/fix/noninteractive-prompt-guard'…
bussyjd Jul 20, 2026
5115f9f
Merge remote-tracking branch 'origin/fix/agent-resume-strip-metadata'…
bussyjd Jul 20, 2026
bb0d1e3
Merge remote-tracking branch 'origin/fix/discovery-x402-resource-path…
bussyjd Jul 20, 2026
36d918b
Merge remote-tracking branch 'origin/fix/init-force-own-cluster-ports…
bussyjd Jul 20, 2026
252f9b9
Merge remote-tracking branch 'origin/fix/tunnel-hostname-idempotent' …
bussyjd Jul 20, 2026
53b45f1
Merge remote-tracking branch 'origin/renovate/cloudflared-updates' in…
bussyjd Jul 20, 2026
2b34310
Merge remote-tracking branch 'origin/renovate/scalar-api-reference-up…
bussyjd Jul 20, 2026
0dd03af
Merge remote-tracking branch 'origin/renovate/ethereum-clients' into …
bussyjd Jul 20, 2026
316d664
Merge remote-tracking branch 'origin/renovate/obolup.sh-dependency-up…
bussyjd Jul 20, 2026
ca07281
Merge remote-tracking branch 'origin/feat/authcapture-unlock' into in…
bussyjd Jul 20, 2026
3fe7ea1
Merge remote-tracking branch 'origin/fix/x402-challenge-url-scheme' i…
bussyjd Jul 20, 2026
0e157e2
fix(x402): drop the model from agent 402 copy + pay-agent
bussyjd Jun 26, 2026
35ee8f0
fix(x402): stop surfacing agentModel in the 402 extra
bussyjd Jun 26, 2026
8fafdbb
fix(x402): keep the internal model out of the bazaar example for agen…
bussyjd Jun 26, 2026
2cddee0
fix(serviceoffer-controller): drop the internal model from /skill.md …
bussyjd Jun 26, 2026
fc5080e
fix(serviceoffer-controller): drop internal model from /api/services.…
bussyjd Jun 27, 2026
84ab5f3
fix(serviceoffer-controller): serve catalog as UTF-8 to stop em-dash …
bussyjd Jun 26, 2026
d65c2ed
Revert "revert(storefront): carve chat widget out of v0.14.0-rc0"
bussyjd Jul 20, 2026
f420d78
fix(x402): surface on-chain settle tx hash on unlock settle failure
bussyjd Jul 20, 2026
6cef65f
fix(chat-widget): cap per-turn spend at displayed price; document MPC…
bussyjd Jul 20, 2026
1eb291e
Merge remote-tracking branch 'origin/feat/authcapture-unlock' into in…
bussyjd Jul 20, 2026
ab28470
Merge branch 'main' into integration/v0.14.0-rc1
bussyjd Jul 20, 2026
f534f60
Merge branch 'main' into feat/chat-widget-reland
bussyjd Jul 21, 2026
d7f191b
Merge feat/chat-widget-reland into integration/v0.14.0-rc1
bussyjd Jul 24, 2026
8e42338
feat(storefront): support secure operator previews
HananINouman Jul 29, 2026
aa9ebf7
feat(storefront): publish local-only preview and SSA branding applies
HananINouman Jul 29, 2026
eac5514
feat: add aggregate /.well-known/x402 and external buyer-tool compat
HananINouman Aug 2, 2026
e63c878
Merge remote-tracking branch 'origin/main' into integration/v0.14.0-rc2
bussyjd Aug 5, 2026
7d78ff7
Merge pull request #805 from ObolNetwork/feat/storefront-live-preview
bussyjd Aug 5, 2026
abd98cc
deps: bundle pending renovate updates for v0.14.0-rc2
bussyjd Aug 5, 2026
a5bd91e
chore(frontend): pin obol-frontend to v0.1.28-rc5
bussyjd Aug 5, 2026
cd406af
fix(x402): harden Bankr external-buyer path and type-specific prompts
HananINouman Aug 5, 2026
1bc489b
feat(storefront): add Poncho as Merit-family external buyer tab
HananINouman Aug 5, 2026
3f3c745
docs: correct Bankr Apps allowlist finding for external buyers
HananINouman Aug 5, 2026
a5d6972
fix(x402): sanitize untrusted strings before logging (CodeQL go/log-i…
bussyjd Aug 6, 2026
b874a19
Merge feat/external-buyer-tool-compat (#806) into integration/v0.14.0…
bussyjd Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Integration tests use `//go:build integration`; skip when prerequisites missing.
| Public (tunnel) | none | `/.well-known/agent-registration.json` | ERC-8004 httpd |
| Public (tunnel) | none | `/skill.md` | service catalog |
| Public (tunnel) | none | `/api/services.json` | service catalog JSON feed (`displayName`, `tagline`, `logoUrl`, `theme`, `themeVars`, `faviconUrl`, `ogImageUrl`, `description(+Html)`, `customCss`, `services[]`) |
| Public (tunnel) | none | `/.well-known/x402` | aggregate x402 discovery fallback (AgentCash/x402scan-style crawlers that don't parse `/openapi.json`'s `x-payment-info`) |
| Public (tunnel) | tunnel hostname only | `/` | storefront landing page (Next.js) |

**NEVER remove hostname restrictions from frontend or eRPC HTTPRoutes** — exposing the frontend/RPC to the public internet is a critical security flaw.
Expand Down Expand Up @@ -457,7 +458,7 @@ A registry digest pin instead of `:latest` on the verifier means your dev rewrit
20. **402 page silently falls back to JSON when the template errors** — `sendPaymentRequiredHTML` swallows template-exec errors and re-sends the JSON body, so referencing a field in `payment_required.html` that isn't in the render's data struct doesn't crash anything: browsers just start getting JSON. Symptom: `Content-Type: application/json` on an `Accept: text/html` request. Any template-field addition needs the struct field added in `paymentrequired.go` AND a test asserting the HTML branch still renders (the existing branding tests check Content-Type/markup).
21. **`html/template` rejects `data:` URIs in URL contexts (`#ZgotmplZ`)** — inline logos/favicons from `sell info set --logo-file` are `data:image/...;base64` URIs; interpolating them into `src=`/`href=` via a plain string yields the literal `#ZgotmplZ` (broken image). Branding asset URLs must go through `storefront.SafeAssetURL` (validates http(s)/`data:image` then returns `template.URL`); regression test `TestPaymentRequiredHTML_InlineDataURILogo`.
22. **Poisoned LiteLLM model group — intermittent 404 `{'detail': 'Not Found'}` on ~50% of requests** — two `model_list` deployments share a `model_name` but disagree on `api_base` (one with `/v1`, one without); LiteLLM shuffles between them and does not retry a 404. Historic cause: auto-discovery registering the same host endpoint that `obol model setup custom` later added correctly (#745). Diagnosis: compare the router's live view (`GET /model/info`) against the `litellm-config` CM — the drift checker compares model NAMES only and cannot see divergent `api_base` (#746). The vLLM access log is decisive: alternating `POST /chat/completions 404` / `POST /v1/chat/completions 200`.
23. **Buyer disconnect ≠ no charge (zombie settlement)** — a client abort routinely does NOT propagate past cloudflared; the upstream agent finishes the turn, the handler returns 2xx, and settlement fires → buyer debited for a response nobody received (proven on-chain 2026-07-14, the Bankr incident). The verifier skips settlement when the request context is already canceled (#743), but that only covers propagated cancels — the real protection is keeping paid agent runs SHORT (`Agent.spec.maxTurns`; runs must finish inside the ~100s tunnel window and typical client timeouts). Concurrency: Hermes caps simultaneous runs in-process (`gateway.api_server.max_concurrent_runs`, internal default 10, 429 "Too many concurrent runs"; exposed as `Agent.spec.maxConcurrentRuns`, 0 = disabled) — keep it coherent with the edge `spec.limits.maxInFlight` Traefik gate; 4xx responses are never settled, so both gates are financially safe.
23. **Buyer disconnect ≠ no charge (zombie settlement)** — a client abort routinely does NOT propagate past cloudflared; the upstream agent can finish 2xx and older seller builds still called `/settle` → buyer debited while the UI showed failure (Bankr ~30s retries ×3 on-chain, 2026-08-05). This deferred-to-`finalize()` protection applies ONLY to `text/event-stream` (SSE) responses — `settlementInterceptor.WriteHeader` still settles eagerly, before committing the status, for every non-streaming response (plain `sell http`, non-streaming `sell agent`), exactly as before this fix. For SSE, `finalize()` skips `/settle` on canceled context, **client Write errors** (broken pipe), or zero body bytes — see `docs/observability.md` ("External buyers (Bankr)"). Separately, Bankr auto-pay often fails verify with `facilitator_error` because it signs EIP-3009 `validAfter=now` (use past buffer + ≥180s timeout). Still keep paid agent runs SHORT (`Agent.spec.maxTurns`) inside the tunnel window. Concurrency: Hermes `max_concurrent_runs` / `Agent.spec.maxConcurrentRuns` must stay coherent with edge `spec.limits.maxInFlight`; 4xx is never settled.

For a fuller debug catalog with symptom->fix mapping, see `.agents/skills/obol-stack-dev/references/release-smoke-debugging.md`.

Expand All @@ -478,6 +479,7 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate
- `/.well-known/agent-registration.json` — ERC-8004 discovery
- `/skill.md` — machine-readable service catalog
- `/api/services.json` — service catalog envelope (`displayName`, `tagline`, `logoUrl`, theme/branding fields, `services[]`)
- `/.well-known/x402` — aggregate x402 discovery fallback (AgentCash/x402scan-style discovery convention; no secret material)
- `/` on tunnel hostname — public storefront landing page (Next.js)

## Dependencies
Expand Down
17 changes: 16 additions & 1 deletion cmd/obol/sell_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,22 @@ func applySellerProfile(cfg *config.Config, profile schemas.StorefrontProfile) e
if err != nil {
return err
}
if err := kubectlApply(cfg, manifest); err != nil {
raw, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshal storefront profile: %w", err)
}
bin, kubeconfig := kubectl.Paths(cfg)
// Inline data:image values can make kubectl's client-side
// last-applied-configuration annotation exceed its 256 KiB limit even
// though the ConfigMap itself remains below Kubernetes' 1 MiB limit.
// The host-side profile record is authoritative, so use server-side apply
// for both initial writes and updates.
if err := kubectl.ApplyServerSideForceConflicts(
bin,
kubeconfig,
raw,
"obol-storefront-profile",
); err != nil {
return fmt.Errorf("apply storefront profile: %w", err)
}
return nil
Expand Down
37 changes: 37 additions & 0 deletions cmd/obol/sell_info_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/schemas"
)

func TestApplySellerProfileUsesServerSideApplyForInlineImages(t *testing.T) {
cfg := &config.Config{ConfigDir: t.TempDir(), BinDir: t.TempDir()}
argsPath := filepath.Join(t.TempDir(), "kubectl-args")
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\ncat >/dev/null\n", argsPath)
if err := os.WriteFile(filepath.Join(cfg.BinDir, "kubectl"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}

profile := schemas.StorefrontProfile{
LogoURL: "data:image/png;base64," + strings.Repeat("a", 270_000),
}
if err := applySellerProfile(cfg, profile); err != nil {
t.Fatalf("applySellerProfile: %v", err)
}

args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatal(err)
}
got := string(args)
for _, want := range []string{
"apply\n",
"--server-side\n",
"--force-conflicts\n",
"--field-manager=obol-storefront-profile\n",
"-f\n-\n",
} {
if !strings.Contains(got, want) {
t.Fatalf("kubectl args missing %q:\n%s", want, got)
}
}
}

func TestClearProfileFields(t *testing.T) {
base := schemas.StorefrontProfile{
DisplayName: "Acme",
Expand Down
58 changes: 58 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,64 @@ A `Transfer` to the expected recipient that exists while the buyer reports

---

## External buyers (Bankr): two failure modes, one canonical ledger

Bankr chat / `bankr x402 call` / Apps against Obol agent offers confused
operators because the UI error and the on-chain outcome often disagreed.
Live Base mainnet testing (2026-08-05) against a public Obol tunnel clarified
what is seller-fixable vs buyer-client limits.

**Context — Bankr surfaces are not one path.** Chat auto-pay, CLI
`bankr x402 call`, and Apps (`bankr.x402.fetch`) are different clients.
Bankr docs also describe chat/CLI auto-pay as oriented around endpoints
deployed via `bankr x402 deploy` / their discovery index
([docs.bankr.bot/x402-cloud/quick-start](https://docs.bankr.bot/x402-cloud/quick-start/)),
and Apps require a manifest `allowedHosts` allowlist
([docs.bankr.bot/apps/sdk](https://docs.bankr.bot/apps/sdk)).

**Allowlisting is not enough for agent offers.** We built a Bankr App with
`pay:x402`, `allowedHosts: ["<our-tunnel-hostname>"]`, and
`bankr.x402.fetch` against bounty-radar. Payment **often verified** on Base
mainnet; the App still failed with **`rpc timeout` ~30s** while the agent was
still running. So the Apps failure we hit was not “missing allowlist” or
“wrong network” — it was Bankr’s short client timeout on slow agent SSE.
HTTP offers through the same App/chat path usually succeed because they
finish in ~1s.

When Bankr *does* attempt a paid call, there are **two separate wire-level
failure modes**:

| Mode | What the buyer sees | What happened | Charge? |
|---|---|---|---|
| **A — Voucher** | JSON `503` with `reason:facilitator_error`, `detail:unexpected_error` | Buyer signed EIP-3009 with `validAfter=wall-clock now` (or a bad typed-data hash). Base USDC rejects (`not yet valid` / `invalid signature`). | Usually **no** (verify never succeeded). |
| **B — Timeout / zombie** | `rpc timeout`, 504, or generic failure after ~30s | Verify **succeeded**, agent still running (often 30–120s to first SSE byte). Bankr client aborted. Cloudflare often does **not** cancel seller context, so older seller builds still called `/settle` after upstream finished → BaseScan shows 0.001 USDC Transfers. | **Yes** on older builds. |

**Seller hardenings** (in-process HandleProxy settlement for agent/http
gateways that settle after upstream — not the Traefik ForwardAuth
verify-only path):

- Settle SSE only in `finalize()` after the stream completes — never on the
first `WriteHeader(200)`.
- Skip `/settle` when `r.Context()` is canceled, when a body `Write` to the
client fails (broken pipe — the reliable signal when cancel does not
propagate), or when zero body bytes were written.
- Upstream proxy errors after verify return structured JSON with
`paymentVerified:true`, `paymentSettled:false`, `retriable:false` so buyers
do not auto-retry storms.

**Buyer guidance** (storefront Bankr prompts are type-specific):
- **http** — prefer Bankr chat auto-pay (fast enough for the ~30s window).
Do not ask chat to run `bankr wallet sign` (it cannot).
- **agent / inference** — do not use Bankr chat/Apps auto-pay (`rpc timeout`
even with a correct Apps `allowedHosts`); use `bankr wallet sign` with a
past `validAfter` buffer and HTTP timeout ≥180s. After any timeout, check
BaseScan before retrying.

**Still true:** chain Transfers are canonical. Seller `paymentSettled:false`
and Bankr UI copy are best-effort signals.

---

## Recording rule conventions

Naming follows the standard Prometheus pattern:
Expand Down
141 changes: 141 additions & 0 deletions flows/clients/x402-generic-buyer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//go:build ignore

// Generic x402 HTTP buyer client for flow-22-external-buyer-compat.
//
// Built entirely from the public x402-foundation/x402/go/v2 SDK's
// documented client pattern (see CLIENT.md "Basic HTTP Client" in the SDK
// module): wrap a plain *http.Client with x402 payment handling and call it
// like any other HTTP client. No Obol CLI, no buy.py, no PurchaseRequest CR
// — this is what a third-party buyer tool's own wallet/agent does under the
// hood (AgentCash, Bankr, or any other x402-compliant client), so a
// successful "paid" run here is evidence the seller works with any
// standards-compliant buyer, not just Obol's own tooling.
//
// Run from the repo root (module context):
//
// go run flows/clients/x402-generic-buyer.go \
// -mode <unpaid|paid> -url <resource-url> \
// [-method GET|POST] [-body '<json>']
//
// Modes:
//
// unpaid call the endpoint with no signer registered — expect a 402
// paid call the endpoint with a signer from X402_CLIENT_KEY — the
// wrapped client detects the 402, signs, retries, and returns
// whatever the seller sends back (expect 200 on success)
//
// -method/-body cover both the plain HTTP demo (GET, empty body) and the
// OpenAI-compatible chat-completions shape (POST + JSON body) used by
// inference/agent offers.
//
// X402_CLIENT_KEY carries the buyer's 0x-prefixed private key via env so it
// never appears on argv. Output is ONE JSON object on stdout; the flow
// asserts on its fields.
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
exactclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client"
evmsigner "github.com/x402-foundation/x402/go/v2/signers/evm"
)

func main() {
url := flag.String("url", "", "paid resource URL")
mode := flag.String("mode", "paid", "unpaid|paid")
network := flag.String("network", "eip155:84532", "CAIP-2 network to register the signer for")
method := flag.String("method", "GET", "HTTP method")
body := flag.String("body", "", "optional request body (e.g. chat-completions JSON)")
timeout := flag.Duration("timeout", 120*time.Second, "per-request timeout")
flag.Parse()

out := map[string]any{"mode": *mode, "method": strings.ToUpper(*method)}
if *url == "" {
out["error"] = "-url is required"
emit(out)
os.Exit(1)
}
if err := run(*url, *mode, *network, strings.ToUpper(*method), *body, *timeout, out); err != nil {
out["error"] = err.Error()
emit(out)
os.Exit(1)
}
emit(out)
}

func emit(m map[string]any) {
b, _ := json.Marshal(m)
fmt.Println(string(b))
}

func run(url, mode, network, method, body string, timeout time.Duration, out map[string]any) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

// mode=unpaid deliberately skips the x402 wrapper entirely — a real
// unpaid buyer is just a plain HTTP client with no x402 awareness, and
// the wrapper's RoundTrip returns an error (not a 402 response) when no
// scheme is registered for the seller's network, since it can't build a
// payment payload. Bypassing it here is what lets this mode observe the
// seller's raw 402 challenge, exactly like a naive caller would.
httpClient := http.DefaultClient
if mode == "paid" {
key := os.Getenv("X402_CLIENT_KEY")
if key == "" {
return fmt.Errorf("X402_CLIENT_KEY is required for -mode paid")
}
signer, err := evmsigner.NewClientSignerFromPrivateKey(key)
if err != nil {
return fmt.Errorf("signer: %w", err)
}
out["buyerAddress"] = signer.Address()

// Following CLIENT.md's quick-start verbatim: create the core
// client, register the scheme, wrap a plain http.Client.
client := x402.Newx402Client().
Register(x402.Network(network), exactclient.NewExactEvmScheme(signer, nil))
httpClient = x402http.WrapHTTPClientWithPayment(http.DefaultClient, x402http.Newx402HTTPClient(client))
}

var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("do: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}

out["status"] = resp.StatusCode
out["body"] = string(respBody)
if v := resp.Header.Get("X-PAYMENT-RESPONSE"); v != "" {
out["paymentResponseHeader"] = v
}
if v := resp.Header.Get("PAYMENT-RESPONSE"); v != "" {
out["paymentResponseHeaderV2"] = v
}
return nil
}
Loading
Loading