diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go index f8e40611ec..a2867fb8c6 100644 --- a/server/internal/daemon/execenv/codex_home.go +++ b/server/internal/daemon/execenv/codex_home.go @@ -1,6 +1,7 @@ package execenv import ( + "crypto/sha256" "encoding/hex" "fmt" "io" @@ -218,18 +219,22 @@ func codexSessionStoreDir(sharedHome, key string) string { // reclaims, its own namespace, so a staging daemon's GC can never delete a // production task's live store and vice versa. // -// The map MUST be injective: distinct profiles are distinct daemons and must -// never share a namespace. A lossy "drop unsafe characters" scheme is not — the -// CLI treats "" and "default" as separate daemons, and would sanitize -// "staging.prod" and "stagingprod" to the same segment. So the empty (default) -// profile gets a reserved bare literal, and every named profile is hex-encoded -// (bijective, filesystem-safe) under a "p_" prefix a bare literal can never -// collide with (MUL-4424). +// The map MUST be collision-free (distinct profiles are distinct daemons and +// must never share a namespace) AND fixed-length (a profile can be as long as a +// filesystem segment allows, ~255 bytes, so any length-expanding encoding would +// overflow the 255-byte limit and fail to create the store dir). A lossy "drop +// unsafe characters" scheme collides ("" vs "default", "staging.prod" vs +// "stagingprod"); a full hex encoding doubles the length and overflows. So the +// empty (default) profile gets a reserved bare literal, and every named profile +// is the hex of its SHA-256 — a constant 64 hex chars, filesystem-safe and +// collision-resistant — under a "p_" prefix the bare literal can never collide +// with (MUL-4424). func codexSessionStoreNamespace(profile string) string { if profile == "" { return "default" } - return "p_" + hex.EncodeToString([]byte(profile)) + sum := sha256.Sum256([]byte(profile)) + return "p_" + hex.EncodeToString(sum[:]) } // codexSessionStoreKey builds the per-(profile, agent, issue) key for a task's diff --git a/server/internal/daemon/execenv/codex_home_sessions_test.go b/server/internal/daemon/execenv/codex_home_sessions_test.go index 2bdf196349..2e482817a8 100644 --- a/server/internal/daemon/execenv/codex_home_sessions_test.go +++ b/server/internal/daemon/execenv/codex_home_sessions_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" ) @@ -620,6 +621,25 @@ func TestCodexSessionStoreNamespaceInjective(t *testing.T) { } } +// TestCodexSessionStoreNamespace_FitsDirectorySegment guards Elon's round-8 +// blocker: a profile the CLI can persist as its own config-dir segment (up to +// the ~255-byte filesystem limit) must yield a namespace that also fits one +// segment and is creatable — a length-expanding encoding (full hex) overflowed +// at 127 bytes. MUL-4424. +func TestCodexSessionStoreNamespace_FitsDirectorySegment(t *testing.T) { + t.Parallel() + base := t.TempDir() + for _, profile := range []string{"", "staging", strings.Repeat("a", 127), strings.Repeat("z", 255)} { + ns := codexSessionStoreNamespace(profile) + if len(ns) > 255 { + t.Errorf("profile (len %d) -> namespace (len %d) exceeds the 255-byte single-segment limit", len(profile), len(ns)) + } + if err := os.MkdirAll(filepath.Join(base, ns), 0o755); err != nil { + t.Errorf("namespace for profile (len %d) could not be created: %v", len(profile), err) + } + } +} + // TestPruneCodexSessionStores_NoCrossProfileCollision proves the injective // namespace holds end-to-end: profiles the CLI treats as distinct never reclaim // each other's stores, including the "" vs "default" and punctuation cases that