Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/agentcrd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions internal/agentcrd/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) —
Expand Down
86 changes: 86 additions & 0 deletions internal/embed/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions internal/embed/embed_plugins_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/embed/plugins/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.pyo
42 changes: 42 additions & 0 deletions internal/embed/plugins/pay_mcp/VENDORED.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading