Files
multica/server/internal/daemon/execenv/codex_home.go
Bohan Jiang b13b6d06bc fix(daemon): writable per-task HOME for Linux Codex sandbox (MUL-4856) (#5508)
Give Linux codex tasks a writable per-task HOME (+ XDG/npm_config_cache) so npm/Prisma stop hitting EROFS on the read-only sandbox home. Gated to Linux; macOS/Windows and non-codex providers unaffected.

Note: does NOT resolve the worktree git-metadata read-only problem tracked in multica-ai/multica#2925 — Codex workspace-write resolves the worktree .git pointer and force-protects the real gitdir read-only even inside writable_roots, so that needs a separate Codex metadata-write permission path.
2026-07-16 13:01:43 +08:00

907 lines
38 KiB
Go

package execenv
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
// Files to symlink from the shared ~/.codex/ into the per-task CODEX_HOME.
// Symlinks share state (e.g. auth tokens) so changes propagate automatically.
var codexSymlinkedFiles = []string{
"auth.json",
}
// Files to copy from the shared ~/.codex/ into the per-task CODEX_HOME.
// Copies are isolated — changes don't affect the shared home.
var codexCopiedFiles = []string{
"config.json",
"config.toml",
"instructions.md",
}
// CodexHomeOptions carries optional inputs for prepareCodexHomeWithOpts that
// affect the generated per-task config.toml.
type CodexHomeOptions struct {
// CodexVersion is the detected Codex CLI version (e.g. "0.121.0"). Empty
// means unknown; on macOS, unknown is treated as "probably broken" so the
// daemon falls back to danger-full-access for network access. See
// codex_sandbox.go for details.
CodexVersion string
// GOOS overrides the target platform when deciding the sandbox policy.
// Empty means use runtime.GOOS. Primarily exists so tests can exercise
// both macOS and Linux paths deterministically.
GOOS string
// ResumeSessionID is the Codex thread/session ID this run intends to
// resume, when any. It is consulted when populating the per-issue session
// store (local_directory tasks) or migrating a legacy per-task home whose
// sessions/ still symlinks the shared ~/.codex/sessions: the single rollout
// for this ID is exposed so thread/resume can find it without pulling the
// whole shared history back in. Empty means a fresh thread (no rollout to
// expose). See prepareCodexSessionsDir (MUL-4424).
ResumeSessionID string
// IsLocalDirectory marks a local_directory task — one running in the user's
// own project directory. These tasks get a fresh codex-home per task ID (the
// daemon never reuses their workdir), so their sessions/ is pointed at the
// per-issue store (SessionStoreKey) that survives across task IDs and holds
// ONLY this issue's rollouts — never the machine's whole ~/.codex/sessions.
// See prepareCodexSessionsDir (MUL-4424).
IsLocalDirectory bool
// SessionStoreKey is a stable, per-(agent, issue) relative path segment that
// identifies this task's persistent Codex sessions store. It survives across
// task IDs (unlike the task-scoped envRoot the GC reclaims) so a follow-up
// run resumes the same thread. Empty when no stable key is available (e.g. a
// task with no issue), in which case sessions/ stays task-local. See
// codexSessionStoreDir and prepareCodexSessionsDir (MUL-4424).
SessionStoreKey string
// WritableRoots are extra absolute paths written into the config.toml
// `[sandbox_workspace_write] writable_roots` so the workspace-write sandbox
// (Linux) can write outside the task workdir — the per-task writable HOME.
// Only meaningful when the policy resolves to workspace-write; ignored on
// darwin danger-full-access. See task_home.go and MUL-4856.
WritableRoots []string
}
// prepareCodexHome is a thin wrapper around prepareCodexHomeWithOpts kept for
// tests that don't care about platform-aware sandbox configuration. It
// assumes a Linux-like environment where workspace-write + network_access
// works correctly.
func prepareCodexHome(codexHome string, logger *slog.Logger) error {
return prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{GOOS: "linux"}, logger)
}
// prepareCodexHomeWithOpts creates a per-task CODEX_HOME directory and seeds
// it with config from the shared ~/.codex/ home. Auth is symlinked (shared),
// config files are copied (isolated). The per-task config.toml gets a
// daemon-managed sandbox block picked by codexSandboxPolicyFor.
func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *slog.Logger) error {
sharedHome := resolveSharedCodexHome()
if err := os.MkdirAll(codexHome, 0o755); err != nil {
return fmt.Errorf("create codex-home dir: %w", err)
}
// Give the task its own local sessions/ directory instead of symlinking the
// shared ~/.codex/sessions in — a huge shared history would otherwise stall
// Codex's `initialize` state backfill (MUL-4424). See prepareCodexSessionsDir.
if err := prepareCodexSessionsDir(codexHome, sharedHome, opts, logger); err != nil {
logger.Warn("execenv: codex-home sessions dir prepare failed", "error", err)
}
// Symlink shared files (auth).
for _, name := range codexSymlinkedFiles {
src := filepath.Join(sharedHome, name)
dst := filepath.Join(codexHome, name)
if err := ensureSymlink(src, dst); err != nil {
logger.Warn("execenv: codex-home symlink failed", "file", name, "error", err)
}
}
// Surface the resulting auth.json state (file kind only, never contents)
// so operators diagnosing token-refresh failures can tell whether the
// per-task home is tracking the shared ~/.codex/auth.json or has drifted
// into a stale local copy.
logCodexAuthState(filepath.Join(codexHome, "auth.json"), logger)
// Sync config files from the shared source (isolated per task).
for _, name := range codexCopiedFiles {
src := filepath.Join(sharedHome, name)
dst := filepath.Join(codexHome, name)
if err := syncCopiedFile(src, dst); err != nil {
logger.Warn("execenv: codex-home sync failed", "file", name, "error", err)
}
}
// Drop `[[skills.config]]` entries inherited from the user's
// ~/.codex/config.toml. Codex Desktop writes plugin-backed skills with a
// `name` and no `path`, which the CLI's stricter TOML parser rejects with
// `missing field path` and bails out of `thread/start`. Multica writes the
// agent's active skills directly to `codex-home/skills/`, so the
// user-level registry is redundant here. See codex_skill_strip.go.
if err := sanitizeCopiedCodexConfig(filepath.Join(codexHome, "config.toml")); err != nil {
logger.Warn("execenv: codex-home sanitize config failed", "error", err)
}
if err := syncCodexModelCatalog(codexHome, sharedHome); err != nil {
return fmt.Errorf("sync codex model_catalog_json: %w", err)
}
if err := exposeSharedCodexPluginCache(codexHome, sharedHome); err != nil {
logger.Warn("execenv: codex-home plugin cache exposure failed", "error", err)
}
// Write a daemon-managed sandbox block into config.toml. On macOS we may
// need to fall back to danger-full-access because of openai/codex#10390;
// see codex_sandbox.go for the full rationale.
policy := codexSandboxPolicyFor(opts.GOOS, opts.CodexVersion)
policy.WritableRoots = opts.WritableRoots
if err := ensureCodexSandboxConfig(filepath.Join(codexHome, "config.toml"), policy, opts.CodexVersion, logger); err != nil {
logger.Warn("execenv: codex-home ensure sandbox config failed", "error", err)
}
// Disable Codex native multi-agent inside daemon-managed task sessions
// so the parent thread's `turn/completed` is not interpreted as task
// completion while spawned subagents are still running. See
// codex_multi_agent.go for the full rationale and escape hatch.
if err := ensureCodexMultiAgentConfig(filepath.Join(codexHome, "config.toml"), logger); err != nil {
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
}
// resolveSharedCodexHome returns the path to the user's shared Codex home.
// Checks $CODEX_HOME first, falls back to ~/.codex.
func resolveSharedCodexHome() string {
if v := os.Getenv("CODEX_HOME"); v != "" {
abs, err := filepath.Abs(v)
if err == nil {
return abs
}
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), ".codex") // last resort fallback
}
return filepath.Join(home, ".codex")
}
// codexSessionStateGlobs are the session-derived SQLite state Codex builds
// inside a CODEX_HOME by indexing everything under sessions/. They are dropped
// during the legacy-symlink migration (prepareCodexSessionsDir) so Codex
// rebuilds them from the now task-local sessions instead of keeping the
// thousands of stale rows it backfilled from the shared ~/.codex/sessions
// history. Everything matched here is a rebuildable derived index — never
// authoritative data.
//
// Deliberately NOT listed: session_index.jsonl, which Codex 0.144.x uses as the
// authoritative store for thread-id → user-set thread name (name edits land in
// SQLite AND this file, never back in the rollout), so it cannot be rebuilt from
// rollouts; and sibling per-task DBs with different prefixes (goals_*, logs_*,
// memories_*) which are not session-derived. All are left intact.
var codexSessionStateGlobs = []string{
"state_*.sqlite",
"state_*.sqlite-shm",
"state_*.sqlite-wal",
}
// codexSessionStoreRoot is the directory under the shared Codex home that holds
// the per-issue session stores. It sits beside the user's own `sessions/` so it
// shares that volume (making resume-rollout hard links zero-copy) but is never
// enumerated by a plain `codex` run, keeping Multica task history out of the
// user's own thread list.
const codexSessionStoreRoot = "multica-sessions"
// codexSessionStoreDir returns the persistent, per-(agent, issue) Codex sessions
// store for key, rooted on the shared Codex home's volume. It survives across
// task IDs (unlike the task-scoped envRoot the GC reclaims) and holds only that
// issue's rollouts. Empty key → "" (caller keeps sessions/ task-local).
func codexSessionStoreDir(sharedHome, key string) string {
if key == "" {
return ""
}
return filepath.Join(sharedHome, codexSessionStoreRoot, key)
}
// codexSessionStoreNamespace maps a daemon's profile to the directory segment
// that isolates its session stores from another profile-daemon's when several
// run on the same machine sharing one ~/.codex (profiles get separate daemon
// state but the same Codex home). Each daemon writes under, and only ever
// 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 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"
}
sum := sha256.Sum256([]byte(profile))
return "p_" + hex.EncodeToString(sum[:])
}
// codexSessionStoreKey builds the per-(profile, agent, issue) key for a task's
// persistent Codex sessions store. The agent/issue IDs are server-issued UUIDs;
// all three segments are sanitized to bare path segments defensively so a
// malformed value can never escape the store root. Returns "" when there is no
// issue to key on (the store is issue-scoped), leaving sessions/ task-local.
func codexSessionStoreKey(profile, agentID, issueID string) string {
issue := sanitizeCodexPathSegment(issueID)
if issue == "" {
return ""
}
agent := sanitizeCodexPathSegment(agentID)
if agent == "" {
agent = "_"
}
return filepath.Join(codexSessionStoreNamespace(profile), agent, issue)
}
// sanitizeCodexPathSegment reduces s to the characters a UUID uses (hex plus
// dashes/underscores), dropping everything else so the result is always a single
// safe path segment — no separators, no "..", no drive letters.
func sanitizeCodexPathSegment(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
}
}
return b.String()
}
// PruneCodexSessionStores reclaims per-issue Codex session stores under the
// shared home's multica-sessions root that have not been touched within
// retention, bounding the lifetime of the conversation history each one holds.
//
// The stores deliberately live outside the task-scoped envRoot the task GC
// reclaims (so resume survives across task IDs), which means without this they
// would accumulate forever — a done or abandoned issue's prompts and full
// rollouts (one reporter saw a single 1.5 GiB rollout) would never be freed, and
// deleting the issue/agent/workspace would not remove them. A store's newest
// mtime is its last activity: Codex writes/extends a rollout as the thread
// advances, so an active or recently-resumed task keeps its store fresh and is
// never reclaimed; a store idle past retention is removed, giving deleted issues
// an eventual-reclamation guarantee. retention <= 0 disables pruning entirely.
//
// It scans ONLY the caller profile's namespace, so a daemon never reclaims a
// store owned by another profile-daemon sharing the same ~/.codex — the
// in-process reservation guard cannot span processes, and the namespace makes
// their store trees disjoint so it does not need to (MUL-4424).
//
// reserve (may be nil) atomically claims a store for deletion: it returns
// ok=false when a live task holds the store — leaving it — and otherwise returns
// a commit to run once removal finishes. Because the caller's reservation and a
// task's mark-active go through one lock in the same process, a store a task is
// about to mount is never removed out from under it — the confirm-inactive and
// the remove are effectively atomic, closing the stat->remove race a plain
// point-in-time active check leaves open. nil disables the guard (tests): every
// idle store is removed.
func PruneCodexSessionStores(profile string, retention time.Duration, now time.Time, reserve func(storeDir string) (commit func(), ok bool), logger *slog.Logger) (removed int, bytesFreed int64) {
if retention <= 0 {
return 0, 0
}
root := filepath.Join(resolveSharedCodexHome(), codexSessionStoreRoot, codexSessionStoreNamespace(profile))
agents, err := os.ReadDir(root)
if err != nil {
return 0, 0 // not created yet, or unreadable — nothing to prune
}
for _, a := range agents {
if !a.IsDir() {
continue
}
agentDir := filepath.Join(root, a.Name())
issues, err := os.ReadDir(agentDir)
if err != nil {
continue
}
kept := 0
for _, is := range issues {
if !is.IsDir() {
continue
}
storeDir := filepath.Join(agentDir, is.Name())
newest, size := codexStoreStat(storeDir)
if newest.IsZero() || now.Sub(newest) <= retention {
kept++
continue
}
// Atomically reserve the store before removing it. A live task holds
// it (or is mounting it right now) → skip; otherwise the reservation
// blocks any task from claiming it until the removal + commit finish.
var commit func()
if reserve != nil {
c, ok := reserve(storeDir)
if !ok {
kept++
continue
}
commit = c
}
err := os.RemoveAll(storeDir)
if commit != nil {
commit()
}
if err != nil {
logger.Warn("execenv: prune codex session store failed", "store", storeDir, "error", err)
kept++
continue
}
removed++
bytesFreed += size
}
// Remove the agent dir once its last issue store is gone, so the tree
// does not leave empty <agent>/ shells behind.
if kept == 0 {
_ = os.Remove(agentDir)
}
}
return removed, bytesFreed
}
// codexStoreStat walks dir once, returning the newest modification time seen
// (the store's last activity) and its total byte size (for GC accounting).
func codexStoreStat(dir string) (newest time.Time, size int64) {
_ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
info, ierr := d.Info()
if ierr != nil {
return nil
}
if info.ModTime().After(newest) {
newest = info.ModTime()
}
if !d.IsDir() {
size += info.Size()
}
return nil
})
return newest, size
}
// prepareCodexSessionsDir points codex-home/sessions at a sessions store that
// holds ONLY this task's own history, never the machine's whole
// ~/.codex/sessions.
//
// Background (MUL-4424): Codex 0.143+ backfills a per-home session-state DB by
// enumerating every rollout visible under sessions/ during `initialize`. When
// the per-task home symlinked the shared sessions dir in, a machine that had
// accumulated thousands of rollouts (one reporter hit ~2000 files / ~22 GiB)
// stalled `initialize` for tens of seconds — the app-server started but the
// task produced no output before it was cancelled. So we scope sessions/ to a
// single task/issue:
//
// - local_directory task: envRoot (and thus codex-home) is fresh per task ID
// and never reused, so sessions/ links to the per-issue store on the shared
// Codex volume — stable across task IDs, GC-safe, and holding only this
// issue's rollouts. See linkCodexSessionsToStore.
// - Fresh managed task: sessions/ is absent — create an empty local dir so
// backfill is trivial; the reused envRoot carries it to the next run.
// - Reused managed task whose sessions/ is already a real dir: it is
// authoritative — the prior run's rollout already lives here — leave it.
// - Reused managed task still holding a legacy symlink into the shared
// sessions (older build): migrate. With a resume, route it through the
// per-issue store (cross-volume-safe); without one, replace the symlink
// with an empty local dir. Either way drop the stale session-state DB so
// Codex rebuilds it from the scoped sessions.
func prepareCodexSessionsDir(codexHome, sharedHome string, opts CodexHomeOptions, logger *slog.Logger) error {
dst := filepath.Join(codexHome, "sessions")
sharedSessions := filepath.Join(sharedHome, "sessions")
storeDir := codexSessionStoreDir(sharedHome, opts.SessionStoreKey)
// local_directory tasks have no reusable envRoot, so their history can only
// persist across task IDs in the per-issue store. The daemon still verifies
// the specific rollout is present before claiming a resume (see
// CodexResumeRolloutPresent), so a missing one no longer masquerades as one.
if opts.IsLocalDirectory {
if storeDir == "" {
// No stable per-issue key (e.g. a non-issue task). Fall back to an
// empty local dir rather than re-exposing the whole shared history.
return os.MkdirAll(dst, 0o755)
}
return linkCodexSessionsToStore(dst, storeDir, sharedSessions, opts.ResumeSessionID, logger)
}
fi, err := os.Lstat(dst)
switch {
case os.IsNotExist(err):
return os.MkdirAll(dst, 0o755) // fresh managed task — empty local dir
case err != nil:
return fmt.Errorf("stat sessions dir %s: %w", dst, err)
}
if fi.Mode()&os.ModeSymlink == 0 {
// Already a real directory (task-local, authoritative). Ensure it
// exists (no-op) and leave its contents alone.
return os.MkdirAll(dst, 0o755)
}
// A symlink/junction. If it already points at this issue's store (a home
// migrated with a resume on a prior reuse), it is authoritative — re-ensure
// the store link and the resume rollout, then leave it.
if storeDir != "" {
if target, rlErr := os.Readlink(dst); rlErr == nil && sameCodexPath(target, storeDir) {
return linkCodexSessionsToStore(dst, storeDir, sharedSessions, opts.ResumeSessionID, logger)
}
}
// Legacy symlink into the shared ~/.codex/sessions — migrate it. Drop the
// session-derived state so Codex re-indexes the scoped sessions instead of
// the stale rows it built from the whole shared home.
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove legacy sessions symlink %s: %w", dst, err)
}
resetCodexSessionState(codexHome, logger)
// With a resume, route through the per-issue store so the rollout is exposed
// cross-volume-safely (hard link within the shared volume + a directory link
// into the task home). Without a resume — or with no stable key — an empty
// local dir is all a fresh thread needs.
if opts.ResumeSessionID != "" && storeDir != "" {
logger.Info("execenv: migrated codex-home sessions from shared symlink to per-issue store",
"codex_home", codexHome, "resume_session", true)
return linkCodexSessionsToStore(dst, storeDir, sharedSessions, opts.ResumeSessionID, logger)
}
logger.Info("execenv: migrated codex-home sessions from shared symlink to task-local dir",
"codex_home", codexHome, "resume_session", false)
return os.MkdirAll(dst, 0o755)
}
// linkCodexSessionsToStore points codex-home/sessions (dst) at the per-issue
// store (storeDir) via an idempotent directory link — a symlink on Unix, a
// junction on Windows — both of which cross filesystem volumes without special
// privilege. The store lives on the shared Codex home's volume, so linking the
// directory (rather than copying rollout files into the task home) is what makes
// resume exposure safe when WorkspacesRoot sits on a different disk than
// ~/.codex (MUL-4424, Windows cross-volume).
//
// When resuming and the store does not yet hold the rollout — e.g. the first run
// after upgrading from the old whole-shared-sessions layout, where the history
// still lives only under ~/.codex/sessions — it hard-links that rollout into the
// store. Both paths are on the shared volume, so the link is zero-copy and never
// puts a (possibly gigabyte) rollout on initialize's critical path.
func linkCodexSessionsToStore(dst, storeDir, sharedSessions, resumeID string, logger *slog.Logger) error {
if err := os.MkdirAll(storeDir, 0o755); err != nil {
return fmt.Errorf("create codex session store %s: %w", storeDir, err)
}
if resumeID != "" && len(findCodexRollouts(storeDir, resumeID)) == 0 {
if err := exposeResumeRollout(sharedSessions, storeDir, resumeID, logger); err != nil {
logger.Warn("execenv: bootstrap resume rollout into session store failed; task will fall back to a fresh thread",
"session_id", resumeID, "error", err)
}
}
if err := ensureCodexSessionsLink(dst, storeDir); err != nil {
return err
}
// Stamp the store as just-used. Mounting it (MkdirAll, rollout lookup, link)
// does not touch its mtime, so without this the GC's idle check would still
// see a >TTL-old store and could reclaim it before the resumed turn writes its
// first rollout — reopening a long-idle issue must not lose context. This is
// the activity refresh; the daemon's in-process active-store guard closes the
// remaining stat→remove race (MUL-4424).
touchCodexSessionStore(storeDir, logger)
return nil
}
// touchCodexSessionStore refreshes storeDir's modification time to now — the
// signal codexStoreStat reads as the store's last activity. Best-effort: a
// failed touch only risks an over-eager prune, which the active-store guard
// still prevents.
func touchCodexSessionStore(storeDir string, logger *slog.Logger) {
now := time.Now()
if err := os.Chtimes(storeDir, now, now); err != nil {
logger.Warn("execenv: refresh codex session store activity failed", "store", storeDir, "error", err)
}
}
// CodexSessionStorePath returns the per-issue Codex session store directory for
// (profile, agentID, issueID) on the shared home, or "" when there is no stable
// key. The daemon marks this path in-use for the duration of a task so
// PruneCodexSessionStores never reclaims a store mid-mount, closing the
// stat→remove race the mtime refresh alone cannot (MUL-4424).
func CodexSessionStorePath(profile, agentID, issueID string) string {
key := codexSessionStoreKey(profile, agentID, issueID)
if key == "" {
return ""
}
return codexSessionStoreDir(resolveSharedCodexHome(), key)
}
// sameCodexPath reports whether two filesystem paths refer to the same location,
// tolerating separator/cleanliness differences. Used to detect a sessions link
// that already points at the per-issue store so a reused home is not re-migrated.
func sameCodexPath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}
// resetCodexSessionState removes the rebuildable, session-derived Codex state
// files from a per-task CODEX_HOME so the next `initialize` re-derives them from
// the task-local sessions. Only session-derived indexes are touched; unrelated
// per-task DBs (goals_*, logs_*, memories_*) are left intact.
func resetCodexSessionState(codexHome string, logger *slog.Logger) {
for _, pattern := range codexSessionStateGlobs {
matches, err := filepath.Glob(filepath.Join(codexHome, pattern))
if err != nil {
continue
}
for _, m := range matches {
if err := os.Remove(m); err != nil && !os.IsNotExist(err) {
logger.Warn("execenv: codex-home reset session state failed", "path", m, "error", err)
}
}
}
}
// ensureCodexSessionsLink points codex-home/sessions (dst) at the per-issue
// session store (src) via a directory link, creating the store if needed.
// Idempotent: a link already pointing at src is left as-is; anything else at dst
// (a real dir, a stale link, a legacy shared-sessions symlink) is replaced. The
// link crosses volumes without privilege (symlink on Unix, junction on Windows),
// so the store can live on the shared Codex volume while the task home lives
// under WorkspacesRoot (see linkCodexSessionsToStore).
func ensureCodexSessionsLink(dst, src string) error {
if err := os.MkdirAll(src, 0o755); err != nil {
return fmt.Errorf("create codex session store %s: %w", src, err)
}
if fi, err := os.Lstat(dst); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
if target, rlErr := os.Readlink(dst); rlErr == nil && sameCodexPath(target, src) {
return nil
}
}
if err := os.RemoveAll(dst); err != nil {
return fmt.Errorf("remove stale sessions path %s: %w", dst, err)
}
}
return createDirLink(src, dst)
}
// codexRolloutGlobs returns the glob patterns that match a session's rollout
// under a Codex sessions directory. It covers the layouts Codex 0.14x writes:
// date-nested (sessions/YYYY/MM/DD/) and flat (directly under sessions/), each
// as a plain .jsonl or a background-compressed .jsonl.zst.
func codexRolloutGlobs(sessionsDir, sessionID string) []string {
name := "rollout-*-" + sessionID + ".jsonl*" // .jsonl and .jsonl.zst
return []string{
filepath.Join(sessionsDir, name),
filepath.Join(sessionsDir, "*", "*", "*", name),
}
}
// findCodexRollouts returns every rollout file for sessionID under sessionsDir,
// across the supported layouts.
func findCodexRollouts(sessionsDir, sessionID string) []string {
if sessionsDir == "" || sessionID == "" {
return nil
}
var out []string
seen := map[string]bool{}
for _, pattern := range codexRolloutGlobs(sessionsDir, sessionID) {
matches, err := filepath.Glob(pattern)
if err != nil {
continue
}
for _, m := range matches {
if !seen[m] {
seen[m] = true
out = append(out, m)
}
}
}
return out
}
// CodexResumeRolloutPresent reports whether sessionID's rollout is present in
// the task's codex-home sessions dir. The daemon uses this after preparing the
// environment to avoid claiming a resume Codex would silently restart from
// scratch — the rollout may be absent when a legacy home's migration could not
// locate it, or when a local_directory task's shared history has been pruned
// (MUL-4424).
func CodexResumeRolloutPresent(codexHome, sessionID string) bool {
if codexHome == "" || sessionID == "" {
return false
}
return len(findCodexRollouts(filepath.Join(codexHome, "sessions"), sessionID)) > 0
}
// exposeResumeRollout links sessionID's rollout(s) out of the shared sessions
// history into the task-local sessions dir, preserving the relative layout so
// thread/resume can find it. Covers plain and compressed rollouts in both the
// nested and flat layouts.
//
// It links rather than copies: a rollout can be large (one reporter saw a
// single 1.5 GiB file) and this runs on `initialize`'s critical path, so an
// unbounded copy would reintroduce the very stall we are fixing. See
// linkCodexRollout for the hard-link-then-symlink strategy; if neither works
// the caller treats the resume as unavailable and falls back to a fresh thread.
func exposeResumeRollout(sharedSessions, localSessions, sessionID string, logger *slog.Logger) error {
matches := findCodexRollouts(sharedSessions, sessionID)
if len(matches) == 0 {
return fmt.Errorf("no rollout found for session %s under %s", sessionID, sharedSessions)
}
linked := 0
for _, src := range matches {
rel, err := filepath.Rel(sharedSessions, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(localSessions, rel)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create rollout dir %s: %w", filepath.Dir(dst), err)
}
if err := linkCodexRollout(src, dst); err != nil {
return fmt.Errorf("link rollout %s: %w", src, err)
}
linked++
}
logger.Info("execenv: exposed resume rollout into task-local sessions", "session_id", sessionID, "files", linked)
return nil
}
// linkCodexRollout materialises src at dst without copying its bytes: a hard
// link first (zero-copy, needs no special privilege and works on Windows within
// a volume), falling back to a symlink across filesystems. It never copies — a
// rollout can be gigabytes and this runs on initialize's critical path, so a
// copy would reintroduce the stall MUL-4424 fixes.
func linkCodexRollout(src, dst string) error {
if err := os.Link(src, dst); err == nil {
return nil
}
return os.Symlink(src, dst)
}
func syncCodexModelCatalog(codexHome, sharedHome string) error {
configPath := filepath.Join(codexHome, "config.toml")
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", configPath, err)
}
var cfg struct {
ModelCatalogJSON string `toml:"model_catalog_json"`
}
if err := toml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse %s: %w", configPath, err)
}
catalogPath := strings.TrimSpace(cfg.ModelCatalogJSON)
if catalogPath == "" {
return nil
}
src, err := resolveCodexConfigPath(catalogPath, sharedHome)
if err != nil {
return err
}
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("model_catalog_json %q resolved to missing file %s: %w", catalogPath, src, err)
}
if filepath.IsAbs(catalogPath) || strings.HasPrefix(catalogPath, "~") {
return nil
}
cleanCatalogPath := filepath.Clean(catalogPath)
if !filepath.IsLocal(cleanCatalogPath) {
return fmt.Errorf("model_catalog_json %q must be a local relative path or an absolute path", catalogPath)
}
dst := filepath.Join(codexHome, cleanCatalogPath)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create model catalog directory %s: %w", filepath.Dir(dst), err)
}
if _, err := os.Lstat(dst); err == nil {
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove stale model catalog %s: %w", dst, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat model catalog %s: %w", dst, err)
}
if err := copyFile(src, dst); err != nil {
return fmt.Errorf("copy model_catalog_json %s to %s: %w", src, dst, err)
}
return nil
}
func resolveCodexConfigPath(configPath, sharedHome string) (string, error) {
if filepath.IsAbs(configPath) {
return filepath.Clean(configPath), nil
}
if strings.HasPrefix(configPath, "~/") || strings.HasPrefix(configPath, `~\`) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve model_catalog_json %q: user home: %w", configPath, err)
}
return filepath.Join(home, configPath[2:]), nil
}
if strings.HasPrefix(configPath, "~") {
return "", fmt.Errorf("model_catalog_json %q uses unsupported ~user expansion", configPath)
}
return filepath.Join(sharedHome, filepath.Clean(configPath)), nil
}
func exposeSharedCodexPluginCache(codexHome, sharedHome string) error {
src := filepath.Join(sharedHome, "plugins", "cache")
dst := filepath.Join(codexHome, "plugins", "cache")
if err := os.MkdirAll(src, 0o755); err != nil {
return fmt.Errorf("create shared plugin cache dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create codex plugin dir: %w", err)
}
if fi, err := os.Lstat(dst); err == nil {
isLink := fi.Mode()&os.ModeSymlink != 0
if isLink {
if target, readlinkErr := os.Readlink(dst); readlinkErr == nil && target == src {
return nil
}
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove stale plugin cache link: %w", err)
}
} else {
if err := os.RemoveAll(dst); err != nil {
return fmt.Errorf("remove stale plugin cache path: %w", err)
}
}
}
if err := createDirLink(src, dst); err != nil {
return fmt.Errorf("expose shared plugin cache: %w", err)
}
return nil
}
// ensureSymlink ensures dst tracks src. If src doesn't exist, it's a no-op.
// If dst is already a symlink pointing at src, it's a no-op. Otherwise — a
// wrong-target symlink, a broken symlink, or a regular file left over from a
// prior createFileLink copy fallback — dst is removed and recreated via
// createFileLink so the per-task home doesn't drift from the shared source.
//
// The "regular file" branch matters on Windows: when os.Symlink fails (no
// Developer Mode / not elevated), createFileLink falls back to copying the
// file. Without this re-creation step, a once-stale auth.json would never
// pick up token refreshes from the shared ~/.codex/auth.json, leaving Codex
// stuck on a revoked refresh token across env reuses (issue #2081).
func ensureSymlink(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil // source doesn't exist — skip
}
if fi, err := os.Lstat(dst); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
if target, err := os.Readlink(dst); err == nil && target == src {
return nil // symlink already points to src
}
}
// Wrong-target symlink, broken symlink, or stale regular file —
// drop it so createFileLink can re-link/re-copy from the current src.
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove stale dst %s: %w", dst, err)
}
}
return createFileLink(src, dst)
}
// logCodexAuthState records the kind of auth.json the per-task CODEX_HOME
// ended up with — symlink (with target), regular file (with size + mtime),
// or missing — so an operator chasing refresh_token_reused / token_expired
// reports can immediately tell whether the per-task home is tracking the
// shared ~/.codex/auth.json or has drifted into a stale local copy.
//
// Never logs the file contents.
func logCodexAuthState(authPath string, logger *slog.Logger) {
fi, err := os.Lstat(authPath)
if err != nil {
logger.Info("execenv: codex auth.json absent", "path", authPath, "error", err)
return
}
if fi.Mode()&os.ModeSymlink != 0 {
target, _ := os.Readlink(authPath)
logger.Info("execenv: codex auth.json is symlink", "path", authPath, "target", target)
return
}
logger.Info("execenv: codex auth.json is regular file",
"path", authPath,
"size", fi.Size(),
"mtime", fi.ModTime().UTC(),
)
}
// (The daemon used to write a minimal inline config here; the authoritative
// sandbox/network directives now live in a managed block rendered by
// codex_sandbox.go's ensureCodexSandboxConfig so they can be updated
// idempotently without touching user-managed keys.)
// syncCopiedFile mirrors a per-task dst onto the current state of the shared
// src so the per-task copy tracks the shared source across Reuse() runs:
//
// - src present, dst absent: copy src → dst
// - src present, dst present: drop dst and re-copy src → dst (refresh)
// - src absent, dst present: drop dst (the shared source has been removed,
// so the per-task stale copy must not linger)
// - src absent, dst absent: no-op
//
// Regression for MUL-2646: the prior "don't overwrite" guard left per-task
// config.toml / config.json / instructions.md stuck on whatever snapshot they
// were seeded with at first Prepare. A user who edited ~/.codex/config.toml
// between runs — switching the active [model_providers.X] base_url, pointing
// env_key at a freshly rotated API key, or removing the file outright to
// drop a provider — kept hitting the stale per-task copy on session resume,
// with Codex calling the new URL using the old key (or replaying a provider
// the user had since deleted from the shared config).
//
// For config.toml the subsequent ensureCodex{Sandbox,MultiAgent,Memory}Config
// passes recreate the file from scratch when the shared source is gone, so
// the per-task home keeps the daemon-managed defaults but loses every
// user-managed [model_providers.X] / model_provider line that no longer
// exists in the shared config. For config.json / instructions.md there is
// no daemon-managed default, so they simply disappear in lockstep with the
// shared source.
func syncCopiedFile(src, dst string) error {
_, srcErr := os.Stat(src)
srcMissing := os.IsNotExist(srcErr)
if srcErr != nil && !srcMissing {
return fmt.Errorf("stat src %s: %w", src, srcErr)
}
if _, err := os.Lstat(dst); err == nil {
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove stale dst %s: %w", dst, err)
}
}
if srcMissing {
return nil
}
return copyFile(src, dst)
}
// copyFile copies src to dst unconditionally.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}