mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user