mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 10:02:36 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
180cbfd439 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
343
server/internal/daemon/execenv/codex_memory.go
Normal file
343
server/internal/daemon/execenv/codex_memory.go
Normal file
@@ -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
|
||||
}
|
||||
607
server/internal/daemon/execenv/codex_memory_test.go
Normal file
607
server/internal/daemon/execenv/codex_memory_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user