feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: agent purchase type within hermes - #614

Closed
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13
Closed

feat: agent purchase type within hermes#614
OisinKyne wants to merge 6 commits into
mainfrom
oisin/rc13

Conversation

@OisinKyne

@OisinKyneOisinKyne commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Hardens the x402 paid-flow stack after rc13 QA caught two distinct issues: stale image
pins that bypassed merged source fixes, and a silent-money-loss path where on-chain
settlement landed but the buyer reported "0 spent." Also adds the missing buyer side for
type:agent ServiceOffers (streaming SSE), plumbs spec.payment.maxTimeoutSeconds
through to the 402 response (previously dropped on the floor), and bumps the Hermes
agent image to pick up the upstream sync_skills() marker-honoring fix.

Driven by plans/rc13report.md (Kody, 2026-06-09); learnings folded into
docs/observability.md and obol-stack-dev/references/release-smoke-debugging.md so
the next debugging session doesn't relearn this from a cold start.

Changes

1. Image pins → 04bebbc (current main HEAD as of branching)

  • internal/embed/infrastructure/base/templates/{x402,llm}.yaml
  • Picks up ab71481 (suppress per-request verifyOnly=false log spam on the in-process
    settle path) and 86b8c9f (buyer drops expired pre-signed auths before signing). Both
    shipped in source for weeks but were not reaching the deployed cluster because the
    embedded pins lagged.
  • Allowlist tests (embed_image_pin_test.go, embed_crd_test.go) updated.

2. maxTimeoutSeconds plumbed end-to-end

  • ServiceOffer.spec.payment.maxTimeoutSecondsRouteRule.MaxTimeoutSeconds
    BuildV2RequirementWithAsset → the 402 wire response.
  • Default raised from hardcoded 60s → 600s (DefaultMaxTimeoutSeconds); operators
    can set any value up to 86400s (24h) via MaxMaxTimeoutSeconds cap.
  • Streaming agent flows can legitimately take minutes-to-hours; the cap is permissive
    because the auth is nonce-bound and replay-safe — the cap exists only to guard against
    operator typos (e.g. ms→s).
  • Regression tests: TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds,
    TestClampMaxTimeoutSeconds, TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds.

3. New buy.py command: pay-agent (streaming SSE for type:agent offers)

  • Sibling of pay, deliberately not an alias of --type inference. inference
    pushes a paid/<model> alias behind LiteLLM; agent responses are first-class data the
    calling agent wants to consume itself (memory, tool-call traces, partial results), so
    no LiteLLM wire-up.
  • Forces "stream": true, sends Accept: text/event-stream, reads response
    line-by-line, flushes each SSE event to stdout so a calling Hermes/OpenClaw can re-emit
    the stream to its own user (telegram, obol hermes chat, REST clients).
  • Default HTTP read timeout 1 hour; operator-overridable via --timeout.
  • obol buy inference now emits a clear pointer at pay-agent instead of just refusing
    type=agent offers (internal/buy/discover.go).
  • (Researched Hermes' ACP mode — JSON-RPC over stdio for editor integration, wrong layer
    for this. The seller already streams OpenAI-compatible SSE through HandleProxy; the
    buyer just needed to consume it.)

4. rc13 headline fix: surface on-chain tx hash on facilitator /settle 5xx

QA caught a 0.001 OBOL mainnet on-chain debit from a request that returned HTTP 503 "Payment settlement failed" while every signal the stack produced said "nothing was
paid." Permit2 settle tx [0xb5122d…932307](https://etherscan.io/tx/0xb5122d818a058e8bf
529380260fa2584ba3d50bfc800f1e906faca34d3932307) mined; the facilitator's post-submit
step then returned 500.

This PR doesn't fix the facilitator (that's the hosted x402.gcp.obol.tech service),
but closes the client-side robustness gap that turned a facilitator hiccup into
silent money loss:

  • Verifier (internal/x402/forwardauth.go): facilitatorSettle now returns the
    parsed response alongside the error on non-200, and the settle interceptor writes
    X-PAYMENT-RESPONSE with the tx hash before committing the 503. Without this the
    on-chain hash was invisible to the buyer.
  • Buyer sidecar (internal/x402/buyer/proxy.go): an upstream 5xx with
    X-PAYMENT-RESPONSE.transaction != "" now triggers ConfirmSpend on the held auth (it
    was spent on-chain), fires OnPaymentUnsettled, and logs the hash + payer + network.
    Previously the auth was released back into the pool, leaving the spent counter
    permanently off and the next reuse attempt to fail on Permit2 nonce-reuse revert.
  • buy.py (internal/embed/skills/buy-x402/scripts/buy.py):
    _print_paid_request_failure decodes the settle header on 5xx and prints a loud ⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN warning with the tx hash and the exact balance --chain <X> command.
  • Regression tests pin the contract on both sides:
    TestForwardAuth_SettleErrorPreservesTxHashInHeader (verifier) and
    TestProxy_UpstreamErrorWithTxHash_PersistsConsume (buyer).

5. Hermes image bump: v2026.5.28v2026.6.5

Closes a separate sub-agent context-bloat bug uncovered while triaging an older
sub-agent during the rc13 session: v2026.5.28's sync_skills() had no
.no-bundled-skills marker check (tools/skills_sync.py:419), so every Hermes pod boot
copied ~24 stock skill categories (~1 MB of SKILL.md text, ~100k tokens of prompt-scan
bloat) from the image-baked /opt/hermes/skills into the PVC — regardless of the marker
our SeedHostFiles writes at agent-create time. Affected every CRD sub-agent, not
just stale PVCs.

Upstream fix landed in v2026.6.5 as commit 2ed96372a ("blank-slate skills"):
sync_skills() now early-returns when the marker exists (tools/skills_sync.py:467).
Verified by reading both tags' source. No containerEnv changes needed — the marker our
code already writes is now honored end-to-end.

  • internal/serviceoffercontroller/agent_render.go (sub-agent default)
  • internal/hermes/hermes.go (master agent default)
  • internal/agentcrd/agent_contract_integration_test.go doc comment updated with the
    v2026.5.28 → v2026.6.5 root cause + upstream commit reference.

Also picks up unrelated fixes between 5.28 and 6.5: gateway max_tokens propagation
chain, multi-profile remote-dashboard sessions, several smaller items.

6. Debugging-lore additions

  • docs/observability.md: new § "Verify settlement against the chain, never the sidecar
    snapshot" with the operator debugging recipe (eth_getLogs curl) and the rc13 tx hash
    as evidence.
  • .agents/skills/obol-stack-dev/references/release-smoke-debugging.md § 11: same
    lesson at the runbook surface so future debugging sessions don't have to rediscover it
    from 0 spent / 2 remaining.

Follow-ups (NOT in this PR — flagged for separate work)

These are deliberately left for separate PRs because they each have non-trivial scope or
live outside this repo. Tracked here so they don't get lost:

  1. Image-pin re-bump after merge. This PR's source changes don't take effect in
    deployed clusters until CI publishes new x402-verifier / x402-buyer images tagged
    with the merge-commit short SHA. A one-line follow-up to
    internal/embed/infrastructure/base/templates/{x402,llm}.yaml will pin those. Until
    then the cluster sees the 04bebbc pin, which has the ab71481 and 86b8c9f fixes but
    not the maxTimeoutSeconds plumb or the settle-tx-hash forensic fix.
    2. Wire the integration test into CI.
    TestIntegration_AgentContract_HonorsRenderedConstraints (//go:build integration, in
    internal/agentcrd/agent_contract_integration_test.go) verifies the no-bundled-skills
    contract end-to-end on a live cluster, but isn't referenced in any .github/workflows/
    or flows/release-smoke.sh. Manual-only invocation means rc13's bloat went undetected
    for weeks. Add it to release-smoke (or a Hermes-bump gate).
  2. Renovate annotation on the Hermes pin. Two defaultImage / defaultHermesImage
    constants in this repo currently track the Hermes release cadence by hand. A Renovate # renovate: ... annotation would surface future bumps automatically — sized as a 2-line
    follow-up.
  3. Stale /data/.hermes/skills cleanup on pre-b121f99 PVCs. Agents created before
    the streamline work (June 2) have ~/.hermes/skills/ already populated; even with the
    v2026.6.5 marker honoring, that dir is already on disk and load_skills() reads it.
    Manual fix per agent: kubectl exec -n <ns> deploy/hermes -- mv /data/.hermes/skills /data/.hermes/skills.bundled-disabled-<ts> + kubectl rollout restart deploy/hermes -n <ns>. Not worth code in this repo — strictly an upgrade-migration concern for known
    existing deployments.
  4. Full on-chain receipt verification in the verifier. Today this PR exposes the tx
    hash so an operator can reconcile manually. The bigger fix is to query an in-cluster RPC
    (eRPC) for the receipt status before deciding 200 vs 5xx — when settle lands on-chain,
    the verifier could serve the upstream response and report success. Needs RPC client
    plumbed into the verifier with per-network config; sized as its own PR.
  5. Settle idempotency on retry. A retried request currently relies on Permit2 nonce
    reuse reverting on-chain to prevent double-debit — that works but burns gas and surfaces
    as cascading 503s. Proper fix: an auth state machine with PENDING → SETTLED
    transitions guarded by tx receipt confirmation.
  6. Facilitator-side post-submit bug. Why does mainnet OBOL /settle return 500
    after a successful on-chain submit while Base Sepolia USDC settles cleanly? Out of scope
    (hosted x402.gcp.obol.tech), but the receipt-tx for the reproducible failure is in
    plans/rc13report.md.
  7. rc12 punch list still confirmed in rc13 (not touched by this PR; recorded in
    plans/rc13report.md):
    • obol wallet import re-breaks Hermes ownership (fix = rollout restart master).
    • Purchase-delete hangs on unconsumed auths; no --force / abandon path.
    • purge data-dir cleanup on headless sudo fails (cleaned manually via root
      container).
  8. Permit2 allowance-drain investigation (carried over from a prior session): does
    the OBOL token's _spendAllowance special-case type(uint256).max? If it doesn't,
    every settled transfer decrements a "max" allowance until 0, and the fix is in periodic
    re-approval, not cmd_pay pre-flights.

Test plan

  • go test ./... clean.
  • New regression tests in this PR all pass:
    • TestForwardAuth_SettleErrorPreservesTxHashInHeader
    • TestProxy_UpstreamErrorWithTxHash_PersistsConsume
    • TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds
    • TestClampMaxTimeoutSeconds,
      TestBuildV2RequirementWithAsset_HonorsMaxTimeoutSeconds
  • python3 buy.py pay-agent shows usage and exits 1; python3 buy.py help text
    lists the new command.
  • Verified upstream Hermes source (../NousResearch/hermes-agent): v2026.5.28
    sync_skills has no marker check; v2026.6.5 early-returns on the marker at
    tools/skills_sync.py:467 (commit 2ed96372a).
  • Manual after merge + image republish: repeat the rc13 mainnet OBOL cycle
    (obol sell inference → curl with X-PAYMENT) and verify that a forced facilitator 500
    (if reproducible) now surfaces the tx hash to the buyer and accounts the auth as spent.
  • Manual after merge + image republish: confirm maxTimeoutSeconds: 1800 in a
    ServiceOffer reaches the 402 wire response (curl the offer endpoint, decode the
    accepts[].maxTimeoutSeconds field).
  • Manual after Hermes v2026.6.5 image pull: run
    TestIntegration_AgentContract_HonorsRenderedConstraints (go test -tags integration -v -run TestIntegration_AgentContract_HonorsRenderedConstraints ./internal/agentcrd/) on
    a fresh cluster and confirm /data/.hermes/skills is empty after pod ready.
  • obol buy inference <type=agent-offer> returns the new actionable error pointing
    at pay-agent.

Implemented #2 + #3

#3 — Renovate annotation (3 files):

  • internal/hermes/hermes.go: // renovate: datasource=docker
    depName=nousresearch/hermes-agent above defaultImage
  • internal/serviceoffercontroller/agent_render.go: same annotation above
    defaultHermesImage
  • renovate.json:
    • New customManager regex that captures the version after the last : from
      hermes-agent:vX.Y.Z strings (verified against both files, doesn't trip on the existing
      rawChartVersion helm annotation).
    • New packageRule grouping nousresearch/hermes-agent bumps with a labelled,
      Monday-scheduled PR template that includes a pre-merge gate explicitly reminding the
      reviewer to run release-smoke (flow-16 + flow-17 mentions).
    • Major-version-update gate that requires dashboard approval — Hermes majors have
      historically changed skill layout / marker semantics, exactly the class of regression
      that broke v2026.5.28.

#2 — CI wiring (3 files):

  • flows/flow-16-sell-agent.sh: 3 new assertions mirroring the Go integration test —
    • .no-bundled-skills marker present on host PVC,
    • Pod-side /data/.hermes/obol-skills populated with the operator subset,
    • Pod-side /data/.hermes/skills absent or empty (the load-bearing check that would
      have caught the v2026.5.28 bloat).
  • flows/release-smoke.sh: flow-16-sell-agent.sh added to the main flow sequence with a
    comment explaining why it's now release-gating.
  • internal/agentcrd/agent_contract_integration_test.go: doc comment updated to call out
    that flow-16 now provides CI coverage of the same invariants — Go test is white-box
    developer surface, flow-16 is the black-box CI gate.

Verification:

  • bash -n on both flow scripts — clean.
  • python3 -m json.tool renovate.json — valid.
  • Regex sanity test against both Go files matches exactly once each, captures
    currentValue=v2026.6.5, doesn't false-match the existing rawChartVersion = "2.0.2"
    annotation.
  • go build ./... and go test ./... — clean.

@OisinKyne
OisinKyne requested a review from bussyjdJune 9, 2026 18:17
OisinKyneand others added 5 commits June 9, 2026 19:44
…ardening, doc alignment
Synthesis of a multi-agent review of this PR (every finding adversarially
verified by two independent checkers; build+786 unit tests green throughout).
Release-blocking:
- flow-16 ended with undefined `summary` → exit 127 under set -euo pipefail,
so every release-smoke run would report FAIL the moment this PR wires
flow-16 into the unconditional flows array. Replaced with emit_metrics
(the lib.sh idiom every other flow ends with).
- Image pins at 04bebbc are this PR's own merge base: the shipped controller
still renders sub-agents with Hermes v2026.5.28 (compiled-in default), the
verifier lacks the tx-hash + maxTimeoutSeconds changes, the buyer lacks the
settled-but-failed ConfirmSpend branch. Cannot be fixed pre-merge (no image
containing this PR exists yet) — documented the mandatory post-merge
rebuild+repin in embed_image_pin_test.go. Dev clusters mask this via
OBOL_DEVELOPMENT rebuilds.
Money-path hardening:
- buyer proxy: fire OnConfirmSpendFailure (confirm_spend_failure_total) when
the settled-but-failed branch fails to persist the consumed nonce — the
alert purpose-built for "chain debited but consumed.json missed it"; the
2xx path already did this for the identical error.
- buy.py: paid requests (pay, pay-agent) no longer follow redirects. urllib
converts a redirected POST into a body-less GET and re-sends every header —
including the signed X-PAYMENT voucher — to the Location target.
- buy.py: mid-stream read failures in pay-agent print an actionable warning
(payment already accepted; do not blindly retry) instead of a traceback.
- buy.py: the settled-on-chain warning fires on any failed status (>=400)
with a tx-hash settle header, matching the Go buyer condition; non-dict
settle JSON no longer raises AttributeError.
- buy.py: auth-TTL floor raised 300s → 600s to track the new default settle
window (x402.DefaultMaxTimeoutSeconds).
Consistency:
- docs/observability.md + release-smoke-debugging.md: corrected the "5xx"
claims to the actual conditions (buyer: >=400; verifier: non-200 /settle).
- Replaced all 9 references to never-committed plans/rc13report.md (docs,
code comments, test comments, buy.py runtime output) with pointers to
docs/observability.md ("Verify settlement against the chain").
- renovate.json: Hermes pre-merge gate referenced nonexistent
flow-17-agent-bundled-skills.sh → flow-16-sell-agent.sh §1.1.1/§1.3.1.
- openapi.go: /api/services.json now advertises the clamped
maxTimeoutSeconds the 402 wire actually enforces.
- SKILL.md: corrected "nothing settles on-chain until that one request
succeeds" (disproven by the rc13 incident this PR handles) and the stale
300s TTL-floor rationale.
- discover.go: the pay-agent pointer also fires for storefront-base probes
of agent-only sellers, not just exact /services/<name> URLs.
- flow-16: pod gate on the Ready condition (not phase Running) so the
exec-based bundled-skills assertions cannot false-pass mid-boot.
Intentionally skipped: buy.py wire-echo fallback maxTimeoutSeconds=60 for
sellers that omit the field (changing the echo risks requirement matching);
PaymentEventUnsettled naming conflation (cosmetic); verifier-side metric for
settle-error-with-tx (follow-up). Note the "default 600s" applies to static
routes + the standalone gateway — ServiceOffer paths pin 300 via the CLI
--max-timeout default and the CRD schema default.
Removed plans whose work landed and whose durable learnings already live in
the skill references:
- obol-sell-demo.md — implemented (`obol sell demo` ships in the CLI)
- post-490-integration-20260513.md — integration landed in May
- release-smoke-hardening-20260513.md — session retro; learnings folded into
obol-stack-dev/references/release-smoke-debugging.md
- inference-v1337-{buy-report,followup}-20260514.md — QA reports; the WAF/UA
findings live in release-smoke-debugging.md §10 (c2dddc1)
Kept: sell-agent-perf.md (in progress), openapi-402-followups.md (pending
walk-through), openapi-redoc-storefront.md (phase 2 deferred),
storefront-buy-inference-cta-handoff.md (active frontend handoff),
volume-permission-hardening.md (PR #615 owns its rewrite).
bussyjd added a commit that referenced this pull request Jun 9, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
@bussyjd

Copy link
Copy Markdown
Contributor

Superseded by the v0.10.0-rc14 release train: #616 — this PR's branch is merged there verbatim (merge commit preserved, full union build + test green). Closing in favor of the consolidated release PR.

@bussyjdbussyjd closed this Jun 10, 2026
bussyjd added a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
…with #614
Applies the verified findings from the cross-review against PR #614 (every
item adversarially confirmed against the refs; union merge-tree clean).
PVC / upgrade path:
- llm.yaml: restore container-level runAsUser/runAsGroup 1000 on x402-buyer.
Clusters upgraded in place from <= rc12 keep hostPath-typed PVs where
kubelet skips fsGroup; their /state dir is 1000:1000 with consumed.json
written 0600 by UID 1000 — a 65532 sidecar cannot read it, Fatalf's on
`load state`, and takes every paid/<model> route down. On fresh local-type
PVs the explicit UID is harmless (fsGroup 65532 grants group access).
embed_buyer_state_test.go updated to pin the new contract.
- plans/volume-permission-hardening.md: new "Upgrading from <= v0.10.0-rc12"
section — supported path is cluster recreation (wallet backup/restore),
with a documented k3d chown escape hatch. troubleshooting.md gets the
symptom->fix entry. The Hermes half of the legacy-PV breakage cannot be
patched at runtime without reintroducing the chown machinery this PR
removes, so it is a documented breaking change instead.
Paid-route availability:
- llm.yaml: Reloader annotation narrowed to litellm-config only. The buyer
ConfigMaps (x402-buyer-config/x402-buyer-auths) are rewritten by the
controller on every buy, top-up, auto-refill, and tombstone cleanup;
with strategy Recreate + 1 replica the previous annotation bounced the
entire inference gateway (all Hermes traffic, in-flight SSE streams) on
every purchase event, inverting CLAUDE.md pitfall 7 (restart is fallback,
not the default buy path). The buyer hot-reloads via /admin/reload.
stack_test.go updated to pin litellm-config-only.
Flow alignment with #614:
- lib.sh: `stack down` -> `stack down --yes` in reset_flow_workspace. #614's
flow-16 (now last in the single-stack array) intentionally leaves a live
agent offer; without --yes the non-TTY ConfirmRunningServicesLoss gate
refuses, graceful down is silently skipped (`|| true`), and teardown
degrades to the raw k3d-delete fallback on every release-smoke run.
- flow-11: post-register Ready poll 120s -> 300s to match flow-14's identical
live-Base-Sepolia chain-watch path (pitfall 13 free-tier RPC throttling).
Known follow-ups (not in this commit): flow-08 buy-retry top-up vs exactly-N
assertions on rare partial failures; flow-11 lacks flow-14's remote-signer
rolled guard; aztec PVC has no permission story (runs as root today);
post-merge controller repin so released sub-agents pick up this PR's
UID-1000 render (tracked in #614's pin-test note).
OisinKyne pushed a commit that referenced this pull request Jun 10, 2026
CLAUDE.md (+6 lines net): `sell mcp` in the command tree + one-line
description; `pay`/`pay-agent` added to the buy.py command table; pitfall 7
notes the rc14 Reloader behavior (litellm-config restarts, buyer CMs stay
hot-reload); new pitfalls 15 (settled-but-failed tx-hash semantics) and 16
(<= rc12 hostPath-PV upgrade breakage).
plans/: dropped sell-agent-perf.md (max_turns 30 + reasoning_effort low
shipped in agent_render.go; streaming shipped in #614); trimmed
openapi-redoc-storefront.md to the deferred phase-2 design (phase 1 —
Scalar UI + /openapi.json — is live); openapi-402-followups.md intro
updated: its trigger (oisin/openapi landing) has fired, items are now
actionable.
@OisinKyne
OisinKyne deleted the oisin/rc13 branch July 1, 2026 12:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@OisinKyne@bussyjd