fix(agent): enable network access for Codex sandbox so Multica CLI can reach API

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
This commit is contained in:
bulai0408
2026-04-13 01:03:43 +08:00
parent 1ee4e0501a
commit 47eb6cb612
4 changed files with 259 additions and 10 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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),