Skip to content
Merged
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
96 changes: 92 additions & 4 deletions internal/hermes/hermes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1147,19 +1151,103 @@ 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},
},
}
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 {
Expand Down
55 changes: 55 additions & 0 deletions internal/hermes/hermes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading