mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network On macOS, Codex's Seatbelt sandbox in workspace-write mode silently ignores '[sandbox_workspace_write] network_access = true' (see openai/codex#10390). That blocks DNS inside the sandbox, so 'multica issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such host' — this is what caused MUL-963. Changes: - New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox policy based on runtime.GOOS and the detected Codex CLI version. Non-darwin or darwin with a known-fixed version keeps workspace-write + network_access=true; older darwin falls back to danger-full-access and logs a warn with upgrade hint. The fix-version threshold is a single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy to bump once upstream ships. - Per-task config.toml now gets a 'multica-managed' marker block (BEGIN/END comments) rewritten idempotently; user-owned keys outside the markers are preserved. Legacy inline sandbox directives from earlier daemon versions are stripped on migration. - execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a codexVersion arg; daemon.go caches detected versions at registration and threads them through to Prepare/Reuse. - Replaces the old ensureCodexNetworkAccess tests with platform-parameterised coverage (linux vs darwin, idempotency, legacy-migration, policy matrix). - docs/codex-sandbox-troubleshooting.md: symptom fingerprint table, decision matrix, self-check commands, trade-offs. Refs: MUL-963 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(daemon): hoist managed sandbox block above user tables (MUL-963) Review on #1246 flagged that upsertMulticaManagedBlock appended the managed block to EOF. If the user's config.toml ends inside a TOML table (e.g. [permissions.multica] or [profiles.foo]), a trailing bare sandbox_mode = "..." is parsed as a key of that preceding table, so Codex silently ignores the policy the daemon meant to apply. Two changes make the block position-independent: - renderMulticaManagedBlock now emits only top-level key=value lines and uses TOML dotted-key form (sandbox_workspace_write.network_access = true) instead of opening a [sandbox_workspace_write] header. The block therefore neither inherits from nor leaks into any surrounding table. - upsertMulticaManagedBlock always hoists the block to the top of the file (stripping any previously written managed block first), so the sandbox_mode line is always at the TOML root regardless of what the user put below it. This also migrates configs written by the original PR #1246 logic where the block was trapped behind a user table. Added tests for the regression scenario (pre-existing [permissions.*] table) and the legacy-trailing-block migration; updated the existing Linux default test and the troubleshooting runbook to reflect the dotted-key form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: CC-Girl <cc-girl@multica.ai> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
211 lines
6.7 KiB
Go
211 lines
6.7 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)
|
|
}
|
|
}
|
|
|
|
// Copy config files (isolated per task).
|
|
for _, name := range codexCopiedFiles {
|
|
src := filepath.Join(sharedHome, name)
|
|
dst := filepath.Join(codexHome, name)
|
|
if err := copyFileIfExists(src, dst); err != nil {
|
|
logger.Warn("execenv: codex-home copy failed", "file", name, "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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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 creates a symlink dst → src. If src doesn't exist, it's a no-op.
|
|
// If dst already exists as a correct symlink, it's a no-op. If dst is a broken
|
|
// symlink, it's replaced.
|
|
func ensureSymlink(src, dst string) error {
|
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
return nil // source doesn't exist — skip
|
|
}
|
|
|
|
// Check if dst already exists.
|
|
if fi, err := os.Lstat(dst); err == nil {
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
// It's a symlink — check if it points to the right place.
|
|
target, err := os.Readlink(dst)
|
|
if err == nil && target == src {
|
|
return nil // already correct
|
|
}
|
|
// Wrong target — remove and recreate.
|
|
os.Remove(dst)
|
|
} else {
|
|
// Regular file exists — don't overwrite.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return createFileLink(src, dst)
|
|
}
|
|
|
|
// (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.)
|
|
|
|
|
|
// copyFileIfExists copies src to dst. If src doesn't exist, it's a no-op.
|
|
// If dst already exists, it's not overwritten.
|
|
func copyFileIfExists(src, dst string) error {
|
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
// Don't overwrite existing file.
|
|
if _, err := os.Stat(dst); err == nil {
|
|
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
|
|
}
|