diff --git a/.claude/workflows/obol-audit.js b/.claude/workflows/obol-audit.js deleted file mode 100644 index eee3ebe0..00000000 --- a/.claude/workflows/obol-audit.js +++ /dev/null @@ -1,99 +0,0 @@ -// 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 deleted file mode 100644 index c875a129..00000000 --- a/docs/testing/proactive-bug-finding.md +++ /dev/null @@ -1,156 +0,0 @@ -# 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 deleted file mode 100755 index 44399b77..00000000 --- a/hack/canary/run-canary.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/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 "$@"