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 }