diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml
index 01ed8d71..b6ce3b0f 100644
--- a/.github/workflows/lint-test.yaml
+++ b/.github/workflows/lint-test.yaml
@@ -47,6 +47,27 @@ jobs:
- name: Run chart-testing (install)
run: ct install --target-branch ${{ github.event.repository.default_branch }}
+ go-test:
+ name: Go build, vet, and unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Go
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ with:
+ go-version-file: 'go.mod'
+
+ - name: Build
+ run: go build ./...
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Unit tests
+ run: go test ./...
+
generate-check:
name: CRD generation up-to-date
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index ebc4ec5d..775dc2d0 100644
--- a/README.md
+++ b/README.md
@@ -3,13 +3,19 @@
-
The Obol Stack: Where agents deploy their infrastructure
+The Obol Stack: Run an AI agent business from your own machine
## Overview
-The [Obol Stack](https://obol.org/stack) is a framework for AI agents to run decentralised infrastructure locally. It provides an agent with the ability to sync blockchain networks (Ethereum, Aztec, etc.), interact with them via skills, and expose services to the public internet through Cloudflare [tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) and [x402](https://www.x402.org/) payment gateways.
+The [Obol Stack](https://obol.org/stack) turns a laptop or home server into a self-sovereign AI agent business:
+
+- **Run an agent locally.** A [Hermes](https://github.com/NousResearch/hermes-agent) agent with its own crypto wallet, backed by your local models (Ollama) or any cloud provider, orchestrated in a local Kubernetes cluster.
+- **Sell to buyers worldwide.** Put inference, agents, or any HTTP service up for sale behind [x402](https://www.x402.org/) micropayments (USDC or OBOL). Buyers pay per request; you get paid onchain, directly to your wallet — no platform in between.
+- **Get discovered.** A Cloudflare [tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) gives you a public URL serving a storefront, a machine-readable service catalog, and optional onchain [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) agent registration.
+- **Buy from other sellers.** Purchase paid inference from any x402 seller and route it to your agents.
+- **Own your chain access.** Sync blockchain networks (Ethereum, Aztec) locally instead of trusting third-party RPCs.
Built on [Kubernetes](https://kubernetes.io) with [Helm](https://helm.sh/) for package management. Read more in the [docs](https://docs.obol.org/next/obol-stack/obol-stack).
@@ -28,13 +34,15 @@ Built on [Kubernetes](https://kubernetes.io) with [Helm](https://helm.sh/) for p
- **Linux**: [Docker Engine installation guide](https://docs.docker.com/engine/install/)
- **macOS/Windows**: [Docker Desktop](https://docs.docker.com/desktop/)
+For local models, install [Ollama](https://ollama.com) and pull at least one chat-capable model (e.g. `ollama pull qwen3:8b`). Skip this if you'll use a cloud provider (see [Models](#models)).
+
### Install
```bash
-bash <(curl -s https://stack.obol.org)
+bash <(curl -fsSL https://stack.obol.org)
```
-The installer will set up the `obol` CLI and all dependencies (`kubectl`, `helm`, `k3d`, `helmfile`, `k9s`) into `~/.local/bin/`, configure your PATH, and offer to start the cluster.
+The installer sets up the `obol` CLI and all dependencies (`kubectl`, `helm`, `k3d`, `helmfile`, `k9s`) into `~/.local/bin/`, verifies release checksums, configures your PATH, and offers to start the cluster.
Verify:
@@ -52,112 +60,132 @@ obol stack up
# Apply agent capabilities to the default stack-managed agent
obol agent init
-# Inspect the default Hermes agent
+# Inspect the default Hermes agent and grab its dashboard token
obol agent list
obol agent auth obol-agent
```
-`obol stack up` provisions the default [Hermes Agent](https://github.com/NousResearch/hermes-agent) runtime behind LiteLLM. `obol agent init` applies the controller-based agent capabilities used for monetization and reconciliation.
+`obol stack up` provisions the cluster, auto-detects your local Ollama models into the LiteLLM gateway, deploys the default Hermes agent (with its own wallet behind a remote signer), and starts a Cloudflare quick-tunnel. From here you can chat with your agent locally at `http://obol.stack:8080` — or go straight to selling.
-## Blockchain Networks
+## Sell: Your First Paid Service
-Install and run blockchain networks as isolated deployments. Each installation gets a unique namespace so you can run multiple instances side-by-side.
+The core loop: put a service on sale → get a public URL → get registered → buyers pay per request, settled onchain to your wallet.
-```bash
-# List available networks
-obol network list
+### 1. Put a model up for sale
-# Install a network (defaults to network name as ID)
-obol network install ethereum
-# → ethereum/mainnet
+```bash
+# Sell local Ollama inference, priced per request (USDC on Base by default)
+obol sell inference my-qwen --model qwen3:8b --price 0.001 --pay-to 0x...
-# Deploy to the cluster (auto-selects if only one deployment exists)
-obol network sync
+# Or price per million tokens, or accept OBOL instead of USDC
+obol sell inference my-qwen --model qwen3:8b --per-mtok 0.50 --token OBOL --pay-to 0x...
+```
-# Or specify by type (auto-selects if only one ethereum deployment)
-obol network sync ethereum
+If you omit `--pay-to`, the agent's own wallet address is auto-detected from the remote signer — your agent earns for itself. Set defaults once with `obol sell pricing --pay-to 0x... --chain base`.
-# Or by full identifier
-obol network sync ethereum/mainnet
+Check it's live:
-# Sync all deployments at once
-obol network sync --all
+```bash
+obol sell list
+obol sell status my-qwen
+obol sell info # buyer's-eye view of everything on sale
```
-**Available networks:** ethereum, aztec
+### 2. Go public
-**Ethereum options:** `--network` (mainnet, sepolia, hoodi), `--execution-client` (reth, geth, nethermind, besu, erigon, ethereumjs), `--consensus-client` (lighthouse, prysm, teku, nimbus, lodestar, grandine)
+A tunnel gives buyers a permanent URL to reach you. Create a tunnel in the Cloudflare dashboard (Networks → Tunnels), route its Public Hostname to `http://traefik.traefik.svc.cluster.local:80`, then paste the connector token (you can paste the whole `cloudflared tunnel run --token …` line):
```bash
-# View installed deployments
-obol kubectl get namespaces | grep -E "ethereum|aztec"
-
-# Delete a deployment (auto-selects if only one exists)
-obol network delete
-obol network delete ethereum/mainnet --force
+obol tunnel setup --hostname stack.example.com
+obol tunnel status
```
-> [!TIP]
-> Use `obol network install --help` to see all options.
+This uses a least-privilege, single-tunnel connector token — no account-wide API key required. Need a domain? `obol domain search`, `obol domain check`, and `obol domain register` wrap Cloudflare Registrar. (Advanced: `obol tunnel setup --management local` uses a browser login instead, which needs `cloudflared` installed.)
-## Applications
+### 3. Get discovered
+
+Your public hostname now serves a full discovery surface for buyers, humans and agents alike:
-Install arbitrary Helm charts as managed applications:
+| Path | What buyers get |
+|------|-----------------|
+| `/` | Storefront landing page with your branding |
+| `/skill.md` | Machine-readable service catalog with worked x402 payment examples |
+| `/api/services.json` | JSON catalog: pricing, models, payment requirements |
+| `/openapi.json` | OpenAPI spec for your paid endpoints (indexed by x402 scanners) |
+| `/.well-known/agent-registration.json` | ERC-8004 agent registration document |
+| `/services//*` | The paid services themselves (402 challenge → pay → response) |
+
+Brand your storefront and optionally register your agent identity onchain:
```bash
-# Install from ArtifactHub
-obol app install bitnami/redis
+obol sell info set --display-name "Acme Labs" --tagline "Paid inference, no middlemen." --logo-url "https://..."
+obol sell register --chain base # publish ERC-8004 registration
+obol sell identity # inspect your onchain identity
+```
-# With specific version
-obol app install bitnami/postgresql@15.0.0
+For the full end-to-end walkthrough, see [docs/guides/monetize-inference.md](docs/guides/monetize-inference.md).
-# Deploy to cluster (auto-selects if only one app is installed)
-obol app sync
+## Sell: Other Service Types
-# Or specify by type (auto-selects if only one postgresql deployment)
-obol app sync postgresql
+Anything that speaks HTTP can be payment-gated:
-# Or by full identifier
-obol app sync postgresql/eager-fox
+```bash
+# Gate any in-cluster HTTP service (here: Ollama's raw API in the llm namespace)
+obol sell http ollama-gated \
+ --upstream ollama --port 11434 --namespace llm --health-path /api/tags \
+ --per-request 0.001 --chain base --pay-to 0x...
-# List and manage
-obol app list
-obol app delete postgresql/eager-fox --force
+# Sell access to an agent itself (wraps an Agent created with `obol agent new`)
+obol sell agent my-researcher --per-request 0.01 --pay-to 0x...
+
+# Run an x402-paid MCP server that proxies a backend API with your own key injected
+obol sell mcp my-tool
```
-Find charts at [Artifact Hub](https://artifacthub.io).
+> [!NOTE]
+> `--namespace` sets both the offer's namespace and the upstream service's namespace. Pass the same `-n ` to follow-up commands (`sell status`, `sell stop`, `sell delete`) — the CLI prints the right invocation after creation.
-## Model Providers
+## Run Your Business
+
+```bash
+obol sell list # everything on sale
+obol sell status # operator health and conditions
+obol sell update --price 0.002 # change price or payout wallet in place
+obol sell stop # take an offer off sale (keeps it)
+obol sell delete # remove an offer
+obol sell resume # replay all offers after a host reboot
+```
-The stack runs [LiteLLM](https://github.com/BerriAI/litellm) as an in-cluster OpenAI-compatible gateway that proxies all LLM traffic. By default, Ollama on the host machine is used.
+`obol stack up` re-publishes your offers automatically after a restart; `obol sell resume --install-boot-unit` adds a systemd user unit on Linux so offers come back on boot.
-**Minimum local model size**: agents in this stack rely heavily on tool calling (skills are exposed as OpenAI-style tools, the agent's identity bootstrap reads files, etc.). Models below ~7B parameters tend to either ignore the structured tool-calling channel and return raw JSON in the assistant message, or hallucinate tool failures without actually invoking the tool. Recommended local minimums for reliable agent behaviour:
+**Back up your business.** Wallets, agent memory, and offers live only on this machine:
-- `llama3.1:8b` — reference Ollama tool-calling implementation
-- `qwen3:8b` — strong tool support, modern
-- `qwen2.5:7b` (instruct) — works, slightly less disciplined under load
+```bash
+obol stack export # full backup archive (wallets, brains, offers, config)
+obol stack import # restore onto a fresh stack
+obol agent wallet backup # wallet-only encrypted backup
+```
-Avoid the 1B–4B and `*-coder` variants for the agent role — they pass simple chats but break under multi-tool workflows. They remain fine for embeddings or single-turn completions exposed via `obol sell inference`.
+## Buy Services
-To switch to a cloud provider:
+Buy paid inference from any x402 seller and route it to your agents:
```bash
-# Interactive — prompts for provider and API key
-obol model setup
+# Walk a seller's catalog, preview cost, pre-sign payments, wire up the model
+obol buy inference https://inference.example.com/
-# Or pass flags directly
-obol model setup --provider anthropic --api-key sk-ant-...
-obol model setup --provider openai --api-key sk-proj-...
+# Pay from a specific agent's wallet and switch that agent onto the paid model
+obol buy inference https://inference.example.com/ --agent research
-# Check which providers are enabled
-obol model status
+# Promote the purchased model to the stack-wide default
+obol buy inference https://inference.example.com/ --set-default
```
-`model setup` patches the LiteLLM config and Secret with your API key, adds the model to the gateway, restarts LiteLLM, and syncs the stack-managed Hermes default agent.
+The CLI probes the seller's 402 pricing, prompts for how many requests to pre-authorize (with a cost preview), signs payment authorizations via your agent's remote signer, and publishes the model as `paid/` through the LiteLLM gateway. Agents can also buy autonomously — the embedded `buy-x402` skill gives them `probe`, `buy`, `pay`, `balance`, and auto-refill tooling.
-## AI Agent Runtimes
+## Agents
-Hermes is the default AI agent runtime deployed by the stack as `obol-agent`. OpenClaw remains available as an optional manual runtime. Multiple Hermes and OpenClaw instances can run side-by-side.
+Hermes is the default runtime, deployed by the stack as `obol-agent`. [OpenClaw](https://github.com/ObolNetwork/openclaw) remains available as an optional runtime. Multiple instances run side-by-side, each in its own namespace with its own wallet.
```bash
# Default stack-managed Hermes agent
@@ -165,29 +193,31 @@ obol agent list
obol agent auth obol-agent
obol hermes skills list
-# Create and deploy an additional Hermes instance
-obol agent new --id research
+# Declare a new sub-agent with a model, skills, an objective, and its own wallet
+obol agent new research --model qwen3:8b --skills ethereum-networks,buy-x402 \
+ --objective "Research onchain data and sell reports" --create-wallet
-# Create and deploy an optional OpenClaw instance
-obol agent new --runtime openclaw
-
-# List optional OpenClaw instances
-obol agent list --runtime openclaw
+# Wallet management
+obol agent wallet address
+obol agent wallet backup
-# Open the OpenClaw web dashboard
+# Optional OpenClaw instance
+obol agent new --runtime openclaw
obol openclaw dashboard
```
-Use `obol agent` for Obol-managed lifecycle and auth flows. Use `obol hermes` for native Hermes CLI commands against the default instance, or pass `--agent ` for a non-default Hermes instance.
+Use `obol agent` for Obol-managed lifecycle and auth flows. Use `obol hermes` for native Hermes CLI commands against the default instance, or pass `--agent ` for a non-default instance. An agent created with `agent new` can itself be put on sale with `obol sell agent `.
### Skills
-The stack ships with embedded Obol skills that are installed automatically for the default Hermes agent and for OpenClaw instances. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns.
+The stack ships with embedded Obol skills installed automatically for the default Hermes agent and OpenClaw instances. Skills give agents domain-specific capabilities — from querying blockchains to buying and selling services.
-#### Infrastructure
+#### Infrastructure & Commerce
| Skill | Purpose |
|-------|---------|
+| `buy-x402` | Buy paid services: probe pricing, pre-sign payments, auto-refill, check balances |
+| `monetize` | Manage the agent's own sell offers (ServiceOffer CRUD) |
| `ethereum-networks` | Read-only Ethereum queries via cast — blocks, balances, contract reads, ERC-20, ENS |
| `ethereum-local-wallet` | Sign and send Ethereum transactions via the per-agent remote-signer |
| `obol-stack` | Kubernetes cluster diagnostics — pods, logs, events, deployments |
@@ -223,42 +253,108 @@ The stack ships with embedded Obol skills that are installed automatically for t
Manage skills at runtime:
```bash
-obol openclaw skills list # list installed skills
-obol openclaw skills sync # re-inject embedded defaults
+obol openclaw skills list # list installed skills
+obol openclaw skills sync # re-inject embedded defaults
obol openclaw skills sync --from ./my-skills # push custom skills from local dir
-obol openclaw skills add # add via openclaw CLI in pod
-obol openclaw skills remove # remove via openclaw CLI in pod
+obol openclaw skills add # add via openclaw CLI in pod
+obol openclaw skills remove # remove via openclaw CLI in pod
```
Skills are delivered via host-path PVC injection — no ConfigMap size limits, works before pod readiness, and survives pod restarts.
-## Public Access (Cloudflare Tunnel)
+## Models
+
+The stack runs [LiteLLM](https://github.com/BerriAI/litellm) as an in-cluster OpenAI-compatible gateway that proxies all LLM traffic. By default, host Ollama models are auto-detected on `obol stack up`.
-A tunnel exposes your stack to the public internet so buyers can discover and
-pay for the services you sell. You don't need it for local use — set one up once
-you're ready to sell, to get a permanent URL.
+To use a cloud provider instead (or as well):
```bash
-# Check tunnel status (a temporary quick-tunnel URL is the default)
-obol tunnel status
+# Interactive — walks you through provider pick, key creation, and free-tier models
+obol model setup
-# Create a permanent URL. Create a tunnel in the Cloudflare dashboard
-# (Networks → Tunnels), route its Public Hostname to
-# http://traefik.traefik.svc.cluster.local:80, then paste the connector token —
-# you can paste the whole `cloudflared tunnel run --token …` line:
-obol tunnel setup --hostname stack.example.com
+# Or scriptable
+obol model setup --provider openrouter --api-key sk-or-...
+obol model setup --provider anthropic --api-key sk-ant-...
+
+# Any OpenAI-compatible endpoint (vLLM, sglang, a remote GPU box)
+obol model setup custom --endpoint http://192.168.1.20:8000/v1 --model my-model
+
+# Manage the roster
+obol model list # what's routed, in priority order
+obol model prefer # promote a model to the default slot
+obol model status # provider state
+```
+
+**Minimum local model size**: agents rely heavily on tool calling. Models below ~7B parameters tend to ignore the structured tool-calling channel or hallucinate tool failures. Recommended local minimums for reliable agent behaviour: `llama3.1:8b`, `qwen3:8b`, or `qwen2.5:7b` (instruct). The 1B–4B and `*-coder` variants remain fine for embeddings or single-turn completions sold via `obol sell inference`.
+
+## Blockchain Networks
+
+Install and run blockchain networks as isolated deployments. Each installation gets a unique namespace so you can run multiple instances side-by-side. Local nodes are automatically registered as priority upstreams for the stack's RPC gateway.
+
+```bash
+# List available networks
+obol network list
+
+# Install a network (defaults to network name as ID)
+obol network install ethereum
+# → ethereum/mainnet
+
+# Deploy to the cluster (auto-selects if only one deployment exists)
+obol network sync
+
+# Or by full identifier, or all at once
+obol network sync ethereum/mainnet
+obol network sync --all
+
+# Add a remote RPC instead of running a node
+obol network add
```
-This uses a least-privilege, single-tunnel connector token — no account-wide API
-key required. (Advanced: `obol tunnel setup --management local` uses a browser
-login on this machine instead, which needs `cloudflared` installed.)
+**Available networks:** ethereum, aztec
+
+**Ethereum options:** `--network` (mainnet, sepolia, hoodi), `--execution-client` (reth, geth, nethermind, besu, erigon, ethereumjs), `--consensus-client` (lighthouse, prysm, teku, nimbus, lodestar, grandine), `--mode` (full, archive), `--since` (partial archive: `merge`, `365d`, a block number)
+
+```bash
+# View installed deployments
+obol kubectl get namespaces | grep -E "ethereum|aztec"
+
+# Delete a deployment
+obol network delete ethereum/mainnet --force
+```
+
+> [!TIP]
+> Use `obol network install --help` to see all options.
+
+## Applications
+
+Install arbitrary Helm charts as managed applications — useful for running upstreams you then put on sale with `obol sell http`:
+
+```bash
+# Install from ArtifactHub
+obol app install bitnami/redis
+
+# With specific version
+obol app install bitnami/postgresql@15.0.0
+
+# Deploy to cluster (auto-selects if only one app is installed)
+obol app sync
+obol app sync postgresql/eager-fox
+
+# List and manage
+obol app list
+obol app delete postgresql/eager-fox --force
+```
+
+Find charts at [Artifact Hub](https://artifacthub.io).
## Managing the Stack
```bash
-obol stack up # Start the cluster
+obol stack up # Start the cluster (replays models, RPCs, agents, offers)
obol stack down # Stop the cluster (preserves data)
-obol stack purge -f # Remove everything (including data)
+obol stack purge -f # Remove everything (offers a full export first)
+obol update # Check for CLI and chart updates
+obol upgrade # Apply chart upgrades
obol k9s # Interactive cluster UI
```
@@ -281,20 +377,9 @@ obol stack down && obol stack up
Access at http://obol.stack:8080 instead.
-#### Direct `X-PAYMENT` Buyers
-
-Raw direct `X-PAYMENT` requests through the Traefik `ForwardAuth` route are not a supported production payment path. The verifier is intentionally `verifyOnly: true`, so Traefik can gate requests but is not the final settlement point.
+#### Monetize Flow Preflight
-Use:
-
-- `x402-buyer` for cluster-routed paid traffic
-- `obol sell inference` for direct buyers that need to send raw `X-PAYMENT`
-
-If you call `x402-verifier /verify` directly for debugging, you must send `X-Forwarded-Uri` (and usually `X-Forwarded-Host`) like Traefik does, or verifier correctly returns `403 forbidden: missing forwarded URI`.
-
-#### Monetize Flow Preflight (Recommended)
-
-Before running sell/buy tests on a machine, verify:
+If sell/buy flows misbehave, verify in order:
```bash
# 1) Kubeconfig matches the currently running k3d cluster (ports can drift).
@@ -312,14 +397,16 @@ obol kubectl exec -n hermes-obol-agent deploy/hermes -c hermes -- \
python3 /data/.hermes/obol-skills/buy-x402/scripts/buy.py balance
```
-Run the paid tests only after all four checks pass.
+#### Direct `X-PAYMENT` Buyers
+
+Raw direct `X-PAYMENT` requests through the Traefik `ForwardAuth` route are not a supported production payment path. The verifier is intentionally `verifyOnly: true`, so Traefik can gate requests but is not the final settlement point. Use `x402-buyer` for cluster-routed paid traffic, or `obol sell inference` for direct buyers that need to send raw `X-PAYMENT`.
+
+If you call `x402-verifier /verify` directly for debugging, you must send `X-Forwarded-Uri` (and usually `X-Forwarded-Host`) like Traefik does, or the verifier correctly returns `403 forbidden: missing forwarded URI`.
#### Known Limitations
-- `PurchaseRequest.status` (`remaining`/`spent` and `conditions[].message`) is a reconciled snapshot, not a live per-request counter.
-- For real-time auth pool state, use `x402-buyer` `GET /status` from the litellm pod.
+- `PurchaseRequest.status` (`remaining`/`spent` and `conditions[].message`) is a reconciled snapshot, not a live per-request counter. For real-time auth pool state, use `x402-buyer` `GET /status` from the litellm pod.
- Agent-managed refill is driven by `buy.py process --all`; use live sidecar status as the source of truth for refill decisions.
-- Raw direct `X-PAYMENT` through Traefik is not a supported production path; use `obol sell inference` if you need a direct buyer flow.
## File Locations
@@ -328,13 +415,13 @@ Follows the [XDG Base Directory](https://specifications.freedesktop.org/basedir-
| Directory | Purpose |
|-----------|---------|
| `~/.config/obol/` | Cluster config, kubeconfig, network and app deployments |
-| `~/.local/share/obol/` | Persistent volumes (blockchain data) |
+| `~/.local/share/obol/` | Persistent volumes (blockchain data, agent state) |
| `~/.local/bin/` | CLI binary and dependencies |
## Updating
```bash
-bash <(curl -s https://stack.obol.org)
+bash <(curl -fsSL https://stack.obol.org)
```
The installer detects your existing installation and upgrades safely.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 171906b9..8823f3eb 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -20,7 +20,7 @@ This guide walks you through installing the Obol Stack, starting a local Kuberne
Run the bootstrap installer:
```bash
-bash <(curl -s https://stack.obol.org)
+bash <(curl -fsSL https://stack.obol.org)
```
This installs the `obol` CLI and all required tools (kubectl, helm, k3d, helmfile, k9s) to `~/.local/bin/`.
diff --git a/internal/embed/infrastructure/base/templates/llm.yaml b/internal/embed/infrastructure/base/templates/llm.yaml
index 917d2bfd..6503adba 100644
--- a/internal/embed/infrastructure/base/templates/llm.yaml
+++ b/internal/embed/infrastructure/base/templates/llm.yaml
@@ -403,18 +403,6 @@ spec:
persistentVolumeClaim:
claimName: x402-buyer-state
----
-apiVersion: policy/v1
-kind: PodDisruptionBudget
-metadata:
- name: litellm
- namespace: llm
-spec:
- minAvailable: 1
- selector:
- matchLabels:
- app: litellm
-
---
apiVersion: v1
kind: Service
diff --git a/internal/embed/infrastructure/base/templates/local-path.yaml b/internal/embed/infrastructure/base/templates/local-path.yaml
index b7ed8408..1f857ff0 100644
--- a/internal/embed/infrastructure/base/templates/local-path.yaml
+++ b/internal/embed/infrastructure/base/templates/local-path.yaml
@@ -121,6 +121,13 @@ spec:
- rancher.io/local-path
- --helper-image
- rancher/mirrored-library-busybox:1.36.1
+ resources:
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
volumeMounts:
- name: config-volume
mountPath: /etc/config/
diff --git a/internal/embed/infrastructure/cloudflared/values.yaml b/internal/embed/infrastructure/cloudflared/values.yaml
index 22b5db60..2777a434 100644
--- a/internal/embed/infrastructure/cloudflared/values.yaml
+++ b/internal/embed/infrastructure/cloudflared/values.yaml
@@ -14,7 +14,7 @@ quickTunnel:
url: "http://traefik.traefik.svc.cluster.local:80"
persistent:
- replicaCount: 2
+ replicaCount: 1
management:
configMapName: "cloudflared-management"
diff --git a/internal/embed/infrastructure/helmfile.yaml b/internal/embed/infrastructure/helmfile.yaml
index f4d9c828..f9440a6b 100644
--- a/internal/embed/infrastructure/helmfile.yaml
+++ b/internal/embed/infrastructure/helmfile.yaml
@@ -150,6 +150,16 @@ releases:
createNamespace: true
chart: stakater/reloader
version: 2.2.7
+ values:
+ - reloader:
+ deployment:
+ resources:
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ limits:
+ cpu: 100m
+ memory: 256Mi
# eRPC
- name: erpc
diff --git a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl
index e46a51f5..35006b99 100644
--- a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl
+++ b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl
@@ -53,6 +53,14 @@ service:
type: ClusterIP
port: 3000
+resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+
podSecurityContext:
fsGroup: 1001
diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json
index a7b0f046..2d1d1320 100644
--- a/internal/schemas/service-catalog.schema.json
+++ b/internal/schemas/service-catalog.schema.json
@@ -6,12 +6,17 @@
"type": "object",
"additionalProperties": false,
"required": [
+ "schemaVersion",
"displayName",
"tagline",
"logoUrl",
"services"
],
"properties": {
+ "schemaVersion": {
+ "const": "1",
+ "description": "Envelope wire version. Bumped only on breaking changes; additive fields do not bump it."
+ },
"displayName": {
"type": "string",
"minLength": 1
diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go
index 1fb21a43..b48a337d 100644
--- a/internal/schemas/service_catalog.go
+++ b/internal/schemas/service_catalog.go
@@ -137,10 +137,20 @@ type StorefrontProfile struct {
ContactEmail string `json:"contactEmail,omitempty"`
}
+// ServiceCatalogSchemaVersion is the current version of the
+// /api/services.json envelope. Bump only on a breaking change to the wire
+// shape; additive fields do not bump the version. Integrators/indexers use
+// it to detect incompatible catalogs instead of guessing from field shapes.
+const ServiceCatalogSchemaVersion = "1"
+
// ServiceCatalog is the public /api/services.json envelope.
type ServiceCatalog struct {
- DisplayName string `json:"displayName"`
- Tagline string `json:"tagline"`
- LogoURL string `json:"logoUrl"`
- Services []ServiceCatalogEntry `json:"services"`
+ // SchemaVersion identifies the envelope wire version (currently "1").
+ // Always populated by the controller; consumers should treat an absent
+ // field as a legacy pre-versioning catalog.
+ SchemaVersion string `json:"schemaVersion"`
+ DisplayName string `json:"displayName"`
+ Tagline string `json:"tagline"`
+ LogoURL string `json:"logoUrl"`
+ Services []ServiceCatalogEntry `json:"services"`
}
diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go
index f94f7c6b..93cf9e5b 100644
--- a/internal/serviceoffercontroller/controller.go
+++ b/internal/serviceoffercontroller/controller.go
@@ -1231,6 +1231,11 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti
if err := c.applyObject(ctx, c.services.Namespace(skillCatalogNamespace), buildSkillCatalogService()); err != nil {
return err
}
+ // Headers Middleware must exist before the routes that reference it, or
+ // Traefik drops the routes for a dangling ExtensionRef.
+ if err := c.applyObject(ctx, c.middlewares.Namespace(skillCatalogNamespace), buildCatalogHeadersMiddleware()); err != nil {
+ return err
+ }
if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildSkillCatalogHTTPRoute()); err != nil {
return err
}
diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go
index bf0bde54..ee8be8a1 100644
--- a/internal/serviceoffercontroller/render.go
+++ b/internal/serviceoffercontroller/render.go
@@ -28,6 +28,12 @@ const (
servicesJSONRouteName = "obol-services-json-route"
openAPIRouteName = "obol-openapi-route"
apiDocsRouteName = "obol-api-docs-route"
+
+ // catalogHeadersMiddlewareName is the Traefik headers Middleware attached
+ // to the public catalog HTTPRoutes (/skill.md, /openapi.json, /api,
+ // /api/services.json). The busybox httpd serving those files cannot set
+ // custom response headers, so CORS + caching are applied at the gateway.
+ catalogHeadersMiddlewareName = "obol-catalog-headers"
)
// restrictedPodSecurityContext returns a Pod-level securityContext that
@@ -377,6 +383,59 @@ func buildSkillCatalogService() *unstructured.Unstructured {
}
}
+// buildCatalogHeadersMiddleware renders the Traefik headers Middleware for
+// the public discovery surfaces. These are read-only public documents served
+// without credentials, so a wildcard CORS origin is correct — browser-based
+// buyers, dashboards, and aggregators must be able to fetch them cross-origin.
+// Cache-Control keeps CDN/browser refetch pressure off the busybox httpd
+// while staying short enough (5 min) that catalog updates propagate quickly.
+//
+// Deliberately NOT attached to the /services/* paid routes (the 402/paid
+// path has its own header semantics) nor to the ERC-8004
+// /.well-known/agent-registration.json routes (those live in per-agent
+// namespaces, where an ExtensionRef cannot reference this x402-namespace
+// Middleware).
+func buildCatalogHeadersMiddleware() *unstructured.Unstructured {
+ return &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "traefik.io/v1alpha1",
+ "kind": "Middleware",
+ "metadata": map[string]any{
+ "name": catalogHeadersMiddlewareName,
+ "namespace": skillCatalogNamespace,
+ "labels": map[string]any{
+ "obol.org/managed-by": "serviceoffer-controller",
+ },
+ },
+ "spec": map[string]any{
+ "headers": map[string]any{
+ "accessControlAllowOriginList": []any{"*"},
+ "accessControlAllowMethods": []any{"GET", "OPTIONS"},
+ "customResponseHeaders": map[string]any{
+ "Cache-Control": "public, max-age=300",
+ },
+ },
+ },
+ },
+ }
+}
+
+// catalogHeadersFilters is the HTTPRoute rule filter list that attaches the
+// catalog headers Middleware — same ExtensionRef mechanism the x402
+// ForwardAuth Middleware uses on gated routes.
+func catalogHeadersFilters() []any {
+ return []any{
+ map[string]any{
+ "type": "ExtensionRef",
+ "extensionRef": map[string]any{
+ "group": "traefik.io",
+ "kind": "Middleware",
+ "name": catalogHeadersMiddlewareName,
+ },
+ },
+ }
+}
+
func buildSkillCatalogHTTPRoute() *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
@@ -407,6 +466,7 @@ func buildSkillCatalogHTTPRoute() *unstructured.Unstructured {
},
},
},
+ "filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
"name": skillCatalogConfigMapName,
@@ -459,6 +519,7 @@ func buildOpenAPIHTTPRoute() *unstructured.Unstructured {
},
},
},
+ "filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
"name": skillCatalogConfigMapName,
@@ -520,6 +581,7 @@ func buildAPIDocsHTTPRoute() *unstructured.Unstructured {
},
},
},
+ "filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
"name": skillCatalogConfigMapName,
@@ -564,6 +626,7 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured {
},
},
},
+ "filters": catalogHeadersFilters(),
"backendRefs": []any{
map[string]any{
"name": skillCatalogConfigMapName,
@@ -1013,6 +1076,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
description = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer))
}
lines = append(lines, fmt.Sprintf("- **Description**: %s", description), "")
+ lines = append(lines, skillCatalogTryIt(offer, endpoint)...)
}
return strings.Join(lines, "\n")
@@ -1056,6 +1120,69 @@ func skillCatalogHowToPay(baseURL string) []string {
}
}
+// catalogModelName resolves the model id a buyer should put in a paid
+// chat-completions body. type=agent offers leave spec.model empty by design
+// (the model lives on the linked Agent), so fall back to the controller's
+// resolved view. Shared by /api/services.json and the /skill.md worked
+// examples so both surfaces advertise the same id.
+func catalogModelName(offer *monetizeapi.ServiceOffer) string {
+ if offer == nil {
+ return ""
+ }
+ if offer.Spec.Model.Name != "" {
+ return offer.Spec.Model.Name
+ }
+ if offer.Status.AgentResolution != nil {
+ return offer.Status.AgentResolution.Model
+ }
+ return ""
+}
+
+// skillCatalogTryIt renders the per-offer "Try it" subsection: one curl that
+// probes the 402 pricing, and one worked paid request. The paid example for
+// chat-shaped offers is buyprompts.Build's Example — the exact same bytes
+// /api/services.json publishes in the entry's buy.example — so the two
+// surfaces cannot drift. Agent buyers convert off copy-paste, not prose.
+func skillCatalogTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []string {
+ lines := []string{
+ "#### Try it",
+ "",
+ "Probe the price (no payment; the `402` body carries the signable `accepts[]` requirements):",
+ "",
+ "```bash",
+ fmt.Sprintf("curl -i %s", endpoint),
+ "```",
+ "",
+ }
+ block := buyprompts.Build(buyprompts.Input{
+ Type: fallbackOfferType(offer),
+ URL: endpoint,
+ Model: catalogModelName(offer),
+ })
+ if block.Example != "" {
+ // inference/agent: OpenAI-style chat-completions with the real model id.
+ lines = append(lines,
+ "Then sign one `accepts[]` entry — see [How to pay (x402)](#how-to-pay-x402) — and send a paid request:",
+ "",
+ "```",
+ block.Example,
+ "```",
+ "",
+ )
+ } else {
+ // http/fine-tuning: retry the gated path itself with the payment attached.
+ lines = append(lines,
+ "Then sign one `accepts[]` entry — see [How to pay (x402)](#how-to-pay-x402) — and retry the identical request with the payment attached:",
+ "",
+ "```bash",
+ fmt.Sprintf("curl -i %s -H \"X-PAYMENT: \"", endpoint),
+ "```",
+ "",
+ )
+ }
+ return lines
+}
+
// offerOperationallyReady reports whether an offer is usable for x402
// payments today. This is intentionally LOOSER than the controller's
// Ready=True condition: ModelReady + UpstreamHealthy + PaymentGateReady
@@ -1175,13 +1302,7 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string,
if desc == "" {
desc = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer))
}
- // type=agent offers leave spec.model empty by design (the model
- // lives on the linked Agent). Fall back to the controller's
- // resolved view so the storefront can display it.
- modelName := offer.Spec.Model.Name
- if modelName == "" && offer.Status.AgentResolution != nil {
- modelName = offer.Status.AgentResolution.Model
- }
+ modelName := catalogModelName(offer)
drainEndsAt := ""
if offer.IsDraining() {
@@ -1256,10 +1377,11 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string,
}
catalog := schemas.ServiceCatalog{
- DisplayName: profile.DisplayName,
- Tagline: profile.Tagline,
- LogoURL: profile.LogoURL,
- Services: services,
+ SchemaVersion: schemas.ServiceCatalogSchemaVersion,
+ DisplayName: profile.DisplayName,
+ Tagline: profile.Tagline,
+ LogoURL: profile.LogoURL,
+ Services: services,
}
if catalog.Services == nil {
catalog.Services = []schemas.ServiceCatalogEntry{}
@@ -1275,14 +1397,15 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string,
func fallbackServiceCatalogJSON(baseURL string) string {
profile := storefront.ResolvePublished(nil, baseURL)
catalog := schemas.ServiceCatalog{
- DisplayName: profile.DisplayName,
- Tagline: profile.Tagline,
- LogoURL: profile.LogoURL,
- Services: []schemas.ServiceCatalogEntry{},
+ SchemaVersion: schemas.ServiceCatalogSchemaVersion,
+ DisplayName: profile.DisplayName,
+ Tagline: profile.Tagline,
+ LogoURL: profile.LogoURL,
+ Services: []schemas.ServiceCatalogEntry{},
}
out, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
- return `{"displayName":"Obol Stack","tagline":"Unlock Agent and API services with digital payments.","logoUrl":"/obol-stack-logo.png","services":[]}`
+ return `{"schemaVersion":"1","displayName":"Obol Stack","tagline":"Unlock Agent and API services with digital payments.","logoUrl":"/obol-stack-logo.png","services":[]}`
}
return string(out)
}
diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go
index c1b023f1..58e4dc36 100644
--- a/internal/serviceoffercontroller/render_test.go
+++ b/internal/serviceoffercontroller/render_test.go
@@ -11,6 +11,7 @@ import (
"github.com/ObolNetwork/obol-stack/internal/schemas"
"github.com/santhosh-tekuri/jsonschema/v6"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
)
@@ -1569,3 +1570,185 @@ func TestBuildServiceCatalogJSON_RegistrationPendingFalseForFullyReady(t *testin
t.Error("RegistrationPending must be false for fully Ready=True offers")
}
}
+
+// TestBuildCatalogHeadersMiddleware locks the CORS + cache posture on the
+// public catalog surfaces: wildcard origin (read-only public data, no
+// credentials), GET/OPTIONS only, and a 5-minute public cache.
+func TestBuildCatalogHeadersMiddleware(t *testing.T) {
+ mw := buildCatalogHeadersMiddleware()
+ if got := mw.GetAPIVersion(); got != "traefik.io/v1alpha1" {
+ t.Fatalf("apiVersion = %q, want traefik.io/v1alpha1", got)
+ }
+ if got := mw.GetKind(); got != "Middleware" {
+ t.Fatalf("kind = %q, want Middleware", got)
+ }
+ if mw.GetName() != catalogHeadersMiddlewareName {
+ t.Fatalf("name = %q, want %q", mw.GetName(), catalogHeadersMiddlewareName)
+ }
+ if mw.GetNamespace() != skillCatalogNamespace {
+ t.Fatalf("namespace = %q, want %q", mw.GetNamespace(), skillCatalogNamespace)
+ }
+
+ headers := mw.Object["spec"].(map[string]any)["headers"].(map[string]any)
+ origins := headers["accessControlAllowOriginList"].([]any)
+ if len(origins) != 1 || origins[0] != "*" {
+ t.Errorf("accessControlAllowOriginList = %v, want [*]", origins)
+ }
+ methods := headers["accessControlAllowMethods"].([]any)
+ if len(methods) != 2 || methods[0] != "GET" || methods[1] != "OPTIONS" {
+ t.Errorf("accessControlAllowMethods = %v, want [GET OPTIONS]", methods)
+ }
+ custom := headers["customResponseHeaders"].(map[string]any)
+ if custom["Cache-Control"] != "public, max-age=300" {
+ t.Errorf("Cache-Control = %v, want 'public, max-age=300'", custom["Cache-Control"])
+ }
+}
+
+// TestCatalogRoutesCarryHeadersFilter asserts every catalog HTTPRoute
+// (/skill.md, /api/services.json, /openapi.json, /api) attaches the headers
+// Middleware via an ExtensionRef filter — the same mechanism the x402
+// middleware uses on paid routes. Paid /services/* routes must NOT carry it
+// (locked separately by TestBuildHTTPRoute's no-filters assertion).
+func TestCatalogRoutesCarryHeadersFilter(t *testing.T) {
+ routes := map[string]*unstructured.Unstructured{
+ "/skill.md": buildSkillCatalogHTTPRoute(),
+ "/api/services.json": buildServicesJSONHTTPRoute(),
+ "/openapi.json": buildOpenAPIHTTPRoute(),
+ "/api": buildAPIDocsHTTPRoute(),
+ }
+ for path, route := range routes {
+ rules := route.Object["spec"].(map[string]any)["rules"].([]any)
+ firstRule := rules[0].(map[string]any)
+ filters, found := firstRule["filters"].([]any)
+ if !found || len(filters) != 1 {
+ t.Errorf("%s route: filters = %v, want exactly one ExtensionRef filter", path, firstRule["filters"])
+ continue
+ }
+ filter := filters[0].(map[string]any)
+ if filter["type"] != "ExtensionRef" {
+ t.Errorf("%s route: filter type = %v, want ExtensionRef", path, filter["type"])
+ }
+ ref := filter["extensionRef"].(map[string]any)
+ if ref["group"] != "traefik.io" || ref["kind"] != "Middleware" || ref["name"] != catalogHeadersMiddlewareName {
+ t.Errorf("%s route: extensionRef = %v, want traefik.io Middleware %q", path, ref, catalogHeadersMiddlewareName)
+ }
+ }
+}
+
+// TestBuildServiceCatalogJSON_SchemaVersion locks the envelope version
+// marker: always present, always "1" until a breaking wire change bumps it.
+func TestBuildServiceCatalogJSON_SchemaVersion(t *testing.T) {
+ jsonStr := buildServiceCatalogJSON(nil, "https://example.com", nil)
+ assertServiceCatalogSchema(t, jsonStr)
+
+ if got := decodeServiceCatalog(t, jsonStr).SchemaVersion; got != schemas.ServiceCatalogSchemaVersion {
+ t.Errorf("schemaVersion = %q, want %q", got, schemas.ServiceCatalogSchemaVersion)
+ }
+ // Assert on the raw wire too, so a struct/tag rename can't silently
+ // satisfy the decode-side check.
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
+ t.Fatalf("unmarshal raw catalog: %v", err)
+ }
+ if string(raw["schemaVersion"]) != `"1"` {
+ t.Errorf(`raw schemaVersion = %s, want "1"`, raw["schemaVersion"])
+ }
+
+ // The fallback envelope must carry the same version.
+ fallback := fallbackServiceCatalogJSON("https://example.com")
+ assertServiceCatalogSchema(t, fallback)
+ if got := decodeServiceCatalog(t, fallback).SchemaVersion; got != schemas.ServiceCatalogSchemaVersion {
+ t.Errorf("fallback schemaVersion = %q, want %q", got, schemas.ServiceCatalogSchemaVersion)
+ }
+}
+
+// TestBuildSkillCatalogMarkdown_TryIt asserts every offer detail block ends
+// with a copy-paste "Try it" section: a 402 probe curl plus a worked paid
+// request. Chat-shaped offers must show the REAL model id (including the
+// AgentResolution fallback for type=agent) — the same id services.json
+// publishes — and http offers a curl of the gated path.
+func TestBuildSkillCatalogMarkdown_TryIt(t *testing.T) {
+ readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}}
+ inferenceOffer := &monetizeapi.ServiceOffer{
+ ObjectMeta: metav1.ObjectMeta{Name: "flow-qwen", Namespace: "llm"},
+ Spec: monetizeapi.ServiceOfferSpec{
+ Type: "inference",
+ Model: monetizeapi.ServiceOfferModel{Name: "qwen36-deep"},
+ Payment: monetizeapi.ServiceOfferPayment{
+ Network: "base",
+ PayTo: "0x1111111111111111111111111111111111111111",
+ Price: monetizeapi.ServiceOfferPriceTable{PerMTok: "0.10"},
+ },
+ },
+ Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
+ }
+ agentOffer := &monetizeapi.ServiceOffer{
+ ObjectMeta: metav1.ObjectMeta{Name: "quant", Namespace: "agent-quant"},
+ Spec: monetizeapi.ServiceOfferSpec{
+ Type: "agent",
+ Payment: monetizeapi.ServiceOfferPayment{
+ Network: "base",
+ PayTo: "0x2222222222222222222222222222222222222222",
+ Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.05"},
+ },
+ },
+ Status: monetizeapi.ServiceOfferStatus{
+ AgentResolution: &monetizeapi.ServiceOfferAgentResolution{Model: "qwen3.5:9b", Runtime: "hermes"},
+ Conditions: readyCond,
+ },
+ }
+ httpOffer := &monetizeapi.ServiceOffer{
+ ObjectMeta: metav1.ObjectMeta{Name: "echo", Namespace: "demo"},
+ Spec: monetizeapi.ServiceOfferSpec{
+ Type: "http",
+ Payment: monetizeapi.ServiceOfferPayment{
+ Network: "base",
+ PayTo: "0x3333333333333333333333333333333333333333",
+ Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"},
+ },
+ },
+ Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
+ }
+
+ content := buildSkillCatalogMarkdown(
+ []*monetizeapi.ServiceOffer{inferenceOffer, agentOffer, httpOffer},
+ "https://seller.example", nil,
+ )
+
+ if got := strings.Count(content, "#### Try it"); got != 3 {
+ t.Fatalf("Try it sections = %d, want 3:\n%s", got, content)
+ }
+ // 402 probe curl per offer.
+ for _, probe := range []string{
+ "curl -i https://seller.example/services/flow-qwen",
+ "curl -i https://seller.example/services/quant",
+ "curl -i https://seller.example/services/echo",
+ } {
+ if !strings.Contains(content, probe) {
+ t.Errorf("missing 402 probe %q:\n%s", probe, content)
+ }
+ }
+ // Chat-shaped offers: worked chat-completions example with the real model
+ // id (identical bytes to services.json's buy.example — buyprompts is the
+ // single authoring point).
+ if !strings.Contains(content, "POST https://seller.example/services/flow-qwen/v1/chat/completions") {
+ t.Errorf("inference Try it missing chat-completions example:\n%s", content)
+ }
+ if !strings.Contains(content, `"model": "qwen36-deep"`) {
+ t.Errorf("inference Try it missing real model id qwen36-deep:\n%s", content)
+ }
+ if !strings.Contains(content, `"model": "qwen3.5:9b"`) {
+ t.Errorf("agent Try it missing AgentResolution model qwen3.5:9b:\n%s", content)
+ }
+ if !strings.Contains(content, "X-PAYMENT: ") {
+ t.Errorf("Try it examples missing X-PAYMENT placeholder:\n%s", content)
+ }
+ // http offer: paid retry is a curl of the gated path.
+ if !strings.Contains(content, `curl -i https://seller.example/services/echo -H "X-PAYMENT: "`) {
+ t.Errorf("http Try it missing paid curl:\n%s", content)
+ }
+ // Payment mechanics are referenced, not duplicated.
+ if !strings.Contains(content, "[How to pay (x402)](#how-to-pay-x402)") {
+ t.Errorf("Try it missing How-to-pay anchor link:\n%s", content)
+ }
+}
diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go
index 4b445786..212b1a4b 100644
--- a/internal/x402/forwardauth.go
+++ b/internal/x402/forwardauth.go
@@ -376,6 +376,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
// understand; it remains the default when ForwardAuthConfig.SendPaymentRequired
// is unset and the fallback when the renderer has nothing else to do.
func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) {
+ setCatalogLinkHeader(w)
resp := buildPaymentRequired(r, requirements, extensions)
body, err := json.Marshal(resp)
@@ -390,6 +391,16 @@ func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirement
_, _ = w.Write(body)
}
+// setCatalogLinkHeader advertises the seller's machine-readable service
+// catalog on every 402 response (RFC 8288 web linking). An agent that lands
+// on a paid endpoint directly — with no prior knowledge of the seller's
+// layout — can follow the link to /api/services.json and self-serve
+// discovery of every other offer. Header-only addition: the 402 body schema
+// and the verification/settlement flow are unchanged.
+func setCatalogLinkHeader(w http.ResponseWriter) {
+ w.Header().Set("Link", `; rel="catalog"`)
+}
+
// buildPaymentRequired assembles the v2 PaymentRequired object for the
// incoming request, including the bazaar service metadata on the resource
// block (serviceName/iconUrl — see specs/extensions/bazaar.md, soft-drop
diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go
index 83603f8d..0fc7c3f8 100644
--- a/internal/x402/forwardauth_test.go
+++ b/internal/x402/forwardauth_test.go
@@ -866,3 +866,49 @@ func TestForwardAuth_SettlesInProcess_SuppressesWarning(t *testing.T) {
t.Fatalf("SettlesInProcess=true must suppress the verifyOnly=false warning, got:\n%s", gotLog)
}
}
+
+// TestForwardAuth_402CarriesCatalogLinkHeader locks the discovery Link
+// header on the default (JSON) 402 path through the middleware itself —
+// both on the no-payment challenge and on the re-issued challenge after an
+// invalid payment. Header-only: verification behaviour is asserted by the
+// sibling tests and must not change.
+func TestForwardAuth_402CarriesCatalogLinkHeader(t *testing.T) {
+ const wantLink = `; rel="catalog"`
+
+ var verifyCalled, settleCalled atomic.Int32
+ fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled)
+ defer fac.Close()
+
+ mw := NewForwardAuthMiddleware(ForwardAuthConfig{
+ FacilitatorURL: fac.URL,
+ VerifyOnly: true,
+ }, testRequirements())
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("inner handler should not be called on a 402")
+ })
+
+ t.Run("no payment", func(t *testing.T) {
+ req := httptest.NewRequest("POST", "/v1/chat/completions", nil)
+ rec := httptest.NewRecorder()
+ mw(inner).ServeHTTP(rec, req)
+ if rec.Code != http.StatusPaymentRequired {
+ t.Fatalf("status = %d, want 402", rec.Code)
+ }
+ if got := rec.Header().Get("Link"); got != wantLink {
+ t.Errorf("Link = %q, want %q", got, wantLink)
+ }
+ })
+
+ t.Run("invalid payment rechallenge", func(t *testing.T) {
+ req := httptest.NewRequest("POST", "/v1/chat/completions", nil)
+ req.Header.Set("X-PAYMENT", validPaymentHeader())
+ rec := httptest.NewRecorder()
+ mw(inner).ServeHTTP(rec, req)
+ if rec.Code != http.StatusPaymentRequired {
+ t.Fatalf("status = %d, want 402", rec.Code)
+ }
+ if got := rec.Header().Get("Link"); got != wantLink {
+ t.Errorf("Link = %q, want %q", got, wantLink)
+ }
+ })
+}
diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go
index 979cb083..f9bd445e 100644
--- a/internal/x402/paymentrequired.go
+++ b/internal/x402/paymentrequired.go
@@ -210,6 +210,9 @@ func isLocalHost(host string) bool {
// full OG metadata, a service-info card, three "ways to pay" prompt cards
// (Obol Agent, other AI agent, raw JSON), and copy buttons.
func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any, display PaymentDisplay) {
+ // Catalog discovery link rides on the HTML branch too (and survives the
+ // JSON fallbacks below — Header().Set is idempotent).
+ setCatalogLinkHeader(w)
jsonBody := buildPaymentRequired(r, requirements, extensions)
indented, err := json.MarshalIndent(jsonBody, "", " ")
if err != nil {
diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go
index 4cf77151..ef969f47 100644
--- a/internal/x402/paymentrequired_test.go
+++ b/internal/x402/paymentrequired_test.go
@@ -505,3 +505,38 @@ func TestAgentCopy_NoModelProseAndRunnableExample(t *testing.T) {
// The "other AI agent" prompt embeds the concrete message too.
mustContain(t, c.PromptOther, defaultAgentTaskExample)
}
+
+// Every 402 — JSON and HTML branch alike — must advertise the seller's
+// machine-readable catalog via an RFC 8288 Link header, so an agent that
+// lands on a paid endpoint with no prior knowledge can self-serve discovery.
+// Header-only invariant: the body schema assertions live in the sibling
+// tests above and must not change.
+func TestPaymentRequired_CarriesCatalogLinkHeader(t *testing.T) {
+ const wantLink = `; rel="catalog"`
+ render := NewHTMLAwarePaymentRequired(sampleDisplay())
+
+ t.Run("json", func(t *testing.T) {
+ r := httptest.NewRequest("GET", "/services/agent-quant", nil)
+ w := httptest.NewRecorder()
+ render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
+ if w.Code != http.StatusPaymentRequired {
+ t.Fatalf("status = %d, want 402", w.Code)
+ }
+ if got := w.Header().Get("Link"); got != wantLink {
+ t.Errorf("Link = %q, want %q", got, wantLink)
+ }
+ })
+
+ t.Run("html", func(t *testing.T) {
+ r := httptest.NewRequest("GET", "/services/agent-quant", nil)
+ r.Header.Set("Accept", "text/html")
+ w := httptest.NewRecorder()
+ render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil)
+ if w.Code != http.StatusPaymentRequired {
+ t.Fatalf("status = %d, want 402", w.Code)
+ }
+ if got := w.Header().Get("Link"); got != wantLink {
+ t.Errorf("Link = %q, want %q", got, wantLink)
+ }
+ })
+}
diff --git a/obolup.sh b/obolup.sh
index d6d2aeca..70caec19 100755
--- a/obolup.sh
+++ b/obolup.sh
@@ -473,6 +473,101 @@ EOF
log_success "Installed development wrapper at $OBOL_BIN_DIR/obol"
}
+# Compute the SHA256 hash of a file.
+# Portable: macOS ships shasum, Linux ships sha256sum.
+sha256_file() {
+ local file="$1"
+ if command_exists sha256sum; then
+ sha256sum "$file" | awk '{print $1}'
+ elif command_exists shasum; then
+ shasum -a 256 "$file" | awk '{print $1}'
+ else
+ return 1
+ fi
+}
+
+# Download a URL to a file with retries (3 attempts, short sleep between).
+# Used for the obol release binary and its SHA256SUMS file only.
+download_with_retries() {
+ local url="$1"
+ local dest="$2"
+ local attempts=3
+ local attempt
+
+ for attempt in $(seq 1 "$attempts"); do
+ if command_exists curl; then
+ if curl -fsSL "$url" -o "$dest"; then
+ return 0
+ fi
+ elif command_exists wget; then
+ if wget -q "$url" -O "$dest"; then
+ return 0
+ fi
+ else
+ log_error "Neither curl nor wget is available"
+ return 1
+ fi
+
+ if [[ "$attempt" -lt "$attempts" ]]; then
+ log_warn "Download failed (attempt $attempt/$attempts), retrying in 2s..."
+ sleep 2
+ fi
+ done
+
+ rm -f "$dest"
+ return 1
+}
+
+# Verify a downloaded release binary against the published SHA256SUMS file.
+# Fails (returns 1) on checksum mismatch. Warns and continues (returns 0) if
+# SHA256SUMS is unavailable (older releases) or no local sha256 tool exists.
+verify_release_checksum() {
+ local release_tag="$1"
+ local binary_name="$2"
+ local binary_path="$3"
+
+ local sums_url="https://github.com/ObolNetwork/obol-stack/releases/download/${release_tag}/SHA256SUMS"
+ local tmp_sums="${binary_path}.sha256sums"
+
+ if ! download_with_retries "$sums_url" "$tmp_sums"; then
+ log_warn "SHA256SUMS not published for $release_tag, skipping checksum verification"
+ return 0
+ fi
+
+ # Match on the artifact name as published (e.g. obol_darwin_arm64),
+ # handling both "hash name" and "hash *name" (binary mode) formats.
+ local expected_hash
+ expected_hash=$(awk -v name="$binary_name" '$2 == name || $2 == "*"name {print $1; exit}' "$tmp_sums")
+ rm -f "$tmp_sums"
+
+ if [[ -z "$expected_hash" ]]; then
+ log_warn "No checksum entry for $binary_name in SHA256SUMS, skipping verification"
+ return 0
+ fi
+
+ local actual_hash
+ if ! actual_hash=$(sha256_file "$binary_path"); then
+ log_warn "Neither sha256sum nor shasum is available, skipping checksum verification"
+ return 0
+ fi
+
+ if [[ "$actual_hash" != "$expected_hash" ]]; then
+ log_error "Checksum mismatch for $binary_name ($release_tag)"
+ echo ""
+ echo " Expected: $expected_hash"
+ echo " Actual: $actual_hash"
+ echo ""
+ echo "The downloaded binary may be corrupted or tampered with."
+ echo "Please re-run the installer. If the problem persists, report an issue at:"
+ echo " https://github.com/ObolNetwork/obol-stack/issues"
+ echo ""
+ return 1
+ fi
+
+ log_success "Checksum verified for $binary_name"
+ return 0
+}
+
# Download and install binary from GitHub releases
download_release() {
local release_tag="$1"
@@ -506,21 +601,16 @@ download_release() {
local tmp_obol="$OBOL_BIN_DIR/obol.tmp"
- # Download binary
- if command_exists curl; then
- if ! curl -fsSL "$download_url" -o "$tmp_obol"; then
- log_warn "Failed to download release $release_tag"
- rm -f "$tmp_obol"
- return 1
- fi
- elif command_exists wget; then
- if ! wget -q "$download_url" -O "$tmp_obol"; then
- log_warn "Failed to download release $release_tag"
- rm -f "$tmp_obol"
- return 1
- fi
- else
- log_error "Neither curl nor wget is available"
+ # Download binary (with retries)
+ if ! download_with_retries "$download_url" "$tmp_obol"; then
+ log_warn "Failed to download release $release_tag"
+ rm -f "$tmp_obol"
+ return 1
+ fi
+
+ # Verify against the published SHA256SUMS before installing
+ if ! verify_release_checksum "$release_tag" "$binary_name" "$tmp_obol"; then
+ rm -f "$tmp_obol"
return 1
fi
@@ -700,7 +790,7 @@ copy_bootstrap_script() {
log_warn "Failed to download bootstrap script (non-critical)"
log_info "You can manually download it later with:"
echo ""
- echo " curl -sSL $script_source_url -o $OBOL_BIN_DIR/obolup.sh"
+ echo " curl -fsSL $script_source_url -o $OBOL_BIN_DIR/obolup.sh"
echo " chmod +x $OBOL_BIN_DIR/obolup.sh"
echo ""
fi
@@ -818,7 +908,7 @@ install_kubectl() {
# Download kubectl
local download_url="https://dl.k8s.io/release/v${target_version}/bin/${platform}/${arch}/kubectl"
- if curl -sSL "$download_url" -o "$OBOL_BIN_DIR/kubectl.tmp"; then
+ if curl -fsSL "$download_url" -o "$OBOL_BIN_DIR/kubectl.tmp"; then
chmod +x "$OBOL_BIN_DIR/kubectl.tmp"
mv "$OBOL_BIN_DIR/kubectl.tmp" "$OBOL_BIN_DIR/kubectl"
log_success "kubectl v$target_version installed"
@@ -877,7 +967,7 @@ install_helm() {
local tmp_dir=$(mktemp -d)
local download_url="https://get.helm.sh/helm-v${target_version}-${platform}-${arch}.tar.gz"
- if curl -sSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
+ if curl -fsSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
mv "$tmp_dir/${platform}-${arch}/helm" "$OBOL_BIN_DIR/helm"
chmod +x "$OBOL_BIN_DIR/helm"
rm -rf "$tmp_dir"
@@ -940,7 +1030,7 @@ install_k3d() {
# Download k3d
local download_url="https://github.com/k3d-io/k3d/releases/download/v${target_version}/k3d-${k3d_platform}-${k3d_arch}"
- if curl -sSL "$download_url" -o "$OBOL_BIN_DIR/k3d.tmp"; then
+ if curl -fsSL "$download_url" -o "$OBOL_BIN_DIR/k3d.tmp"; then
chmod +x "$OBOL_BIN_DIR/k3d.tmp"
mv "$OBOL_BIN_DIR/k3d.tmp" "$OBOL_BIN_DIR/k3d"
log_success "k3d v$target_version installed"
@@ -1010,7 +1100,7 @@ install_helmfile() {
local tmp_dir=$(mktemp -d)
local download_url="https://github.com/helmfile/helmfile/releases/download/v${target_version}/helmfile_${target_version}_${helmfile_platform}_${helmfile_arch}.tar.gz"
- if curl -sSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
+ if curl -fsSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
mv "$tmp_dir/helmfile" "$OBOL_BIN_DIR/helmfile"
chmod +x "$OBOL_BIN_DIR/helmfile"
rm -rf "$tmp_dir"
@@ -1140,7 +1230,7 @@ install_k9s() {
local tmp_dir=$(mktemp -d)
local download_url="https://github.com/derailed/k9s/releases/download/v${target_version}/k9s_${k9s_platform}_${k9s_arch}.tar.gz"
- if curl -sSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
+ if curl -fsSL "$download_url" | tar xz -C "$tmp_dir" 2>/dev/null; then
mv "$tmp_dir/k9s" "$OBOL_BIN_DIR/k9s"
chmod +x "$OBOL_BIN_DIR/k9s"
rm -rf "$tmp_dir"
@@ -1556,10 +1646,10 @@ install_dependencies() {
check_hosts_file() {
log_info "Checking /etc/hosts for obol.stack entry..."
- # Check if /etc/hosts contains obol.stack pointing to localhost
- if grep -q "obol.stack" /etc/hosts 2>/dev/null; then
+ # Check if /etc/hosts contains obol.stack (exact hostname, not e.g. dev.obol.stack)
+ if grep -E "^[^#]*[[:space:]]obol\.stack([[:space:]]|$)" /etc/hosts >/dev/null 2>&1; then
# Check if it points to localhost (127.0.0.1 or ::1)
- if grep -E "^(127\.0\.0\.1|::1)[[:space:]].*obol\.stack" /etc/hosts >/dev/null 2>&1; then
+ if grep -E "^(127\.0\.0\.1|::1)[[:space:]]([^#]*[[:space:]])?obol\.stack([[:space:]]|$)" /etc/hosts >/dev/null 2>&1; then
log_success "obol.stack already configured in /etc/hosts"
return 0
else
@@ -1578,6 +1668,13 @@ update_hosts_file() {
local hosts_entry="127.0.0.1 obol.stack"
+ # Idempotency guard: skip the append if a localhost entry already exists
+ # (protects against duplicates on re-run, even if the caller's check changes)
+ if grep -E "^(127\.0\.0\.1|::1)[[:space:]]([^#]*[[:space:]])?obol\.stack([[:space:]]|$)" /etc/hosts >/dev/null 2>&1; then
+ log_success "obol.stack already configured in /etc/hosts, skipping"
+ return 0
+ fi
+
# Check if sudo is available
if ! command_exists sudo; then
log_error "sudo not available, cannot update /etc/hosts automatically"