package agentconfig import ( "context" "crypto/sha256" "fmt" "os" "path/filepath" "reflect" "strings" "testing" "time" ) func TestLoadRuntimeConfigLocalWinsAndReplacesOrderedArrays(t *testing.T) { root := t.TempDir() repoGlobal := runtimeGlobalFixture() userLocal := runtimeLocalFixture( root, "local-profile", ` selection: rules: - id: local-only target: profile: local-profile `, ` isolation: fallback_modes: [clone] `, ` retention: completed_days: 5 `, ` alpha: workspace: `+yamlQuote(filepath.Join(root, "workspace"))+` selected_milestone: milestone-a override: defaults: default_profile: project-profile profile_aliases: shared: project-profile selection: rules: - id: project-only target: profile: project-profile `, ) snapshot, err := LoadRuntimeConfigBytes([]byte(repoGlobal), []byte(userLocal)) if err != nil { t.Fatalf("LoadRuntimeConfigBytes: %v", err) } config := snapshot.Config() if got, want := config.Defaults.DefaultProfile, "local-profile"; got != want { t.Fatalf("default profile = %q, want %q", got, want) } if config.Defaults.AutoResumeInterrupted { t.Fatal("auto_resume_interrupted = true, want explicit local false") } if got, want := config.Defaults.ProfileAliases, map[string]string{ "global": "global-profile", "local": "local-profile", "shared": "local-profile", }; !reflect.DeepEqual(got, want) { t.Fatalf("profile aliases = %#v, want %#v", got, want) } if got, want := selectionRuleIDs(config.Selection.Rules), []string{"local-only"}; !reflect.DeepEqual(got, want) { t.Fatalf("selection rule IDs = %v, want whole replacement %v", got, want) } if got, want := config.Isolation.FallbackModes, []string{"clone"}; !reflect.DeepEqual(got, want) { t.Fatalf("fallback modes = %v, want whole replacement %v", got, want) } if got, want := config.Retention, (RetentionPolicy{ CompletedDays: 5, BlockedDays: 30, MaxProjectLogRecords: 500, }); got != want { t.Fatalf("retention = %#v, want %#v", got, want) } project, ok := snapshot.Project("alpha") if !ok { t.Fatal("project alpha is missing") } if got, want := project.Defaults.DefaultProfile, "project-profile"; got != want { t.Fatalf("project default profile = %q, want %q", got, want) } if got, want := project.Defaults.ProfileAliases["global"], "global-profile"; got != want { t.Fatalf("project inherited alias = %q, want %q", got, want) } if got, want := project.Defaults.ProfileAliases["shared"], "project-profile"; got != want { t.Fatalf("project local-wins alias = %q, want %q", got, want) } if got, want := selectionRuleIDs(project.Selection.Rules), []string{"project-only"}; !reflect.DeepEqual(got, want) { t.Fatalf("project selection rule IDs = %v, want %v", got, want) } if project.AutoResumeInterrupted { t.Fatal("project auto resume did not inherit explicit local false") } } func TestLoadRuntimeConfigRejectsInvalidDocumentsAndValues(t *testing.T) { root := t.TempDir() validGlobal := runtimeGlobalFixture() validLocal := runtimeLocalFixture(root, "local-profile", "", "", "", "") tests := []struct { name string global string local string want string }{ { name: "unknown repo field", global: validGlobal + "unexpected: true\n", local: validLocal, want: "field unexpected not found", }, { name: "unknown local credential field", global: validGlobal, local: validLocal + "token: forbidden\n", want: "field token not found", }, { name: "multiple local documents", global: validGlobal, local: validLocal + "---\nversion: \"1\"\n", want: "exactly one YAML document", }, { name: "unsupported version", global: strings.Replace(validGlobal, `version: "1"`, `version: "2"`, 1), local: validLocal, want: "unsupported repo-global runtime config version", }, { name: "relative local root", global: validGlobal, local: strings.Replace(validLocal, yamlQuote(filepath.Join(root, "state")), "relative/state", 1), want: "absolute clean path", }, { name: "invalid isolation mode", global: strings.Replace(validGlobal, "default_mode: overlay", "default_mode: direct", 1), local: validLocal, want: "unsupported mode", }, { name: "duplicate ordered rule", global: strings.Replace( validGlobal, " - id: global-second", " - id: global-first", 1, ), local: validLocal, want: "repeats rule id", }, { name: "negative retention", global: strings.Replace(validGlobal, "completed_days: 14", "completed_days: -1", 1), local: validLocal, want: "must be non-negative", }, { name: "local target missing from configured catalog", global: runtimeGlobalWithCatalogFixture(), local: validLocal, want: `references unknown profile "local-profile"`, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := LoadRuntimeConfigBytes([]byte(test.global), []byte(test.local)) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("LoadRuntimeConfigBytes error = %v, want containing %q", err, test.want) } }) } } func TestLoadRuntimeConfigRejectsPartialSelectionDefaultTargets(t *testing.T) { root := t.TempDir() catalogGlobal := runtimeGlobalWithCatalogFixture() // Use a catalog-known profile so the local config itself is valid. validLocal := runtimeLocalFixture(root, "global-profile", "", "", "", "") tests := []struct { name string global string local string want string }{ { name: "provider-only default selection rejects", global: strings.Replace(catalogGlobal, " profile: global-profile\n", " provider: codex\n", 1), local: validLocal, want: "profile is required when provider or model is set", }, { name: "model-only default selection rejects", global: strings.Replace(catalogGlobal, " profile: global-profile\n", " model: global-model\n", 1), local: validLocal, want: "profile is required when provider or model is set", }, { name: "provider and model without profile rejects", global: strings.Replace(catalogGlobal, " profile: global-profile\n", " provider: codex\n model: global-model\n", 1), local: validLocal, want: "profile is required when provider or model is set", }, { name: "fully empty default selection remains valid", global: strings.Replace(catalogGlobal, " profile: global-profile\n", "", 1), local: validLocal, want: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := LoadRuntimeConfigBytes([]byte(test.global), []byte(test.local)) if test.want == "" { if err != nil { t.Fatalf("LoadRuntimeConfigBytes error = %v, want nil", err) } return } if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("LoadRuntimeConfigBytes error = %v, want containing %q", err, test.want) } }) } } func TestRuntimeSnapshotAccessorsAreImmutableAndRevisioned(t *testing.T) { root := t.TempDir() global := runtimeGlobalFixture() localA := runtimeLocalFixture(root, "local-a", "", "", "", "") snapshotA, err := LoadRuntimeConfigBytes([]byte(global), []byte(localA)) if err != nil { t.Fatalf("load snapshot A: %v", err) } if !strings.HasPrefix(snapshotA.Revision(), "sha256:") { t.Fatalf("revision = %q, want sha256 prefix", snapshotA.Revision()) } sourcesA := snapshotA.SourceRevisions() if !strings.HasPrefix(sourcesA.RepoGlobal, "sha256:") || !strings.HasPrefix(sourcesA.UserLocal, "sha256:") { t.Fatalf("source revisions = %#v, want sha256 prefixes", sourcesA) } initialConfig := snapshotA.Config() if initialConfig.Revision != snapshotA.Revision() { t.Fatalf("config revision = %q, want snapshot revision %q", initialConfig.Revision, snapshotA.Revision()) } if initialConfig.SourceRevisions != sourcesA { t.Fatalf("config source revisions = %#v, want %#v", initialConfig.SourceRevisions, sourcesA) } if !strings.HasPrefix(initialConfig.Selection.Revision, "sha256:") { t.Fatalf("selection revision = %q, want sha256 prefix", initialConfig.Selection.Revision) } mutated := snapshotA.Config() mutated.Defaults.ProfileAliases["global"] = "mutated" mutated.Selection.Rules[0].ID = "mutated" mutated.Isolation.FallbackModes[0] = "mutated" mutated.Projects["injected"] = ProjectRegistration{ID: "injected"} fresh := snapshotA.Config() if got, want := fresh.Defaults.ProfileAliases["global"], "global-profile"; got != want { t.Fatalf("fresh alias = %q, want %q", got, want) } if got, want := fresh.Selection.Rules[0].ID, "global-first"; got != want { t.Fatalf("fresh rule ID = %q, want %q", got, want) } if got, want := fresh.Isolation.FallbackModes[0], "worktree"; got != want { t.Fatalf("fresh fallback = %q, want %q", got, want) } if _, exists := fresh.Projects["injected"]; exists { t.Fatal("mutation leaked into immutable snapshot projects") } localB := strings.Replace(localA, "local-a", "local-b", 1) snapshotB, err := LoadRuntimeConfigBytes([]byte(global), []byte(localB)) if err != nil { t.Fatalf("load snapshot B: %v", err) } if snapshotA.Revision() == snapshotB.Revision() { t.Fatalf("runtime revisions did not change: %q", snapshotA.Revision()) } sourcesB := snapshotB.SourceRevisions() if sourcesA.RepoGlobal != sourcesB.RepoGlobal { t.Fatalf("repo-global revision changed: A=%q B=%q", sourcesA.RepoGlobal, sourcesB.RepoGlobal) } if sourcesA.UserLocal == sourcesB.UserLocal { t.Fatalf("user-local revision did not change: %q", sourcesA.UserLocal) } if got, want := snapshotA.Config().Defaults.DefaultProfile, "local-a"; got != want { t.Fatalf("snapshot A changed after loading B: got %q, want %q", got, want) } } func TestLoadRuntimeConfigDoesNotWriteRepoGlobalInput(t *testing.T) { root := t.TempDir() repoPath := filepath.Join(root, "repo-runtime.yaml") localPath := filepath.Join(root, "local-runtime.yaml") repoBytes := []byte(runtimeGlobalFixture()) if err := os.WriteFile(repoPath, repoBytes, 0o444); err != nil { t.Fatalf("write repo fixture: %v", err) } if err := os.WriteFile(localPath, []byte(runtimeLocalFixture(root, "local-profile", "", "", "", "")), 0o600); err != nil { t.Fatalf("write local fixture: %v", err) } before := sha256.Sum256(repoBytes) if _, err := LoadRuntimeConfig(repoPath, localPath); err != nil { t.Fatalf("LoadRuntimeConfig: %v", err) } afterBytes, err := os.ReadFile(repoPath) if err != nil { t.Fatalf("read repo fixture after load: %v", err) } after := sha256.Sum256(afterBytes) if before != after { t.Fatalf("repo-global digest changed: before=%x after=%x", before, after) } info, err := os.Stat(repoPath) if err != nil { t.Fatalf("stat repo fixture: %v", err) } if got, want := info.Mode().Perm(), os.FileMode(0o444); got != want { t.Fatalf("repo-global mode = %o, want %o", got, want) } } func TestClientProcessSpecsAreUserLocalAndImmutable(t *testing.T) { root := t.TempDir() local := runtimeLocalWithClientsFixture(root, ` clients: flutter: executable: /opt/iop/flutter args: [--socket, local.sock] working_directory: /opt/iop launch_on_start: true restart_on_crash: true restart_limit: 3 restart_backoff_millis: 25 focus_args: [--focus-existing] unity: executable: /opt/iop/unity args: [--character] working_directory: /opt/iop `) snapshot, err := LoadRuntimeConfigBytes( []byte(runtimeGlobalFixture()), []byte(local), ) if err != nil { t.Fatalf("LoadRuntimeConfigBytes: %v", err) } config := snapshot.Config() if got, want := config.Clients["flutter"].RestartLimit, 3; got != want { t.Fatalf("flutter restart limit = %d, want %d", got, want) } if got, want := config.Clients["unity"].Executable, "/opt/iop/unity"; got != want { t.Fatalf("unity executable = %q, want %q", got, want) } flutter := config.Clients["flutter"] flutter.Args[0] = "mutated" flutter.FocusArgs[0] = "mutated" config.Clients["flutter"] = flutter delete(config.Clients, "unity") fresh := snapshot.Config() if got, want := fresh.Clients["flutter"].Args[0], "--socket"; got != want { t.Fatalf("client args mutation leaked: got %q, want %q", got, want) } if got, want := fresh.Clients["flutter"].FocusArgs[0], "--focus-existing"; got != want { t.Fatalf("focus args mutation leaked: got %q, want %q", got, want) } if _, ok := fresh.Clients["unity"]; !ok { t.Fatal("client map mutation leaked into snapshot") } } func TestClientProcessSpecValidationMatrix(t *testing.T) { root := t.TempDir() validGlobal := runtimeGlobalFixture() validLocal := runtimeLocalWithClientsFixture(root, ` clients: flutter: executable: /opt/iop/flutter working_directory: /opt/iop restart_on_crash: true restart_limit: 2 focus_args: [--focus-existing] `) tests := []struct { name string global string local string want string }{ { name: "valid", global: validGlobal, local: validLocal, }, { name: "repo-global clients rejected", global: validGlobal + "clients: {}\n", local: validLocal, want: "field clients not found", }, { name: "unknown kind", global: validGlobal, local: strings.Replace(validLocal, " flutter:\n", " electron:\n", 1), want: `unsupported client kind "electron"`, }, { name: "relative executable", global: validGlobal, local: strings.Replace(validLocal, "/opt/iop/flutter", "bin/flutter", 1), want: "executable must be an absolute clean path", }, { name: "relative working directory", global: validGlobal, local: strings.Replace(validLocal, "/opt/iop\n", "relative\n", 1), want: "working_directory must be an absolute clean path", }, { name: "negative restart limit", global: validGlobal, local: strings.Replace(validLocal, "restart_limit: 2", "restart_limit: -1", 1), want: "restart limits must be non-negative", }, { name: "restart requires positive limit", global: validGlobal, local: strings.Replace(validLocal, "restart_limit: 2", "restart_limit: 0", 1), want: "restart_limit must be positive", }, { name: "disabled restart rejects policy", global: validGlobal, local: strings.Replace(validLocal, "restart_on_crash: true", "restart_on_crash: false", 1), want: "restart policy requires restart_on_crash", }, { name: "unity cannot bypass flutter focus", global: validGlobal, local: strings.Replace( validLocal, " flutter:\n", " unity:\n", 1, ), want: "cannot configure Flutter focus arguments", }, { name: "environment field rejected", global: validGlobal, local: strings.Replace( validLocal, " working_directory: /opt/iop\n", " working_directory: /opt/iop\n environment:\n TOKEN: forbidden\n", 1, ), want: "field environment not found", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := LoadRuntimeConfigBytes( []byte(test.global), []byte(test.local), ) if test.want == "" { if err != nil { t.Fatalf("LoadRuntimeConfigBytes: %v", err) } return } if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want containing %q", err, test.want) } }) } } func TestRuntimeConfigWatcherPublishesOnlyValidNextInvocationRevision(t *testing.T) { root := t.TempDir() repoPath := filepath.Join(root, "repo-runtime.yaml") localPath := filepath.Join(root, "local-runtime.yaml") repoBytes := []byte(runtimeGlobalFixture()) if err := os.WriteFile(repoPath, repoBytes, 0o444); err != nil { t.Fatalf("write repo fixture: %v", err) } localA := runtimeLocalFixture(root, "local-a", "", "", "", "") if err := os.WriteFile(localPath, []byte(localA), 0o600); err != nil { t.Fatalf("write local fixture A: %v", err) } repoDigest := sha256.Sum256(repoBytes) watcher, err := NewRuntimeConfigWatcher(context.Background(), repoPath, localPath, 10*time.Millisecond) if err != nil { t.Fatalf("NewRuntimeConfigWatcher: %v", err) } defer watcher.Close() invocationA := watcher.Snapshot() if got, want := invocationA.Config().Defaults.DefaultProfile, "local-a"; got != want { t.Fatalf("initial profile = %q, want %q", got, want) } writeAtomicFixture(t, localPath, strings.Replace(localA, "version:", "unknown: true\nversion:", 1)) select { case reloadErr := <-watcher.Errors(): if reloadErr == nil || !strings.Contains(reloadErr.Error(), "field unknown not found") { t.Fatalf("watcher error = %v, want strict unknown-field error", reloadErr) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for invalid reload error") } if got, want := watcher.Snapshot().Revision(), invocationA.Revision(); got != want { t.Fatalf("invalid edit published revision %q, want retained %q", got, want) } localB := strings.Replace(localA, "local-a", "local-b", 1) writeAtomicFixture(t, localPath, localB) var invocationB RuntimeSnapshot select { case invocationB = <-watcher.Updates(): case <-time.After(2 * time.Second): t.Fatal("timed out waiting for valid revision B") } if invocationB.Revision() == invocationA.Revision() { t.Fatalf("watcher revisions are equal: %q", invocationB.Revision()) } if got, want := invocationB.Config().Defaults.DefaultProfile, "local-b"; got != want { t.Fatalf("revision B profile = %q, want %q", got, want) } if got, want := watcher.Snapshot().Revision(), invocationB.Revision(); got != want { t.Fatalf("current watcher revision = %q, want B %q", got, want) } if got, want := invocationA.Config().Defaults.DefaultProfile, "local-a"; got != want { t.Fatalf("pinned invocation A changed to %q, want %q", got, want) } afterRepoBytes, err := os.ReadFile(repoPath) if err != nil { t.Fatalf("read repo fixture after watch: %v", err) } if after := sha256.Sum256(afterRepoBytes); after != repoDigest { t.Fatalf("watcher mutated repo-global input: before=%x after=%x", repoDigest, after) } } func runtimeGlobalFixture() string { return `version: "1" defaults: default_profile: global-profile auto_resume_interrupted: true profile_aliases: global: global-profile shared: global-profile selection: timezone: UTC default: profile: global-profile rules: - id: global-first match: stages: [worker] min_grade: 1 max_grade: 10 target: profile: global-profile - id: global-second target: profile: backup-profile isolation: default_mode: overlay fallback_modes: [worktree, clone] retention: completed_days: 14 blocked_days: 30 max_project_log_records: 500 ` } func runtimeGlobalWithCatalogFixture() string { catalog := `catalog: version: "1" providers: - id: codex command: codex version_probe: args: [--version] authentication: args: [login, status] capabilities: [run] models: - id: global-model provider: codex target: native-global profiles: - id: global-profile provider: codex model: global-model capabilities: [run] - id: backup-profile provider: codex model: global-model capabilities: [run] ` return strings.Replace(runtimeGlobalFixture(), "version: \"1\"\n", "version: \"1\"\n"+catalog, 1) } func runtimeLocalFixture( root string, defaultProfile string, selectionOverride string, isolationOverride string, retentionOverride string, projects string, ) string { return fmt.Sprintf(`version: "1" device: state_root: %s overlay_root: %s log_root: %s temp_root: %s cache_root: %s override: defaults: default_profile: %s auto_resume_interrupted: false profile_aliases: local: %s shared: %s %s%s%sprojects: %s`, yamlQuote(filepath.Join(root, "state")), yamlQuote(filepath.Join(root, "overlays")), yamlQuote(filepath.Join(root, "logs")), yamlQuote(filepath.Join(root, "tmp")), yamlQuote(filepath.Join(root, "cache")), defaultProfile, defaultProfile, defaultProfile, selectionOverride, isolationOverride, retentionOverride, projects, ) } func runtimeLocalWithClientsFixture(root, clients string) string { return strings.Replace( runtimeLocalFixture(root, "local-profile", "", "", "", ""), "override:\n", strings.TrimPrefix(clients, "\n")+"\noverride:\n", 1, ) } func yamlQuote(value string) string { return fmt.Sprintf("%q", value) } func selectionRuleIDs(rules []SelectionRule) []string { ids := make([]string, len(rules)) for index, rule := range rules { ids[index] = rule.ID } return ids } func writeAtomicFixture(t *testing.T, path, content string) { t.Helper() nextPath := path + ".next" if err := os.WriteFile(nextPath, []byte(content), 0o600); err != nil { t.Fatalf("write next fixture: %v", err) } if err := os.Rename(nextPath, path); err != nil { t.Fatalf("replace fixture: %v", err) } }