Skip to content

Commit 3567648

Browse files
committed
feat(embed): ship pay_mcp hermes plugin by default (CopyPlugins)
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.
1 parent bdb1a54 commit 3567648

16 files changed

Lines changed: 1364 additions & 0 deletions

File tree

internal/agentcrd/agent.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ func HostSkillsPath(cfg *config.Config, name string) string {
4545
return filepath.Join(HostHomePath(cfg, name), "obol-skills")
4646
}
4747

48+
// HostPluginsPath is the per-agent user-plugins dir. Hermes discovers
49+
// directory plugins at $HERMES_HOME/plugins; with HERMES_HOME=/data/.hermes
50+
// inside the pod that resolves here on the host PVC.
51+
func HostPluginsPath(cfg *config.Config, name string) string {
52+
return filepath.Join(HostHomePath(cfg, name), "plugins")
53+
}
54+
4855
// HostSoulPath is where the seeded Hermes identity file lives. Hermes reads
4956
// uppercase SOUL.md from HERMES_HOME, so keep this path aligned with upstream
5057
// Hermes profile semantics.
@@ -112,9 +119,28 @@ func SeedHostFiles(cfg *config.Config, name string, skills []string, objective s
112119
if err := writeNoBundledSkillsMarker(cfg, name); err != nil {
113120
return false, fmt.Errorf("write no-bundled-skills marker: %w", err)
114121
}
122+
if err := SeedHostPlugins(cfg, name); err != nil {
123+
return false, fmt.Errorf("seed plugins: %w", err)
124+
}
115125
return WriteSoul(cfg, name, objective, opts.OverwriteSoul)
116126
}
117127

128+
// SeedHostPlugins copies the embedded hermes plugins into the agent's
129+
// user-plugins dir on the host PVC. The plugins are enabled via the
130+
// plugins.enabled list in the rendered Hermes config (see
131+
// serviceoffercontroller.renderHermesConfig); pay_mcp stays inert unless the
132+
// pod also has a signer (REMOTE_SIGNER_URL), which the reconciler wires only
133+
// for wallet-bearing agents. Refreshes shipped plugins on every reconcile and
134+
// leaves user-added plugins with other names untouched (CopyPlugins only
135+
// writes embedded files).
136+
func SeedHostPlugins(cfg *config.Config, name string) error {
137+
dst := HostPluginsPath(cfg, name)
138+
if err := os.MkdirAll(dst, 0o755); err != nil {
139+
return fmt.Errorf("create plugins dir %s: %w", dst, err)
140+
}
141+
return embed.CopyPlugins(dst)
142+
}
143+
118144
// writeNoBundledSkillsMarker drops a `.no-bundled-skills` file into the agent's
119145
// Hermes profile dir so the runtime skips seeding its ~80 bundled skills.
120146
// Idempotent: an existing marker is left as-is. The file is intentionally empty;

internal/agentcrd/agent_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,39 @@ func TestSeedHostFiles_FreshAgent(t *testing.T) {
150150
if _, err := os.Stat(marker); err != nil {
151151
t.Errorf("no-bundled-skills marker missing: %v", err)
152152
}
153+
154+
// A fresh seed must also drop the pay_mcp plugin into the user-plugins dir
155+
// so a wallet-bearing sell agent can settle paid MCP tools.
156+
pluginInit := filepath.Join(HostPluginsPath(cfg, "quant"), "pay_mcp", "__init__.py")
157+
if _, err := os.Stat(pluginInit); err != nil {
158+
t.Errorf("pay_mcp plugin not seeded: %v", err)
159+
}
160+
}
161+
162+
// SeedHostPlugins seeds the embedded plugins into the agent's user-plugins dir
163+
// and must leave a user-added plugin with a different name untouched on re-seed.
164+
func TestSeedHostPlugins_SeedsAndPreserves(t *testing.T) {
165+
dir := t.TempDir()
166+
cfg := &config.Config{DataDir: dir}
167+
168+
custom := filepath.Join(HostPluginsPath(cfg, "quant"), "operator-plugin")
169+
if err := os.MkdirAll(custom, 0o755); err != nil {
170+
t.Fatal(err)
171+
}
172+
if err := os.WriteFile(filepath.Join(custom, "plugin.yaml"), []byte("name: operator-plugin\n"), 0o600); err != nil {
173+
t.Fatal(err)
174+
}
175+
176+
if err := SeedHostPlugins(cfg, "quant"); err != nil {
177+
t.Fatalf("SeedHostPlugins: %v", err)
178+
}
179+
180+
if _, err := os.Stat(filepath.Join(HostPluginsPath(cfg, "quant"), "pay_mcp", "plugin.yaml")); err != nil {
181+
t.Errorf("pay_mcp not seeded: %v", err)
182+
}
183+
if _, err := os.Stat(filepath.Join(custom, "plugin.yaml")); err != nil {
184+
t.Errorf("operator plugin clobbered by seed: %v", err)
185+
}
153186
}
154187

155188
// The marker must already exist on a re-seed (e.g. agent objective change) —

internal/embed/embed.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ var networksFS embed.FS
2929
//go:embed all:skills
3030
var skillsFS embed.FS
3131

32+
//go:embed all:plugins
33+
var pluginsFS embed.FS
34+
3235
//go:embed all:bountytasks
3336
var bountyTasksFS embed.FS
3437

@@ -331,6 +334,89 @@ func GetEmbeddedSkillNames() ([]string, error) {
331334
return names, nil
332335
}
333336

337+
// CopyPlugins recursively copies all embedded hermes plugins to the destination
338+
// directory (the agent's user-plugins dir, e.g. $HERMES_HOME/plugins). Mirrors
339+
// CopySkills: it only writes files from the embedded FS, so user-added plugins
340+
// with different names are preserved, and re-running on an existing deployment
341+
// refreshes the shipped plugins to the current binary.
342+
//
343+
// __pycache__ dirs and .pyc/.pyo files are skipped defensively — they can get
344+
// generated when a dev runs the plugin locally before `go build` and would
345+
// otherwise be baked into the embed.FS and seeded onto every agent's PVC,
346+
// confusing python on a different interpreter version. The plugins/.gitignore
347+
// keeps them out of the repo; this is belt-and-suspenders.
348+
func CopyPlugins(destDir string) error {
349+
return fs.WalkDir(pluginsFS, "plugins", func(path string, d fs.DirEntry, err error) error {
350+
if err != nil {
351+
return err
352+
}
353+
354+
// Skip root plugins directory
355+
if path == "plugins" {
356+
return nil
357+
}
358+
359+
// Skip generated python caches.
360+
if d.IsDir() && d.Name() == "__pycache__" {
361+
return fs.SkipDir
362+
}
363+
if !d.IsDir() {
364+
if name := d.Name(); strings.HasSuffix(name, ".pyc") || strings.HasSuffix(name, ".pyo") {
365+
return nil
366+
}
367+
}
368+
369+
// Get relative path within plugins/
370+
relPath := strings.TrimPrefix(path, "plugins/")
371+
destPath := filepath.Join(destDir, relPath)
372+
373+
if d.IsDir() {
374+
if err := os.MkdirAll(destPath, 0o755); err != nil {
375+
return fmt.Errorf("failed to create directory %s: %w", destPath, err)
376+
}
377+
378+
return nil
379+
}
380+
381+
// Ensure parent directory exists
382+
parentDir := filepath.Dir(destPath)
383+
if err := os.MkdirAll(parentDir, 0o755); err != nil {
384+
return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err)
385+
}
386+
387+
// Read embedded file
388+
data, err := pluginsFS.ReadFile(path)
389+
if err != nil {
390+
return fmt.Errorf("failed to read embedded file %s: %w", path, err)
391+
}
392+
393+
// Write to destination
394+
if err := os.WriteFile(destPath, data, 0o600); err != nil {
395+
return fmt.Errorf("failed to write file %s: %w", destPath, err)
396+
}
397+
398+
return nil
399+
})
400+
}
401+
402+
// GetEmbeddedPluginNames returns the names of all embedded plugin directories.
403+
func GetEmbeddedPluginNames() ([]string, error) {
404+
entries, err := fs.ReadDir(pluginsFS, "plugins")
405+
if err != nil {
406+
return nil, fmt.Errorf("failed to read embedded plugins: %w", err)
407+
}
408+
409+
var names []string
410+
411+
for _, entry := range entries {
412+
if entry.IsDir() {
413+
names = append(names, entry.Name())
414+
}
415+
}
416+
417+
return names, nil
418+
}
419+
334420
// CopyNetwork recursively copies an embedded network to the destination directory
335421
func CopyNetwork(networkName, destDir string) error {
336422
networkPath := filepath.Join("networks", networkName)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package embed
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// payMCPFiles are the files the embedded pay_mcp plugin must ship. The plugin
11+
// loads from a directory, so plugin.yaml + __init__.py are load-critical; the
12+
// rest are imported relatively by __init__/register().
13+
var payMCPFiles = []string{
14+
"plugin.yaml", "__init__.py", "x402.py", "rails.py", "payment.py", "recovery.py",
15+
}
16+
17+
func TestGetEmbeddedPluginNames(t *testing.T) {
18+
names, err := GetEmbeddedPluginNames()
19+
if err != nil {
20+
t.Fatalf("unexpected error: %v", err)
21+
}
22+
found := false
23+
for _, n := range names {
24+
if n == "pay_mcp" {
25+
found = true
26+
}
27+
}
28+
if !found {
29+
t.Fatalf("embedded plugins %v missing pay_mcp", names)
30+
}
31+
}
32+
33+
func TestCopyPlugins_SeedsPayMCP(t *testing.T) {
34+
dst := t.TempDir()
35+
if err := CopyPlugins(dst); err != nil {
36+
t.Fatalf("CopyPlugins: %v", err)
37+
}
38+
39+
for _, f := range payMCPFiles {
40+
p := filepath.Join(dst, "pay_mcp", f)
41+
info, err := os.Stat(p)
42+
if err != nil {
43+
t.Errorf("missing seeded file %s: %v", f, err)
44+
continue
45+
}
46+
if info.Size() == 0 {
47+
t.Errorf("seeded file %s is empty", f)
48+
}
49+
}
50+
}
51+
52+
func TestCopyPlugins_NoPycache(t *testing.T) {
53+
dst := t.TempDir()
54+
if err := CopyPlugins(dst); err != nil {
55+
t.Fatalf("CopyPlugins: %v", err)
56+
}
57+
err := filepath.Walk(dst, func(path string, info os.FileInfo, err error) error {
58+
if err != nil {
59+
return err
60+
}
61+
if info.IsDir() && info.Name() == "__pycache__" {
62+
t.Errorf("__pycache__ leaked into seed: %s", path)
63+
}
64+
if !info.IsDir() && (strings.HasSuffix(path, ".pyc") || strings.HasSuffix(path, ".pyo")) {
65+
t.Errorf("compiled python leaked into seed: %s", path)
66+
}
67+
return nil
68+
})
69+
if err != nil {
70+
t.Fatalf("walk: %v", err)
71+
}
72+
}
73+
74+
// TestCopyPlugins_RelativeImportsOnly is the inject-by-default invariant: a
75+
// user-dir plugin is loaded under the synthetic package name
76+
// hermes_plugins.pay_mcp, and a stock hermes image has no bundled
77+
// plugins.pay_mcp to satisfy an absolute self-import. So the embedded copy must
78+
// import its own modules relatively (`from . import x402`), never
79+
// `from plugins.pay_mcp import x402`. Upstream locks this with
80+
// tests/plugins/test_pay_mcp_userdir_load.py; we re-assert on the vendored copy
81+
// because the two are synced by hand.
82+
func TestCopyPlugins_RelativeImportsOnly(t *testing.T) {
83+
dst := t.TempDir()
84+
if err := CopyPlugins(dst); err != nil {
85+
t.Fatalf("CopyPlugins: %v", err)
86+
}
87+
pyFiles := []string{"__init__.py", "x402.py", "rails.py", "payment.py", "recovery.py"}
88+
for _, f := range pyFiles {
89+
data, err := os.ReadFile(filepath.Join(dst, "pay_mcp", f))
90+
if err != nil {
91+
t.Fatalf("read %s: %v", f, err)
92+
}
93+
if strings.Contains(string(data), "from plugins.pay_mcp") ||
94+
strings.Contains(string(data), "import plugins.pay_mcp") {
95+
t.Errorf("%s uses an absolute self-import; must be relative "+
96+
"(breaks load from the user-plugins dir on a stock image)", f)
97+
}
98+
}
99+
}
100+
101+
func TestCopyPlugins_ManifestNamesPayMCP(t *testing.T) {
102+
dst := t.TempDir()
103+
if err := CopyPlugins(dst); err != nil {
104+
t.Fatalf("CopyPlugins: %v", err)
105+
}
106+
data, err := os.ReadFile(filepath.Join(dst, "pay_mcp", "plugin.yaml"))
107+
if err != nil {
108+
t.Fatalf("read plugin.yaml: %v", err)
109+
}
110+
// The seeded manifest name must match the plugins.enabled entry the agent
111+
// configs write (pay_mcp), or the plugin is discovered-but-not-enabled.
112+
if !strings.Contains(string(data), "name: pay_mcp") {
113+
t.Errorf("plugin.yaml does not declare `name: pay_mcp`:\n%s", data)
114+
}
115+
}
116+
117+
// TestCopyPlugins_PreservesUserPlugins mirrors the skills contract: re-seeding
118+
// must not delete a user-added plugin with a different name.
119+
func TestCopyPlugins_PreservesUserPlugins(t *testing.T) {
120+
dst := t.TempDir()
121+
custom := filepath.Join(dst, "my-own-plugin")
122+
if err := os.MkdirAll(custom, 0o755); err != nil {
123+
t.Fatal(err)
124+
}
125+
if err := os.WriteFile(filepath.Join(custom, "plugin.yaml"), []byte("name: my-own-plugin\n"), 0o600); err != nil {
126+
t.Fatal(err)
127+
}
128+
if err := CopyPlugins(dst); err != nil {
129+
t.Fatalf("CopyPlugins: %v", err)
130+
}
131+
if _, err := os.Stat(filepath.Join(custom, "plugin.yaml")); err != nil {
132+
t.Errorf("user plugin was clobbered by re-seed: %v", err)
133+
}
134+
if _, err := os.Stat(filepath.Join(dst, "pay_mcp", "__init__.py")); err != nil {
135+
t.Errorf("pay_mcp not seeded alongside user plugin: %v", err)
136+
}
137+
}

internal/embed/plugins/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Vendored plugin: pay_mcp
2+
3+
This directory is a **verbatim copy** of the `pay_mcp` hermes-agent plugin,
4+
embedded into the obol-stack binary so the stack can seed it onto every agent's
5+
profile by default (the same way embedded skills are seeded). See
6+
`internal/embed/embed.go``CopyPlugins` / `GetEmbeddedPluginNames`.
7+
8+
## Source
9+
10+
- Upstream: hermes-agent, `plugins/pay_mcp/`
11+
(branch `feat/pay-mcp-plugin` on `bussyjd/hermes-agent`).
12+
- Synced at hermes commit `d8ca58055` (relative-import fix for user-dir load).
13+
- Files: `__init__.py`, `x402.py`, `rails.py`, `payment.py`, `recovery.py`,
14+
`plugin.yaml`.
15+
16+
## Why a copy (not a submodule)
17+
18+
obol pins the **upstream** `nousresearch/hermes-agent` image and does not build
19+
it, so the plugin can't ride in via the image. Embedding the source in the
20+
obol-stack binary lets `obol` drop it into the agent's user-plugins dir
21+
(`/data/.hermes/plugins/pay_mcp/`, i.e. `$HERMES_HOME/plugins`) on stack-up /
22+
agent reconcile, with no image rebuild. The agent then auto-loads it (the obol
23+
config seeds `plugins.enabled: [pay_mcp]`) and it self-activates from the
24+
`REMOTE_SIGNER_URL` already on the pod.
25+
26+
## Invariants (do not break when re-syncing)
27+
28+
- **Relative imports only.** Intra-package imports must be `from . import x402`,
29+
never `from plugins.pay_mcp import x402`. Hermes loads a user-dir plugin under
30+
the synthetic package name `hermes_plugins.pay_mcp`, and a stock image has no
31+
bundled `plugins.pay_mcp` to satisfy an absolute import. (Locked upstream by
32+
`tests/plugins/test_pay_mcp_userdir_load.py`.)
33+
- **No secrets.** Only public addresses/constants. `import secrets` is the
34+
Python stdlib module (nonce generation), not a credential.
35+
- **Inert by default.** `register()` builds no rails and wires nothing unless a
36+
signer is configured, so it is safe to ship everywhere.
37+
38+
## Re-syncing
39+
40+
Re-copy the six files from the upstream plugin dir, keep the relative imports,
41+
and re-run `go test ./internal/embed/...` (the content-parity test checks the
42+
expected files exist, are non-empty, and contain no absolute self-imports).

0 commit comments

Comments
 (0)