mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
multica-agent
Sol-Boy
parent
d9f5dee413
commit
ca593b3103
+6
-6
@@ -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
@@ -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=
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user