From 85b1d611b96f5171eb723c86c8bd307f2e680895 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sat, 20 Jun 2026 12:05:49 +0400 Subject: [PATCH 1/2] chore(deps): bump nousresearch/hermes-agent v2026.6.5 -> v2026.6.19 Latest hermes-agent release (2026-06-19; image verified published on Docker Hub). Bumps both pinned constants (agent_render.go defaultHermesImage, hermes.go defaultImage). Per the agent-contract integration test header, re-run flow-16-sell-agent.sh + internal/agentcrd contract test (bundled-skills-empty + marker invariants) before merging. --- internal/hermes/hermes.go | 2 +- internal/serviceoffercontroller/agent_render.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index 5b901042..c9dd6d98 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -35,7 +35,7 @@ const ( rawChartVersion = "2.0.2" // renovate: datasource=docker depName=nousresearch/hermes-agent - defaultImage = "nousresearch/hermes-agent:v2026.6.5" + defaultImage = "nousresearch/hermes-agent:v2026.6.19" // Use the upstream image venv instead of cloning Hermes into the PVC on // every cold start. The init container below validates the required extras // are present so image regressions fail before the gateway starts. diff --git a/internal/serviceoffercontroller/agent_render.go b/internal/serviceoffercontroller/agent_render.go index e1ade18a..f92f1d9a 100644 --- a/internal/serviceoffercontroller/agent_render.go +++ b/internal/serviceoffercontroller/agent_render.go @@ -27,7 +27,7 @@ const ( hermesDataPVC = "hermes-data" hermesAPIPath = "/health" // renovate: datasource=docker depName=nousresearch/hermes-agent - defaultHermesImage = "nousresearch/hermes-agent:v2026.6.5" + defaultHermesImage = "nousresearch/hermes-agent:v2026.6.19" ) // agentLabels returns the standard label set we attach to every primitive From 7604d76e4a2ab201fb6e13b91a00f355ca899daa Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 21 Jun 2026 10:45:31 +0400 Subject: [PATCH 2/2] feat(embed): ship pay_mcp hermes plugin by default (CopyPlugins) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed the pay_mcp plugin in the obol-stack binary and seed it onto every agent's user-plugins dir, mirroring how embedded skills are seeded — so an agent can settle paid (x402) MCP tools out of the box. obol pins the upstream hermes image and can't ride the plugin in via the image, so we vendor + seed. - internal/embed: CopyPlugins + GetEmbeddedPluginNames (mirror CopySkills), embed internal/embed/plugins/pay_mcp (vendored, relative-imports invariant). - internal/hermes (master agent): syncObolPlugins seeds $HERMES_HOME/plugins; generateConfig sets plugins.enabled: [pay_mcp] (user plugins are opt-in). - internal/serviceoffercontroller + internal/agentcrd (sell sub-agents): renderHermesConfig enables pay_mcp; SeedHostPlugins seeds it on the host PVC. Inert unless the pod has REMOTE_SIGNER_URL (wallet-bearing agents only). - Tests: CopyPlugins (seed, no __pycache__, relative-imports-only, manifest name, preserve user plugins), config-enables-pay_mcp on both agent paths, SeedHostPlugins seed+preserve. Plugin stays inert without a signer, so it's safe to ship everywhere; users remain free to customize their own profile/plugins. --- internal/agentcrd/agent.go | 26 ++ internal/agentcrd/agent_test.go | 33 ++ internal/embed/embed.go | 86 ++++ internal/embed/embed_plugins_test.go | 137 +++++++ internal/embed/plugins/.gitignore | 3 + internal/embed/plugins/pay_mcp/VENDORED.md | 42 ++ internal/embed/plugins/pay_mcp/__init__.py | 67 ++++ internal/embed/plugins/pay_mcp/payment.py | 367 ++++++++++++++++++ internal/embed/plugins/pay_mcp/plugin.yaml | 6 + internal/embed/plugins/pay_mcp/rails.py | 151 +++++++ internal/embed/plugins/pay_mcp/recovery.py | 92 +++++ internal/embed/plugins/pay_mcp/x402.py | 266 +++++++++++++ internal/hermes/hermes.go | 36 ++ internal/hermes/hermes_test.go | 49 +++ .../serviceoffercontroller/agent_render.go | 3 + .../agent_render_test.go | 18 + 16 files changed, 1382 insertions(+) create mode 100644 internal/embed/embed_plugins_test.go create mode 100644 internal/embed/plugins/.gitignore create mode 100644 internal/embed/plugins/pay_mcp/VENDORED.md create mode 100644 internal/embed/plugins/pay_mcp/__init__.py create mode 100644 internal/embed/plugins/pay_mcp/payment.py create mode 100644 internal/embed/plugins/pay_mcp/plugin.yaml create mode 100644 internal/embed/plugins/pay_mcp/rails.py create mode 100644 internal/embed/plugins/pay_mcp/recovery.py create mode 100644 internal/embed/plugins/pay_mcp/x402.py diff --git a/internal/agentcrd/agent.go b/internal/agentcrd/agent.go index c2f81c06..2194b70f 100644 --- a/internal/agentcrd/agent.go +++ b/internal/agentcrd/agent.go @@ -45,6 +45,13 @@ func HostSkillsPath(cfg *config.Config, name string) string { return filepath.Join(HostHomePath(cfg, name), "obol-skills") } +// HostPluginsPath is the per-agent user-plugins dir. Hermes discovers +// directory plugins at $HERMES_HOME/plugins; with HERMES_HOME=/data/.hermes +// inside the pod that resolves here on the host PVC. +func HostPluginsPath(cfg *config.Config, name string) string { + return filepath.Join(HostHomePath(cfg, name), "plugins") +} + // HostSoulPath is where the seeded Hermes identity file lives. Hermes reads // uppercase SOUL.md from HERMES_HOME, so keep this path aligned with upstream // Hermes profile semantics. @@ -112,9 +119,28 @@ func SeedHostFiles(cfg *config.Config, name string, skills []string, objective s if err := writeNoBundledSkillsMarker(cfg, name); err != nil { return false, fmt.Errorf("write no-bundled-skills marker: %w", err) } + if err := SeedHostPlugins(cfg, name); err != nil { + return false, fmt.Errorf("seed plugins: %w", err) + } return WriteSoul(cfg, name, objective, opts.OverwriteSoul) } +// SeedHostPlugins copies the embedded hermes plugins into the agent's +// user-plugins dir on the host PVC. The plugins are enabled via the +// plugins.enabled list in the rendered Hermes config (see +// serviceoffercontroller.renderHermesConfig); pay_mcp stays inert unless the +// pod also has a signer (REMOTE_SIGNER_URL), which the reconciler wires only +// for wallet-bearing agents. Refreshes shipped plugins on every reconcile and +// leaves user-added plugins with other names untouched (CopyPlugins only +// writes embedded files). +func SeedHostPlugins(cfg *config.Config, name string) error { + dst := HostPluginsPath(cfg, name) + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("create plugins dir %s: %w", dst, err) + } + return embed.CopyPlugins(dst) +} + // writeNoBundledSkillsMarker drops a `.no-bundled-skills` file into the agent's // Hermes profile dir so the runtime skips seeding its ~80 bundled skills. // Idempotent: an existing marker is left as-is. The file is intentionally empty; diff --git a/internal/agentcrd/agent_test.go b/internal/agentcrd/agent_test.go index 931e96c8..4a910224 100644 --- a/internal/agentcrd/agent_test.go +++ b/internal/agentcrd/agent_test.go @@ -150,6 +150,39 @@ func TestSeedHostFiles_FreshAgent(t *testing.T) { if _, err := os.Stat(marker); err != nil { t.Errorf("no-bundled-skills marker missing: %v", err) } + + // A fresh seed must also drop the pay_mcp plugin into the user-plugins dir + // so a wallet-bearing sell agent can settle paid MCP tools. + pluginInit := filepath.Join(HostPluginsPath(cfg, "quant"), "pay_mcp", "__init__.py") + if _, err := os.Stat(pluginInit); err != nil { + t.Errorf("pay_mcp plugin not seeded: %v", err) + } +} + +// SeedHostPlugins seeds the embedded plugins into the agent's user-plugins dir +// and must leave a user-added plugin with a different name untouched on re-seed. +func TestSeedHostPlugins_SeedsAndPreserves(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{DataDir: dir} + + custom := filepath.Join(HostPluginsPath(cfg, "quant"), "operator-plugin") + if err := os.MkdirAll(custom, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(custom, "plugin.yaml"), []byte("name: operator-plugin\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := SeedHostPlugins(cfg, "quant"); err != nil { + t.Fatalf("SeedHostPlugins: %v", err) + } + + if _, err := os.Stat(filepath.Join(HostPluginsPath(cfg, "quant"), "pay_mcp", "plugin.yaml")); err != nil { + t.Errorf("pay_mcp not seeded: %v", err) + } + if _, err := os.Stat(filepath.Join(custom, "plugin.yaml")); err != nil { + t.Errorf("operator plugin clobbered by seed: %v", err) + } } // The marker must already exist on a re-seed (e.g. agent objective change) — diff --git a/internal/embed/embed.go b/internal/embed/embed.go index 1deb3df4..5aa53ace 100644 --- a/internal/embed/embed.go +++ b/internal/embed/embed.go @@ -29,6 +29,9 @@ var networksFS embed.FS //go:embed all:skills var skillsFS embed.FS +//go:embed all:plugins +var pluginsFS embed.FS + // InfrastructureDigest returns a stable digest of the embedded infrastructure // assets. Callers use this to decide whether an existing copied defaults tree // needs to be refreshed from the current binary. @@ -294,6 +297,89 @@ func GetEmbeddedSkillNames() ([]string, error) { return names, nil } +// CopyPlugins recursively copies all embedded hermes plugins to the destination +// directory (the agent's user-plugins dir, e.g. $HERMES_HOME/plugins). Mirrors +// CopySkills: it only writes files from the embedded FS, so user-added plugins +// with different names are preserved, and re-running on an existing deployment +// refreshes the shipped plugins to the current binary. +// +// __pycache__ dirs and .pyc/.pyo files are skipped defensively — they can get +// generated when a dev runs the plugin locally before `go build` and would +// otherwise be baked into the embed.FS and seeded onto every agent's PVC, +// confusing python on a different interpreter version. The plugins/.gitignore +// keeps them out of the repo; this is belt-and-suspenders. +func CopyPlugins(destDir string) error { + return fs.WalkDir(pluginsFS, "plugins", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip root plugins directory + if path == "plugins" { + return nil + } + + // Skip generated python caches. + if d.IsDir() && d.Name() == "__pycache__" { + return fs.SkipDir + } + if !d.IsDir() { + if name := d.Name(); strings.HasSuffix(name, ".pyc") || strings.HasSuffix(name, ".pyo") { + return nil + } + } + + // Get relative path within plugins/ + relPath := strings.TrimPrefix(path, "plugins/") + destPath := filepath.Join(destDir, relPath) + + if d.IsDir() { + if err := os.MkdirAll(destPath, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", destPath, err) + } + + return nil + } + + // Ensure parent directory exists + parentDir := filepath.Dir(destPath) + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) + } + + // Read embedded file + data, err := pluginsFS.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read embedded file %s: %w", path, err) + } + + // Write to destination + if err := os.WriteFile(destPath, data, 0o600); err != nil { + return fmt.Errorf("failed to write file %s: %w", destPath, err) + } + + return nil + }) +} + +// GetEmbeddedPluginNames returns the names of all embedded plugin directories. +func GetEmbeddedPluginNames() ([]string, error) { + entries, err := fs.ReadDir(pluginsFS, "plugins") + if err != nil { + return nil, fmt.Errorf("failed to read embedded plugins: %w", err) + } + + var names []string + + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + + return names, nil +} + // CopyNetwork recursively copies an embedded network to the destination directory func CopyNetwork(networkName, destDir string) error { networkPath := filepath.Join("networks", networkName) diff --git a/internal/embed/embed_plugins_test.go b/internal/embed/embed_plugins_test.go new file mode 100644 index 00000000..87c23b9c --- /dev/null +++ b/internal/embed/embed_plugins_test.go @@ -0,0 +1,137 @@ +package embed + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// payMCPFiles are the files the embedded pay_mcp plugin must ship. The plugin +// loads from a directory, so plugin.yaml + __init__.py are load-critical; the +// rest are imported relatively by __init__/register(). +var payMCPFiles = []string{ + "plugin.yaml", "__init__.py", "x402.py", "rails.py", "payment.py", "recovery.py", +} + +func TestGetEmbeddedPluginNames(t *testing.T) { + names, err := GetEmbeddedPluginNames() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + found := false + for _, n := range names { + if n == "pay_mcp" { + found = true + } + } + if !found { + t.Fatalf("embedded plugins %v missing pay_mcp", names) + } +} + +func TestCopyPlugins_SeedsPayMCP(t *testing.T) { + dst := t.TempDir() + if err := CopyPlugins(dst); err != nil { + t.Fatalf("CopyPlugins: %v", err) + } + + for _, f := range payMCPFiles { + p := filepath.Join(dst, "pay_mcp", f) + info, err := os.Stat(p) + if err != nil { + t.Errorf("missing seeded file %s: %v", f, err) + continue + } + if info.Size() == 0 { + t.Errorf("seeded file %s is empty", f) + } + } +} + +func TestCopyPlugins_NoPycache(t *testing.T) { + dst := t.TempDir() + if err := CopyPlugins(dst); err != nil { + t.Fatalf("CopyPlugins: %v", err) + } + err := filepath.Walk(dst, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() && info.Name() == "__pycache__" { + t.Errorf("__pycache__ leaked into seed: %s", path) + } + if !info.IsDir() && (strings.HasSuffix(path, ".pyc") || strings.HasSuffix(path, ".pyo")) { + t.Errorf("compiled python leaked into seed: %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestCopyPlugins_RelativeImportsOnly is the inject-by-default invariant: a +// user-dir plugin is loaded under the synthetic package name +// hermes_plugins.pay_mcp, and a stock hermes image has no bundled +// plugins.pay_mcp to satisfy an absolute self-import. So the embedded copy must +// import its own modules relatively (`from . import x402`), never +// `from plugins.pay_mcp import x402`. Upstream locks this with +// tests/plugins/test_pay_mcp_userdir_load.py; we re-assert on the vendored copy +// because the two are synced by hand. +func TestCopyPlugins_RelativeImportsOnly(t *testing.T) { + dst := t.TempDir() + if err := CopyPlugins(dst); err != nil { + t.Fatalf("CopyPlugins: %v", err) + } + pyFiles := []string{"__init__.py", "x402.py", "rails.py", "payment.py", "recovery.py"} + for _, f := range pyFiles { + data, err := os.ReadFile(filepath.Join(dst, "pay_mcp", f)) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + if strings.Contains(string(data), "from plugins.pay_mcp") || + strings.Contains(string(data), "import plugins.pay_mcp") { + t.Errorf("%s uses an absolute self-import; must be relative "+ + "(breaks load from the user-plugins dir on a stock image)", f) + } + } +} + +func TestCopyPlugins_ManifestNamesPayMCP(t *testing.T) { + dst := t.TempDir() + if err := CopyPlugins(dst); err != nil { + t.Fatalf("CopyPlugins: %v", err) + } + data, err := os.ReadFile(filepath.Join(dst, "pay_mcp", "plugin.yaml")) + if err != nil { + t.Fatalf("read plugin.yaml: %v", err) + } + // The seeded manifest name must match the plugins.enabled entry the agent + // configs write (pay_mcp), or the plugin is discovered-but-not-enabled. + if !strings.Contains(string(data), "name: pay_mcp") { + t.Errorf("plugin.yaml does not declare `name: pay_mcp`:\n%s", data) + } +} + +// TestCopyPlugins_PreservesUserPlugins mirrors the skills contract: re-seeding +// must not delete a user-added plugin with a different name. +func TestCopyPlugins_PreservesUserPlugins(t *testing.T) { + dst := t.TempDir() + custom := filepath.Join(dst, "my-own-plugin") + if err := os.MkdirAll(custom, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(custom, "plugin.yaml"), []byte("name: my-own-plugin\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := CopyPlugins(dst); err != nil { + t.Fatalf("CopyPlugins: %v", err) + } + if _, err := os.Stat(filepath.Join(custom, "plugin.yaml")); err != nil { + t.Errorf("user plugin was clobbered by re-seed: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, "pay_mcp", "__init__.py")); err != nil { + t.Errorf("pay_mcp not seeded alongside user plugin: %v", err) + } +} diff --git a/internal/embed/plugins/.gitignore b/internal/embed/plugins/.gitignore new file mode 100644 index 00000000..3bbe7b6f --- /dev/null +++ b/internal/embed/plugins/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/internal/embed/plugins/pay_mcp/VENDORED.md b/internal/embed/plugins/pay_mcp/VENDORED.md new file mode 100644 index 00000000..8a3cf77b --- /dev/null +++ b/internal/embed/plugins/pay_mcp/VENDORED.md @@ -0,0 +1,42 @@ +# Vendored plugin: pay_mcp + +This directory is a **verbatim copy** of the `pay_mcp` hermes-agent plugin, +embedded into the obol-stack binary so the stack can seed it onto every agent's +profile by default (the same way embedded skills are seeded). See +`internal/embed/embed.go` → `CopyPlugins` / `GetEmbeddedPluginNames`. + +## Source + +- Upstream: hermes-agent, `plugins/pay_mcp/` + (branch `feat/pay-mcp-plugin` on `bussyjd/hermes-agent`). +- Synced at hermes commit `d8ca58055` (relative-import fix for user-dir load). +- Files: `__init__.py`, `x402.py`, `rails.py`, `payment.py`, `recovery.py`, + `plugin.yaml`. + +## Why a copy (not a submodule) + +obol pins the **upstream** `nousresearch/hermes-agent` image and does not build +it, so the plugin can't ride in via the image. Embedding the source in the +obol-stack binary lets `obol` drop it into the agent's user-plugins dir +(`/data/.hermes/plugins/pay_mcp/`, i.e. `$HERMES_HOME/plugins`) on stack-up / +agent reconcile, with no image rebuild. The agent then auto-loads it (the obol +config seeds `plugins.enabled: [pay_mcp]`) and it self-activates from the +`REMOTE_SIGNER_URL` already on the pod. + +## Invariants (do not break when re-syncing) + +- **Relative imports only.** Intra-package imports must be `from . import x402`, + never `from plugins.pay_mcp import x402`. Hermes loads a user-dir plugin under + the synthetic package name `hermes_plugins.pay_mcp`, and a stock image has no + bundled `plugins.pay_mcp` to satisfy an absolute import. (Locked upstream by + `tests/plugins/test_pay_mcp_userdir_load.py`.) +- **No secrets.** Only public addresses/constants. `import secrets` is the + Python stdlib module (nonce generation), not a credential. +- **Inert by default.** `register()` builds no rails and wires nothing unless a + signer is configured, so it is safe to ship everywhere. + +## Re-syncing + +Re-copy the six files from the upstream plugin dir, keep the relative imports, +and re-run `go test ./internal/embed/...` (the content-parity test checks the +expected files exist, are non-empty, and contain no absolute self-imports). diff --git a/internal/embed/plugins/pay_mcp/__init__.py b/internal/embed/plugins/pay_mcp/__init__.py new file mode 100644 index 00000000..d592c786 --- /dev/null +++ b/internal/embed/plugins/pay_mcp/__init__.py @@ -0,0 +1,67 @@ +"""pay_mcp — settle paid MCP tools (HTTP 402) via pluggable x402 rails. + +When a paid MCP server returns a 402, the plugin settles through a configured +rail (the obol-agent wallet today) and retries with the x402 voucher. It wires +into the MCP call path one of two ways, picked automatically at register(): + + * **core seam** — when ``tools/payment_hook.py`` + the ``tools/mcp_tool.py`` + interceptor are present (a hermes build that carries the upstream-mergeable + seam), the core calls this plugin's handler. Preferred. + * **self-wired** — otherwise (a stock upstream image with the plugin merely + dropped into the user-plugins dir), the plugin wraps the MCP SDK's + ``ClientSession.call_tool`` itself (see ``recovery.install``). This is what + lets obol inject the plugin by default without rebuilding the image. + +Settlement requires an in-chat confirmation by default; an opt-in unattended +policy (caps + asset/recipient allowlists) lets autonomous turns settle. +""" + +from __future__ import annotations + +import logging + +from . import payment, rails, x402 + +logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Activate the pay-for-MCP settlement handler. + + Builds the configured settlement rails and registers the 402 payment + handler. Stays inert (handler not registered) when no rail is configured, + so it is harmless to bundle and only acts where a signer is available. + """ + config = payment.load_config() + built = rails.build_rails(config) + if not built: + logger.info("pay_mcp: no settlement rails configured — inactive") + return + + payment.configure( + built, config["payment_headers"], + auto_max_atomic=config.get("auto_approve_max_atomic", 0), + auto_session_atomic=config.get("auto_approve_session_atomic", 0), + auto_assets=config.get("auto_approve_assets", []), + auto_recipients=config.get("auto_approve_recipients", []), + ) + # Prefer the core seam; fall back to self-wiring on a stock image. + try: + from tools.payment_hook import register_payment_recovery + register_payment_recovery(payment.handle_payment, detector=x402.detect_challenge) + wiring = "core-seam" + except ImportError: + from . import recovery + wiring = "self-wired" if recovery.install() else "INERT (self-wire failed)" + unattended = ( + f"<= {payment._AUTO_MAX_ATOMIC} atomic/call, session <= " + f"{payment._AUTO_SESSION_ATOMIC}, {len(payment._AUTO_ASSETS)} asset(s)/" + f"{len(payment._AUTO_RECIPIENTS)} payee(s) allowlisted" + if payment._auto_enabled() + else ("off — confirm required" + + (" (incomplete unattended policy ignored)" + if config.get("auto_approve_max_atomic", 0) else "")) + ) + logger.info("pay_mcp: active [%s] with rails: %s (headers: %s; unattended: %s)", + wiring, ", ".join(r.name for r in built), + ", ".join(config["payment_headers"]), unattended) diff --git a/internal/embed/plugins/pay_mcp/payment.py b/internal/embed/plugins/pay_mcp/payment.py new file mode 100644 index 00000000..5ecc3294 --- /dev/null +++ b/internal/embed/plugins/pay_mcp/payment.py @@ -0,0 +1,367 @@ +"""Payment orchestration: parse a 402 -> confirm in chat -> settle -> voucher. + +``handle_payment`` is the callable the core MCP payment interceptor invokes +(via ``tools/payment_hook.py``). It runs on the agent thread, so the in-chat +confirmation blocks on a heartbeat-safe Event — never on an event loop. +""" + +from __future__ import annotations + +import logging +import os +import threading +import uuid + +from . import rails as rails_mod +from . import x402 + +logger = logging.getLogger(__name__) + +# obol's in-pod remote-signer default (obol/buy.py uses the same address). +_DEFAULT_SIGNER_URL = "http://remote-signer:9000" + +# obol/x402-rs read the voucher from X-PAYMENT (carrying a v2 body). A strictly +# standards-conformant v2 server reads PAYMENT-SIGNATURE; override via +# ``pay_mcp.payment_headers`` for those. We never emit a v1-shaped body. +_DEFAULT_PAYMENT_HEADERS = ["X-PAYMENT"] + +# Set once in register(). +_RAILS: list = [] +_PAYMENT_HEADERS: list = list(_DEFAULT_PAYMENT_HEADERS) + +# Unattended-settlement policy (autonomous agent turns). Disabled by default — +# every payment then requires an in-chat/CLI confirm. It engages ONLY when a +# COMPLETE bounded policy is configured (see _auto_enabled): a positive per-call +# cap AND a finite session cap AND a non-empty asset allowlist AND a non-empty +# recipient allowlist. With those, a charge auto-settles only when it is within +# both caps and pays an allowlisted asset to an allowlisted recipient; anything +# else is declined — never silently paid, never left blocking on a prompt no +# human will answer. The caps bound atomic units of the (pinned) asset, and the +# asset/recipient pins keep a hostile seller from redirecting funds or inflating +# value via an unexpected token. +_AUTO_MAX_ATOMIC: int = 0 +_AUTO_SESSION_ATOMIC: int = 0 +_AUTO_ASSETS: set = set() # allowed ":" and bare "", lowercased +_AUTO_RECIPIENTS: set = set() # allowed payTo addresses, lowercased +_auto_spent_atomic: int = 0 # cumulative unattended spend this process (guarded by _auto_lock) +_auto_lock = threading.Lock() # serializes the session-budget check-and-reserve + + +def _norm_set(values) -> set: + if isinstance(values, str): + values = [values] + return {str(v).strip().lower() for v in (values or []) if str(v).strip()} + + +def configure(rails: list, payment_headers: list, + auto_max_atomic: int = 0, auto_session_atomic: int = 0, + auto_assets=None, auto_recipients=None) -> None: + global _RAILS, _PAYMENT_HEADERS, _AUTO_MAX_ATOMIC, _AUTO_SESSION_ATOMIC + global _AUTO_ASSETS, _AUTO_RECIPIENTS, _auto_spent_atomic + _RAILS = rails + _PAYMENT_HEADERS = payment_headers or list(_DEFAULT_PAYMENT_HEADERS) + _AUTO_MAX_ATOMIC = max(0, _safe_int(auto_max_atomic, 0, "auto_approve_max_atomic")) + _AUTO_SESSION_ATOMIC = max(0, _safe_int(auto_session_atomic, 0, "auto_approve_session_atomic")) + _AUTO_ASSETS = _norm_set(auto_assets) + _AUTO_RECIPIENTS = _norm_set(auto_recipients) + with _auto_lock: + _auto_spent_atomic = 0 # reconfiguring resets the unattended spend tally + + +def _safe_int(value, default: int, name: str = "") -> int: + """int() that degrades to ``default`` (logged) instead of crashing the plugin. + + A malformed cap must disable autonomy (fail-closed), never take the whole + pay_mcp plugin — including the interactive confirm path — down with it. + """ + try: + return int(value) + except (TypeError, ValueError): + if value not in (None, ""): + logger.warning("pay_mcp: ignoring malformed %s=%r; using %s", name, value, default) + return default + + +def _auto_enabled() -> bool: + """Unattended mode engages only with a complete, bounded policy.""" + return bool( + _AUTO_MAX_ATOMIC > 0 + and _AUTO_SESSION_ATOMIC > 0 + and _AUTO_ASSETS + and _AUTO_RECIPIENTS + ) + + +def load_config() -> dict: + """Resolve pay_mcp settings from env then config.yaml (env wins). + + ``signer_url`` resolves to None unless explicitly opted in, keeping the + plugin inert outside an obol environment. It activates when any of these is + set: ``HERMES_X402_SIGNER_URL`` / ``REMOTE_SIGNER_URL`` env, a + ``pay_mcp.signer_url`` in config.yaml, or ``pay_mcp.enabled: true`` (which + then uses the obol in-pod default). + """ + signer_url = None + auth_ttl = None + enabled = False + payment_headers = None + auto_max_atomic = 0 + auto_session_atomic = 0 + auto_assets = [] + auto_recipients = [] + try: + from hermes_cli.config import load_config as _load, cfg_get + raw = _load() + signer_url = cfg_get(raw, "pay_mcp", "signer_url", default=None) + auth_ttl = cfg_get(raw, "pay_mcp", "auth_ttl", default=None) + enabled = bool(cfg_get(raw, "pay_mcp", "enabled", default=False)) + payment_headers = cfg_get(raw, "pay_mcp", "payment_headers", default=None) + auto_max_atomic = cfg_get(raw, "pay_mcp", "auto_approve_max_atomic", default=0) + auto_session_atomic = cfg_get(raw, "pay_mcp", "auto_approve_session_atomic", default=0) + auto_assets = cfg_get(raw, "pay_mcp", "auto_approve_assets", default=[]) or [] + auto_recipients = cfg_get(raw, "pay_mcp", "auto_approve_recipients", default=[]) or [] + except Exception as exc: + logger.debug("pay_mcp: config.yaml unavailable: %s", exc) + + signer_url = ( + os.environ.get("HERMES_X402_SIGNER_URL") + or os.environ.get("REMOTE_SIGNER_URL") + or signer_url + ) + if not signer_url and enabled: + signer_url = _DEFAULT_SIGNER_URL + + auth_ttl = os.environ.get("HERMES_X402_AUTH_TTL") or auth_ttl or rails_mod._DEFAULT_AUTH_TTL + if isinstance(payment_headers, str): + payment_headers = [payment_headers] + if not payment_headers: + payment_headers = list(_DEFAULT_PAYMENT_HEADERS) + auto_max_atomic = os.environ.get("HERMES_X402_AUTO_MAX_ATOMIC") or auto_max_atomic or 0 + auto_session_atomic = ( + os.environ.get("HERMES_X402_AUTO_SESSION_ATOMIC") or auto_session_atomic or 0 + ) + # Comma-separated env override for the allowlists (CSV → list). + for env, target in (("HERMES_X402_AUTO_ASSETS", "assets"), + ("HERMES_X402_AUTO_RECIPIENTS", "recipients")): + raw_env = os.environ.get(env) + if raw_env: + items = [s for s in (p.strip() for p in raw_env.split(",")) if s] + if target == "assets": + auto_assets = items + else: + auto_recipients = items + return { + "signer_url": signer_url, + "auth_ttl": _safe_int(auth_ttl, rails_mod._DEFAULT_AUTH_TTL, "auth_ttl"), + "payment_headers": list(payment_headers), + "auto_approve_max_atomic": _safe_int(auto_max_atomic, 0, "auto_approve_max_atomic"), + "auto_approve_session_atomic": _safe_int( + auto_session_atomic, 0, "auto_approve_session_atomic"), + "auto_approve_assets": list(auto_assets), + "auto_approve_recipients": list(auto_recipients), + } + + +def handle_payment(server_name: str, challenge: dict) -> dict: + """Parse a 402 challenge, confirm with the user, settle, return a voucher. + + Returns one of: + * ``{"voucher_meta": {...}, "voucher_headers": {...}}`` — settled; core + injects the channel-appropriate form (MCP _meta or HTTP header) + retries. + * ``{"error": str, "declined": bool}`` — declined/failed; core surfaces it. + ``declined`` marks user/policy outcomes that are NOT server faults. + * ``{}`` — not a payment we handle; core falls through. + """ + quote = x402.parse_challenge( + challenge.get("headers") or {}, challenge.get("body") or "", + ) + if quote is None: + return {} # not an x402 'exact' 402 we understand + + rail = rails_mod.select_rail(quote, _RAILS) + if rail is None: + return { + "error": ( + f"No settlement rail can pay this charge " + f"(scheme={quote.scheme}, network={quote.network})." + ), + "declined": True, + } + + price = quote.human_amount() + choice = _approve(quote, price, server_name, rail) + if choice != "Pay": + reason = ("exceeds the unattended payment policy" + if _auto_enabled() else "was declined") + return { + "error": f"Payment of {price} to {server_name} {reason}.", + "declined": True, + } + + try: + payload = rail.settle(quote) # raw x402 PaymentPayload dict + except rails_mod.X402Error as exc: + if _auto_enabled(): + _release_reservation(quote) # settle raised → no charge → free the budget + return {"error": f"Settlement failed: {exc}"} + except Exception as exc: # defensive — a rail bug must not crash the call + logger.warning("pay_mcp: unexpected settle error: %s", exc) + if _auto_enabled(): + _release_reservation(quote) + return {"error": f"Settlement failed: {exc}"} + + logger.info("pay_mcp: settled %s to %s via %s (payTo=%s)", + price, server_name, rail.name, quote.pay_to) + header = x402.encode_header(payload) + return { + "voucher_meta": {x402.MCP_PAYMENT_META_KEY: payload}, + "voucher_headers": {name: header for name in _PAYMENT_HEADERS}, + } + + +def _approve(quote, price: str, server_name: str, rail) -> str: + """Return "Pay" to settle, anything else to decline. + + Unattended policy first: when a COMPLETE autonomous policy is configured + (:func:`_auto_enabled`), decide without a human — this is what lets an agent + turn consume a signed authorization with no one watching. If unattended was + requested via a cap but the policy is incomplete, refuse autonomy and fall + back to the interactive confirm (which fail-closes when no human is present). + """ + if _AUTO_MAX_ATOMIC > 0: + if _auto_enabled(): + return _auto_approve(quote, price, server_name, rail) + logger.error( + "pay_mcp[auto]: unattended settle requested (auto_approve_max_atomic=%s) but " + "policy is INCOMPLETE — also set auto_approve_session_atomic>0, " + "auto_approve_assets, and auto_approve_recipients. Refusing autonomous settle.", + _AUTO_MAX_ATOMIC) + return confirm_price( + price, server_name, recipient=quote.pay_to, rail=rail.name, + asset=quote.asset, atomic=quote.amount, + ) + + +def _auto_approve(quote, price: str, server_name: str, rail) -> str: + """Unattended settle within the pre-authorized policy (no human prompt). + + Approves only a charge that (a) pays an allowlisted asset, (b) pays an + allowlisted recipient, (c) parses to ``0 < amount <= per-call cap``, and (d) + keeps cumulative process spend within the session cap. Everything else is + declined — never silently paid, never left blocking on an unanswerable + prompt. The session-budget check-and-reserve is atomic under ``_auto_lock`` + so parallel tool calls cannot race past the cap; the reservation is rolled + back by :func:`_release_reservation` if the settle then fails. Every decision + logs at WARNING for an auditable spend trail — grep ``pay_mcp[auto]``. + """ + global _auto_spent_atomic + asset = (quote.asset or "").lower() + if not asset or not ({f"{quote.chain_id}:{asset}", asset} & _AUTO_ASSETS): + logger.warning("pay_mcp[auto]: DECLINED %s to %s — asset %s on chain %s not in " + "allowlist", price, server_name, quote.asset, quote.chain_id) + return "" + if (quote.pay_to or "").lower() not in _AUTO_RECIPIENTS: + logger.warning("pay_mcp[auto]: DECLINED %s to %s — recipient %s not in allowlist", + price, server_name, quote.pay_to) + return "" + try: + amt = int(str(quote.amount)) + except (TypeError, ValueError): + logger.warning("pay_mcp[auto]: DECLINED %s to %s — unparseable amount %r", + price, server_name, quote.amount) + return "" + if amt <= 0 or amt > _AUTO_MAX_ATOMIC: + logger.warning("pay_mcp[auto]: DECLINED %s (%s atomic) to %s — over per-call " + "cap %s atomic", price, amt, server_name, _AUTO_MAX_ATOMIC) + return "" + with _auto_lock: + if _AUTO_SESSION_ATOMIC > 0 and _auto_spent_atomic + amt > _AUTO_SESSION_ATOMIC: + logger.warning("pay_mcp[auto]: DECLINED %s to %s — session budget exhausted " + "(%s + %s > %s atomic)", price, server_name, + _auto_spent_atomic, amt, _AUTO_SESSION_ATOMIC) + return "" + _auto_spent_atomic += amt # reserve under lock + spent_now = _auto_spent_atomic + logger.warning("pay_mcp[auto]: APPROVED (unattended) %s = %s atomic to %s via %s " + "(payTo=%s asset=%s); session spend %s/%s atomic", + price, amt, server_name, rail.name, quote.pay_to, quote.asset, + spent_now, _AUTO_SESSION_ATOMIC) + return "Pay" + + +def _release_reservation(quote) -> None: + """Roll back a reserved unattended amount when the settle FAILED (no charge).""" + global _auto_spent_atomic + try: + amt = int(str(quote.amount)) + except (TypeError, ValueError): + return + with _auto_lock: + _auto_spent_atomic = max(0, _auto_spent_atomic - amt) + logger.warning("pay_mcp[auto]: released %s atomic reservation to %s after settle failure", + amt, quote.pay_to) + + +def confirm_price(price: str, server: str, recipient: str = "", rail: str = "", + asset: str = "", atomic: str = "") -> str: + """In-chat price confirmation — blocks for Pay/Cancel on CLI + gateways. + + Surfaces the token contract and exact atomic amount (not just the + 6-decimal-assumed float) so the user approves what is actually signed. + Returns "Pay" only on explicit approval; any other outcome (Cancel, + timeout, undeliverable prompt) returns a non-"Pay" string. + """ + detail = price + if atomic: + detail += f" (~{atomic} atomic units, 6-decimal assumed)" + if asset: + detail += f"\nToken contract: {asset}" + question = ( + f"{server} is requesting payment of {detail}" + + (f"\nRecipient: {recipient}" if recipient else "") + + (f"\nRail: {rail}" if rail else "") + + "\nApprove this payment?" + ) + + # session_key is a contextvar set by the gateway before agent.run — not a + # handler kwarg. A registered clarify notify callback means we're on a chat + # surface; otherwise fall back to the CLI prompt. + from tools.approval import get_current_session_key + from tools import clarify_gateway as clarify + + session_key = get_current_session_key() + if session_key and clarify.get_notify(session_key) is not None: + clarify_id = uuid.uuid4().hex[:10] + entry = clarify.register( + clarify_id=clarify_id, session_key=session_key, + question=question, choices=["Pay", "Cancel"], + ) + try: + clarify.get_notify(session_key)(entry) + except Exception as exc: + logger.warning("pay_mcp: confirm notify failed: %s", exc) + # Drop only THIS entry — clear_session would resolve every pending + # clarify in the session with an empty answer. + clarify.resolve_gateway_clarify(clarify_id, "") + return "" + response = clarify.wait_for_response( + clarify_id, timeout=float(clarify.get_clarify_timeout()), + ) + return response or "" + + # CLI / no-gateway fallback. Forward the thread-local prompt_toolkit + # approval callback (hermes convention) so an interactive CLI shows the + # modal instead of fail-closing to deny. Never permanently allowlist. + from tools.approval import prompt_dangerous_approval + try: + from tools.terminal_tool import _get_approval_callback + approval_cb = _get_approval_callback() + except Exception: + approval_cb = None + decision = prompt_dangerous_approval( + command=f"pay {price} to {server}", + description=f"x402 payment confirmation ({detail})", + allow_permanent=False, + approval_callback=approval_cb, + ) + return "Pay" if decision in {"once", "session", "always"} else "Cancel" diff --git a/internal/embed/plugins/pay_mcp/plugin.yaml b/internal/embed/plugins/pay_mcp/plugin.yaml new file mode 100644 index 00000000..f801570f --- /dev/null +++ b/internal/embed/plugins/pay_mcp/plugin.yaml @@ -0,0 +1,6 @@ +name: pay_mcp +version: 0.1.0 +description: "Pay-for-MCP — settles HTTP 402 from paid MCP servers via x402 (v2, EIP-3009 'exact') using the obol-agent wallet/remote-signer, after an in-chat price confirmation. Modular settlement rails (obol credit layer today; MPP/local-key/prepaid-pool pluggable). Inert until a signer is configured: set HERMES_X402_SIGNER_URL / REMOTE_SIGNER_URL env, pay_mcp.signer_url in config.yaml, or pay_mcp.enabled: true (uses the obol in-pod default)." +author: NousResearch +kind: backend +provides_hooks: [] diff --git a/internal/embed/plugins/pay_mcp/rails.py b/internal/embed/plugins/pay_mcp/rails.py new file mode 100644 index 00000000..7065aca4 --- /dev/null +++ b/internal/embed/plugins/pay_mcp/rails.py @@ -0,0 +1,151 @@ +"""Settlement rails — the pluggable 'generic credit layer'. + +A rail turns a normalized :class:`~plugins.pay_mcp.x402.Quote` into the base64 +voucher to attach to the retried MCP call. The default rail signs an x402 +'exact' EIP-3009 voucher with the obol-agent's wallet via the remote-signer — +the same path obol's ``buy.py`` uses, so no private key lives in-process and no +new dependency is required. Add a rail (MPP, a local key, a prepaid pool) by +subclassing :class:`SettlementRail` and wiring it into :func:`build_rails`. +""" + +from __future__ import annotations + +import logging +import secrets +import time +from abc import ABC, abstractmethod +from typing import Optional + +import httpx + +from . import x402 +from .x402 import Quote + +logger = logging.getLogger(__name__) + +# Voucher validity window (seconds). It only needs to cover sign+settle +# latency since the voucher is settled on the immediate retry; obol's buy.py +# defaults to 30 days, we keep it short. +_DEFAULT_AUTH_TTL = 600 + +# EVM chains we can correctly sign an EIP-3009 ('exact') voucher for today. +# Each must have a known-correct chainId-based USDC EIP-712 domain in x402.py; +# chains with a salt-based domain (e.g. Polygon native USDC) are excluded so we +# refuse rather than mint an invalid voucher. +_SUPPORTED_CHAIN_IDS = {8453, 84532, 1} + + +class X402Error(RuntimeError): + """A settlement-rail failure (signing, network, or configuration).""" + + +class SettlementRail(ABC): + """A pluggable settlement source behind one interface.""" + + name: str = "rail" + + @abstractmethod + def can_settle(self, quote: Quote) -> bool: + """Return True if this rail can satisfy ``quote``.""" + + @abstractmethod + def settle(self, quote: Quote) -> dict: + """Settle ``quote``; return the raw x402 ``PaymentPayload`` dict. + + The plugin adapts it per delivery channel: the MCP ``_meta`` value, or a + base64 HTTP header (see :func:`plugins.pay_mcp.x402.encode_header`). + """ + + +class ObolSignerRail(SettlementRail): + """obol-agent rail: sign an x402 'exact' EIP-3009 voucher via the agent's + wallet, held by the obol remote-signer (mirrors ``buy.py``/``signer.py``). + """ + + name = "obol" + + def __init__(self, signer_url: str, auth_ttl: int = _DEFAULT_AUTH_TTL, + timeout: float = 30.0) -> None: + self.signer_url = signer_url.rstrip("/") + self.auth_ttl = auth_ttl + self.timeout = timeout + + def can_settle(self, quote: Quote) -> bool: + # EVM 'exact' (EIP-3009) on a chain we can sign a valid domain for. + return ( + quote.scheme == "exact" + and quote.chain_id in _SUPPORTED_CHAIN_IDS + and bool(quote.pay_to) + and bool(self.signer_url) + ) + + def settle(self, quote: Quote) -> dict: + signer_address = self._signer_address() + now = int(time.time()) + authorization = { + "from": signer_address, + "to": quote.pay_to, + "value": str(quote.amount), + "validAfter": "0", + "validBefore": str(now + self.auth_ttl), + "nonce": "0x" + secrets.token_hex(32), + } + typed_data = x402.eip3009_typed_data(quote, authorization) + signature = self._sign(signer_address, typed_data) + return x402.build_payment_payload(quote, authorization, signature) + + # -- remote-signer HTTP (sync httpx; mirrors obol signer.py) -- + + def _signer_address(self) -> str: + try: + resp = httpx.get(f"{self.signer_url}/api/v1/keys", timeout=self.timeout) + resp.raise_for_status() + keys = (resp.json() or {}).get("keys") or [] + except Exception as exc: + raise X402Error( + f"remote-signer unreachable at {self.signer_url}: {exc}" + ) from exc + if not keys: + raise X402Error("remote-signer has no keys (wallet not provisioned)") + return keys[0] + + def _sign(self, address: str, typed_data: dict) -> str: + try: + resp = httpx.post( + f"{self.signer_url}/api/v1/sign/{address}/typed-data", + json=typed_data, timeout=self.timeout, + ) + resp.raise_for_status() + signature = (resp.json() or {}).get("signature", "") + except Exception as exc: + raise X402Error(f"remote-signer typed-data signing failed: {exc}") from exc + if not signature: + raise X402Error("remote-signer returned an empty signature") + return signature + + +def build_rails(config: dict) -> list[SettlementRail]: + """Construct enabled rails from plugin config. + + Returns an empty list when nothing is configured, leaving the plugin inert + (the payment handler is never registered). + """ + rails: list[SettlementRail] = [] + signer_url = config.get("signer_url") + if signer_url: + rails.append(ObolSignerRail( + signer_url=signer_url, + auth_ttl=int(config.get("auth_ttl", _DEFAULT_AUTH_TTL)), + )) + return rails + + +def select_rail(quote: Quote, rails: list[SettlementRail]) -> Optional[SettlementRail]: + """Return the first rail (by priority order) that can settle ``quote``.""" + for rail in rails: + try: + if rail.can_settle(quote): + return rail + except Exception as exc: # a probe must never crash selection + logger.debug("pay_mcp: rail %s can_settle raised: %s", rail.name, exc) + return None diff --git a/internal/embed/plugins/pay_mcp/recovery.py b/internal/embed/plugins/pay_mcp/recovery.py new file mode 100644 index 00000000..eb24815b --- /dev/null +++ b/internal/embed/plugins/pay_mcp/recovery.py @@ -0,0 +1,92 @@ +"""Self-wiring x402 payment recovery for a STOCK hermes image (no core seam). + +The clean integration is the protocol-agnostic seam in ``tools/mcp_tool.py`` + +``tools/payment_hook.py``. But when those aren't present — e.g. the agent runs an +unmodified upstream ``nousresearch/hermes-agent`` image and this plugin was simply +dropped into the user-plugins dir (the "inject by default, like a skill" path) — +the plugin wires itself in by wrapping the MCP SDK's +``ClientSession.call_tool``. + +Every priced-tool 402 is detected on the MCP event loop (where the session +lives), settled, and the call transparently retried with the voucher in +``params._meta["x402/payment"]`` — so the agent only ever sees the paid result. +Settlement (synchronous remote-signer signing) runs in an executor so it never +blocks the loop. The wrap is idempotent and preserves the original; non-priced +calls and non-x402 errors pass straight through. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from . import payment, x402 + +logger = logging.getLogger(__name__) + +_installed = False + + +def _challenge_from_result(result): + """Return the x402 challenge doc from a CallToolResult, or None. + + Reads the raw (unsanitized) structuredContent + text content, so amount / + asset / payTo are exact — unlike the post-sanitize error string the host's + tool handler hands to the model. + """ + structured = getattr(result, "structuredContent", None) + text = "".join( + b.text for b in (getattr(result, "content", None) or []) + if isinstance(getattr(b, "text", None), str) + ) + return x402.detect_challenge(structured, text) + + +def _settle(label: str, doc: dict): + """Run the (synchronous) settle off the event loop; return the _meta voucher or None.""" + try: + outcome = payment.handle_payment(label, {"headers": {}, "body": json.dumps(doc)}) + except Exception as exc: # noqa: BLE001 — a settle bug must not break the tool call + logger.warning("pay_mcp: self-wired settle raised for %s: %s", label, exc) + return None + return outcome.get("voucher_meta") if isinstance(outcome, dict) else None + + +def make_recovering_call_tool(orig): + """Return a ``call_tool`` that wraps *orig* with x402 402 recovery. + + Factored out of :func:`install` so the recovery flow is unit-testable + without monkeypatching the global SDK class. + """ + async def call_tool(self, name, arguments=None, *args, meta=None, **kwargs): + result = await orig(self, name, arguments, *args, meta=meta, **kwargs) + # Only recover an UNPAID call that came back as an x402 challenge. + if meta is not None or not getattr(result, "isError", False): + return result + doc = _challenge_from_result(result) + if doc is None: + return result + voucher = await asyncio.get_running_loop().run_in_executor(None, _settle, name, doc) + if not voucher: + return result # declined / no rail / over-policy — surface the original 402 + logger.info("pay_mcp: self-wired settle for tool %s — retrying with voucher", name) + return await orig(self, name, arguments, *args, meta=voucher, **kwargs) + + return call_tool + + +def install() -> bool: + """Wrap ``ClientSession.call_tool`` to auto-recover x402 402s. Idempotent.""" + global _installed + if _installed: + return True + try: + from mcp.client.session import ClientSession + except Exception as exc: # noqa: BLE001 + logger.warning("pay_mcp: cannot self-wire (mcp SDK unavailable): %s", exc) + return False + ClientSession.call_tool = make_recovering_call_tool(ClientSession.call_tool) + _installed = True + logger.info("pay_mcp: self-wired via ClientSession.call_tool (no core seam present)") + return True diff --git a/internal/embed/plugins/pay_mcp/x402.py b/internal/embed/plugins/pay_mcp/x402.py new file mode 100644 index 00000000..4257cb75 --- /dev/null +++ b/internal/embed/plugins/pay_mcp/x402.py @@ -0,0 +1,266 @@ +"""x402 v2 'exact' wire helpers — parse a 402 challenge and build a voucher. + +Pure functions, no I/O. Mirrors the obol ``buy.py`` envelope (which declares +``x402Version: 2`` with the ``amount``/``accepted`` v2 field shape) and the +official x402 v2 ``PaymentPayload``. NOTE: the obol/x402-rs ecosystem reads the +voucher from the ``X-PAYMENT`` header carrying this v2 body, while a strictly +standards-conformant v2 server reads ``PAYMENT-SIGNATURE``; the header name is +caller-configurable (see payment.py). v1-shaped bodies are out of scope. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from typing import Optional + +# MCP transport (specs/transports-v2/mcp.md): the voucher rides the MCP request +# _meta under this key; the settle receipt comes back under the response key. +MCP_PAYMENT_META_KEY = "x402/payment" +MCP_PAYMENT_RESPONSE_META_KEY = "x402/payment-response" + +# CAIP-2 id / chain name -> numeric EVM chain id (resolution only; settle +# support is gated separately in rails.ObolSignerRail.can_settle). +_CHAIN_IDS = { + "base": 8453, "base-mainnet": 8453, "base-sepolia": 84532, + "ethereum": 1, "mainnet": 1, +} + +# Authoritative per-chain USDC EIP-712 signing domain (name, version). Mirrors +# obol buy.py's ``USDC_EIP712_DOMAIN`` table. Only chains with a known-correct +# EIP-3009 (chainId-based) domain belong here. For these chains the table is +# AUTHORITATIVE over the seller-advertised ``extra.name``/``extra.version`` — +# those mirror the contract's ``name()`` getter, a human-readable display string +# that is NOT always equal to the EIP-712 signing domain (e.g. base-sepolia USDC +# advertises "USD Coin" via name() but its EIP-712 domain is "USDC"; see +# ObolNetwork/obol-stack#612). Signing with the wrong name yields a valid-looking +# signature the facilitator silently rejects. +_USDC_DOMAIN = { + 8453: ("USD Coin", "2"), + 84532: ("USDC", "2"), + 1: ("USD Coin", "2"), +} + + +@dataclass +class Quote: + """A normalized, settle-ready x402 'exact' payment requirement.""" + + scheme: str + network: str # CAIP-2, e.g. "eip155:84532" + amount: str # atomic units, decimal string + asset: str # token contract address + pay_to: str # recipient address + max_timeout: int = 60 + extra: dict = field(default_factory=dict) + accepted: dict = field(default_factory=dict) # raw requirement, echoed back + + @property + def chain_id(self) -> int: + return chain_id_of(self.network) + + def human_amount(self) -> str: + return human_amount(self.amount, self.extra) + + +def chain_id_of(network: str) -> int: + """Resolve an EVM chain id from a CAIP-2 id or a chain name (0 if unknown).""" + net = (network or "").strip().lower() + if net.startswith("eip155:"): + try: + return int(net.split(":", 1)[1]) + except ValueError: + return 0 + return _CHAIN_IDS.get(net, 0) + + +def human_amount(atomic: str, extra: Optional[dict] = None) -> str: + """Best-effort human price. Assumes 6-decimal stablecoins (USDC/EURC). + + The token contract and exact atomic amount are surfaced separately in the + confirmation, so this is a hint, not the authoritative value. + """ + symbol = "USDC" + if extra and isinstance(extra.get("name"), str) and extra["name"]: + symbol = extra["name"] + try: + value = int(str(atomic)) / 1_000_000 + except (TypeError, ValueError): + return f"{atomic} {symbol} (atomic units)" + text = f"{value:.6f}".rstrip("0").rstrip(".") + return f"{text} {symbol}" + + +def _b64_json(blob: str) -> Optional[dict]: + try: + return json.loads(base64.b64decode(blob).decode("utf-8")) + except Exception: + return None + + +def parse_challenge(headers: dict, body: str) -> Optional[Quote]: + """Parse an x402 (v2, 'exact') 402 into a :class:`Quote`. + + Reads requirements from the ``PAYMENT-REQUIRED`` header (standard v2, + base64 JSON) first — header-only, no response body needed — then falls back + to an ``accepts[]`` array in the JSON body (obol/x402-rs and v1 style). + Returns None if no usable 'exact' requirement is present. + """ + accepts: list = [] + + header_blob = None + for key in ("payment-required", "x-payment-required"): + for name, value in (headers or {}).items(): + if name.lower() == key and value: + header_blob = value + break + if header_blob: + break + if header_blob: + decoded = _b64_json(header_blob) + if isinstance(decoded, dict): + accepts = decoded.get("accepts") or [] + + if not accepts and body: + try: + doc = json.loads(body) + except Exception: + doc = None + if isinstance(doc, dict): + accepts = doc.get("accepts") or [] + + for req in accepts: + if not isinstance(req, dict) or req.get("scheme", "exact") != "exact": + continue + amount = req.get("amount", req.get("maxAmountRequired")) + if amount is None: + continue + return Quote( + scheme="exact", + network=str(req.get("network", "")), + amount=str(amount), + asset=str(req.get("asset", "")), + pay_to=str(req.get("payTo", "")), + max_timeout=int(req.get("maxTimeoutSeconds", 60) or 60), + extra=req.get("extra") or {}, + accepted=req, + ) + return None + + +def detect_challenge(structured, text: str) -> Optional[dict]: + """Return the x402 challenge doc from an MCP isError result, or None. + + The in-band detector core calls (via ``tools.payment_hook``) to decide + whether an isError ``CallToolResult`` is an x402 payment challenge — + cheap and side-effect-free. The canonical paid-MCP path (x402's MCP + wrapper) carries ``{"x402Version": ..., "accepts": [...]}`` in + structuredContent or the text block over HTTP 200 (not an HTTP 402). + """ + if isinstance(structured, dict) and ( + "x402Version" in structured or "accepts" in structured + ): + return structured + if text: + try: + doc = json.loads(text) + except (json.JSONDecodeError, TypeError): + doc = None + if isinstance(doc, dict) and ("x402Version" in doc or "accepts" in doc): + return doc + return None + + +def eip3009_typed_data(quote: Quote, authorization: dict) -> dict: + """Build the EIP-712 ``TransferWithAuthorization`` payload to sign. + + Domain resolution mirrors obol ``buy.py::_resolve_eip3009_domain`` — the + seller-advertised ``extra.name``/``extra.version`` are display-only for known + chains and are NOT trusted as the signing domain: + + 1. an explicit full domain object (``extra.eip712Domain`` with both + ``name`` and ``version``) — Obol convention, authoritative; + 2. else, for a chain in the known-good :data:`_USDC_DOMAIN` table, the + TABLE value (authoritative; the human-readable ``extra.name`` is + ignored so a seller advertising a wrong name — e.g. base-sepolia USDC + reporting "USD Coin", ObolNetwork/obol-stack#612 — cannot make us sign + the wrong domain); + 3. else (unknown chain) the seller-advertised ``extra.name``/``version`` + if present; + 4. else refuse (raise): no trusted domain and nothing advertised. + """ + advertised_domain = (quote.extra or {}).get("eip712Domain") or {} + adv_name = advertised_domain.get("name") if isinstance(advertised_domain, dict) else None + adv_version = advertised_domain.get("version") if isinstance(advertised_domain, dict) else None + if adv_name and adv_version: + # (1) explicit full domain object — honor it first. + name, version = adv_name, adv_version + elif quote.chain_id in _USDC_DOMAIN: + # (2) known chain — the table wins over display-only extra.name/version. + name, version = _USDC_DOMAIN[quote.chain_id] + else: + # (3) unknown chain — fall back to advertised extra.name/version. + name = (quote.extra or {}).get("name") + version = (quote.extra or {}).get("version") + if not name or not version: + # (4) nothing trustworthy and nothing advertised — refuse to sign. + raise ValueError( + f"refusing to sign: no trusted EIP-712 domain for chainId " + f"{quote.chain_id} and the seller omitted extra.eip712Domain " + f"and extra.name/version" + ) + return { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + }, + "primaryType": "TransferWithAuthorization", + "domain": { + "name": name, + "version": version, + "chainId": quote.chain_id, + "verifyingContract": quote.asset, + }, + "message": dict(authorization), + } + + +def build_payment_payload(quote: Quote, authorization: dict, signature: str) -> dict: + """The raw x402 v2 ``PaymentPayload`` dict. + + Used verbatim as the MCP ``_meta["x402/payment"]`` value (canonical paid-MCP), + or base64-encoded via :func:`encode_header` for the HTTP ``X-PAYMENT`` header. + """ + return { + "x402Version": 2, + "accepted": quote.accepted, # the seller's exact requirement, echoed back + "payload": { + "signature": signature, + "authorization": authorization, + }, + } + + +def encode_header(payload: dict) -> str: + """Base64 a ``PaymentPayload`` for an HTTP ``X-PAYMENT`` / ``PAYMENT-SIGNATURE`` header.""" + return base64.b64encode( + json.dumps(payload, separators=(",", ":")).encode("utf-8") + ).decode("ascii") + + +def build_payment_header(quote: Quote, authorization: dict, signature: str) -> str: + """Convenience: the base64 HTTP-header form of the voucher.""" + return encode_header(build_payment_payload(quote, authorization, signature)) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index c9dd6d98..f7981adb 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -30,6 +30,14 @@ const ( helmfileFileName = "helmfile.yaml" gatewayTokenFileName = ".gateway-token" obolSkillsDirName = "obol-skills" + // pluginsDirName is the agent's user-plugins dir under $HERMES_HOME. Hermes + // discovers directory plugins at ~/.hermes/plugins//; with + // HERMES_HOME=/data/.hermes that resolves to /data/.hermes/plugins. + pluginsDirName = "plugins" + // payMCPPluginName must match plugins/pay_mcp/plugin.yaml's name. It is the + // embedded plugin we seed + enable by default so agents can settle paid + // MCP tools (x402) using the pod's remote-signer. + payMCPPluginName = "pay_mcp" // renovate: datasource=helm depName=raw registryUrl=https://bedag.github.io/helm-charts/ rawChartVersion = "2.0.2" @@ -1074,6 +1082,9 @@ func syncRuntimeFiles(cfg *config.Config, id string, configData []byte, u *ui.UI if err := syncObolSkills(cfg, id); err != nil { return err } + if err := syncObolPlugins(cfg, id); err != nil { + return err + } if err := removeLegacyHeartbeat(targetDir); err != nil { return err } @@ -1099,6 +1110,23 @@ func syncObolSkills(cfg *config.Config, id string) error { return nil } +// syncObolPlugins seeds the embedded hermes plugins into the agent's +// user-plugins dir on the host PVC ($HERMES_HOME/plugins → /data/.hermes/plugins +// inside the pod). Mirrors syncObolSkills: it refreshes shipped plugins on every +// sync and preserves any user-added plugins with different names. The plugins +// are activated via the plugins.enabled list in generateConfig, and pay_mcp in +// particular self-activates from the REMOTE_SIGNER_URL already on the pod. +func syncObolPlugins(cfg *config.Config, id string) error { + targetDir := filepath.Join(agentruntime.HomePath(cfg, agentruntime.Hermes, id), pluginsDirName) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to create Obol plugins directory: %w", err) + } + if err := obolembed.CopyPlugins(targetDir); err != nil { + return fmt.Errorf("failed to copy Obol plugins: %w", err) + } + return nil +} + // configuredModels returns the agent-facing model list and the primary model // name. Both are returned as round-trippable LiteLLM `model_name` strings: // the agent passes `primary` back as the `model` field on chat-completion @@ -1156,6 +1184,14 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { "skills": map[string]any{ "external_dirs": []string{"/data/.hermes/" + obolSkillsDirName}, }, + // User-installed plugins are opt-in via plugins.enabled (a stock-image + // safety gate). We seed pay_mcp into the user-plugins dir (see + // syncObolPlugins), so enable it here. Bundled in-image backend/platform + // plugins auto-load regardless of this list, so naming only pay_mcp does + // not suppress them. + "plugins": map[string]any{ + "enabled": []string{payMCPPluginName}, + }, } return yaml.Marshal(payload) } diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 90a75a5b..43efb23a 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -119,6 +119,55 @@ func TestGenerateConfig_UsesLiteLLMCustomProvider(t *testing.T) { } } +// syncObolPlugins seeds the embedded plugins into the master agent's +// user-plugins dir on the host PVC ($HERMES_HOME/plugins). Lock that pay_mcp +// lands there so the agent can discover it at boot. +func TestSyncObolPlugins_SeedsPayMCP(t *testing.T) { + cfg := testConfig(t) + id := agentruntime.DefaultInstanceID + if err := syncObolPlugins(cfg, id); err != nil { + t.Fatalf("syncObolPlugins: %v", err) + } + home := agentruntime.HomePath(cfg, agentruntime.Hermes, id) + for _, f := range []string{"__init__.py", "plugin.yaml"} { + p := filepath.Join(home, pluginsDirName, "pay_mcp", f) + if _, err := os.Stat(p); err != nil { + t.Errorf("pay_mcp %s not seeded to user-plugins dir: %v", f, err) + } + } +} + +// The master agent seeds the pay_mcp plugin into its user-plugins dir; a +// user-installed plugin only loads when named in plugins.enabled, so the +// generated config must enable it (else it is discovered-but-inert). +func TestGenerateConfig_EnablesPayMCPPlugin(t *testing.T) { + raw, err := generateConfig(testConfig(t), "gpt-5.2") + if err != nil { + t.Fatalf("generateConfig() error = %v", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v", err) + } + pluginsCfg, ok := cfg["plugins"].(map[string]any) + if !ok { + t.Fatalf("plugins config missing or wrong type: %#v", cfg["plugins"]) + } + enabled, ok := pluginsCfg["enabled"].([]any) + if !ok { + t.Fatalf("plugins.enabled missing or wrong type: %#v", pluginsCfg["enabled"]) + } + found := false + for _, e := range enabled { + if fmt.Sprint(e) == "pay_mcp" { + found = true + } + } + if !found { + t.Fatalf("plugins.enabled = %#v, want it to include pay_mcp", enabled) + } +} + func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { values := generateValues( "hermes-obol-agent", diff --git a/internal/serviceoffercontroller/agent_render.go b/internal/serviceoffercontroller/agent_render.go index f92f1d9a..ef635816 100644 --- a/internal/serviceoffercontroller/agent_render.go +++ b/internal/serviceoffercontroller/agent_render.go @@ -121,6 +121,9 @@ agent: skills: external_dirs: - /data/.hermes/obol-skills +plugins: + enabled: + - pay_mcp `, model, litellmKey) } diff --git a/internal/serviceoffercontroller/agent_render_test.go b/internal/serviceoffercontroller/agent_render_test.go index dfc227c5..4b971347 100644 --- a/internal/serviceoffercontroller/agent_render_test.go +++ b/internal/serviceoffercontroller/agent_render_test.go @@ -358,6 +358,24 @@ func TestRenderHermesConfig_HasModelAndSkillsDir(t *testing.T) { } } +// Sell sub-agents are seeded with the pay_mcp plugin so a wallet-bearing agent +// can settle paid (3rd-party) MCP tools during its turn. A user-installed +// plugin only loads when named in plugins.enabled, so the rendered config must +// enable it. (The plugin stays inert without REMOTE_SIGNER_URL, which the +// reconciler wires only for wallet-bearing agents.) +func TestRenderHermesConfig_EnablesPayMCPPlugin(t *testing.T) { + cfg := renderHermesConfig("qwen3.5:9b", "lit-key") + for _, must := range []string{ + "plugins:", + "enabled:", + "- pay_mcp", + } { + if !strings.Contains(cfg, must) { + t.Errorf("hermes config missing plugin-enable line %q\n---\n%s", must, cfg) + } + } +} + // Sub-agents share LiteLLM with the master, so we cannot cap output tokens // per-model. Instead, every CRD-rendered agent runs under tighter Hermes // knobs so a single sale stays inside the 100s Cloudflare free-tunnel