diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go index 3c66ee916..6881c75e4 100644 --- a/server/internal/daemon/execenv/codex_home.go +++ b/server/internal/daemon/execenv/codex_home.go @@ -125,6 +125,14 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s logger.Warn("execenv: codex-home ensure multi-agent config failed", "error", err) } + // Disable Codex native auto-memory inside daemon-managed task sessions + // so cross-task and cross-workspace context leaks (multica#3130) cannot + // happen via `codex-home/memories/` or `~/.codex/memories/`. See + // codex_memory.go for the full rationale and escape hatch. + if err := ensureCodexMemoryConfig(filepath.Join(codexHome, "config.toml"), logger); err != nil { + logger.Warn("execenv: codex-home ensure memory config failed", "error", err) + } + return nil } diff --git a/server/internal/daemon/execenv/codex_memory.go b/server/internal/daemon/execenv/codex_memory.go new file mode 100644 index 000000000..494462cfb --- /dev/null +++ b/server/internal/daemon/execenv/codex_memory.go @@ -0,0 +1,343 @@ +package execenv + +import ( + "fmt" + "log/slog" + "os" + "regexp" + "strings" +) + +// Background +// +// Codex CLI ships a native auto-memory subsystem: by default it writes +// summaries of agent turns to `$CODEX_HOME/memories/raw_memories.md` +// plus `state_*.sqlite`, and reads them back into the model context on +// the next turn. The decision of what gets written is internal to Codex +// — users cannot audit or edit the contents from any Multica UI. +// +// This conflicts with the Multica daemon's context model. Multica +// already keeps per-(agent, issue) state via PriorSessionID, the issue +// description / comments, issue metadata, and CLAUDE.md skill memory — +// each channel is explicit, user-visible, and editable. Layering Codex +// native memory on top introduces an opaque, daemon-uncontrolled second +// store that can leak across tasks and (worse) across workspaces: +// +// - Per-task `codex-home/memories/` is preserved across `Reuse()` and +// is never cleared by `prepareCodexHomeWithOpts`, so stale memories +// from a prior turn on the same (agent, issue) feed into the next. +// - Codex CLI may also read user-level state from `~/.codex/memories/` +// entirely outside the daemon's per-task isolation, dragging +// unrelated host-project context into Multica tasks. The reproduction +// in github.com/multica-ai/multica#3130 saw Raw Memories from +// `D:\Project\MoHaYu\WowChat` (a host-local project) injected into a +// brand-new Multica issue's first Codex turn. +// +// Mitigation: write a managed block into the per-task `config.toml` that +// disables both the `features.memories` flag and the `memories.*` +// generation/consumption switches. Codex then neither writes nor reads +// from its memory subsystem, eliminating both leak paths regardless of +// where the residual files sit on disk. The user's global +// `~/.codex/config.toml` is never modified. +// +// Users who explicitly want Codex native memory inside a Multica task +// (and accept the leak risk) can keep the feature enabled by setting +// `MULTICA_CODEX_MEMORY=1` in the daemon environment. The long-term +// answer for durable agent context is a Multica-owned, user-visible, +// project- or issue-scoped memory store — not re-enabling Codex's +// hidden auto-memory. +// +// Layout note +// +// TOML rejects redefining a table that has already been created — +// including implicitly via a dotted key — so the managed block must +// adapt to the user's existing config. Two independent managed blocks +// are written: +// +// 1. memory-feature: disables `features.memories`. If the user's +// config contains a top-level `[features]` table, the override is +// injected inside that table; otherwise it goes at the file root +// in dotted-key form. +// 2. memory-config: disables `memories.generate_memories` and +// `memories.use_memories`. If the user's config contains a +// top-level `[memories]` table, the overrides are injected inside +// that table; otherwise they go at the file root in dotted-key +// form. + +// MulticaCodexMemoryEnv is the env var users can set to keep Codex +// native memory enabled inside daemon-managed tasks. Anything truthy +// (1, true, yes, on; case-insensitive) keeps the feature on; everything +// else (including unset) disables it. +const MulticaCodexMemoryEnv = "MULTICA_CODEX_MEMORY" + +const ( + multicaMemoryFeatureBeginMarker = "# BEGIN multica-managed memory-feature (do not edit; regenerated by daemon)" + multicaMemoryFeatureEndMarker = "# END multica-managed memory-feature" + multicaMemoryConfigBeginMarker = "# BEGIN multica-managed memory-config (do not edit; regenerated by daemon)" + multicaMemoryConfigEndMarker = "# END multica-managed memory-config" +) + +// `\n*` rather than `\n?` so reruns don't accumulate blank lines when +// these blocks coexist with the sandbox / multi-agent blocks. +var memoryFeatureBlockRe = regexp.MustCompile( + `(?ms)^` + regexp.QuoteMeta(multicaMemoryFeatureBeginMarker) + + `.*?^` + regexp.QuoteMeta(multicaMemoryFeatureEndMarker) + `\n*`) + +var memoryConfigBlockRe = regexp.MustCompile( + `(?ms)^` + regexp.QuoteMeta(multicaMemoryConfigBeginMarker) + + `.*?^` + regexp.QuoteMeta(multicaMemoryConfigEndMarker) + `\n*`) + +var ( + // matches a top-level `[memories]` table header. + rootMemoriesTableHeaderRe = regexp.MustCompile(`^\s*\[\s*memories\s*\]\s*(?:#.*)?$`) + // matches `memories = ...` inside a `[features]` table. + featuresTableMemoriesRe = regexp.MustCompile(`^\s*memories\s*=`) + // matches `features.memories = ...` at the TOML root. + rootDottedFeaturesMemoriesRe = regexp.MustCompile(`^\s*features\s*\.\s*memories\s*=`) + // matches `generate_memories = ...` / `use_memories = ...` inside a + // `[memories]` table. + memoriesTableGenerateRe = regexp.MustCompile(`^\s*generate_memories\s*=`) + memoriesTableUseRe = regexp.MustCompile(`^\s*use_memories\s*=`) + // matches `memories.generate_memories = ...` / `memories.use_memories = ...` + // at the TOML root. + rootDottedMemoriesGenerateRe = regexp.MustCompile(`^\s*memories\s*\.\s*generate_memories\s*=`) + rootDottedMemoriesUseRe = regexp.MustCompile(`^\s*memories\s*\.\s*use_memories\s*=`) +) + +// codexMemoryEnabled reports whether the user opted into keeping Codex +// native memory on for daemon-managed tasks. +func codexMemoryEnabled() bool { + raw := strings.TrimSpace(os.Getenv(MulticaCodexMemoryEnv)) + switch strings.ToLower(raw) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// renderMulticaMemoryFeatureBlock returns the daemon-managed memory-feature +// block. The body uses `memories = false` when injected inside a +// `[features]` table, and `features.memories = false` otherwise. +func renderMulticaMemoryFeatureBlock(inFeaturesTable bool) string { + var b strings.Builder + b.WriteString(multicaMemoryFeatureBeginMarker) + b.WriteString("\n") + if inFeaturesTable { + b.WriteString("memories = false\n") + } else { + b.WriteString("features.memories = false\n") + } + b.WriteString(multicaMemoryFeatureEndMarker) + b.WriteString("\n") + return b.String() +} + +// renderMulticaMemoryConfigBlock returns the daemon-managed memory-config +// block. The body uses bare keys when injected inside a `[memories]` table +// and dotted-key form otherwise. +func renderMulticaMemoryConfigBlock(inMemoriesTable bool) string { + var b strings.Builder + b.WriteString(multicaMemoryConfigBeginMarker) + b.WriteString("\n") + if inMemoriesTable { + b.WriteString("generate_memories = false\n") + b.WriteString("use_memories = false\n") + } else { + b.WriteString("memories.generate_memories = false\n") + b.WriteString("memories.use_memories = false\n") + } + b.WriteString(multicaMemoryConfigEndMarker) + b.WriteString("\n") + return b.String() +} + +// stripUserMemoryDirectives removes user-written directives that would +// conflict with the managed blocks: +// +// - `features.memories = ...` and `memories.{generate,use}_memories = ...` +// at the TOML root (dotted-key form). +// - `memories = ...` inside a top-level `[features]` table. +// - `generate_memories = ...` / `use_memories = ...` inside a top-level +// `[memories]` table. +// +// Other tables (`[features.experimental]`, `[memories.advanced]`, ...) are +// preserved untouched: they live under their own scope and don't redefine +// the keys the managed blocks set at the root / in the canonical tables. +func stripUserMemoryDirectives(content string) string { + lines := strings.Split(content, "\n") + out := make([]string, 0, len(lines)) + currentTable := "" // empty = TOML root + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if rootFeaturesTableHeaderRe.MatchString(line) { + currentTable = "[features]" + out = append(out, line) + continue + } + if rootMemoriesTableHeaderRe.MatchString(line) { + currentTable = "[memories]" + out = append(out, line) + continue + } + if strings.HasPrefix(trimmed, "[") { + currentTable = trimmed + out = append(out, line) + continue + } + switch currentTable { + case "": + if rootDottedFeaturesMemoriesRe.MatchString(trimmed) || + rootDottedMemoriesGenerateRe.MatchString(trimmed) || + rootDottedMemoriesUseRe.MatchString(trimmed) { + continue + } + case "[features]": + if featuresTableMemoriesRe.MatchString(trimmed) { + continue + } + case "[memories]": + if memoriesTableGenerateRe.MatchString(trimmed) || + memoriesTableUseRe.MatchString(trimmed) { + continue + } + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +// hasRootMemoriesTable reports whether the file contains a top-level +// `[memories]` table header. Sub-tables like `[memories.advanced]` do NOT +// count: they implicitly create `memories` but don't conflict with a +// root-level `memories.generate_memories` dotted key. +func hasRootMemoriesTable(content string) bool { + for _, line := range strings.Split(content, "\n") { + if rootMemoriesTableHeaderRe.MatchString(line) { + return true + } + } + return false +} + +// injectMemoryFeatureBlockIntoFeaturesTable inserts the in-table +// memory-feature block immediately after the first `[features]` header +// line. Caller must have already stripped any prior managed block and any +// user-set `memories` directive from inside the table. +func injectMemoryFeatureBlockIntoFeaturesTable(content string) string { + return injectAfterHeader(content, rootFeaturesTableHeaderRe, renderMulticaMemoryFeatureBlock(true)) +} + +// injectMemoryConfigBlockIntoMemoriesTable inserts the in-table +// memory-config block immediately after the first `[memories]` header +// line. Caller must have already stripped any prior managed block and any +// user-set `generate_memories` / `use_memories` directive from inside the +// table. +func injectMemoryConfigBlockIntoMemoriesTable(content string) string { + return injectAfterHeader(content, rootMemoriesTableHeaderRe, renderMulticaMemoryConfigBlock(true)) +} + +// injectAfterHeader inserts block right after the first line matching +// headerRe. Blank lines that would otherwise sit between the inserted +// block and the next non-blank line are dropped so the END marker is +// butted against the next table / key. Without this normalization, the +// `\n*` quantifier in memoryFeatureBlockRe / memoryConfigBlockRe would +// greedily consume those blank lines on the next strip, and the rewrite +// would no longer be byte-exact idempotent. +// +// The trailing "" element produced by Split when the file ends with `\n` +// is preserved so the rewritten file keeps its EOF newline. +func injectAfterHeader(content string, headerRe *regexp.Regexp, block string) string { + blockLines := strings.Split(strings.TrimRight(block, "\n"), "\n") + lines := strings.Split(content, "\n") + for i, line := range lines { + if !headerRe.MatchString(line) { + continue + } + tail := lines[i+1:] + // Drop leading blanks that follow the inserted block, but never + // strip the lone trailing-newline marker. + for len(tail) > 1 && tail[0] == "" { + tail = tail[1:] + } + out := make([]string, 0, len(lines[:i+1])+len(blockLines)+len(tail)) + out = append(out, lines[:i+1]...) + out = append(out, blockLines...) + out = append(out, tail...) + return strings.Join(out, "\n") + } + return content +} + +// ensureCodexMemoryConfig writes the daemon-managed memory blocks into the +// per-task config.toml so Codex native memory stays disabled. Idempotent: +// running it twice produces the same file. +// +// When MULTICA_CODEX_MEMORY is set to a truthy value, the function is a +// no-op — the user has explicitly opted into Codex native memory and +// accepts the leak risk. Toggling the env var across prepare runs is not +// supported: the per-task config is short-lived (recreated per task), so +// users should set the var once at daemon start. +func ensureCodexMemoryConfig(configPath string, logger *slog.Logger) error { + if codexMemoryEnabled() { + if logger != nil { + logger.Info("codex memory: leaving Codex native memory untouched per MULTICA_CODEX_MEMORY", + "config_path", configPath, + ) + } + return nil + } + + data, err := os.ReadFile(configPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read config.toml: %w", err) + } + existing := string(data) + + // Always strip any previously written managed blocks (root or in-table + // form) so reruns and layout transitions stay clean. + existing = memoryFeatureBlockRe.ReplaceAllString(existing, "") + existing = memoryConfigBlockRe.ReplaceAllString(existing, "") + // Strip user-set directives in every encoding; the managed blocks re-add + // the canonical forms below. + existing = stripUserMemoryDirectives(existing) + + hasFeatures := hasRootFeaturesTable(existing) + hasMemories := hasRootMemoriesTable(existing) + + // In-table injections happen first so the root-prepended blocks land + // at the very top of the file (above any user tables). + if hasFeatures { + existing = injectMemoryFeatureBlockIntoFeaturesTable(existing) + } + if hasMemories { + existing = injectMemoryConfigBlockIntoMemoriesTable(existing) + } + + // Build the root-form blocks (if any) and prepend them. + var prepend strings.Builder + if !hasFeatures { + prepend.WriteString(renderMulticaMemoryFeatureBlock(false)) + } + if !hasMemories { + prepend.WriteString(renderMulticaMemoryConfigBlock(false)) + } + prependStr := prepend.String() + + existing = strings.TrimLeft(existing, "\n") + if prependStr != "" { + if existing == "" { + existing = prependStr + } else { + existing = prependStr + "\n" + existing + } + } + + if existing == string(data) { + return nil + } + + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + return fmt.Errorf("write config.toml: %w", err) + } + return nil +} diff --git a/server/internal/daemon/execenv/codex_memory_test.go b/server/internal/daemon/execenv/codex_memory_test.go new file mode 100644 index 000000000..c2a785a4d --- /dev/null +++ b/server/internal/daemon/execenv/codex_memory_test.go @@ -0,0 +1,607 @@ +package execenv + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// requireMemoryDisabled asserts that the parsed config has Codex memory +// effectively turned off: features.memories = false, plus +// memories.generate_memories = false and memories.use_memories = false. +func requireMemoryDisabled(t *testing.T, parsed map[string]any) { + t.Helper() + + features, ok := parsed["features"].(map[string]any) + if !ok { + t.Fatalf("expected `features` table in parsed config, got: %#v", parsed["features"]) + } + v, ok := features["memories"].(bool) + if !ok { + t.Fatalf("expected features.memories to be a bool, got: %#v", features["memories"]) + } + if v { + t.Errorf("expected features.memories = false, got true") + } + + memories, ok := parsed["memories"].(map[string]any) + if !ok { + t.Fatalf("expected `memories` table in parsed config, got: %#v", parsed["memories"]) + } + gen, ok := memories["generate_memories"].(bool) + if !ok { + t.Fatalf("expected memories.generate_memories to be a bool, got: %#v", memories["generate_memories"]) + } + if gen { + t.Errorf("expected memories.generate_memories = false, got true") + } + use, ok := memories["use_memories"].(bool) + if !ok { + t.Fatalf("expected memories.use_memories to be a bool, got: %#v", memories["use_memories"]) + } + if use { + t.Errorf("expected memories.use_memories = false, got true") + } +} + +func TestStripUserMemoryDirectives(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "drops root dotted-key forms", + in: `model = "o3" +features.memories = true +memories.generate_memories = true +memories.use_memories = true + +[profiles.default] +model = "o3" +`, + want: `model = "o3" + +[profiles.default] +model = "o3" +`, + }, + { + name: "drops root dotted-key form with whitespace", + in: `model = "o3" +features . memories = true +memories . generate_memories = true +`, + want: `model = "o3" +`, + }, + { + name: "drops memories inside [features] table", + in: `[features] +memories = true +multi_agent = false + +[profiles.default] +model = "o3" +`, + want: `[features] +multi_agent = false + +[profiles.default] +model = "o3" +`, + }, + { + name: "drops generate_memories / use_memories inside [memories] table", + in: `[memories] +generate_memories = true +use_memories = true +some_other_key = "keep" + +[profiles.default] +model = "o3" +`, + want: `[memories] +some_other_key = "keep" + +[profiles.default] +model = "o3" +`, + }, + { + name: "preserves keys under nested [features.experimental]", + in: `[features.experimental] +memories = true +`, + want: `[features.experimental] +memories = true +`, + }, + { + name: "preserves keys under nested [memories.advanced]", + in: `[memories.advanced] +generate_memories = true +use_memories = true +`, + want: `[memories.advanced] +generate_memories = true +use_memories = true +`, + }, + { + name: "no memory directives — content unchanged", + in: `model = "o3" + +[profiles.default] +model = "o3" +`, + want: `model = "o3" + +[profiles.default] +model = "o3" +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := stripUserMemoryDirectives(tt.in) + if got != tt.want { + t.Errorf("stripUserMemoryDirectives mismatch\n--- got ---\n%s\n--- want ---\n%s", got, tt.want) + } + }) + } +} + +func TestEnsureCodexMemoryConfigEmptyFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read result: %v", err) + } + got := string(data) + for _, want := range []string{ + "features.memories = false", + "memories.generate_memories = false", + "memories.use_memories = false", + multicaMemoryFeatureBeginMarker, + multicaMemoryFeatureEndMarker, + multicaMemoryConfigBeginMarker, + multicaMemoryConfigEndMarker, + } { + if !strings.Contains(got, want) { + t.Errorf("expected %q in output, got:\n%s", want, got) + } + } + requireMemoryDisabled(t, parseTOML(t, got)) +} + +func TestEnsureCodexMemoryConfigDottedKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `model = "o3" +features.memories = true +memories.generate_memories = true +memories.use_memories = true + +[profiles.default] +model = "o3" +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + for _, banned := range []string{ + "features.memories = true", + "memories.generate_memories = true", + "memories.use_memories = true", + } { + if strings.Contains(got, banned) { + t.Errorf("expected user %q stripped, got:\n%s", banned, got) + } + } + if !strings.Contains(got, `[profiles.default]`) || !strings.Contains(got, `model = "o3"`) { + t.Errorf("expected unrelated content preserved, got:\n%s", got) + } + requireMemoryDisabled(t, parseTOML(t, got)) +} + +func TestEnsureCodexMemoryConfigFeaturesTable(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `[features] +memories = true +experimental_thinking = true + +[profiles.default] +model = "o3" +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + + // User's `memories = true` inside [features] must be gone. + // Managed override must be INSIDE [features], not as a root dotted key + // (which would redefine the table and break the strict TOML parser). + if strings.Contains(got, "memories = true") { + t.Errorf("expected user memories = true to be stripped, got:\n%s", got) + } + if strings.Contains(got, "features.memories = false") { + t.Errorf("managed memory-feature block must NOT use root dotted-key form when [features] table exists (would redefine the table); got:\n%s", got) + } + if !strings.Contains(got, "[features]") { + t.Errorf("expected [features] header preserved, got:\n%s", got) + } + if !strings.Contains(got, "experimental_thinking = true") { + t.Errorf("expected sibling features.* keys preserved, got:\n%s", got) + } + + // memory-config side still root-form because no [memories] table existed. + if !strings.Contains(got, "memories.generate_memories = false") { + t.Errorf("expected memories.generate_memories = false at root, got:\n%s", got) + } + if !strings.Contains(got, "memories.use_memories = false") { + t.Errorf("expected memories.use_memories = false at root, got:\n%s", got) + } + + requireMemoryDisabled(t, parseTOML(t, got)) +} + +func TestEnsureCodexMemoryConfigMemoriesTable(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `[memories] +generate_memories = true +use_memories = true +storage_path = "/somewhere" + +[profiles.default] +model = "o3" +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + + if strings.Contains(got, "generate_memories = true") { + t.Errorf("expected user generate_memories = true to be stripped, got:\n%s", got) + } + if strings.Contains(got, "use_memories = true") { + t.Errorf("expected user use_memories = true to be stripped, got:\n%s", got) + } + if strings.Contains(got, "memories.generate_memories = false") { + t.Errorf("managed memory-config block must NOT use root dotted-key form when [memories] table exists (would redefine the table); got:\n%s", got) + } + if !strings.Contains(got, `storage_path = "/somewhere"`) { + t.Errorf("expected sibling memories.* keys preserved, got:\n%s", got) + } + + // memory-feature side still root-form because no [features] table existed. + if !strings.Contains(got, "features.memories = false") { + t.Errorf("expected features.memories = false at root, got:\n%s", got) + } + + requireMemoryDisabled(t, parseTOML(t, got)) +} + +func TestEnsureCodexMemoryConfigBothTables(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `[features] +memories = true +experimental_thinking = true + +[memories] +generate_memories = true +use_memories = true +storage_path = "/somewhere" +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + + // No root dotted-key forms when both tables exist. + if strings.Contains(got, "features.memories = false") { + t.Errorf("managed block must NOT use root dotted-key form when [features] table exists; got:\n%s", got) + } + if strings.Contains(got, "memories.generate_memories = false") { + t.Errorf("managed block must NOT use root dotted-key form when [memories] table exists; got:\n%s", got) + } + + // User content preserved. + if !strings.Contains(got, "experimental_thinking = true") { + t.Errorf("expected experimental_thinking preserved, got:\n%s", got) + } + if !strings.Contains(got, `storage_path = "/somewhere"`) { + t.Errorf("expected storage_path preserved, got:\n%s", got) + } + + requireMemoryDisabled(t, parseTOML(t, got)) +} + +func TestEnsureCodexMemoryConfigFeaturesSubtableOnly(t *testing.T) { + t.Parallel() + + // User has [features.experimental] but no bare [features] header. The + // dotted-key form at root is fine — both implicitly define `features`, + // neither defines `[features]` explicitly, so no redefinition. + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `[features.experimental] +thinking = true +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + if !strings.Contains(got, "features.memories = false") { + t.Errorf("expected root dotted-key form when only sub-tables exist, got:\n%s", got) + } + + parsed := parseTOML(t, got) + requireMemoryDisabled(t, parsed) + features := parsed["features"].(map[string]any) + exp, _ := features["experimental"].(map[string]any) + if v, _ := exp["thinking"].(bool); !v { + t.Errorf("expected features.experimental.thinking preserved, got: %#v", exp) + } +} + +func TestEnsureCodexMemoryConfigIdempotent(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "root_form": `model = "o3" +features.memories = true +memories.generate_memories = true +memories.use_memories = true +`, + "features_table_only": `[features] +memories = true +multi_agent = false +`, + "memories_table_only": `[memories] +generate_memories = true +use_memories = true +storage_path = "/somewhere" +`, + "both_tables": `[features] +memories = true + +[memories] +generate_memories = true +use_memories = true +`, + "empty": ``, + } + for name, original := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("first run failed: %v", err) + } + first, _ := os.ReadFile(configPath) + infoFirst, _ := os.Stat(configPath) + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("second run failed: %v", err) + } + second, _ := os.ReadFile(configPath) + infoSecond, _ := os.Stat(configPath) + + if string(first) != string(second) { + t.Errorf("expected idempotent rewrite\n--- first ---\n%s\n--- second ---\n%s", first, second) + } + if !infoSecond.ModTime().Equal(infoFirst.ModTime()) { + t.Errorf("expected no rewrite on second pass (file was touched)") + } + requireMemoryDisabled(t, parseTOML(t, string(second))) + }) + } +} + +func TestEnsureCodexMemoryConfigEscapeHatch(t *testing.T) { + // Cannot run in parallel: mutates process env. + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `model = "o3" +features.memories = true +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + t.Setenv(MulticaCodexMemoryEnv, "1") + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + if got != original { + t.Errorf("expected file untouched when escape hatch set\n--- got ---\n%s\n--- want ---\n%s", got, original) + } +} + +func TestCodexMemoryEnabledTruthy(t *testing.T) { + for _, v := range []string{"1", "true", "TRUE", "yes", "On"} { + t.Run(v, func(t *testing.T) { + t.Setenv(MulticaCodexMemoryEnv, v) + if !codexMemoryEnabled() { + t.Errorf("expected %q to be truthy", v) + } + }) + } +} + +func TestCodexMemoryEnabledFalsy(t *testing.T) { + for _, v := range []string{"", "0", "false", "no", "off", "anything else"} { + t.Run(v, func(t *testing.T) { + t.Setenv(MulticaCodexMemoryEnv, v) + if codexMemoryEnabled() { + t.Errorf("expected %q to be falsy", v) + } + }) + } +} + +func TestEnsureCodexMemoryConfigCoexistsWithSandboxAndMultiAgent(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `model = "o3" +features.memories = true +features.multi_agent = true +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + policy := codexSandboxPolicy{Mode: "workspace-write", NetworkAccess: true, Reason: "test"} + if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", nil); err != nil { + t.Fatalf("ensureCodexSandboxConfig failed: %v", err) + } + if err := ensureCodexMultiAgentConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMultiAgentConfig failed: %v", err) + } + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + got := string(data) + for _, marker := range []string{ + multicaManagedBeginMarker, + multicaMultiAgentBeginMarker, + multicaMemoryFeatureBeginMarker, + multicaMemoryConfigBeginMarker, + } { + if !strings.Contains(got, marker) { + t.Errorf("expected marker %q in combined output, got:\n%s", marker, got) + } + } + if strings.Contains(got, "features.memories = true") { + t.Errorf("expected user features.memories = true stripped, got:\n%s", got) + } + if strings.Contains(got, "features.multi_agent = true") { + t.Errorf("expected user features.multi_agent = true stripped, got:\n%s", got) + } + + // File must parse as valid TOML with everything disabled. + parsed := parseTOML(t, got) + requireMemoryDisabled(t, parsed) + requireMultiAgentDisabled(t, parsed) + + // Re-running all three must be idempotent. + if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", nil); err != nil { + t.Fatalf("ensureCodexSandboxConfig (rerun) failed: %v", err) + } + if err := ensureCodexMultiAgentConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMultiAgentConfig (rerun) failed: %v", err) + } + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig (rerun) failed: %v", err) + } + dataAfter, _ := os.ReadFile(configPath) + if string(dataAfter) != got { + t.Errorf("expected idempotent combined rewrite\n--- first ---\n%s\n--- second ---\n%s", got, dataAfter) + } +} + +// Regression: when the user's config has a `[features]` table, naively +// writing `features.memories = false` at the TOML root implicitly +// redefines the same table. The strict TOML parser used by Codex +// (`toml-rs`) rejects that with `table 'features' already exists`. Same +// trap exists for `[memories]`. +func TestRegressionMemoryProducesValidTOML(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + original := `[features] +experimental_thinking = true + +[memories] +storage_path = "/somewhere" +` + if err := os.WriteFile(configPath, []byte(original), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + if err := ensureCodexMemoryConfig(configPath, nil); err != nil { + t.Fatalf("ensureCodexMemoryConfig failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + parsed := parseTOML(t, string(data)) + requireMemoryDisabled(t, parsed) + + features := parsed["features"].(map[string]any) + if v, _ := features["experimental_thinking"].(bool); !v { + t.Errorf("expected user's features.experimental_thinking preserved, got %v", features["experimental_thinking"]) + } + memories := parsed["memories"].(map[string]any) + if v, _ := memories["storage_path"].(string); v != "/somewhere" { + t.Errorf("expected user's memories.storage_path preserved, got %v", memories["storage_path"]) + } +}