diff --git a/server/go.mod b/server/go.mod index 8ce8ab990..af3e14b5c 100644 --- a/server/go.mod +++ b/server/go.mod @@ -31,9 +31,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/tdewolff/parse/v2 v2.8.16 github.com/yuin/goldmark v1.8.6 - golang.org/x/net v0.60.0 - golang.org/x/sync v0.23.0 - golang.org/x/sys v0.48.0 + golang.org/x/net v0.62.0 + golang.org/x/sync v0.24.0 + golang.org/x/sys v0.49.0 google.golang.org/protobuf v1.36.12 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -72,9 +72,9 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/mod v0.41.0 // indirect - golang.org/x/telemetry v0.0.0-20260910141331-15ceca2b0a1f // indirect - golang.org/x/text v0.42.0 // indirect - golang.org/x/tools v0.50.0 // indirect + golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/tools v0.51.0 // indirect golang.org/x/vuln v1.8.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index ed102b84b..3c6ba1362 100644 --- a/server/go.sum +++ b/server/go.sum @@ -170,20 +170,20 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20260910141331-15ceca2b0a1f h1:5LLXkapW8RSq5f6l0ztG82Xt1OYp5uRIyMHuZVmkm4k= -golang.org/x/telemetry v0.0.0-20260910141331-15ceca2b0a1f/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 h1:DftxASHKl6JolFPqoN+2WrFO53JvkHwRXvFqf8gsivA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index fe84e86c7..b3ee761b1 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -589,6 +589,25 @@ func TestTaskMulticaEnvironmentIncludesPrivateConfigRoot(t *testing.T) { } } +func TestHermesTaskEnvPassthroughCoversTaskEnvironment(t *testing.T) { + t.Parallel() + names := execenv.HermesTaskEnvPassthroughNames() + seen := make(map[string]bool, len(names)) + for _, name := range names { + if !strings.HasPrefix(name, "MULTICA_") || seen[name] { + t.Errorf("invalid or duplicate Hermes task environment name: %q", name) + } + seen[name] = true + } + env := taskMulticaEnvironment(Task{ID: "task-test", AgentID: "agent-test", WorkspaceID: "workspace-test"}, + "hermes", "agent-name", "mat_fixture", "/task/config", "/task/workspaces", "https://task.example", 19514, 3, "/task/tmp") + for name := range env { + if strings.HasPrefix(name, "MULTICA_") && !seen[name] { + t.Errorf("daemon injects %s but Hermes tool subprocesses will drop it", name) + } + } +} + func TestTaskMulticaEnvironmentTempPolicy(t *testing.T) { t.Setenv("CLAUDE_CODE_TMPDIR", "") for _, provider := range []string{"claude", "codex", "pi", "opencode", "cursor"} { diff --git a/server/internal/daemon/execenv/hermes_home.go b/server/internal/daemon/execenv/hermes_home.go index 3a91da528..33b76885c 100644 --- a/server/internal/daemon/execenv/hermes_home.go +++ b/server/internal/daemon/execenv/hermes_home.go @@ -591,7 +591,8 @@ func linkSharedHermesEntry(src, dst string) error { // survive — the bound skills still load from the task-local skills/ dir, which is // the point of the fix; only the user's global skills would be missing. The file // is written 0600 (it can hold inline api_key secrets) via atomic replace, so -// reuse also repairs a prior file's permissions. +// reuse also repairs a prior file's permissions. The terminal passthrough +// allowlist preserves task credentials in tool shells without storing values. func writeDerivedHermesConfig(sharedHome, hermesHome string, env map[string]string, logger *slog.Logger) error { srcConfig := filepath.Join(sharedHome, "config.yaml") dstConfig := filepath.Join(hermesHome, "config.yaml") @@ -622,6 +623,9 @@ func writeDerivedHermesConfig(sharedHome, hermesHome string, env map[string]stri if err := setHermesExternalDirs(doc, computeHermesExternalDirs(sharedHome, nil, env)); err != nil { return err } + if err := setHermesTerminalEnvPassthrough(doc); err != nil { + return err + } return marshalYAMLToFile(doc, dstConfig) } @@ -639,9 +643,106 @@ func writeDerivedHermesConfig(sharedHome, hermesHome string, env map[string]stri // Supermemory/Hindsight/etc. bank isn't shared across managed tasks; the // built-in per-task memories/ dir is already isolated above. disableHermesMemoryProvider(&doc) + if err := setHermesTerminalEnvPassthrough(&doc); err != nil { + logger.Warn("execenv: hermes-home set terminal passthrough failed; keeping other derived settings", "error", err) + } return marshalYAMLToFile(&doc, dstConfig) } +// HermesTaskEnvPassthroughNames lists the daemon-owned task context that Hermes +// tool subprocesses need, including optional autopilot and quick-create fields. +// Only names are persisted; Hermes ignores names absent from the process env. +func HermesTaskEnvPassthroughNames() []string { + return []string{ + "MULTICA_TOKEN", + "MULTICA_TASK_CONFIG_ROOT", + "MULTICA_TASK_WORKSPACES_ROOT", + "MULTICA_SERVER_URL", + "MULTICA_DAEMON_PORT", + "MULTICA_WORKSPACE_ID", + "MULTICA_AGENT_NAME", + "MULTICA_AGENT_ID", + "MULTICA_TASK_ID", + "MULTICA_TASK_SLOT", + "MULTICA_AUTOPILOT_RUN_ID", + "MULTICA_AUTOPILOT_ID", + "MULTICA_QUICK_CREATE_TASK_ID", + "MULTICA_QUICK_CREATE_ATTACHMENT_IDS", + } +} + +// setHermesTerminalEnvPassthrough lets Multica CLI calls in Hermes tool shells +// use the task environment already injected by the daemon. Only names are +// persisted; user entries and other terminal settings remain intact. +func setHermesTerminalEnvPassthrough(doc *yaml.Node) error { + top := yamlDocumentRoot(doc) + if top == nil { + return fmt.Errorf("hermes config: unexpected root node") + } + original := yamlMapValue(top, "terminal") + terminal := resolveHermesYAMLAlias(original) + if original != nil && terminal == nil { + return fmt.Errorf("hermes config: invalid terminal alias") + } + if terminal != nil { + // Decode validates recursive/excessive aliases before materializing references. + var validated any + if err := terminal.Decode(&validated); err != nil { + return fmt.Errorf("hermes terminal config: %w", err) + } + } + // Preserve references to values we replace. Detach nested anchors first so + // aliases of the whole terminal section keep their original allowlist too. + for _, node := range []*yaml.Node{yamlMapValue(terminal, "env_passthrough"), original} { + if node != nil && node.Anchor != "" { + expandAliasesOf(doc, node) + } + } + if terminal == nil || terminal.Kind != yaml.MappingNode { + terminal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + } else { + detached := *terminal + detached.Anchor = "" + detached.Content = append([]*yaml.Node(nil), terminal.Content...) + terminal = &detached + } + yamlSetMapValue(top, "terminal", terminal) + names := []string{} + seen := map[string]bool{} + add := func(name string) { + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + if existing := resolveHermesYAMLAlias(yamlMapValue(terminal, "env_passthrough")); existing != nil && existing.Kind == yaml.SequenceNode { + for _, entry := range existing.Content { + entry = resolveHermesYAMLAlias(entry) + if entry != nil && entry.Kind == yaml.ScalarNode && entry.Tag == "!!str" { + add(entry.Value) + } + } + } + for _, name := range HermesTaskEnvPassthroughNames() { + add(name) + } + yamlSetMapValue(terminal, "env_passthrough", yamlStringSeq(names)) + return nil +} + +// resolveHermesYAMLAlias follows references without looping on malformed nodes. +func resolveHermesYAMLAlias(node *yaml.Node) *yaml.Node { + seen := map[*yaml.Node]bool{} + for node != nil && node.Kind == yaml.AliasNode { + if seen[node] { + return nil + } + seen[node] = true + node = node.Alias + } + return node +} + // disableHermesMemoryProvider forces skills-adjacent `memory.provider` to empty // in the derived config. Hermes activates an external memory plugin only when // memory.provider is a non-blank string (agent/agent_init.py), so "" is the diff --git a/server/internal/daemon/execenv/hermes_home_test.go b/server/internal/daemon/execenv/hermes_home_test.go index 0fd28d0c5..972f6efe9 100644 --- a/server/internal/daemon/execenv/hermes_home_test.go +++ b/server/internal/daemon/execenv/hermes_home_test.go @@ -3,6 +3,7 @@ package execenv import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -829,3 +830,213 @@ func mustWrite(t *testing.T, path, content string) { t.Fatalf("write %s: %v", path, err) } } + +func TestHermesTerminalEnvPassthrough(t *testing.T) { + t.Parallel() + required := []string{ + "MULTICA_TOKEN", "MULTICA_TASK_CONFIG_ROOT", "MULTICA_TASK_WORKSPACES_ROOT", + "MULTICA_SERVER_URL", "MULTICA_DAEMON_PORT", "MULTICA_WORKSPACE_ID", + "MULTICA_AGENT_NAME", "MULTICA_AGENT_ID", "MULTICA_TASK_ID", "MULTICA_TASK_SLOT", + "MULTICA_AUTOPILOT_RUN_ID", "MULTICA_AUTOPILOT_ID", + "MULTICA_QUICK_CREATE_TASK_ID", "MULTICA_QUICK_CREATE_ATTACHMENT_IDS", + } + for _, tc := range []struct { + name, config string + existing []string + }{ + {name: "no config"}, + {name: "existing config without terminal", config: "model: hermes-4\n"}, + {name: "null terminal", config: "model: hermes-4\nterminal: null\n"}, + {name: "scalar terminal", config: "model: hermes-4\nterminal: invalid\n"}, + {name: "sequence terminal", config: "model: hermes-4\nterminal: [invalid]\n"}, + {name: "existing terminal", config: "model: hermes-4\nterminal:\n timeout: 42\n"}, + {name: "scalar terminal anchor", config: "model: hermes-4\nterminal: &terminal invalid\ncopy: *terminal\n"}, + {name: "terminal alias", config: "model: hermes-4\ndefaults: &terminal {timeout: 42, env_passthrough: [CUSTOM_KEY]}\nterminal: *terminal\ncopy: *terminal\n", existing: []string{"CUSTOM_KEY"}}, + {name: "allowlist alias", config: "model: hermes-4\nshared_names: &names [CUSTOM_KEY, MULTICA_TOKEN, CUSTOM_KEY]\nterminal: {timeout: 42, env_passthrough: *names}\ncopy: *names\n", existing: []string{"CUSTOM_KEY", "MULTICA_TOKEN"}}, + {name: "terminal anchor", config: "model: hermes-4\nterminal: &terminal {timeout: 42, env_passthrough: &names [CUSTOM_KEY]}\nshared_names: *names\ncopy: *terminal\n", existing: []string{"CUSTOM_KEY"}}, + {name: "existing allowlist", config: "model: hermes-4\nterminal:\n timeout: 42\n env_passthrough: [CUSTOM_KEY, MULTICA_TOKEN, CUSTOM_KEY]\n", existing: []string{"CUSTOM_KEY", "MULTICA_TOKEN"}}, + } { + t.Run(tc.name, func(t *testing.T) { + shared := t.TempDir() + source := filepath.Join(shared, "config.yaml") + if tc.config != "" { + mustWrite(t, source, tc.config) + } + task := TaskContextForEnv{IssueID: "hermes-env", AgentSkills: []SkillContextForEnv{{Name: "Helper", Content: "Help."}}} + env, err := Prepare(PrepareParams{ + WorkspacesRoot: t.TempDir(), + WorkspaceID: "ws-hermes-env", + TaskID: "cccc1111-2222-3333-4444-555566667777", + Provider: "hermes", + HermesSourceHome: shared, + HermesEnv: map[string]string{ + "MULTICA_TOKEN": "mat_fixture_not_for_config", + "MULTICA_AUTOPILOT_RUN_ID": "autopilot_run_fixture", + "MULTICA_AUTOPILOT_ID": "autopilot_fixture", + "MULTICA_QUICK_CREATE_TASK_ID": "quick_create_fixture", + "MULTICA_QUICK_CREATE_ATTACHMENT_IDS": `["attachment_fixture"]`, + "UNRELATED_SECRET": "unrelated_fixture", + }, + Task: task, + }, testLogger()) + if err != nil { + t.Fatal(err) + } + defer env.Cleanup(true) + want := append([]string{}, tc.existing...) + for _, key := range required { + found := false + for _, old := range want { + if old == key { + found = true + } + } + if !found { + want = append(want, key) + } + } + for _, phase := range []string{"fresh", "reuse"} { + if phase == "reuse" { + // A stale task-local policy must be rebuilt from the shared config. + mustWrite(t, filepath.Join(env.HermesHome, "config.yaml"), "terminal: {env_passthrough: [STALE_KEY]}\n") + reused := Reuse(ReuseParams{WorkDir: env.WorkDir, Provider: "hermes", HermesSourceHome: shared, Task: task}, testLogger()) + if reused == nil { + t.Fatal("Reuse returned nil") + } + env = reused + } + data, err := os.ReadFile(filepath.Join(env.HermesHome, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Model string + Terminal struct { + Timeout int + EnvPassthrough []string `yaml:"env_passthrough"` + } + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cfg.Terminal.EnvPassthrough, want) { + t.Errorf("%s env_passthrough = %v, want %v", phase, cfg.Terminal.EnvPassthrough, want) + } + if tc.config != "" && cfg.Model != "hermes-4" { + t.Errorf("%s lost user model", phase) + } + if strings.Contains(tc.config, "timeout:") && cfg.Terminal.Timeout != 42 { + t.Errorf("%s lost terminal timeout", phase) + } + var before, after map[string]any + if err := yaml.Unmarshal([]byte(tc.config), &before); err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(data, &after); err != nil { + t.Fatal(err) + } + for _, key := range []string{"defaults", "shared_names", "copy"} { + if !reflect.DeepEqual(before[key], after[key]) { + t.Errorf("%s changed %s: got %v, want %v", phase, key, after[key], before[key]) + } + } + for _, forbidden := range []string{ + "mat_fixture_not_for_config", "autopilot_run_fixture", "autopilot_fixture", + "quick_create_fixture", "attachment_fixture", "unrelated_fixture", "UNRELATED_SECRET", "STALE_KEY", + } { + if strings.Contains(string(data), forbidden) { + t.Errorf("%s config contains %s", phase, forbidden) + } + } + original, err := os.ReadFile(source) + if tc.config == "" { + if !os.IsNotExist(err) { + t.Fatalf("created shared config: %v", err) + } + } else if err != nil || string(original) != tc.config { + t.Fatalf("shared config changed: %v", err) + } + } + }) + } +} + +func TestHermesTerminalConfigInvalidAliases(t *testing.T) { + t.Parallel() + for _, config := range []string{ + "terminal: *missing\n", + } { + source, overlay := t.TempDir(), t.TempDir() + mustWrite(t, filepath.Join(source, "config.yaml"), config) + if err := writeDerivedHermesConfig(source, overlay, nil, testLogger()); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(overlay, "config.yaml")) + if err != nil { + t.Fatal(err) + } + if string(data) != config { + t.Errorf("invalid alias config was not preserved: got %q, want %q", data, config) + } + } + cycle := &yaml.Node{Kind: yaml.AliasNode} + cycle.Alias = cycle + for _, node := range []*yaml.Node{cycle, {Kind: yaml.AliasNode}} { + if got := resolveHermesYAMLAlias(node); got != nil { + t.Errorf("invalid alias resolved to %v", got) + } + } +} + +func TestHermesTerminalConfigFailurePreservesDerivedSettings(t *testing.T) { + t.Parallel() + for _, terminalConfig := range []string{ + "terminal: &terminal {nested: *terminal}\n", + "names: &names [*names]\nterminal: {env_passthrough: *names}\n", + } { + source, overlay := t.TempDir(), t.TempDir() + config := "model: hermes-4\nskills: {external_dirs: [custom-skills]}\nmemory: {provider: supermemory}\n" + terminalConfig + sourceConfig := filepath.Join(source, "config.yaml") + mustWrite(t, sourceConfig, config) + if err := writeDerivedHermesConfig(source, overlay, nil, testLogger()); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(overlay, "config.yaml")) + if err != nil { + t.Fatal(err) + } + var before, after yaml.Node + if err := yaml.Unmarshal([]byte(config), &before); err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(data, &after); err != nil { + t.Fatal(err) + } + wantDirs := []string{filepath.Join(source, "custom-skills"), filepath.Join(source, "skills")} + if got := existingHermesExternalDirs(&after); !reflect.DeepEqual(got, wantDirs) { + t.Errorf("external_dirs = %v, want %v", got, wantDirs) + } + top := yamlDocumentRoot(&after) + provider := yamlMapValue(yamlMapValue(top, "memory"), "provider") + if provider == nil || provider.Value != "" { + t.Errorf("external memory provider was not disabled: %v", provider) + } + for _, key := range []string{"model", "terminal", "names"} { + original, err := yaml.Marshal(yamlMapValue(yamlDocumentRoot(&before), key)) + if err != nil { + t.Fatal(err) + } + derived, err := yaml.Marshal(yamlMapValue(top, key)) + if err != nil { + t.Fatal(err) + } + if string(derived) != string(original) { + t.Errorf("changed %s: got %q, want %q", key, derived, original) + } + } + original, err := os.ReadFile(sourceConfig) + if err != nil || string(original) != config { + t.Fatalf("shared config changed: %v", err) + } + } +}