diff --git a/cmd/obol/agent.go b/cmd/obol/agent.go index 123fbfbe..73b061ce 100644 --- a/cmd/obol/agent.go +++ b/cmd/obol/agent.go @@ -205,7 +205,7 @@ func agentWalletCommand(cfg *config.Config) *cli.Command { }, { Name: "backup", - Usage: "Back up wallet keys for an OpenClaw agent instance", + Usage: "Back up wallet keys for an agent instance", ArgsUsage: "[instance-name]", Flags: []cli.Flag{ agentRuntimeFlag(""), @@ -247,7 +247,7 @@ func agentWalletCommand(cfg *config.Config) *cli.Command { }, { Name: "restore", - Usage: "Restore wallet keys for an OpenClaw agent instance", + Usage: "Restore wallet keys for an agent instance", ArgsUsage: "[instance-name]", Flags: []cli.Flag{ agentRuntimeFlag(""), diff --git a/cmd/obol/agent_test.go b/cmd/obol/agent_test.go index 92993a55..304fe107 100644 --- a/cmd/obol/agent_test.go +++ b/cmd/obol/agent_test.go @@ -72,6 +72,23 @@ func TestAgentWalletCommand_Structure(t *testing.T) { } } +func TestAgentWalletCommand_UsageIsRuntimeNeutral(t *testing.T) { + cfg := newTestConfig(t) + wallet := findSubcommand(t, agentCommand(cfg), "wallet") + + for _, name := range []string{"backup", "restore"} { + t.Run(name, func(t *testing.T) { + sub := findSubcommand(t, wallet, name) + if strings.Contains(sub.Usage, "OpenClaw") { + t.Fatalf("%s usage still says OpenClaw-only: %q", name, sub.Usage) + } + if !strings.Contains(sub.Usage, "agent instance") { + t.Fatalf("%s usage = %q, want generic agent instance wording", name, sub.Usage) + } + }) + } +} + func TestResolveAgentTarget(t *testing.T) { tests := []struct { name string diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index 98616cd6..2abd2d7e 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -33,10 +33,11 @@ const ( // renovate: datasource=helm depName=raw registryUrl=https://bedag.github.io/helm-charts/ rawChartVersion = "2.0.2" - defaultImage = "nousresearch/hermes-agent:v2026.4.23" - hermesInstallDir = "/data/.hermes/hermes-agent" - hermesRepoURL = "https://github.com/NousResearch/hermes-agent.git" - hermesBinary = hermesInstallDir + "/venv/bin/hermes" + defaultImage = "nousresearch/hermes-agent:v2026.4.30" + // 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. + hermesBinary = "/opt/hermes/.venv/bin/hermes" containerUID = 10000 containerGID = 10000 @@ -799,67 +800,23 @@ func generateValues(namespace, hostname, dashboardHostname, agentBaseURL, token, fsGroup: %d initContainers: - name: init-hermes-data - image: busybox:1.36 - command: - - sh - - -c - - mkdir -p /data/.hermes && chown -R %d:%d /data/.hermes - securityContext: - runAsUser: 0 - volumeMounts: - - name: data - mountPath: /data - - name: bootstrap-hermes-install image: %s imagePullPolicy: IfNotPresent command: - sh - -ec - | - install_dir=%s - repo_url=%s mkdir -p /data/.hermes/home /data/.hermes/workspace - lock_dir="${install_dir}.lock" - got_lock=0 - for _ in $(seq 1 120); do - if mkdir "$lock_dir" 2>/dev/null; then - got_lock=1 - break - fi - sleep 1 - done - if [ "$got_lock" != 1 ]; then - echo "Timed out waiting for Hermes install lock: $lock_dir" >&2 + if [ ! -x /opt/hermes/.venv/bin/hermes ]; then + echo "Hermes binary missing from image: /opt/hermes/.venv/bin/hermes" >&2 exit 1 fi - cleanup_lock() { - rmdir "$lock_dir" 2>/dev/null || true - } - trap cleanup_lock EXIT - - if [ ! -d "$install_dir/.git" ] || { [ ! -f "$install_dir/pyproject.toml" ] && [ ! -f "$install_dir/setup.py" ]; }; then - rm -rf "${install_dir}.tmp" - if [ -e "$install_dir" ]; then - mv "$install_dir" "${install_dir}.backup.$(date +%%s)" - fi - git clone --depth 1 "$repo_url" "${install_dir}.tmp" - mv "${install_dir}.tmp" "$install_dir" - fi - cd "$install_dir" - # Reinstall when the venv is missing the hermes binary OR - # any selected extra is absent. The upstream image installs - # ".[all]"; we re-create the venv from a fresh clone, so the - # extras must be re-requested explicitly. The import check - # picks one module per extra so existing PVCs trigger a - # rebuild when we add a new extra to the install line. - if [ ! -x "$install_dir/venv/bin/hermes" ] || \ - ! "$install_dir/venv/bin/python3" -c "import fastapi, uvicorn, telegram, mcp, ptyprocess, simple_term_menu, googleapiclient" >/dev/null 2>&1; then - rm -rf "$install_dir/venv" - uv venv --python python3 --system-site-packages venv - VIRTUAL_ENV="$install_dir/venv" uv pip install -e ".[web,messaging,mcp,pty,cli,acp,google]" + if ! /opt/hermes/.venv/bin/python3 -c "import fastapi, uvicorn, telegram, mcp, ptyprocess, simple_term_menu, googleapiclient" >/dev/null 2>&1; then + echo "Hermes image is missing required extras: web,messaging,mcp,pty,cli,acp,google" >&2 + exit 1 fi if [ -f /data/.hermes/state.db ]; then - if ! python3 - <<'PY' + if ! /opt/hermes/.venv/bin/python3 - <<'PY' import sqlite3 conn = sqlite3.connect('/data/.hermes/state.db') row = conn.execute('PRAGMA quick_check').fetchone() @@ -875,8 +832,6 @@ func generateValues(namespace, hostname, dashboardHostname, agentBaseURL, token, echo "Backed up malformed Hermes state DB to $backup_dir" fi fi - cleanup_lock - trap - EXIT volumeMounts: - name: data mountPath: /data @@ -917,7 +872,7 @@ func generateValues(namespace, hostname, dashboardHostname, agentBaseURL, token, value: %s - name: OBOL_SKILLS_DIR value: /data/.hermes/%s - `, desc.DataPVCName, namespace, desc.ServiceName, desc.ServiceName, namespace, desc.ServiceName, desc.ServiceName, desc.ServiceName, desc.ServiceName, containerUID, containerGID, containerGID, containerUID, containerGID, quoteYAML(image()), quoteYAML(hermesInstallDir), quoteYAML(hermesRepoURL), desc.ServiceName, quoteYAML(image()), quoteYAML(hermesBinary), desc.DefaultPort, desc.DefaultPort, quoteYAML(primary), quoteYAML(namespace), obolSkillsDirName) + `, desc.DataPVCName, namespace, desc.ServiceName, desc.ServiceName, namespace, desc.ServiceName, desc.ServiceName, desc.ServiceName, desc.ServiceName, containerUID, containerGID, containerGID, quoteYAML(image()), desc.ServiceName, quoteYAML(image()), quoteYAML(hermesBinary), desc.DefaultPort, desc.DefaultPort, quoteYAML(primary), quoteYAML(namespace), obolSkillsDirName) if agentBaseURL != "" { fmt.Fprintf(&b, " - name: AGENT_BASE_URL\n value: %s\n", quoteYAML(agentBaseURL)) diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 4e27c869..53e6d3cd 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -143,18 +143,12 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { "containerPort: 8642", "containerPort: 9119", "init-hermes-data", - "bootstrap-hermes-install", - `install_dir="/data/.hermes/hermes-agent"`, - `repo_url="https://github.com/NousResearch/hermes-agent.git"`, - `lock_dir="${install_dir}.lock"`, - `Timed out waiting for Hermes install lock`, - `git clone --depth 1 "$repo_url" "${install_dir}.tmp"`, - "uv venv --python python3 --system-site-packages venv", - `uv pip install -e ".[web,messaging,mcp,pty,cli,acp,google]"`, + `Hermes binary missing from image: /opt/hermes/.venv/bin/hermes`, + `Hermes image is missing required extras: web,messaging,mcp,pty,cli,acp,google`, `import fastapi, uvicorn, telegram, mcp, ptyprocess, simple_term_menu, googleapiclient`, `PRAGMA quick_check`, `state-db-corrupt-$ts`, - `- "/data/.hermes/hermes-agent/venv/bin/hermes"`, + `- "/opt/hermes/.venv/bin/hermes"`, `- "hermes-obol-agent.obol.stack"`, `- "obol-agent.obol.stack"`, "name: hermes-dashboard", @@ -165,6 +159,17 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { } } + for _, banned := range []string{ + "bootstrap-hermes-install", + "git clone", + "uv pip install", + "/data/.hermes/hermes-agent", + } { + if strings.Contains(values, banned) { + t.Fatalf("generateValues() should not rebuild Hermes inside the PVC, found %q:\n%s", banned, values) + } + } + var parsed any if err := yaml.Unmarshal([]byte(values), &parsed); err != nil { t.Fatalf("generateValues() produced invalid YAML: %v\n%s", err, values) @@ -203,7 +208,7 @@ func TestHermesExecArgs_UsesNativeHermesBinary(t *testing.T) { "-n", "hermes-obol-agent", "deploy/hermes", "--", - "/data/.hermes/hermes-agent/venv/bin/hermes", + "/opt/hermes/.venv/bin/hermes", "skills", "audit", } diff --git a/internal/hermes/wallet_backup.go b/internal/hermes/wallet_backup.go index 99ad3f0a..50820501 100644 --- a/internal/hermes/wallet_backup.go +++ b/internal/hermes/wallet_backup.go @@ -172,6 +172,7 @@ func RestoreWalletCmd(cfg *config.Config, id string, opts RestoreWalletOptions, } if opts.ApplyCluster { + applyWalletMetadataConfigMapFn(cfg, id, deployDir) applyHermesKeystorePasswordSecret(cfg, id, w.KeystorePassword, u) restartHermesRemoteSignerFn(cfg, id, u) } diff --git a/internal/hermes/wallet_backup_test.go b/internal/hermes/wallet_backup_test.go new file mode 100644 index 00000000..beb3b6d3 --- /dev/null +++ b/internal/hermes/wallet_backup_test.go @@ -0,0 +1,219 @@ +package hermes + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/agentruntime" + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/ObolNetwork/obol-stack/internal/walletbackup" +) + +func setupHermesBackupInstance(t *testing.T, id string) (*config.Config, string, *WalletInfo) { + t.Helper() + cfg, deployDir := walletImportTestConfig(t, id) + stubVolumeOwnership(t) + + keystorePath := filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, id), "test-keystore-"+id+".json") + wallet := &WalletInfo{ + Address: "0x1111111111111111111111111111111111111111", + PublicKey: "0x04abc", + KeystoreUUID: "test-keystore-" + id, + KeystorePath: keystorePath, + CreatedAt: "2026-05-04T00:00:00Z", + Password: "password-" + id, + } + if err := os.WriteFile(keystorePath, []byte(`{"version":3}`), 0o600); err != nil { + t.Fatalf("write keystore: %v", err) + } + if err := WriteWalletMetadata(deployDir, wallet); err != nil { + t.Fatalf("write wallet metadata: %v", err) + } + if err := os.WriteFile(filepath.Join(deployDir, "values-remote-signer.yaml"), []byte(generateRemoteSignerValues(wallet)), 0o600); err != nil { + t.Fatalf("write remote signer values: %v", err) + } + + return cfg, id, wallet +} + +func TestBackupRestoreWalletCmd_HermesRoundTrip(t *testing.T) { + tests := []struct { + name string + passphrase string + }{ + {name: "plain"}, + {name: "encrypted", passphrase: "correct horse battery staple"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, id, original := setupHermesBackupInstance(t, "source-"+tt.name) + + backupPath := filepath.Join(t.TempDir(), "backup") + if tt.passphrase != "" { + backupPath += ".enc" + } else { + backupPath += ".json" + } + + if err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + Passphrase: tt.passphrase, + HasPassFlag: true, + }, newTestUI()); err != nil { + t.Fatalf("backup: %v", err) + } + + payload, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("read backup: %v", err) + } + if gotEncrypted := walletbackup.IsEncrypted(payload); gotEncrypted != (tt.passphrase != "") { + t.Fatalf("encrypted = %v, want %v", gotEncrypted, tt.passphrase != "") + } + decoded, err := walletbackup.Decode(payload, tt.passphrase) + if err != nil { + t.Fatalf("decode backup: %v", err) + } + if decoded.Instance != id { + t.Fatalf("backup instance = %q, want %q", decoded.Instance, id) + } + if len(decoded.Wallets) != 1 || decoded.Wallets[0].Address != original.Address { + t.Fatalf("backup wallet = %+v, want address %s", decoded.Wallets, original.Address) + } + + restoreID := "restore-" + tt.name + restoreDir := DeploymentPath(cfg, restoreID) + if err := os.MkdirAll(restoreDir, 0o755); err != nil { + t.Fatalf("mkdir restore dir: %v", err) + } + + if err := RestoreWalletCmd(cfg, restoreID, RestoreWalletOptions{ + Input: backupPath, + Passphrase: tt.passphrase, + HasPassFlag: true, + }, newTestUI()); err != nil { + t.Fatalf("restore: %v", err) + } + + restored, err := ReadWalletMetadata(restoreDir) + if err != nil { + t.Fatalf("read restored metadata: %v", err) + } + if restored.Address != original.Address { + t.Fatalf("restored address = %q, want %q", restored.Address, original.Address) + } + if restored.KeystoreUUID != original.KeystoreUUID { + t.Fatalf("restored keystore UUID = %q, want %q", restored.KeystoreUUID, original.KeystoreUUID) + } + + keystorePath := filepath.Join(agentruntime.KeystoreVolumePath(cfg, agentruntime.Hermes, restoreID), original.KeystoreUUID+".json") + if _, err := os.Stat(keystorePath); err != nil { + t.Fatalf("restored keystore missing: %v", err) + } + + password, err := walletbackup.ReadKeystorePassword(restoreDir) + if err != nil { + t.Fatalf("read restored keystore password: %v", err) + } + if password != original.Password { + t.Fatalf("restored password = %q, want original password", password) + } + }) + } +} + +func TestRestoreWalletCmd_HermesRequiresForceForExistingWallet(t *testing.T) { + cfg, id, _ := setupHermesBackupInstance(t, "existing") + + backupPath := filepath.Join(t.TempDir(), "backup.json") + if err := BackupWalletCmd(cfg, id, BackupWalletOptions{ + Output: backupPath, + HasPassFlag: true, + }, newTestUI()); err != nil { + t.Fatalf("backup: %v", err) + } + + if err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: backupPath, + HasPassFlag: true, + Force: false, + }, newTestUI()); err == nil { + t.Fatal("expected restore over existing Hermes wallet to require force") + } + + if err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: backupPath, + HasPassFlag: true, + Force: true, + }, newTestUI()); err != nil { + t.Fatalf("forced restore: %v", err) + } +} + +func TestRestoreWalletCmd_ApplyClusterPublishesWalletMetadataBeforeRestart(t *testing.T) { + const id = "obol-agent" + + cfg, deployDir := walletImportTestConfig(t, id) + stubVolumeOwnership(t) + + var calls []string + origApply := applyWalletMetadataConfigMapFn + origRestart := restartHermesRemoteSignerFn + t.Cleanup(func() { + applyWalletMetadataConfigMapFn = origApply + restartHermesRemoteSignerFn = origRestart + }) + + applyWalletMetadataConfigMapFn = func(_ *config.Config, gotID, gotDeployDir string) { + if gotID != id { + t.Fatalf("applyWalletMetadataConfigMap id = %q, want %q", gotID, id) + } + if gotDeployDir != deployDir { + t.Fatalf("applyWalletMetadataConfigMap deployDir = %q, want %q", gotDeployDir, deployDir) + } + calls = append(calls, "wallet-metadata") + } + restartHermesRemoteSignerFn = func(_ *config.Config, _ string, _ *ui.UI) { + calls = append(calls, "restart") + } + + backup := &walletbackup.File{ + Version: walletbackup.Version, + Instance: "source", + Wallets: []walletbackup.Wallet{{ + Address: "0x1111111111111111111111111111111111111111", + PublicKey: "0x04abc", + KeystoreUUID: "restored-wallet", + CreatedAt: "2026-05-04T00:00:00Z", + Keystore: json.RawMessage(`{"version":3}`), + KeystorePassword: "secret", + }}, + } + payload, _, err := walletbackup.Encode(backup, "") + if err != nil { + t.Fatalf("encode backup: %v", err) + } + + backupPath := filepath.Join(t.TempDir(), "backup.json") + if err := os.WriteFile(backupPath, payload, 0o600); err != nil { + t.Fatalf("write backup: %v", err) + } + + if err := RestoreWalletCmd(cfg, id, RestoreWalletOptions{ + Input: backupPath, + Force: true, + ApplyCluster: true, + }, newTestUI()); err != nil { + t.Fatalf("restore: %v", err) + } + + want := []string{"wallet-metadata", "restart"} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("cluster apply calls = %v, want %v", calls, want) + } +} diff --git a/internal/hermes/wallet_import.go b/internal/hermes/wallet_import.go index e72cce3c..91a9acb6 100644 --- a/internal/hermes/wallet_import.go +++ b/internal/hermes/wallet_import.go @@ -28,10 +28,11 @@ type ImportPrivateKeyWalletOptions struct { // without standing up a real k3d cluster. Production wires them to the real // helmfile-sync + kubectl rollout helpers. var ( - syncFn = Sync - restartHermesRemoteSignerFn = restartHermesRemoteSigner - ensureVolumeWritableFn = ensureVolumeWritable - fixRuntimeVolumeOwnershipFn = fixRuntimeVolumeOwnership + syncFn = Sync + restartHermesRemoteSignerFn = restartHermesRemoteSigner + ensureVolumeWritableFn = ensureVolumeWritable + fixRuntimeVolumeOwnershipFn = fixRuntimeVolumeOwnership + applyWalletMetadataConfigMapFn = applyWalletMetadataConfigMap ) // ImportPrivateKeyWalletCmd imports an existing private key as the