MUL-8302: fix(daemon): preserve task environment in Hermes tool shells (#9607)

* fix(daemon): preserve task environment in Hermes tool shells

* fix(daemon): complete Hermes task env and preserve derived isolation (MUL-8302)

Co-authored-by: multica-agent <[email protected]>

* fix(deps): patch x/net HTML vulnerability (MUL-8302)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiahao
2026-10-28 13:45:15 +08:00
committed by GitHub
co-authored by multica-agent Sol-Boy
parent d9f5dee413
commit ca593b3103
5 changed files with 350 additions and 19 deletions
+6 -6
View File
@@ -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
)
+12 -12
View File
@@ -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=
+19
View File
@@ -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"} {
+102 -1
View File
@@ -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
@@ -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)
}
}
}