mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Give Linux codex tasks a writable per-task HOME (+ XDG/npm_config_cache) so npm/Prisma stop hitting EROFS on the read-only sandbox home. Gated to Linux; macOS/Windows and non-codex providers unaffected. Note: does NOT resolve the worktree git-metadata read-only problem tracked in multica-ai/multica#2925 — Codex workspace-write resolves the worktree .git pointer and force-protects the real gitdir read-only even inside writable_roots, so that needs a separate Codex metadata-write permission path.
254 lines
9.6 KiB
Go
254 lines
9.6 KiB
Go
package execenv
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
// TestRenderManagedBlockWritableRoots verifies the workspace-write managed block
|
|
// emits writable_roots as a top-level dotted key (never a table header) and that
|
|
// the result — including the network_access dotted key sharing the same implicit
|
|
// table — is valid TOML.
|
|
func TestRenderManagedBlockWritableRoots(t *testing.T) {
|
|
t.Parallel()
|
|
policy := codexSandboxPolicyFor("linux", "0.121.0")
|
|
policy.WritableRoots = []string{"/home/u/multica_workspaces/ws/abc/home", "/home/u/multica_workspaces/.repos/ws"}
|
|
|
|
block := renderMulticaManagedBlock(policy)
|
|
|
|
if !strings.Contains(block, `sandbox_workspace_write.writable_roots = ["/home/u/multica_workspaces/ws/abc/home", "/home/u/multica_workspaces/.repos/ws"]`) {
|
|
t.Errorf("missing dotted-key writable_roots array, got:\n%s", block)
|
|
}
|
|
if strings.Contains(block, "[sandbox_workspace_write]") {
|
|
t.Errorf("must not open a [sandbox_workspace_write] table header, got:\n%s", block)
|
|
}
|
|
|
|
// Both network_access and writable_roots build the same implicit table via
|
|
// dotted keys — assert the parser accepts it.
|
|
var parsed map[string]any
|
|
if err := toml.Unmarshal([]byte(block[strings.Index(block, "sandbox_mode"):]), &parsed); err != nil {
|
|
t.Fatalf("generated block is invalid TOML: %v\nblock:\n%s", err, block)
|
|
}
|
|
sww, ok := parsed["sandbox_workspace_write"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("sandbox_workspace_write not parsed as a table: %#v", parsed["sandbox_workspace_write"])
|
|
}
|
|
roots, ok := sww["writable_roots"].([]any)
|
|
if !ok || len(roots) != 2 {
|
|
t.Fatalf("writable_roots did not round-trip as a 2-element array: %#v", sww["writable_roots"])
|
|
}
|
|
}
|
|
|
|
// TestRenderManagedBlockNoWritableRoots verifies that with no writable roots (or
|
|
// under danger-full-access), the writable_roots key is omitted entirely.
|
|
func TestRenderManagedBlockNoWritableRoots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
linux := renderMulticaManagedBlock(codexSandboxPolicyFor("linux", "0.121.0"))
|
|
if strings.Contains(linux, "writable_roots") {
|
|
t.Errorf("workspace-write with no roots must omit writable_roots, got:\n%s", linux)
|
|
}
|
|
|
|
// danger-full-access must never emit workspace-write keys even if roots set.
|
|
darwin := codexSandboxPolicyFor("darwin", "0.121.0")
|
|
darwin.WritableRoots = []string{"/should/not/appear"}
|
|
block := renderMulticaManagedBlock(darwin)
|
|
if strings.Contains(block, "writable_roots") || strings.Contains(block, "sandbox_workspace_write") {
|
|
t.Errorf("danger-full-access must omit all workspace-write keys, got:\n%s", block)
|
|
}
|
|
}
|
|
|
|
// TestEnsureCodexSandboxConfigWritableRoots verifies the writable_roots flow all
|
|
// the way through ensureCodexSandboxConfig into the on-disk config.toml.
|
|
func TestEnsureCodexSandboxConfigWritableRoots(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.toml")
|
|
|
|
policy := codexSandboxPolicyFor("linux", "0.121.0")
|
|
policy.WritableRoots = []string{filepath.Join(dir, "home"), filepath.Join(dir, "repos")}
|
|
if err := ensureCodexSandboxConfig(configPath, policy, "0.121.0", testLogger()); err != nil {
|
|
t.Fatalf("ensureCodexSandboxConfig failed: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read config.toml: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "sandbox_workspace_write.writable_roots = [") {
|
|
t.Errorf("config.toml missing writable_roots, got:\n%s", data)
|
|
}
|
|
// Whole file must be valid TOML (the managed block coexists with any user
|
|
// content — here there is none, so parsing the file directly is enough).
|
|
var parsed map[string]any
|
|
if err := toml.Unmarshal(data, &parsed); err != nil {
|
|
t.Fatalf("on-disk config.toml is invalid TOML: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPrepareCodexSandboxHomeLinux verifies the Linux path creates a writable
|
|
// home and returns exactly that home as the only writable root (no repo cache —
|
|
// Codex re-protects resolved gitdirs, so writable_roots can't unblock git; see
|
|
// task_home.go / #2925).
|
|
func TestPrepareCodexSandboxHomeLinux(t *testing.T) {
|
|
// Not parallel: mutates HOME via os.UserHomeDir seeding in prepareTaskHome.
|
|
fakeHome := t.TempDir()
|
|
t.Setenv("HOME", fakeHome)
|
|
|
|
envRoot := t.TempDir()
|
|
home, roots, err := prepareCodexSandboxHome(envRoot, "linux", "", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("prepareCodexSandboxHome: %v", err)
|
|
}
|
|
|
|
wantHome := filepath.Join(envRoot, "home")
|
|
if home != wantHome {
|
|
t.Errorf("home = %q, want %q", home, wantHome)
|
|
}
|
|
if fi, err := os.Stat(home); err != nil || !fi.IsDir() {
|
|
t.Errorf("task home dir not created: err=%v", err)
|
|
}
|
|
if len(roots) != 1 || roots[0] != wantHome {
|
|
t.Errorf("writable roots = %v, want [%q]", roots, wantHome)
|
|
}
|
|
}
|
|
|
|
// TestPrepareCodexSandboxHomeNonLinux verifies macOS and Windows skip the
|
|
// redirect entirely (real HOME stays writable / no Landlock sandbox), and that
|
|
// no home directory is created.
|
|
func TestPrepareCodexSandboxHomeNonLinux(t *testing.T) {
|
|
for _, goos := range []string{"darwin", "windows"} {
|
|
t.Run(goos, func(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
home, roots, err := prepareCodexSandboxHome(envRoot, goos, "", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("prepareCodexSandboxHome(%s): %v", goos, err)
|
|
}
|
|
if home != "" || roots != nil {
|
|
t.Fatalf("%s should skip the writable home, got home=%q roots=%v", goos, home, roots)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(envRoot, "home")); !os.IsNotExist(err) {
|
|
t.Errorf("%s must not create a task home dir, stat err=%v", goos, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareTaskHomeSeedsCredentials verifies the per-task home is created
|
|
// writable and seeded with symlinks to the user's real credential/config files,
|
|
// skipping missing sources.
|
|
func TestPrepareTaskHomeSeedsCredentials(t *testing.T) {
|
|
fakeHome := t.TempDir()
|
|
// Present sources (writeFile creates parent dirs, so .ssh and .config/gh
|
|
// become directories).
|
|
writeFile(t, filepath.Join(fakeHome, ".gitconfig"), "[user]\n\tname = X\n")
|
|
writeFile(t, filepath.Join(fakeHome, ".npmrc"), "//registry/:_authToken=abc\n")
|
|
writeFile(t, filepath.Join(fakeHome, ".ssh", "id_ed25519"), "KEY\n")
|
|
writeFile(t, filepath.Join(fakeHome, ".config", "gh", "hosts.yml"), "github.com: {}\n")
|
|
// .netrc and .config/git deliberately absent — must be skipped.
|
|
|
|
t.Setenv("HOME", fakeHome)
|
|
|
|
taskHome := filepath.Join(t.TempDir(), "home")
|
|
if err := prepareTaskHome(taskHome, testLogger()); err != nil {
|
|
t.Fatalf("prepareTaskHome: %v", err)
|
|
}
|
|
|
|
assertSymlinkTo(t, filepath.Join(taskHome, ".gitconfig"), filepath.Join(fakeHome, ".gitconfig"))
|
|
assertSymlinkTo(t, filepath.Join(taskHome, ".npmrc"), filepath.Join(fakeHome, ".npmrc"))
|
|
assertSymlinkTo(t, filepath.Join(taskHome, ".ssh"), filepath.Join(fakeHome, ".ssh"))
|
|
assertSymlinkTo(t, filepath.Join(taskHome, ".config", "gh"), filepath.Join(fakeHome, ".config", "gh"))
|
|
|
|
// A read through the .gitconfig symlink resolves the real content.
|
|
if b, err := os.ReadFile(filepath.Join(taskHome, ".gitconfig")); err != nil || !strings.Contains(string(b), "name = X") {
|
|
t.Errorf("gitconfig not readable through symlink: %q err=%v", b, err)
|
|
}
|
|
|
|
// Missing sources are skipped, not linked as broken symlinks.
|
|
if _, err := os.Lstat(filepath.Join(taskHome, ".netrc")); !os.IsNotExist(err) {
|
|
t.Errorf(".netrc should be skipped (missing source), lstat err=%v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(taskHome, ".config", "git")); !os.IsNotExist(err) {
|
|
t.Errorf(".config/git should be skipped (missing source), lstat err=%v", err)
|
|
}
|
|
|
|
// .config must be a real, writable directory (not a symlink) so other tools
|
|
// can write new config there.
|
|
fi, err := os.Lstat(filepath.Join(taskHome, ".config"))
|
|
if err != nil {
|
|
t.Fatalf(".config not created: %v", err)
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
t.Error(".config must be a real directory, not a symlink")
|
|
}
|
|
// The task home is writable: creating a cache file under it succeeds.
|
|
if err := os.MkdirAll(filepath.Join(taskHome, ".cache", "prisma"), 0o755); err != nil {
|
|
t.Errorf("task home not writable: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPrepareTaskHomeNoRealHome verifies prepareTaskHome degrades gracefully when
|
|
// there is no user home to seed from — the writable home is still created.
|
|
func TestPrepareTaskHomeNoRealHome(t *testing.T) {
|
|
// An empty HOME makes os.UserHomeDir fail on unix.
|
|
t.Setenv("HOME", "")
|
|
|
|
taskHome := filepath.Join(t.TempDir(), "home")
|
|
if err := prepareTaskHome(taskHome, testLogger()); err != nil {
|
|
t.Fatalf("prepareTaskHome should not fail without a real home: %v", err)
|
|
}
|
|
if fi, err := os.Stat(taskHome); err != nil || !fi.IsDir() {
|
|
t.Errorf("task home not created without a real home: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskHomeEnv(t *testing.T) {
|
|
t.Parallel()
|
|
home := filepath.FromSlash("/tmp/task/home")
|
|
env := TaskHomeEnv(home)
|
|
|
|
want := map[string]string{
|
|
"HOME": home,
|
|
"XDG_CACHE_HOME": filepath.Join(home, ".cache"),
|
|
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
|
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
|
|
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
|
"npm_config_cache": filepath.Join(home, ".npm"),
|
|
}
|
|
for k, v := range want {
|
|
if env[k] != v {
|
|
t.Errorf("TaskHomeEnv[%q] = %q, want %q", k, env[k], v)
|
|
}
|
|
}
|
|
if len(env) != len(want) {
|
|
t.Errorf("TaskHomeEnv returned %d keys, want %d: %v", len(env), len(want), env)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func assertSymlinkTo(t *testing.T, link, wantTarget string) {
|
|
t.Helper()
|
|
fi, err := os.Lstat(link)
|
|
if err != nil {
|
|
t.Errorf("expected symlink %s: %v", link, err)
|
|
return
|
|
}
|
|
if fi.Mode()&os.ModeSymlink == 0 {
|
|
t.Errorf("%s is not a symlink", link)
|
|
return
|
|
}
|
|
target, err := os.Readlink(link)
|
|
if err != nil {
|
|
t.Errorf("readlink %s: %v", link, err)
|
|
return
|
|
}
|
|
if target != wantTarget {
|
|
t.Errorf("symlink %s -> %s, want %s", link, target, wantTarget)
|
|
}
|
|
}
|