mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* fix(daemon/execenv): refresh stale Codex config copies across env reuse (MUL-2646)
`copyFileIfExists` previously short-circuited whenever the per-task
`codex-home/{config.toml,config.json,instructions.md}` already existed,
so once the files were seeded at first Prepare they were never refreshed
again — even though `Reuse()` calls `prepareCodexHomeWithOpts` on every
resume. A user who rotated their Codex `~/.codex/config.toml` between
runs (e.g. switching the active `[model_providers.X]` `base_url`, or
pointing `env_key` at a freshly rotated API key) kept reading the stale
per-task copy on session resume. Codex then issued requests to the new
URL using the old key and the API rejected the token.
Treat any existing `dst` as something to drop and re-copy from the
current shared source, mirroring the symlink path that already refreshes
`auth.json` (#2126). The daemon-managed sandbox / multi-agent / memory
blocks are applied via marker-bracketed idempotent passes after the
copy, so a re-copy + re-ensure cycle preserves them.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon/execenv): drop per-task Codex copy when shared source removed (MUL-2646)
Extend the MUL-2646 fix to the deletion arm of "sync the shared source":
`syncCopiedFile` (renamed from `copyFileIfExists`) now also removes the
per-task `dst` when the shared `src` is absent. The prior version
short-circuited on missing src and left `config.toml` / `config.json` /
`instructions.md` from the previous Prepare lingering in the per-task
home — so a user who removed a provider by deleting `~/.codex/config.toml`,
or pulled `config.json` / `instructions.md` out of the shared home, would
keep replaying the stale copy on session resume.
For `config.toml` the subsequent `ensureCodex{Sandbox,MultiAgent,Memory}Config`
passes recreate the file with only the daemon-managed default blocks, so
removing the shared file cleanly drops every user-managed
`[model_providers.X]` / `model_provider` line. For `config.json` and
`instructions.md` there is no daemon default, so they disappear in
lockstep with the shared source.
Adds `TestPrepareCodexHome_DropsCopiedConfigWhenSharedSourceRemoved`
covering the new path, and extends the refresh-arm test to assert the
multi-agent / memory marker blocks are still present after the copy is
refreshed.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
337 lines
13 KiB
Go
337 lines
13 KiB
Go
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Directories to symlink from the shared ~/.codex/ into the per-task CODEX_HOME.
|
|
// The shared directory is created if it doesn't exist, ensuring Codex session
|
|
// logs are always written to the global home where users can find them.
|
|
var codexSymlinkedDirs = []string{
|
|
"sessions",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Symlink shared directories (sessions) so logs stay in the global home.
|
|
for _, name := range codexSymlinkedDirs {
|
|
src := filepath.Join(sharedHome, name)
|
|
dst := filepath.Join(codexHome, name)
|
|
if err := ensureDirSymlink(src, dst); err != nil {
|
|
logger.Warn("execenv: codex-home dir symlink failed", "dir", name, "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 := 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)
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ensureDirSymlink creates a symlink dst → src for a directory.
|
|
// Unlike ensureSymlink, it creates the source directory if it doesn't exist,
|
|
// so Codex can write to it immediately.
|
|
func ensureDirSymlink(src, dst string) error {
|
|
if err := os.MkdirAll(src, 0o755); err != nil {
|
|
return fmt.Errorf("create shared dir %s: %w", src, err)
|
|
}
|
|
|
|
// Check if dst already exists.
|
|
if fi, err := os.Lstat(dst); err == nil {
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
target, err := os.Readlink(dst)
|
|
if err == nil && target == src {
|
|
return nil // already correct
|
|
}
|
|
os.Remove(dst)
|
|
} else {
|
|
// Regular file/dir exists — don't overwrite.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return createDirLink(src, dst)
|
|
}
|
|
|
|
// 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
|
|
}
|