Files
multica/server/internal/daemon/execenv/codex_real_home_test.go
Bohan Jiang 98766cc06a feat(daemon): remove the Linux Codex per-task HOME; default Linux to danger-full-access (MUL-5578) (#6233)
* feat(daemon): default Linux Codex to danger-full-access on the real HOME (MUL-5578)

Linux Codex tasks ran under the `workspace-write` Landlock sandbox with a
generated per-task HOME: the daemon rewrote HOME/XDG_*/npm_config_cache into
`<envRoot>/home`, symlinked a hand-maintained allowlist of seven credential
paths back into it, and granted that directory as a `writable_roots` entry.

That mechanism could not converge. Any host CLI outside the allowlist (aws,
kubectl, gcloud, glab, rclone, cargo, …) started every task as if unconfigured
even though it works in the daemon user's shell, and three open issues asked to
extend it in three incompatible directions (#5636, #5573, #3867). The
containment it bought was also narrower than it looked: workspace-write
restricts writes only — reads and network were already unrestricted, and `.ssh`
was seeded into the task home — so credentials under the real HOME were
readable and exfiltratable regardless.

Linux now runs `danger-full-access` on the daemon user's real HOME and inherited
XDG environment, matching macOS and Windows. The task filesystem boundary is the
boundary the daemon itself runs inside (VM, container, or dedicated Unix user),
which is what the other providers already assumed — Claude Code runs with
`--permission-mode bypassPermissions` today.

This also removes the split-brain the mechanism could enter: the task-HOME
decision read the platform default only, so a `-c sandbox_mode=danger-full-access`
override (which passes arg filtering and wins over config.toml) left a task
running unsandboxed while the daemon still redirected HOME and emitted
writable_roots for a sandbox that was not in effect. With no HOME rewrite there
is only one environment contract left to disagree about.

Removed: prepareTaskHome, prepareCodexSandboxHome, TaskHomeEnv, Env.TaskHome,
both seed allowlists, and the now-unfed WritableRoots plumbing through
codexSandboxPolicy / CodexHomeOptions. Env roots created by older daemons keep
working; their leftover `home/` directory is simply ignored and reclaimed with
the env root.

Task-scoped CODEX_HOME is untouched — it is managed Codex state, not the Unix
HOME. macOS, Windows, and non-Codex providers are unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* docs: document the agent execution security model (MUL-5578)

The docs had no page describing what a task can reach on the machine that runs
it — the sandbox posture was only discoverable from source. Adds one, stating
plainly that tasks run with the full permissions of the daemon user and that
isolation must come from a dedicated Unix user, container, or VM.

Also separates what Multica genuinely isolates (per-task workdir, task-scoped
CODEX_HOME, agent+task-bound API tokens) from what is not a boundary (the coding
tool's own sandbox and approval settings), and links it from step 5 of the
self-host quickstart, where the daemon is first installed.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): address review on the Linux full-access default (MUL-5578)

Docs, both must-fix:

- The Chinese page rendered the product concept as `Agent`; the repo glossary
  in developers/conventions.zh.mdx mandates 智能体. Fixed all nine occurrences.
  ja/ko already used エージェント / 에이전트 and needed no change.
- The security page asserted without qualification that every task runs with
  the daemon user's full permissions and that the Codex filesystem sandbox is
  always off. codexSandboxPolicyForWindows still keeps workspace-write when a
  user explicitly opts into a native windows.sandbox, so an authoritative
  security page contradicted the code. It now states that Multica makes no
  filesystem-sandbox guarantee, names the Windows opt-in as the one current
  exception, and says which combinations sandbox anything is a compatibility
  detail that moves with tool versions. All four locales updated, plus the
  historical callout which now says Linux matches the macOS/Windows *default*.

Both stale comments from the non-blocking notes:

- prepareCodexHome no longer claims to assume workspace-write + network_access;
  it pins GOOS=linux, which now resolves to danger-full-access.
- ensureCodexSandboxConfig's warn-level logging is no longer described as a
  macOS-only fallback; it fires for every danger-full-access resolution.

Adds TestCodexTaskShellEnvInheritsRealHome at the daemon env-assembly layer:
HOME and the XDG base dirs must reach a Codex task's shell tools from the
inherited daemon environment. Verified it fails when that pass-through breaks.
It guards the pass-through, not runTask's decision not to inject a HOME of its
own — that decision is inline in runTask and has no unit seam.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 15:42:06 +08:00

99 lines
3.5 KiB
Go

package execenv
import (
"os"
"path/filepath"
"testing"
)
// Codex tasks keep the daemon user's real HOME on every platform. Earlier
// daemons redirected HOME/XDG into a seeded per-task `home/` directory under
// the env root so the Linux workspace-write sandbox could write to `~`; Linux
// now runs danger-full-access and that mechanism is gone (MUL-5578 / #6218).
//
// These tests pin the two halves that are easy to regress: the per-task home
// must not come back, and the task-scoped CODEX_HOME — a separate, still-live
// mechanism — must not be confused with it.
// legacyTaskHomeDirName is the directory older daemons created under the env
// root to hold the redirected HOME. Nothing writes it anymore; it is named here
// only so the tests can assert its absence.
const legacyTaskHomeDirName = "home"
func TestPrepareCodexKeepsRealHomeAndScopesOnlyCodexHome(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-real-home",
TaskID: "b1c2d3e4-f5a6-7890-bcde-f01234567890",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "real-home-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Lstat(filepath.Join(env.RootDir, legacyTaskHomeDirName)); !os.IsNotExist(err) {
t.Errorf("per-task HOME directory must not be created under the env root (err=%v)", err)
}
// The task-scoped CODEX_HOME is a different mechanism and stays: it is
// managed Codex state (config, sessions, skills, MCP), not the Unix HOME.
if want := filepath.Join(env.RootDir, codexHomeDirName); env.CodexHome != want {
t.Errorf("CodexHome = %q, want %q", env.CodexHome, want)
}
if _, err := os.Stat(env.CodexHome); err != nil {
t.Errorf("task-scoped CODEX_HOME should exist: %v", err)
}
}
// TestReuseCodexToleratesLegacyTaskHome covers an env root prepared by an older
// daemon: its leftover `home/` directory (including the credential symlinks it
// seeded) must neither break reuse nor be adopted as a HOME again.
func TestReuseCodexToleratesLegacyTaskHome(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-legacy-home",
TaskID: "c1d2e3f4-a5b6-7890-cdef-012345678901",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "legacy-home-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
// Simulate what an older daemon left behind.
legacyHome := filepath.Join(env.RootDir, legacyTaskHomeDirName)
if err := os.MkdirAll(filepath.Join(legacyHome, ".config"), 0o700); err != nil {
t.Fatalf("seed legacy task home: %v", err)
}
if err := os.WriteFile(filepath.Join(legacyHome, ".npmrc"), []byte("//registry:_authToken=x\n"), 0o600); err != nil {
t.Fatalf("seed legacy .npmrc: %v", err)
}
reused := Reuse(ReuseParams{
WorkDir: env.WorkDir,
Provider: "codex",
Task: TaskContextForEnv{IssueID: "legacy-home-test"},
}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil on an env root carrying a legacy task home")
}
if reused.CodexHome == "" {
t.Error("expected CodexHome to be restored on reuse")
}
}