mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* fix(daemon): run Codex unsandboxed on Windows to stop reject-by-policy (MUL-4957) Windows has no Landlock/Seatbelt-equivalent filesystem sandbox that the daemon configures, so the per-task `sandbox_mode = "workspace-write"` it wrote was unenforceable. Worse than having no sandbox, it pushed Codex into rejecting non-safe mutation commands "by policy": `multica issue create` fails with "was rejected by policy" because Codex can neither sandbox the command nor (under approval_policy = "never") escalate it to the daemon's auto-approver, so the request never reaches the approver. Mirror the existing macOS fallback and give Windows danger-full-access so those commands run. Also generalize the danger-full-access warn log so it no longer hardcodes "on macOS" and only surfaces the macOS-specific upgrade hint on macOS (new codexSandboxPolicy.Hint field). Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): correct Windows sandbox rationale and respect user windows.sandbox (MUL-4957) Addresses two review must-fixes on #5672: 1. Correct a false security fact. The comments and log Reason claimed Windows has no filesystem sandbox backend. Codex 0.144.5 does ship a native Windows sandbox (windows.sandbox = "unelevated"/"elevated"); it is experimental with open upstream reliability bugs, so the daemon defaults to danger-full-access as a deliberate compatibility choice. Enabling the native sandbox is tracked as separate follow-up work. 2. Stop silently downgrading users who opted into isolation. The fallback was unconditional. Add codexSandboxPolicyForConfig: on Windows an explicit windows.sandbox = unelevated|elevated keeps workspace-write so Codex enforces task isolation with the user's chosen backend; danger-full-access applies only when windows.sandbox is absent, disabled, or unparseable. This is also the branch point for a future native-sandbox rollout (flip the default; callers unchanged). Adds fixture tests locking the priority (user opt-in kept vs. unconfigured fallback) plus predicate coverage for codexSandboxPolicyForConfig. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): fail closed on undecidable Windows sandbox config, honor -c windows.sandbox (MUL-4957) Second review round on #5672. Two must-fixes. 1. Undecidable config no longer fails open. The old bool detector collapsed "unparseable / invalid value / failed copy" into "unconfigured" and then loosened to danger-full-access. Replaced with a tri-state (absent/native/undecidable): only exact-lowercase unelevated|elevated (the sole values Codex accepts — verified: any other value makes Codex refuse to load the config) counts as native; any other present value, unparseable TOML, a read error, or a missing per-task config when a shared ~/.codex/config.toml exists (i.e. the copy failed) is undecidable and fails closed to workspace-write — it never loosens — logged at error level. 2. windows.sandbox set via `-c`/`--config` custom args is now honored. Such args never land in config.toml, so config-only detection silently downgraded those users' isolation. The effective Codex args (daemon defaults + profile-fixed + per-agent custom_args) are threaded through PrepareParams/ReuseParams/CodexHomeOptions into the sandbox decision and scanned for a windows.sandbox override (inline, two-token, quoted, spaced; last-wins). Also drops issue-status-bound source comments (openai/codex#24098 has since closed). Adds unit coverage for config/args classification, the fold precedence (undecidable > native > absent), and the copy-failed fail-closed path. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): fail closed on config-sync errors and honor shell-quoted -c windows.sandbox (MUL-4957) Round-3 review must-fixes: 1. resolveWindowsSandboxState now takes the config.toml sync error and a tri-state shared-config presence instead of re-stat-ing inside. A failed sync (stale/absent per-task copy) or an un-stat-able shared source is undecidable and keeps workspace-write, closing the fail-open where a failed sync was read as "unconfigured". Splits IO from the decision so the paths are unit-testable without faulting the filesystem. 2. The Windows sandbox decision consumes agent.NormalizeCodexLaunchArgs (the shared helper buildCodexArgs now uses) so a shell-quoted -c windows.sandbox opt-in is normalized identically to launch, instead of being missed by a raw-token scan and silently downgraded. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): abort when the Codex sandbox block cannot be written (MUL-4957) Round-4 review must-fix: ensureCodexSandboxConfig failures were warn-and-continue, so a computed fail-closed workspace-write policy could stay only in memory while config.toml kept a stale danger-full-access from a prior run — the decision failed closed but the effective config failed open. prepareCodexHomeWithOpts now returns the error, which blocks startup on both paths: fresh Prepare fails the task, and Reuse leaves env.CodexHome unset, which configureCodexTaskShellEnvironment already refuses to start. Regression covers the full reuse scenario (stale danger-full-access + failed config sync + failed managed-block write); it fails with "got nil" without the fix. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: J <j@multica.ai>
1238 lines
51 KiB
Go
1238 lines
51 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 — task-local config and cache refreshes don't mutate
|
|
// the shared home.
|
|
var codexCopiedFiles = []string{
|
|
"config.json",
|
|
"config.toml",
|
|
"instructions.md",
|
|
}
|
|
|
|
const (
|
|
codexModelsCacheFile = "models_cache.json"
|
|
codexModelsCacheBindingFile = ".models_cache_config.sha256"
|
|
)
|
|
|
|
// Files whose contents select the model provider/catalog used by Codex. The
|
|
// task-local models cache is only reusable while this source configuration
|
|
// remains unchanged. A model_catalog_json referenced by config.toml is folded
|
|
// into the binding separately by codexModelsCacheConfigFingerprint.
|
|
var codexModelsCacheConfigFiles = []string{
|
|
"config.json",
|
|
"config.toml",
|
|
}
|
|
|
|
// 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
|
|
// CodexCustomArgs are the effective Codex CLI args this task will launch
|
|
// with (daemon defaults + profile-fixed + per-agent custom_args). Only the
|
|
// Windows sandbox decision reads them, to honor a `-c windows.sandbox=...`
|
|
// override that never lands in config.toml. See resolveWindowsSandboxState
|
|
// and MUL-4957.
|
|
CodexCustomArgs []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)
|
|
}
|
|
|
|
// sharedConfigPresence is the tri-state existence of the shared
|
|
// ~/.codex/config.toml copy source. It is three-valued so a stat that fails for
|
|
// a reason other than "not found" (permission/IO) never masquerades as a
|
|
// confident "the user has no config" — which would let the daemon loosen to
|
|
// danger-full-access on doubt. See resolveWindowsSandboxState (MUL-4957).
|
|
type sharedConfigPresence int
|
|
|
|
const (
|
|
// sharedConfigAbsent: the shared config.toml is confidently not present
|
|
// (os.IsNotExist), so an absent per-task copy is a genuine "unconfigured".
|
|
sharedConfigAbsent sharedConfigPresence = iota
|
|
// sharedConfigPresent: the shared config.toml exists.
|
|
sharedConfigPresent
|
|
// sharedConfigUndecidable: the stat failed for a reason other than
|
|
// not-found; the daemon cannot tell whether the user has a config.
|
|
sharedConfigUndecidable
|
|
)
|
|
|
|
// statSharedCodexConfig classifies the shared ~/.codex/config.toml (the copy
|
|
// source) into the tri-state above, distinguishing a genuine absence from a
|
|
// stat that could not complete.
|
|
func statSharedCodexConfig(sharedHome string) sharedConfigPresence {
|
|
if sharedHome == "" {
|
|
return sharedConfigAbsent
|
|
}
|
|
_, err := os.Stat(filepath.Join(sharedHome, "config.toml"))
|
|
switch {
|
|
case err == nil:
|
|
return sharedConfigPresent
|
|
case os.IsNotExist(err):
|
|
return sharedConfigAbsent
|
|
default:
|
|
return sharedConfigUndecidable
|
|
}
|
|
}
|
|
|
|
// resolveWindowsSandboxState determines, for a Windows task, whether a native
|
|
// Codex sandbox is configured — across the per-task config.toml and the
|
|
// effective custom args — failing closed (Undecidable) when it cannot tell.
|
|
//
|
|
// Two signals it does NOT gather itself (the caller does) keep the fail-closed
|
|
// logic unit-testable without faulting the filesystem, and close MUL-4957's
|
|
// round-3 must-fix where a failed sync could be misread as "unconfigured":
|
|
//
|
|
// - configSyncErr: the error (if any) from syncing the shared config.toml
|
|
// into this per-task home. Non-nil means the per-task config.toml is
|
|
// unreliable — stale from a prior run, or never (re)written — so neither its
|
|
// contents nor its absence reflect the user's intent. Fail closed.
|
|
// - sharedPresence: whether the shared config.toml source exists. Only a
|
|
// confident absence lets an absent per-task copy count as genuinely
|
|
// unconfigured; a present-or-undecidable source whose per-task copy is
|
|
// missing means the copy silently did not land, so fail closed.
|
|
func resolveWindowsSandboxState(configFile string, configSyncErr error, sharedPresence sharedConfigPresence, customArgs []string, logger *slog.Logger) windowsSandboxConfig {
|
|
configState := classifyPerTaskWindowsSandbox(configFile, configSyncErr, sharedPresence)
|
|
state := resolveWindowsSandbox(configState, windowsSandboxFromCustomArgs(customArgs))
|
|
if state == windowsSandboxUndecidable && logger != nil {
|
|
logger.Error("codex sandbox: cannot determine Windows native sandbox config; keeping workspace-write and refusing to loosen to danger-full-access",
|
|
"config_file", configFile)
|
|
}
|
|
return state
|
|
}
|
|
|
|
// classifyPerTaskWindowsSandbox inspects the per-task config.toml given the
|
|
// outcome of syncing it from the shared source, failing closed whenever the
|
|
// file cannot be trusted or read.
|
|
func classifyPerTaskWindowsSandbox(configFile string, configSyncErr error, sharedPresence sharedConfigPresence) windowsSandboxConfig {
|
|
// A failed shared→per-task sync leaves config.toml stale or missing; neither
|
|
// its contents nor its absence reflect the user's intent. Fail closed.
|
|
if configSyncErr != nil {
|
|
return windowsSandboxUndecidable
|
|
}
|
|
data, err := os.ReadFile(configFile)
|
|
switch {
|
|
case err == nil:
|
|
return windowsSandboxFromConfig(string(data))
|
|
case os.IsNotExist(err):
|
|
// Sync succeeded and the per-task config is absent. That is a genuine
|
|
// "no config" only when the shared source is confidently absent too; a
|
|
// present or undecidable source whose copy is missing means the copy
|
|
// did not land → fail closed rather than loosen.
|
|
if sharedPresence == sharedConfigAbsent {
|
|
return windowsSandboxAbsent
|
|
}
|
|
return windowsSandboxUndecidable
|
|
default:
|
|
// A read error (permission/IO) on a file the daemon just wrote.
|
|
return windowsSandboxUndecidable
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
freshHome := false
|
|
if _, err := os.Lstat(codexHome); os.IsNotExist(err) {
|
|
freshHome = true
|
|
}
|
|
|
|
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 isolated files from the shared source. Track the config.toml sync
|
|
// outcome specifically: on Windows a failed sync makes the per-task config
|
|
// untrustworthy, so the sandbox decision must fail closed rather than read a
|
|
// stale or absent copy as "unconfigured" and loosen (MUL-4957).
|
|
var configSyncErr error
|
|
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)
|
|
if name == "config.toml" {
|
|
configSyncErr = 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)
|
|
}
|
|
|
|
// Seed the shared model cache only for a fresh task home. On reuse, keep a
|
|
// task-local cache that Codex may have refreshed, but only while the source
|
|
// provider/catalog configuration is still the one that cache was bound to.
|
|
// If binding fails, discard the optional cache so Codex refreshes it instead
|
|
// of potentially using models from the wrong provider.
|
|
if err := syncCodexModelsCache(codexHome, sharedHome, freshHome); err != nil {
|
|
logger.Warn("execenv: codex-home models cache sync failed; discarding cache", "error", err)
|
|
if removeErr := os.RemoveAll(filepath.Join(codexHome, codexModelsCacheFile)); removeErr != nil {
|
|
return fmt.Errorf("sync codex models cache: %v; discard unsafe cache: %w", err, removeErr)
|
|
}
|
|
}
|
|
|
|
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,
|
|
// and on Windows the daemon defaults to danger-full-access unless the user
|
|
// opted into a native windows.sandbox; see codex_sandbox.go for the full
|
|
// rationale. On Windows, resolve the native-sandbox state across the copied
|
|
// config and the effective custom args so an explicit user opt-in is honored
|
|
// and an undecidable config fails closed instead of loosening.
|
|
configFile := filepath.Join(codexHome, "config.toml")
|
|
winState := windowsSandboxAbsent
|
|
if resolveGOOS(opts.GOOS) == "windows" {
|
|
winState = resolveWindowsSandboxState(configFile, configSyncErr, statSharedCodexConfig(sharedHome), opts.CodexCustomArgs, logger)
|
|
}
|
|
policy := codexSandboxPolicyForConfig(opts.GOOS, opts.CodexVersion, winState)
|
|
policy.WritableRoots = opts.WritableRoots
|
|
if err := ensureCodexSandboxConfig(configFile, policy, opts.CodexVersion, logger); err != nil {
|
|
// The managed block is the authoritative on-disk sandbox policy. If it
|
|
// can't be written, config.toml keeps whatever it already had — on a
|
|
// reused home that may be a stale danger-full-access from a prior run —
|
|
// so the fail-closed policy just computed above would only exist in
|
|
// memory while the effective config silently stays loose. Abort rather
|
|
// than launch Codex with an unenforced sandbox: on fresh Prepare this
|
|
// fails the task; on Reuse the caller leaves env.CodexHome unset, which
|
|
// configureCodexTaskShellEnvironment then refuses to start (MUL-4957).
|
|
return fmt.Errorf("ensure codex sandbox config: %w", 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
|
|
}
|
|
|
|
// syncCodexModelsCache seeds models_cache.json once for a fresh task home and
|
|
// binds it to the shared provider/catalog configuration. Codex can replace the
|
|
// task-local cache after startup, so an unchanged binding preserves whatever
|
|
// the task last wrote rather than restoring a potentially stale shared copy.
|
|
//
|
|
// A changed or missing binding makes an existing cache unsafe: Codex's cache
|
|
// format (as of 0.144.x) records the client version and fetch time but not the
|
|
// provider identity. Reusing that cache after config.toml switches providers
|
|
// can therefore pair provider B with provider A's model catalog. Drop it and
|
|
// let Codex fetch a catalog for the new effective configuration. We
|
|
// deliberately do not seed the shared cache in this case because it carries
|
|
// the same provider-identity ambiguity.
|
|
func syncCodexModelsCache(codexHome, sharedHome string, freshHome bool) error {
|
|
fingerprint, err := codexModelsCacheConfigFingerprint(sharedHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bindingPath := filepath.Join(codexHome, codexModelsCacheBindingFile)
|
|
previous, bound, err := readCodexModelsCacheBinding(bindingPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cachePath := filepath.Join(codexHome, codexModelsCacheFile)
|
|
cacheInfo, cacheErr := os.Lstat(cachePath)
|
|
cacheExists := cacheErr == nil
|
|
if cacheErr != nil && !os.IsNotExist(cacheErr) {
|
|
return fmt.Errorf("stat codex models cache %s: %w", cachePath, cacheErr)
|
|
}
|
|
|
|
if bound && previous == fingerprint {
|
|
// The cache belongs to the current config. Preserve both an existing
|
|
// task-refreshed cache and an intentional absence after a failed fetch;
|
|
// seeding on reuse could reintroduce an unbound shared snapshot.
|
|
if cacheExists && !cacheInfo.Mode().IsRegular() {
|
|
if err := os.RemoveAll(cachePath); err != nil {
|
|
return fmt.Errorf("remove non-regular codex models cache %s: %w", cachePath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if cacheExists {
|
|
if err := os.RemoveAll(cachePath); err != nil {
|
|
return fmt.Errorf("remove unbound codex models cache %s: %w", cachePath, err)
|
|
}
|
|
}
|
|
|
|
if freshHome && !bound && !cacheExists {
|
|
// A shared snapshot is useful on the one path where the task home itself
|
|
// did not exist yet; subsequent task-local refreshes stay isolated. An
|
|
// existing legacy home without a binding never seeds because its prior
|
|
// effective configuration is unknown even when its cache is absent.
|
|
if err := seedCopiedFile(filepath.Join(sharedHome, codexModelsCacheFile), cachePath); err != nil {
|
|
return fmt.Errorf("seed codex models cache: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := writeCodexModelsCacheBinding(bindingPath, fingerprint); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// codexModelsCacheConfigFingerprint hashes the shared config files plus the
|
|
// contents of any model_catalog_json they reference. The digest is stored in
|
|
// the isolated task home; no config contents or credentials are persisted.
|
|
func codexModelsCacheConfigFingerprint(sharedHome string) (string, error) {
|
|
h := sha256.New()
|
|
var configTOML []byte
|
|
|
|
for _, name := range codexModelsCacheConfigFiles {
|
|
path := filepath.Join(sharedHome, name)
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
fmt.Fprintf(h, "%s\x00missing\x00", name)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("read codex model cache config %s: %w", path, err)
|
|
}
|
|
fmt.Fprintf(h, "%s\x00%d\x00", name, len(data))
|
|
_, _ = h.Write(data)
|
|
if name == "config.toml" {
|
|
configTOML = data
|
|
}
|
|
}
|
|
|
|
if len(configTOML) > 0 {
|
|
var cfg struct {
|
|
ModelCatalogJSON string `toml:"model_catalog_json"`
|
|
}
|
|
if err := toml.Unmarshal(configTOML, &cfg); err != nil {
|
|
return "", fmt.Errorf("parse codex model cache config %s: %w", filepath.Join(sharedHome, "config.toml"), err)
|
|
}
|
|
catalogPath := strings.TrimSpace(cfg.ModelCatalogJSON)
|
|
if catalogPath != "" {
|
|
resolved, err := resolveCodexConfigPath(catalogPath, sharedHome)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data, err := os.ReadFile(resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read model_catalog_json %s: %w", resolved, err)
|
|
}
|
|
fmt.Fprintf(h, "model_catalog_json\x00%d\x00", len(data))
|
|
_, _ = h.Write(data)
|
|
}
|
|
}
|
|
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
// readCodexModelsCacheBinding returns bound=false for a missing or non-regular
|
|
// marker. Non-regular paths are removed so a reused task cannot redirect the
|
|
// later binding write outside its isolated CODEX_HOME.
|
|
func readCodexModelsCacheBinding(path string) (fingerprint string, bound bool, err error) {
|
|
fi, err := os.Lstat(path)
|
|
if os.IsNotExist(err) {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("stat codex models cache binding %s: %w", path, err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
if err := os.RemoveAll(path); err != nil {
|
|
return "", false, fmt.Errorf("remove non-regular codex models cache binding %s: %w", path, err)
|
|
}
|
|
return "", false, nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("read codex models cache binding %s: %w", path, err)
|
|
}
|
|
return strings.TrimSpace(string(data)), true, nil
|
|
}
|
|
|
|
func writeCodexModelsCacheBinding(path, fingerprint string) error {
|
|
if err := os.RemoveAll(path); err != nil {
|
|
return fmt.Errorf("remove prior codex models cache binding %s: %w", path, err)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("create codex models cache binding %s: %w", path, err)
|
|
}
|
|
if _, err := io.WriteString(f, fingerprint+"\n"); err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("write codex models cache binding %s: %w", path, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("close codex models cache binding %s: %w", path, 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)
|
|
}
|
|
|
|
// seedCopiedFile copies src only when dst has no task-local regular file.
|
|
// Unlike syncCopiedFile, it never overwrites or removes a cache refreshed by a
|
|
// prior run. Non-regular destinations are removed defensively so a reused task
|
|
// cannot turn the cache path into a link outside its isolated CODEX_HOME.
|
|
func seedCopiedFile(src, dst string) error {
|
|
if fi, err := os.Lstat(dst); err == nil {
|
|
if fi.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
if err := os.RemoveAll(dst); err != nil {
|
|
return fmt.Errorf("remove non-regular dst %s: %w", dst, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("stat dst %s: %w", dst, err)
|
|
}
|
|
|
|
if _, err := os.Stat(src); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("stat src %s: %w", src, err)
|
|
}
|
|
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
|
|
}
|