diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index c9dd6d98..479ca5a5 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -695,6 +695,10 @@ func writeDeploymentFiles(cfg *config.Config, id, deploymentDir, agentBaseURL st if err != nil { return err } + configData, err = mergePreservedHermesConfigKeys(cfg, id, configData) + if err != nil { + return err + } if err := os.WriteFile(filepath.Join(deploymentDir, valuesFileName), []byte(generateValues(namespace, hostname, dashboardHost, agentBaseURL, token, primary, configData)), 0o600); err != nil { return fmt.Errorf("failed to write %s: %w", valuesFileName, err) @@ -1147,12 +1151,16 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { "api_key": litellmMasterKey(cfg), }, "terminal": map[string]any{ - "backend": "local", - "cwd": "/data/.hermes/workspace", - "timeout": 180, - "lifetime_seconds": 300, + "backend": "local", + "cwd": "/data/.hermes/workspace", + // pay-agent streams up to 1h; chat buys must not die at 180s. + "timeout": 3600, + "lifetime_seconds": 3700, "docker_mount_cwd_to_workspace": false, }, + // Tirith blocks .dev seller URLs in pay-agent shell commands unless + // explicitly allowed. Stack-managed buyers need this for tunnel hosts. + "command_allowlist": []string{"tirith:lookalike_tld"}, "skills": map[string]any{ "external_dirs": []string{"/data/.hermes/" + obolSkillsDirName}, }, @@ -1160,6 +1168,86 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { return yaml.Marshal(payload) } +// mergePreservedHermesConfigKeys carries operator-edited Hermes keys across +// obol agent sync. generateConfig only knows stack-managed defaults; security +// knobs like command_allowlist set via `hermes config` must survive re-render. +func mergePreservedHermesConfigKeys(cfg *config.Config, id string, generated []byte) ([]byte, error) { + path := filepath.Join(agentruntime.HomePath(cfg, agentruntime.Hermes, id), "config.yaml") + existingData, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return generated, nil + } + return nil, fmt.Errorf("read existing Hermes config %s: %w", path, err) + } + + var gen map[string]any + var exist map[string]any + if err := yaml.Unmarshal(generated, &gen); err != nil { + return nil, fmt.Errorf("parse generated Hermes config: %w", err) + } + if err := yaml.Unmarshal(existingData, &exist); err != nil { + return generated, nil + } + + gen["command_allowlist"] = mergeCommandAllowlist( + stringSliceFromConfig(gen["command_allowlist"]), + stringSliceFromConfig(exist["command_allowlist"]), + ) + + out, err := yaml.Marshal(gen) + if err != nil { + return nil, fmt.Errorf("marshal merged Hermes config: %w", err) + } + return out, nil +} + +func mergeCommandAllowlist(generated, existing []string) []string { + // No capacity hints: the command_allowlist is an operator config list of a + // handful of entries, so preallocation is noise — and summing the two + // lengths trips CodeQL's allocation-size-overflow heuristic (a false + // positive here, but not worth a standing dismissal). Grow on demand. + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, list := range [][]string{generated, existing} { + for _, entry := range list { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if _, ok := seen[entry]; ok { + continue + } + seen[entry] = struct{}{} + out = append(out, entry) + } + } + return out +} + +func stringSliceFromConfig(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + case string: + s := strings.TrimSpace(t) + if s == "" { + return nil + } + return []string{s} + default: + return nil + } +} + func currentAgentBaseURL(deploymentDir string) string { raw, err := os.ReadFile(filepath.Join(deploymentDir, valuesFileName)) if err != nil { diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 90a75a5b..672798ef 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -417,3 +417,58 @@ func mkdirInstance(t *testing.T, cfg *config.Config, id string) { t.Fatalf("create Hermes instance %q: %v", id, err) } } + +func TestGenerateConfig_IncludesPayAgentAllowlist(t *testing.T) { + raw, err := generateConfig(testConfig(t), "claude-sonnet-4-6") + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + allowlist, ok := cfg["command_allowlist"].([]any) + if !ok || len(allowlist) == 0 { + t.Fatalf("command_allowlist = %#v, want non-empty list", cfg["command_allowlist"]) + } + if allowlist[0] != "tirith:lookalike_tld" { + t.Fatalf("command_allowlist[0] = %v, want tirith:lookalike_tld", allowlist[0]) + } + term, ok := cfg["terminal"].(map[string]any) + if !ok { + t.Fatal("terminal config missing") + } + if term["timeout"] != 3600 { + t.Fatalf("terminal.timeout = %v, want 3600 for pay-agent", term["timeout"]) + } +} + +func TestMergePreservedHermesConfigKeys_UnionsAllowlist(t *testing.T) { + cfg := testConfig(t) + id := "obol-agent" + home := agentruntime.HomePath(cfg, agentruntime.Hermes, id) + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatalf("mkdir home: %v", err) + } + existing := []byte("command_allowlist:\n - custom:rule\n") + if err := os.WriteFile(filepath.Join(home, "config.yaml"), existing, 0o600); err != nil { + t.Fatalf("write existing config: %v", err) + } + generated, err := generateConfig(cfg, "claude-sonnet-4-6") + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + merged, err := mergePreservedHermesConfigKeys(cfg, id, generated) + if err != nil { + t.Fatalf("mergePreservedHermesConfigKeys: %v", err) + } + var out map[string]any + if err := yaml.Unmarshal(merged, &out); err != nil { + t.Fatalf("yaml.Unmarshal merged: %v", err) + } + got := stringSliceFromConfig(out["command_allowlist"]) + want := []string{"tirith:lookalike_tld", "custom:rule"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("command_allowlist = %#v, want %#v", got, want) + } +}