From 47eb6cb612cf06420557ddd8ea14808783d4bd66 Mon Sep 17 00:00:00 2001 From: bulai0408 Date: Mon, 13 Apr 2026 01:03:43 +0800 Subject: [PATCH] fix(agent): enable network access for Codex sandbox so Multica CLI can reach API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex tasks running in workspace-write sandbox mode could not resolve api.multica.ai because the hardcoded sandbox parameter in thread/start overrode any config.toml settings, and the default sandbox policy blocks network access. Changes: - Remove hardcoded `sandbox: "workspace-write"` from thread/start RPC — let Codex read sandbox config from its own config.toml instead - Auto-generate config.toml in per-task CODEX_HOME with `sandbox_mode = "workspace-write"` and `network_access = true`, preserving any existing user settings - Fix Reuse() to restore CodexHome for Codex provider on workdir reuse Closes #368 --- server/internal/daemon/execenv/codex_home.go | 55 +++++ server/internal/daemon/execenv/execenv.go | 12 ++ .../internal/daemon/execenv/execenv_test.go | 200 +++++++++++++++++- server/pkg/agent/codex.go | 2 +- 4 files changed, 259 insertions(+), 10 deletions(-) diff --git a/server/internal/daemon/execenv/codex_home.go b/server/internal/daemon/execenv/codex_home.go index aa569bba3..79296ab3a 100644 --- a/server/internal/daemon/execenv/codex_home.go +++ b/server/internal/daemon/execenv/codex_home.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" ) // Directories to symlink from the shared ~/.codex/ into the per-task CODEX_HOME. @@ -66,6 +67,12 @@ func prepareCodexHome(codexHome string, logger *slog.Logger) error { } } + // Ensure config.toml has workspace-write sandbox with network access enabled. + // Codex needs network access to reach the Multica API (api.multica.ai). + if err := ensureCodexNetworkAccess(filepath.Join(codexHome, "config.toml")); err != nil { + logger.Warn("execenv: codex-home ensure network access failed", "error", err) + } + return nil } @@ -137,6 +144,54 @@ func ensureSymlink(src, dst string) error { return os.Symlink(src, dst) } +// defaultCodexConfig is the minimal config.toml for Codex tasks. +// It sets workspace-write sandbox mode with network access enabled so the +// Multica CLI can reach api.multica.ai. +const defaultCodexConfig = `sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +` + +// ensureCodexNetworkAccess ensures that config.toml exists and contains the +// sandbox_workspace_write section with network_access = true. If the file +// doesn't exist, it creates one with defaults. If it exists but lacks the +// network_access setting, the section is appended. +func ensureCodexNetworkAccess(configPath string) error { + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + // No config.toml — create with defaults. + return os.WriteFile(configPath, []byte(defaultCodexConfig), 0o644) + } + if err != nil { + return fmt.Errorf("read config.toml: %w", err) + } + + content := string(data) + + // If the file already has network_access configured under sandbox_workspace_write, leave it alone. + if strings.Contains(content, "[sandbox_workspace_write]") && strings.Contains(content, "network_access") { + return nil + } + + // Append the section. If sandbox_mode is already set, only append the section block. + var appendStr string + if strings.Contains(content, "[sandbox_workspace_write]") { + // Section exists but missing network_access — append the key under it. + content = strings.Replace(content, "[sandbox_workspace_write]", "[sandbox_workspace_write]\nnetwork_access = true", 1) + return os.WriteFile(configPath, []byte(content), 0o644) + } + + // Section doesn't exist — append both sandbox_mode (if missing) and the section. + appendStr = "\n" + if !strings.Contains(content, "sandbox_mode") { + appendStr += "sandbox_mode = \"workspace-write\"\n" + } + appendStr += "\n[sandbox_workspace_write]\nnetwork_access = true\n" + + return os.WriteFile(configPath, append(data, []byte(appendStr)...), 0o644) +} + // 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 { diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 168b82111..87ace616a 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -140,6 +140,18 @@ func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger logger.Warn("execenv: refresh context files failed", "error", err) } + // Restore CodexHome for Codex provider — the per-task codex-home directory + // lives alongside the workdir. Re-run prepareCodexHome to ensure config + // (especially network access) is up to date. + if provider == "codex" { + codexHome := filepath.Join(env.RootDir, "codex-home") + if err := prepareCodexHome(codexHome, logger); err != nil { + logger.Warn("execenv: refresh codex-home failed", "error", err) + } else { + env.CodexHome = codexHome + } + } + logger.Info("execenv: reusing env", "workdir", workDir) return env } diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index b12e68187..c587c5086 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -664,10 +664,14 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) { t.Errorf("config.json content = %q", data) } - // config.toml should be copied. + // config.toml should be copied and have network access appended. data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml")) - if string(data) != `model = "o3"` { - t.Errorf("config.toml content = %q", data) + tomlStr := string(data) + if !strings.Contains(tomlStr, `model = "o3"`) { + t.Errorf("config.toml missing original model setting, got: %q", tomlStr) + } + if !strings.Contains(tomlStr, "network_access = true") { + t.Errorf("config.toml missing network_access, got: %q", tomlStr) } // instructions.md should be copied. @@ -689,17 +693,25 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) { t.Fatalf("prepareCodexHome failed: %v", err) } - // Directory should only contain the sessions symlink (no auth.json, no config.json, etc.). + // Directory should contain sessions symlink + auto-generated config.toml. entries, err := os.ReadDir(codexHome) if err != nil { t.Fatalf("failed to read codex-home: %v", err) } - if len(entries) != 1 { - names := make([]string, len(entries)) - for i, e := range entries { - names[i] = e.Name() + entryNames := make(map[string]bool, len(entries)) + for _, e := range entries { + entryNames[e.Name()] = true + } + if !entryNames["sessions"] { + t.Error("expected sessions symlink") + } + if !entryNames["config.toml"] { + t.Error("expected config.toml (auto-generated for network access)") + } + for name := range entryNames { + if name != "sessions" && name != "config.toml" { + t.Errorf("unexpected entry: %s", name) } - t.Errorf("expected only sessions symlink in codex-home, got: %v", names) } // sessions should be a symlink to the shared sessions dir. sessionsPath := filepath.Join(codexHome, "sessions") @@ -712,6 +724,176 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) { } } +func TestEnsureCodexNetworkAccessCreatesDefault(t *testing.T) { + t.Parallel() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + if err := ensureCodexNetworkAccess(configPath); err != nil { + t.Fatalf("ensureCodexNetworkAccess failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config.toml: %v", err) + } + s := string(data) + if !strings.Contains(s, `sandbox_mode = "workspace-write"`) { + t.Error("missing sandbox_mode") + } + if !strings.Contains(s, "[sandbox_workspace_write]") { + t.Error("missing [sandbox_workspace_write] section") + } + if !strings.Contains(s, "network_access = true") { + t.Error("missing network_access = true") + } +} + +func TestEnsureCodexNetworkAccessPreservesExisting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + existing := `model = "o3" + +[sandbox_workspace_write] +network_access = true +` + os.WriteFile(configPath, []byte(existing), 0o644) + + if err := ensureCodexNetworkAccess(configPath); err != nil { + t.Fatalf("ensureCodexNetworkAccess failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + if string(data) != existing { + t.Errorf("config should be unchanged, got:\n%s", data) + } +} + +func TestEnsureCodexNetworkAccessAppendsToExisting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + existing := `model = "o3" +sandbox_mode = "workspace-write" +` + os.WriteFile(configPath, []byte(existing), 0o644) + + if err := ensureCodexNetworkAccess(configPath); err != nil { + t.Fatalf("ensureCodexNetworkAccess failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + s := string(data) + if !strings.Contains(s, `model = "o3"`) { + t.Error("lost existing model setting") + } + if !strings.Contains(s, "[sandbox_workspace_write]") { + t.Error("missing [sandbox_workspace_write] section") + } + if !strings.Contains(s, "network_access = true") { + t.Error("missing network_access = true") + } +} + +func TestEnsureCodexNetworkAccessAddsMissingKey(t *testing.T) { + t.Parallel() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + // Section exists but without network_access. + existing := `[sandbox_workspace_write] +allow_commands = ["git"] +` + os.WriteFile(configPath, []byte(existing), 0o644) + + if err := ensureCodexNetworkAccess(configPath); err != nil { + t.Fatalf("ensureCodexNetworkAccess failed: %v", err) + } + + data, _ := os.ReadFile(configPath) + s := string(data) + if !strings.Contains(s, "network_access = true") { + t.Error("missing network_access = true") + } + if !strings.Contains(s, `allow_commands = ["git"]`) { + t.Error("lost existing allow_commands") + } +} + +func TestPrepareCodexHomeEnsuresNetworkAccess(t *testing.T) { + // Cannot use t.Parallel() with t.Setenv. + + // Empty shared home — no config.toml to copy. + sharedHome := t.TempDir() + t.Setenv("CODEX_HOME", sharedHome) + + codexHome := filepath.Join(t.TempDir(), "codex-home") + if err := prepareCodexHome(codexHome, testLogger()); err != nil { + t.Fatalf("prepareCodexHome failed: %v", err) + } + + // config.toml should be created with network access defaults. + data, err := os.ReadFile(filepath.Join(codexHome, "config.toml")) + if err != nil { + t.Fatalf("config.toml not created: %v", err) + } + s := string(data) + if !strings.Contains(s, "network_access = true") { + t.Error("config.toml missing network_access = true") + } + if !strings.Contains(s, `sandbox_mode = "workspace-write"`) { + t.Error("config.toml missing sandbox_mode") + } +} + +func TestReuseRestoresCodexHome(t *testing.T) { + // Cannot use t.Parallel() with t.Setenv. + + sharedHome := t.TempDir() + t.Setenv("CODEX_HOME", sharedHome) + + workspacesRoot := t.TempDir() + + // First, Prepare a codex env. + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + WorkspaceID: "ws-codex-reuse", + TaskID: "e5f6a7b8-c9d0-1234-efab-567890123456", + AgentName: "Codex Agent", + Provider: "codex", + Task: TaskContextForEnv{IssueID: "reuse-test"}, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if env.CodexHome == "" { + t.Fatal("expected CodexHome to be set after Prepare") + } + + // Reuse should restore CodexHome. + reused := Reuse(env.WorkDir, "codex", TaskContextForEnv{IssueID: "reuse-test"}, testLogger()) + if reused == nil { + t.Fatal("Reuse returned nil") + } + if reused.CodexHome == "" { + t.Fatal("expected CodexHome to be restored after Reuse") + } + + // Verify config.toml has network access. + data, err := os.ReadFile(filepath.Join(reused.CodexHome, "config.toml")) + if err != nil { + t.Fatalf("config.toml not found in reused CodexHome: %v", err) + } + if !strings.Contains(string(data), "network_access = true") { + t.Error("reused config.toml missing network_access = true") + } +} + func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index 4bd6e137d..17e4f87eb 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -149,7 +149,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti "profile": nil, "cwd": opts.Cwd, "approvalPolicy": nil, - "sandbox": "workspace-write", + "sandbox": nil, "config": nil, "baseInstructions": nil, "developerInstructions": nilIfEmpty(opts.SystemPrompt),