diff --git a/.claude/workflows/obol-audit.js b/.claude/workflows/obol-audit.js new file mode 100644 index 00000000..eee3ebe0 --- /dev/null +++ b/.claude/workflows/obol-audit.js @@ -0,0 +1,99 @@ +// obol-audit — hostile-operator bug sweep (layer 4 of docs/testing/proactive-bug-finding.md). +// +// Fan out N operator-lens finders over the current tree, adversarially verify +// each candidate with two independent refuters, return the ranked survivors. +// Re-run after any large change. Pass args to tell it what is already fixed so +// it does not re-report known issues: +// Workflow({ name: "obol-audit", args: { repo: "/abs/path", known: ["short description of a fixed bug", ...] } }) +// +// Defaults: repo = the obol-stack checkout, known = [] (report everything). + +export const meta = { + name: 'obol-audit', + description: 'Hostile-operator audit: lens finders + adversarial refuters → ranked confirmed bugs', + phases: [ + { title: 'Hunt', detail: 'one finder per operator lens' }, + { title: 'Verify', detail: 'two adversarial refuters per candidate' }, + ], +} + +const REPO = (args && args.repo) || '/Users/bussyjd/Development/OBOL-WORKBENCH/obol-stack' +const KNOWN = (args && args.known) || [] + +const knownBlock = KNOWN.length + ? `KNOWN ISSUES — re-reporting any of these is a FAILURE, exclude them:\n${KNOWN.map((k, i) => `(${i + 1}) ${k}`).join('\n')}` + : 'No known-issue exclusions supplied — report every real bug you find.' + +const COMMON = `You are a hostile-but-realistic node operator auditing the obol-stack Go repo at ${REPO}. READ-ONLY: no file edits, no commits, no kubectl, no deploys. You may grep, read, run go test, and write throwaway /tmp programs to confirm behavior. + +${knownBlock} + +Find NEW, REAL bugs of your assigned lens — behavior a reasonable operator hits that produces wrong results, broken routes, lost/misleading state, leaked internal data, free rides, or misleading output. For each: trace the ACTUAL code path end to end (function + file:line — no speculation), give the concrete operator scenario that triggers it, and confirm it is not a known issue and not already guarded by a test. Quality over quantity: top findings only (max 3), ranked by confidence x severity. Zero findings is fine — do NOT pad with style nits or untriggerable theoretical races.` + +// The invariants these lenses probe map to docs/testing/proactive-bug-finding.md. +const LENSES = [ + { key: 'state-staleness', prompt: 'Lens: SPEC-CHANGE STALENESS (invariant 1). When an operator mutates a spec field (hostname, payTo, price, network, type, upstream) or deletes+recreates an offer, which derived status / published document / on-chain artifact fails to reset or re-derive? Read the reconcile paths and ask of each derived artifact: what resets this when its input changes?' }, + { key: 'name-injectivity', prompt: 'Lens: GENERATED NAME COLLISIONS (invariant 2). Enumerate every generated resource name (middlewares, routes, services, configmaps, grants, tunnel/hermes resources) and check each is injective over (namespace, name, purpose), including truncation collisions and delimiter ambiguity (can two distinct (ns,name) pairs join to the same string?).' }, + { key: 'status-truth', prompt: 'Lens: STATUS LIES (invariant 3). Find conditions/statuses set on apply-success rather than observed reality, or stuck after the cause resolves. Check every readiness signal: PaymentConfigured, Registered, agent/purchase readiness, wallet address, tunnel status, sell status output, dashboard sources.' }, + { key: 'public-surface-leak', prompt: 'Lens: INTERNAL DATA IN PUBLIC SURFACES (invariant 4). Enumerate every publicly-served document (openapi.json, .well-known/x402, agent-registration.json, skill.md, catalog, storefront, 402 body, chat page, async job-status/result endpoints) and trace each field back to its source: does anything pull internal CR fields, cluster-internal hostnames/IPs, raw errors, secrets, or model endpoints without a public-intent gate?' }, + { key: 'url-construction', prompt: 'Lens: URL/HOST/PATH CONSTRUCTION (invariant 5). Audit every URL build for double slashes, missing/hardcoded scheme, port handling, trailing-slash sensitivity, host-header trust, path traversal via offer names, http fallbacks for non-local hosts.' }, + { key: 'payment-verification', prompt: 'Lens: PAYMENT CORRECTNESS (invariant 6). Audit the x402 settle/verify flow for money-losing or free-ride bugs: request reaching upstream without settle; replay of a payment across offers/paths/methods/prices with the same tuple; concurrent duplicate submissions; amount/asset/network confusion; nonce single-use scope; price-change-in-flight; a cheap-route payment passing on an expensive route.' }, + { key: 'tx-sequencing', prompt: 'Lens: ON-CHAIN CLIENT CORRECTNESS. Audit the erc8004 client/signer and CLI registration flow: missing receipt.Status checks (tx sent but reverted → success assumed), gas estimation on stale state, chain-id confusion, recovery paths trusting ambiguous historical matches without re-verifying current ownership, partial multi-network failure leaving inconsistent CRD state.' }, + { key: 'cli-validation', prompt: 'Lens: CLI INPUT TRAPS. Audit cmd/obol for flags whose help mismatches behavior, silent normalization that surprises (TrimRight/ToLower on case-sensitive addresses/hostnames), values passed unvalidated into k8s names / YAML / URLs / on-chain amounts (injection, panics, zero/negative prices), and defaults that differ between sibling subcommands.' }, +] + +const FINDINGS_SCHEMA = { + type: 'object', + required: ['findings'], + properties: { + findings: { + type: 'array', + items: { + type: 'object', + required: ['title', 'file', 'lines', 'severity', 'scenario', 'codePath', 'whyNotKnown'], + properties: { + title: { type: 'string' }, + file: { type: 'string' }, + lines: { type: 'string' }, + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + scenario: { type: 'string' }, + codePath: { type: 'string' }, + whyNotKnown: { type: 'string' }, + }, + }, + }, + }, +} + +const VERDICT_SCHEMA = { + type: 'object', + required: ['refuted', 'notes'], + properties: { + refuted: { type: 'boolean' }, + notes: { type: 'string' }, + }, +} + +phase('Hunt') +const found = await parallel(LENSES.map((l) => () => + agent(`${COMMON}\n\n${l.prompt}`, { label: `hunt:${l.key}`, phase: 'Hunt', model: 'sonnet', schema: FINDINGS_SCHEMA }) +)) +const candidates = found.filter(Boolean).flatMap((r, i) => r.findings.map((f) => ({ ...f, lens: LENSES[i].key }))) +log(`${candidates.length} candidate findings across ${LENSES.length} lenses`) + +phase('Verify') +const verified = await parallel(candidates.map((c) => () => + parallel([0, 1].map((n) => () => + agent(`${COMMON}\n\nYou are adversarial refuter #${n + 1}. A finder claims this bug. Try HARD to refute it by reading the actual code: is the cited path real, is the scenario operator-triggerable, is it already guarded/fixed/known, does the claimed consequence follow? Default to refuted=true if uncertain or if it is a style/theoretical issue.\n\nFinding (lens ${c.lens}):\n${JSON.stringify(c, null, 2)}`, + { label: `refute:${c.lens}`, phase: 'Verify', model: 'sonnet', schema: VERDICT_SCHEMA }) + )).then((votes) => { + const live = votes.filter(Boolean) + return { ...c, survives: live.length >= 1 && live.every((v) => !v.refuted), refuterNotes: live.map((v) => v.notes) } + }) +)) + +const sev = { critical: 0, high: 1, medium: 2, low: 3 } +const confirmed = verified.filter(Boolean).filter((f) => f.survives).sort((a, b) => (sev[a.severity] ?? 9) - (sev[b.severity] ?? 9)) +const rejected = verified.filter(Boolean).filter((f) => !f.survives).map((f) => ({ title: f.title, lens: f.lens })) +log(`${confirmed.length} confirmed, ${rejected.length} rejected`) +return { confirmed, rejected } diff --git a/docs/testing/proactive-bug-finding.md b/docs/testing/proactive-bug-finding.md new file mode 100644 index 00000000..c875a129 --- /dev/null +++ b/docs/testing/proactive-bug-finding.md @@ -0,0 +1,156 @@ +# Finding operator bugs before operators do + +The Canary402 field report (10 issues) had one thing in common: every bug was +found by a real operator doing a **reasonable-but-unanticipated sequence on a +live deployment** — switch chain after registering, configure the tunnel +before selling, combine two documented flags, pass a path where an origin was +expected. None were "the code is wrong on the happy path." Example-based unit +tests never exercise those sequences, which is why humans found them first. + +The approach that finds them without waiting for a human is **an automated +synthetic operator driving generated operation sequences, checked against +invariants instead of hand-written expectations.** A normal test asserts +"after X, status is Y"; an invariant asserts something that must hold after +*any* sequence. Once the invariants exist, anything can generate the traffic. + +This document describes the four layers, cheapest first, and records what the +first run found. + +## The invariants (oracles) + +These are the properties that, if they ever break, mean a bug — regardless of +how the system got there: + +1. **Derived state is a pure function of current spec.** After any spec + mutation, every status field / published document / on-chain artifact + reflects the *current* spec, never a stale prior value. (The worst field + bug — cross-chain AgentID reuse — was this invariant broken for one field.) +2. **Generated names are injective over identity.** Any resource in a shared + namespace has a name that is unique per owning offer's `(namespace, name)`. +3. **Status conditions reflect observed reality, not apply-success.** `Ready`, + `RoutePublished`, `Registered`, tunnel "active", etc. are True only when a + real probe confirms the dataplane/on-chain fact — and flip back False when + the fact stops holding. +4. **Public documents contain only public-intent fields.** No internal CR + field (agent objective, tool addresses, cluster-internal hostnames, model + endpoints, raw Go errors) reaches openapi.json / agent-registration.json / + skill.md / catalog / 402 body without an explicit public gate. +5. **Every URL emitted is fetchable as written** (scheme, host, port, path). +6. **No request reaches a paid upstream without a corresponding settle**, and + no payment authorized for one (route, price, network) passes on another. + +## The four layers + +| Layer | What | Cost | Catches | +|---|---|---|---| +| 1. Invariant oracles | The properties above, written once as reusable checks | one-time | shared by all layers below | +| 2. Stateful model tests | Generated op-sequences run against the reconciler with the fake client; assert invariants after each step | CI, seconds | state-machine / staleness / name / status bugs | +| 3. Canary cluster | Nightly ephemeral k3s; the **real CLI** driven through a scenario + flag-combination matrix; invariants checked as black-box probes | nightly, minutes | everything mocks can't reach: Traefik route-age ties, router rejection, header stripping, nonce lag, real 402/settle | +| 4. Agent audit | Periodic hostile-operator LLM sweep over the code, each finding adversarially verified, output = a ranked triage list | weekly, on-demand | judgment-layer bugs with no mechanical oracle: misleading messages, unclear partial-failure reporting, docs-vs-behavior drift | + +Layer 2 is the highest yield per unit cost and belongs in CI. Layer 3 is the +only layer that reproduces the six field bugs that are invisible without real +infrastructure. Layer 4 is the only layer that finds "this output would +mislead an operator," which has no assertion. + +### Running each layer + +- **Layer 2** — `go test ./internal/serviceoffercontroller/...`. The first + invariant oracle lives in `name_injectivity_test.go` (property test over + `sharedNamespaceNameBuilders`); add a builder there and any future + shared-namespace child is covered. +- **Layer 3** — `hack/canary/run-canary.sh` (ephemeral k3s + scenario matrix + + black-box invariant probes). Runnable skeleton; wire into nightly CI. +- **Layer 4** — `Workflow({ name: "obol-audit" })` from Claude Code (the + `.claude/workflows/obol-audit.js` fan-out: N hostile-operator lenses → 2 + adversarial refuters each → ranked survivors). Re-run after any large + change; feed the `KNOWN` list the already-fixed issues so it doesn't + re-report them. + +## First run — 2026-07-16 + +Layer 4 was run against the integration branch with all six Canary402 fixes +merged in (so it hunted for *new* bugs beyond the field report). 8 lenses × 2 +adversarial refuters. **11 findings survived** verification; 3 were rejected by +a refuter (correctly — one was subsumed by the known cross-chain issue, one was +unreachable via the ForwardAuth path, one was harmless). The layer-1 +name-injectivity oracle independently found the same #1 grant bug the agent +sweep did — two methods, one bug, high confidence. + +| Sev | Finding | Location | Lens | Status | +|-----|---------|----------|------|--------| +| CRITICAL | `--price`/`--per-request` never validated → `decimalToAtomic` mis-prices ($0 on `0,01`) or panics on every request (`abc`/`""`/`$0.01`) | `internal/x402/chains.go` | cli-validation | **fixing** | +| HIGH | ReferenceGrant name `ns-name` dash-join not injective — `(foo-bar, baz)` vs `(foo, bar-baz)` collide in shared x402 ns (HTTP 500) | `internal/serviceoffercontroller/render.go` | name-injectivity | **fixed** (PR #767) | +| HIGH | PurchaseRequest `Ready` never re-checked once True — sidecar outage after configure is invisible | `internal/serviceoffercontroller/purchase.go` | status-truth | open | +| HIGH | Agent `Ready` + `status.WalletAddress` go True before the remote-signer holding the key is up | `internal/serviceoffercontroller/agent.go` | status-truth | open | +| HIGH | Raw Go network errors (internal cluster DNS/IP) leak through the public unauthenticated job-status endpoint | `internal/jobbroker/server.go` | public-surface-leak | open | +| HIGH | No payment dedup — concurrent duplicate `X-PAYMENT` all reach the paid upstream, only one settles (free ride) | `internal/x402/forwardauth.go` | payment-verification | open | +| HIGH | `SetMetadata`/`SetAgentURI` never check `receipt.Status` — a mined-but-reverted tx is reported as success | `internal/erc8004/client.go` | tx-sequencing | open | +| HIGH | Registration recovery by owner+URI trusts a historical Registered event without re-verifying current ownership | `cmd/obol/sell.go` | tx-sequencing | open | +| MEDIUM | `status.AgentResolution` survives a `spec.type` flip away from agent → stale model advertised in public catalog forever | `internal/serviceoffercontroller/agent_resolver.go` | state-staleness | open | +| MEDIUM | `obol tunnel status` reports "active" even when the public-reachability probe just failed | `internal/tunnel/tunnel.go` | status-truth | open | +| MEDIUM | SIWX JSON 401 challenge hardcodes `https://` (ignores `isLocalHost`), unlike every other URL builder in the package | `internal/x402/authgate.go` | url-construction | open | + +Four of the eleven are the **status-truth** invariant (#3) broken in a new +place — the same class as the field report's "offer Ready while Traefik +rejected the router." That clustering is the signal to prioritize invariant #3 +as a layer-2 sweep: gate every readiness condition on an observed probe, the +way `RoutePublished` now is. + +## Why this beats more unit tests + +Unit tests encode what the author expected. These bugs live precisely where the +author's expectation was wrong, so an assertion written from the same mental +model can't catch them. Invariants encode what must be true for *any* input, and +the three generators (sequence fuzzer, canary CLI matrix, hostile-operator +agent) explore the input space the author didn't think to. + +## Second run — 2026-07-16, whole product surface + +The first run covered the serviceoffer/x402/sell core. This run pointed layer 4 +at the **14 subsystems it had not touched** — wallet/key handling, backup +export/import, stack lifecycle, self-update, chain networking, buyer flow, +hermes runtime, openclaw import, model/inference serving, storefront serving, +secrets/config, infra shell-out, and CRD validation — each finder given the +full known-issue list so it would not re-report. 14 lenses × 2 adversarial +refuters (68 agents). **21 findings survived** verification (excluding two +TEE findings tracked separately); 4 rejected by a refuter. + +The subsystems the first pass never reached held the most severe bugs — three +criticals, none in the already-hardened core: + +| Sev | Finding | Location | Subsystem | Status | +|-----|---------|----------|-----------|--------| +| CRITICAL | `obol stack init --force --backend ` destroys the live cluster with zero confirmation, and do | `internal/stack/stack.go` | stack-lifecycle | open | +| CRITICAL | Unescaped openclaw.json fields interpolated into Helm values YAML → arbitrary value injection (i | `internal/openclaw/openclaw.go` | openclaw-import | **fixing** | +| CRITICAL | obol sell inference: default (cluster-available) deployment binds the payment-gated port to ALL | `cmd/obol/sell.go` | model-serving | open | +| HIGH | Secret material (keystore password, wallet metadata) keeps a pre-existing file's permissions on | `internal/openclaw/wallet.go` | wallet-key-security | open | +| HIGH | Import silently clobbers preserved DataDir with no --force gate or confirmation | `internal/stackbackup/import.go` | backup-export-import | open | +| HIGH | `obol stack up` silently reverts operator hand-edits to the local defaults tree (e.g. eRPC value | `internal/defaults/defaults.go` | stack-lifecycle | open | +| HIGH | k3s backend Down()/Destroy() sends `sudo kill -TERM`/`sudo kill -9` to a stale PID with no proce | `internal/stack/backend_k3s.go` | stack-lifecycle | open | +| HIGH | kubectl/helm/k3d/helmfile/k9s (and Ollama's third-party installer) are downloaded and installed | `obolup.sh` | self-update-integrity | open | +| HIGH | One-shot buyer flows (pay / pay-agent / go) sign the seller's quoted price verbatim with no ceil | `internal/embed/skills/buy-x402/scripts/buy.py` | buy-flow | open | +| HIGH | Hermes-dashboard messaging gateway defaults to GATEWAY_ALLOW_ALL_USERS=true with no override tha | `internal/hermes/hermes.go` | hermes-agent-runtime | open | +| HIGH | x402 inference gateway's unauthenticated catch-all route lets clients hit the upstream's native | `internal/inference/gateway.go` | model-serving | open | +| HIGH | Stored XSS via breakout in the JSON-LD structured-data block on every storefront page | `web/public-storefront/src/app/layout.tsx` | storefront-serving | open | +| HIGH | obol stack import trusts archive-declared file mode when extracting secrets into cfg.ConfigDir/c | `internal/stackbackup/tar.go` | secrets-config-defaults | open | +| HIGH | Unsanitized agent instance ID injects arbitrary lines into /etc/hosts (root-owned) via sudo tee | `internal/dns/resolver.go` | infra-plumbing-injection | **fixing** | +| HIGH | AgentWallet.Create has no immutability/reset guard — toggling it off strands a live signing key | `internal/monetizeapi/types.go` | crd-validation-consistency | open | +| MEDIUM | readArchiveManifest (Import's first action) has no decompression-bomb guard — CPU-exhaustion DoS | `internal/stackbackup/tar.go` | backup-export-import | open | +| MEDIUM | verify_release_checksum fails open (silently skips verification) instead of failing closed, unde | `obolup.sh` | self-update-integrity | **fixing** | +| MEDIUM | Seller-controlled catalog `endpoint` field is used verbatim as an outbound request target with n | `cmd/obol/buy.go` | buy-flow | open | +| MEDIUM | Unsanitized --id flows into filesystem paths and a recursive-delete target, enabling directory t | `internal/hermes/hermes.go` | hermes-agent-runtime | open | +| MEDIUM | Unvalidated `agents.defaults.workspace` path + symlink-following recursive copy lets an imported | `internal/openclaw/import.go` | openclaw-import | open | +| MEDIUM | ServiceOfferPriceTable's decimal price fields have no CRD pattern — a malformed value written ou | `internal/monetizeapi/types.go` | crd-validation-consistency | open | + +Fixing this round: the openclaw→Helm-YAML injection (arbitrary Helm values incl. +container image → RCE via `helmfile sync`), the agent `--id` → /etc/hosts +injection (which also closes the sibling `--id` path-traversal), the +`stack init --force` unconfirmed-destroy, and the self-update checksum +fail-open. The rest are a ranked worklist. Two clusters stand out: **destructive +ops without the confirmation gate** that Down/Purge already have (stack init, +backup import clobber, k3s kill-by-stale-PID), and **untrusted input reaching a +privileged sink** (openclaw.json → YAML/helm, agent id → /etc/hosts & fs paths, +backup tar → file modes & paths, catalog endpoint → outbound request). The +`obol stack up` hand-edit reversion is the exact risk your ops runbook already +works around by hand. diff --git a/hack/canary/run-canary.sh b/hack/canary/run-canary.sh new file mode 100755 index 00000000..44399b77 --- /dev/null +++ b/hack/canary/run-canary.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# run-canary.sh — layer 3 of docs/testing/proactive-bug-finding.md. +# +# A synthetic operator on a throwaway cluster: spin ephemeral k3s, install the +# stack, drive the REAL `obol` CLI through a scenario + flag-combination matrix, +# then check the invariants as BLACK-BOX probes (HTTP requests, on-chain reads, +# `obol sell status`) — not against hand-written expectations. This is the only +# layer that reproduces the field bugs that need real Traefik / cloudflared / +# chain behavior (route-age ties, router rejection, header stripping, nonce +# lag, real 402/settle). +# +# It is intentionally fail-LOUD: every invariant probe that trips prints +# CANARY-FAIL with the scenario, and the script exits non-zero so nightly CI +# turns red. Wire it into a nightly job; do NOT point it at a production cluster. +# +# Status: runnable skeleton. The scenario matrix and invariant probes are real; +# the cluster bring-up and the CLI/endpoint specifics marked TODO must be filled +# in against your environment (k3d vs kind, tunnel test-mode, funded test wallet +# for the on-chain scenarios). +set -euo pipefail + +OBOL="${OBOL:-obol}" +FAILURES=0 +fail() { echo "CANARY-FAIL: $*" >&2; FAILURES=$((FAILURES + 1)); } +step() { echo "== $*"; } + +# --- 1. ephemeral cluster --------------------------------------------------- +setup_cluster() { + step "creating ephemeral k3s" + # TODO: k3d cluster create obol-canary --wait (or kind); then `obol stack up` + # on the throwaway kubeconfig. NEVER run against a real cluster. + : "${KUBECONFIG:?set KUBECONFIG to the throwaway cluster before running}" +} +teardown_cluster() { step "destroying ephemeral k3s"; : ; } # TODO: k3d cluster delete obol-canary +trap teardown_cluster EXIT + +# --- 2. invariant probes (black-box) ---------------------------------------- +# Each probe returns non-zero (via fail) when an invariant is broken. They read +# only externally-observable state, so they catch bugs regardless of internals. + +# invariant 3 (status truth): an offer reported Ready must actually serve. +probe_ready_means_serves() { # $1=offer $2=ns $3=public-url + local ready; ready=$("$OBOL" sell status "$1" -n "$2" -o json 2>/dev/null | jq -r '.status.conditions[]?|select(.type=="Ready").status' || echo "") + if [ "$ready" = "True" ]; then + local code; code=$(curl -s -o /dev/null -w '%{http_code}' "$3" || echo 000) + # A Ready offer's public URL must not 404 to the storefront / hang. + [ "$code" = "402" ] || [ "$code" = "200" ] || fail "Ready=True but $3 returned HTTP $code (offer $2/$1)" + fi +} + +# invariant 5 (url truth): every URL in the 402 challenge is fetchable as written. +probe_402_urls_fetchable() { # $1=public-url + local body; body=$(curl -s -H 'Accept: application/json' "$1" || echo '{}') + echo "$body" | jq -r '.. | .resource? // .url? // empty' 2>/dev/null | while read -r u; do + [ -z "$u" ] && continue + case "$u" in + http://*local*|https://*) : ;; # ok + http://*) fail "402 challenge advertises non-https URL behind tunnel: $u" ;; + esac + curl -s -o /dev/null --max-time 5 "$u" || fail "402 challenge URL not fetchable: $u" + done +} + +# invariant 4 (no leak): public docs must not contain internal markers. +probe_no_internal_leak() { # $1=public-url (expects a sentinel planted in the Agent objective) + for doc in /api/services.json /openapi.json /.well-known/agent-registration.json /skill.md; do + curl -s "${1}${doc}" 2>/dev/null | grep -qiE 'CANARY_SECRET|cluster\.local|svc:8|internal-only' \ + && fail "internal marker leaked into public ${doc}" + done || true +} + +# invariant 2 (name injectivity): two offers whose (ns,name) dash-collide must both work. +probe_grant_no_collision() { + # (ns=foo-bar, name=baz) and (ns=foo, name=bar-baz): both Ready AND both serve. + # TODO: create both offers, then probe_ready_means_serves each; a collision + # manifests as one of them 500-ing while both report Ready. + : +} + +# --- 3. scenario matrix (the sequences humans hit) -------------------------- +# Each scenario is a sequence of REAL CLI operations followed by invariant +# probes. Add a row here whenever a field bug teaches a new sequence. +run_scenarios() { + step "scenario: switch payment network after registering" + # TODO: obol sell http svc --network base-sepolia --price 0.01 --pay-to $W ... + # register; then `obol sell update svc --network base`; then assert + # status.agentId is chain-scoped (not the sepolia id) and the published + # agent-registration.json has no stale entry. (field issue 1) + + step "scenario: configure tunnel before binding hostname to an offer" + # TODO: create storefront/tunnel first; then `obol sell http svc --hostname H`; + # then probe_ready_means_serves — a stale catch-all storefront route + # manifests as 404. (field issue 5) + + step "scenario: combine --max-in-flight and --rps" + # TODO: obol sell http svc --max-in-flight 10 --rps 5 ...; probe that the route + # actually serves (not silently rejected by Traefik). (field issue 3) + + step "scenario: malformed price (EU comma)" + # TODO: obol sell http svc --price 0,01 ... MUST be rejected by the CLI, not + # accepted and mis-priced/panicking. (2026-07-16 audit critical) + + step "scenario: pass a path to --origin/--endpoint" + # TODO: obol sell register --origin https://host/some/path MUST be rejected. + # (field issue 7) + + step "scenario: dash-colliding offer names across namespaces" + probe_grant_no_collision # (2026-07-16 audit high) +} + +main() { + setup_cluster + run_scenarios + if [ "$FAILURES" -gt 0 ]; then echo "CANARY: $FAILURES invariant(s) broken"; exit 1; fi + echo "CANARY: all invariants held" +} +main "$@"