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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Integration tests use `//go:build integration`; skip when prerequisites missing.
| Public (tunnel) | none | `/services/<name>/*` | x402 ForwardAuth -> upstream |
| 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 |
| Public (tunnel) | none | `/api/services.json` | service catalog JSON feed (`displayName`, `tagline`, `logoUrl`, `services[]`) |
| 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 @@ -99,7 +99,7 @@ obol
- `agent auth` (alias `token`): `--runtime [hermes|openclaw|all]`, `--regenerate`; positional `[instance-name]` defaults to stack-managed agent. Replaces legacy `hermes token`.
- `agent new` (alias `onboard`): CRD-declared sub-agent via `--model`, `--skills`, `--objective`, `--create-wallet`. Without positional name, falls back to legacy host-rendered Hermes/OpenClaw onboard.
- `network install` has dynamic subcommands (one per supported chain; `--help` to list). `network sync [<network>/<id>]` with `--all`.
- `sell info <name>` prints purchase instructions (URL, model, buy.py command).
- `sell info` is the buyer's-eye read of the storefront: `obol sell info` prints branding + every on-sale service (rendered from the published `/api/services.json` envelope, i.e. only operationally-ready offers); `obol sell info <name>` focuses one service + how-to-buy; `--verbose` adds health/detail. Operator health/conditions (incl. not-ready/draining offers) stay under `sell status`. Branding writes live under `sell info set` (no flags on a TTY → interactive; with flags → patch only those fields) and `sell info reset` (no flags → all defaults; with field flags → reset only those). Writes `x402/obol-storefront-profile`; controller merges into the catalog envelope. Example: `obol sell info set --display-name "Acme Labs" --tagline "Paid APIs." --logo-url "https://…"`. **Dev note**: branding changes need both a dev-built `obol` CLI (`go build -o .workspace/bin/obol ./cmd/obol`) and a current `serviceoffer-controller` image — if `sell info set` times out with `timed out waiting for controller to publish /api/services.json`, the profile ConfigMap was likely applied but the running controller is still publishing the legacy bare-array catalog. Rebuild + roll the controller before retrying: `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=serviceoffer-controller OBOL_DEVELOPMENT=true obol stack up`, or `docker build -f Dockerfile.serviceoffer-controller -t ghcr.io/obolnetwork/serviceoffer-controller:latest . && k3d image import … -c <cluster> && kubectl rollout restart deploy/serviceoffer-controller -n x402`.
- `sell mcp [name]` runs a foreground x402-paid MCP server: forwards buyer JSON args to a backend HTTP service, injecting the seller's own API key (buyer never sees it). Payment rides MCP `_meta` (`internal/x402mcp`).
- `sell resume` replays every persisted sell offer (inference incl. detached host-gateway relaunch; http/agent/demo-agent via the manifest ledger at `$OBOL_CONFIG_DIR/sell-http/`) — run after a host reboot; `obol stack up` runs the same path. `--install-boot-unit` adds a systemd user unit (Linux). `sell mcp` is foreground-only, no offer, not resumed.
- `tunnel setup [<token>]`: the one permanent-URL command. Connector-token based (dashboard-managed) — no host binary, no account-wide API key. Accepts the bare connector token, the `--token` flag, a positional arg, or the whole `cloudflared tunnel run --token …` line (prefix stripped via `extractConnectorToken`). Reuses the remote runtime (`ProvisionWithToken` → `TUNNEL_TOKEN` secret, chart `management_mode=remote`); DNS/ingress are configured by the user in the Cloudflare dashboard (route Public Hostname → `http://traefik.traefik.svc.cluster.local:80`), not via API. The API-token provisioning path was removed (no more `tunnel provision`, no setup `--api-token/--account-id/--zone-id/--register-domain`). `--management local` (alias hidden `tunnel login`) is the browser fallback (needs `cloudflared`). `tunnel status` reads connector health from cloudflared's in-cluster `/ready`+`/metrics` (port 2000, no token) plus a public HTTP probe; concise by default, `--verbose` for replicas/pods, `--no-probe` to stay offline. Domain management lives under `obol domain` (`list`, `search`, `check`, `register`) — an optional CLI wrapper around Cloudflare Registrar; still uses a scoped Cloudflare **API token** (Account → Domain perm, via `--api-token`/`CLOUDFLARE_API_TOKEN`; on a TTY it walks you through token creation and prompts). `--api-token` deliberately has NO `-t` alias to avoid colliding with `tunnel setup -t` (connector token — a different credential). `register` is billable (needs a payment method on the CF account); on success it prints the `obol tunnel setup --hostname …` handoff.
Expand Down Expand Up @@ -428,6 +428,7 @@ A registry digest pin instead of `:latest` on the verifier means your dev rewrit
16. **Clusters created on <= v0.10.0-rc12 keep hostPath-typed PVs** — kubelet ignores `fsGroup` there, and v0.10.0's non-root pods (UID 1000, no chown inits) cannot read the legacy 10000-owned data. Symptom after ANY chart re-render (`agent sync`, model sync, tests that sync): `Init:CrashLoopBackOff` with `mkdir /data/.hermes: Permission denied`. Supported path: recreate the cluster (`obol stack export` -> recreate -> `import`); full steps in the v0.10.0 release notes (Breaking changes). Non-destructive workaround: `docker exec <k3d-node> chown -R 1000:1000 /data/<ns>/hermes-data` then delete the pod.
17. **EIP-7702-contaminated test accounts on a Base Sepolia fork** — standard anvil/hardhat accounts #1–#9 (the `test test ... junk` mnemonic) carry EIP-7702 delegation code (`0xef0100…`) from real-chain 7702 experiments on Base Sepolia. Base-Sepolia USDC is FiatTokenV2_2, which verifies EIP-3009 via `SignatureChecker.isValidSignatureNow` — any `from` with code routes to EIP-1271 `isValidSignature` and ignores a perfectly valid ECDSA signature, reverting `FiatTokenV2: invalid signature` (surfacing as facilitator 503 / `unexpected_error`). The buyer/`from` MUST be a freshly generated EOA (account #0 happens to be clean; payTo with code is fine — only the signer is checked). This is why flow-08 funds the agent's generated wallet, and why `flow-17-sell-mcp.sh` generates fresh buyer + seller keys and preflights `cast code "$BUYER_ADDR" == 0x`.
18. **x402 SDK signs `validAfter = now` with no past buffer** — the `x402-foundation/x402/go` client sets EIP-3009 `validAfter` to wall-clock now. An anvil fork's `block.timestamp` is pinned to the forked block and lags real time the longer the fork has been up, so verify/settle revert `FiatTokenV2: authorization is not yet valid`. In a normal release-smoke run flow-17 follows flow-10 immediately so the gap is tiny; flow-17 still defends with `cast rpc evm_setNextBlockTimestamp $((now+30)) && evm_mine` right before the paid call. (obol's own buy.py uses a past buffer and isn't affected.)
19. **`obol sell info set` times out but profile CM updated** — the dev CLI wrote `x402/obol-storefront-profile` but the in-cluster `serviceoffer-controller` is still on an image that publishes `/api/services.json` as a bare `services[]` array (no `displayName` envelope). Symptom: `configmap/obol-storefront-profile configured` then `timed out waiting for controller to publish /api/services.json`; `kubectl get cm -n x402 obol-skill-md -o jsonpath='{.data.services\.json}'` starts with `[` not `{`. Fix: rebuild + import + restart `serviceoffer-controller` (see `sell info` bullet above). `obol stack up` with a warm cache (`built == 0`) does not pick up controller source changes unless `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=serviceoffer-controller`.

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

Expand All @@ -447,7 +448,8 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate
- `/services/*` — x402 payment-gated, safe by design
- `/.well-known/agent-registration.json` — ERC-8004 discovery
- `/skill.md` — machine-readable service catalog
- `/` on tunnel hostname — static storefront landing page (busybox httpd)
- `/api/services.json` — service catalog envelope (`displayName`, `tagline`, `logoUrl`, `services[]`)
- `/` on tunnel hostname — public storefront landing page (Next.js)

## Dependencies

Expand Down
8 changes: 8 additions & 0 deletions cmd/obol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/ObolNetwork/obol-stack/internal/kubectl"
"github.com/ObolNetwork/obol-stack/internal/network"
"github.com/ObolNetwork/obol-stack/internal/stack"
"github.com/ObolNetwork/obol-stack/internal/storefront"
"github.com/ObolNetwork/obol-stack/internal/ui"
"github.com/ObolNetwork/obol-stack/internal/version"
"github.com/urfave/cli/v3"
Expand Down Expand Up @@ -215,6 +216,13 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}
// agent-backed ServiceOffers resolve agent.ref and
// would dangle without their Agent. Best-effort.
agentcrd.ResumeAll(cfg, u)
// Replay recorded storefront branding into the
// (fresh) x402/obol-storefront-profile ConfigMap
// BEFORE offers republish, so the controller's first
// catalog rebuild carries the operator's branding.
// Best-effort. No-op when `obol sell info set` was
// never used.
storefront.ReconcileRecorded(cfg, u)
// Re-apply cluster-side state for locally-persisted
// `obol sell *` offers. ServiceOffer CRs and the
// Service/Endpoints that route to the host gateway
Expand Down
120 changes: 28 additions & 92 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -24,7 +23,6 @@ import (

"github.com/ObolNetwork/obol-stack/internal/agentcrd"
"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/enclave"
"github.com/ObolNetwork/obol-stack/internal/erc8004"
"github.com/ObolNetwork/obol-stack/internal/hermes"
"github.com/ObolNetwork/obol-stack/internal/images"
Expand All @@ -47,7 +45,25 @@ import (
func sellCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "sell",
Usage: "Sell access to services via x402 micropayments",
Usage: "Sell access to agents and services using digital payments",
Description: `Sell access to agents and services using digital payments (x402).

Commands split into two scopes:

The storefront (the whole shop)
info Preview the storefront and everything on sale, as buyers see it
info set Set the storefront's branding (name, tagline, logo)
register Publish the seller's identity to a distribution channel (ERC-8004)
identity Manage the durable on-chain identity record
list List every offer (raw)
resume Re-publish all offers (e.g. after a restart)

A single service (one item on the shelf)
inference | http | agent | mcp | demo Put a service up for sale
status Operator health and conditions for an offer
update Change an offer's price or payout wallet in place
stop Stop selling an offer without deleting it
delete Stop selling an offer and remove it`,
Commands: []*cli.Command{
sellInferenceCommand(cfg),
sellHTTPCommand(cfg),
Expand Down Expand Up @@ -2628,7 +2644,7 @@ Examples:
func sellStopCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "stop",
Usage: "Drain a ServiceOffer gracefully (advertises wind-down via discovery, then tears down the route)",
Usage: "Stop selling a service without deleting it",
ArgsUsage: "<name>",
Description: `Marks a ServiceOffer as draining. While draining:
- The offer stays in /skill.md and /.well-known/agent-registration.json
Expand Down Expand Up @@ -2873,7 +2889,7 @@ Examples:
func sellDeleteCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete the sale of a service entirely and deactivate its ERC-8004 agent registration",
Usage: "Stop selling a service and remove it (also deactivates any registrations)",
ArgsUsage: "<name>",
Flags: []cli.Flag{
&cli.StringFlag{
Expand Down Expand Up @@ -3053,8 +3069,13 @@ Reloads the payment verifier when configuration is changed.`,
func sellRegisterCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "register",
Usage: "Register a service on the ERC-8004 Agent Registry",
Description: `Registers an AgentIdentity on the ERC-8004 Agent Registry for one chain.
Usage: "Publish the seller's identity to a distribution channel (ERC-8004)",
Description: `Publishes the seller's agent identity to a distribution channel so buyers can
discover it. Today the only channel is the ERC-8004 Agent Registry (on-chain);
more channels may follow, so this command is framed around "where do I
advertise" rather than a single registry.

Registers an AgentIdentity on the ERC-8004 Agent Registry for one chain.
The on-chain register tx is signed and broadcast by the Hermes remote-signer
and pays gas from the agent's wallet — make sure it has a small balance on
the target chain (~$0.20–$0.50 of native gas typically suffices).
Expand Down Expand Up @@ -3559,91 +3580,6 @@ func (pf *signerPortForwarder) Stop() {
}
}

// sellInfoCommand returns info about a local inference gateway deployment.
// Kept for the enclave pubkey functionality.
func sellInfoCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "info",
Usage: "Show inference gateway deployment details and encryption key",
ArgsUsage: "<name>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Aliases: []string{"j"},
Usage: "Output as JSON",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
u := getUI(cmd)
name := cmd.Args().First()
if name == "" {
return fmt.Errorf("usage: obol sell info <name>")
}

store := inference.NewStore(cfg.ConfigDir)
d, err := store.Get(name)
if err != nil {
return err
}

var k enclave.Key
var keyErr error
if d.TEEType != "" {
k, keyErr = tee.NewKey(d.EnclaveTag, d.ModelHash)
} else {
k, keyErr = enclave.NewKey(d.EnclaveTag)
}

if u.IsJSON() || cmd.Bool("json") {
out := map[string]any{
"name": d.Name,
"enclave_tag": d.EnclaveTag,
"listen_addr": d.ListenAddr,
"upstream_url": d.UpstreamURL,
"wallet_address": d.WalletAddress,
"price_per_request": d.PricePerRequest,
"price_per_mtok": d.PricePerMTok,
"approx_tokens_per_request": d.ApproxTokensPerRequest,
"chain": d.Chain,
"facilitator_url": d.FacilitatorURL,
"created_at": d.CreatedAt,
"updated_at": d.UpdatedAt,
"algorithm": "ECIES-P256-HKDF-SHA256-AES256GCM",
}
if keyErr == nil {
out["pubkey"] = hex.EncodeToString(k.PublicKeyBytes())
out["persistent"] = k.Persistent()
} else {
out["pubkey_error"] = keyErr.Error()
}
return u.JSON(out)
}

u.Printf("Name: %s", d.Name)
u.Printf("Enclave tag: %s", d.EnclaveTag)
u.Printf("Algorithm: ECIES-P256-HKDF-SHA256-AES256GCM")
if keyErr == nil {
u.Printf("Pubkey: %s", hex.EncodeToString(k.PublicKeyBytes()))
u.Printf("Persistent: %v", k.Persistent())
} else {
u.Printf("Pubkey: (unavailable: %v)", keyErr)
}
u.Blank()
u.Printf("Listen: %s", d.ListenAddr)
u.Printf("Upstream: %s", d.UpstreamURL)
u.Printf("Wallet: %s", d.WalletAddress)
u.Printf("Price: %s", formatInferencePriceSummary(d, ""))
u.Printf("Chain: %s", d.Chain)
u.Printf("Facilitator: %s", d.FacilitatorURL)
u.Printf("Created: %s", d.CreatedAt)
if d.UpdatedAt != "" {
u.Printf("Updated: %s", d.UpdatedAt)
}
return nil
},
}
}

// ---------------------------------------------------------------------------
// kubectl helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading