Files
multica/server/internal/daemon/execenv/isolation_test.go
T
Bohan Jiangandmultica-agent d2532f766b MUL-6491 fix(daemon): fence env-root resets on a live execution, not a task id (#7356)
* fix(daemon): fence env-root resets on a live execution, not a task id

Follow-up to #7347. The identity marker answers WHO owns an env root, not
whether that owner is still running, so Prepare read "owner == my task id"
as "my own rerun, safe to reset".

That is reachable rather than theoretical. ReclaimStaleDispatchedTaskForRuntime
re-delivers a task row whose prepare lease expired using the SAME task id,
and a failing lease renewal only logs — it does not cancel the Prepare still
in flight. The re-delivered execution then wiped the running one's workdir,
config and worktree: the cross-execution deletion the env-root claim exists
to prevent, arriving through the one door it left open.

Add a second signal alongside the marker: an OS advisory lock on the env
root, held for the life of the Environment and released by Cleanup. The
kernel drops it when the holder exits for any reason, so it answers "is the
previous execution still alive?" across processes with no heartbeat, no PID
table and no stale-state cleanup path — a crashed execution's directory is
recoverable on the next dispatch, a running one's is not.

Taking the lock first also serialises the claim, which replaces the
Mkdir/O_EXCL arbitration with something simpler and stronger: no other
execution can be mid-claim, so the checks below it cannot race.

That in turn fixes the torn-marker case from the same review. The marker was
created empty and written a moment later, so a crash in between left a
zero-length file that read back as "no owner" while still counting as
directory content — the env root could then be neither adopted nor reset,
and stayed wedged until someone deleted it by hand. Under the lock an
unnamed marker is simply rewritten.

Tests, each verified to fail without its fix: an overlapping execution of one
task id is refused and the live work survives; the same id recovers once the
prior execution released the root; a torn marker is repaired instead of
wedging.

Refs #7326

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): release the env-root lock at the end of the task run

Windows CI caught that the lock outlived the work it guards. It was released
only by Environment.Cleanup, which production never calls — the GC reclaims
env roots on its own schedule — so the lock was effectively held until the
daemon exited. Two consequences: every later dispatch of that task would
fail closed as "held by a running execution", and on Windows the open handle
pinned the directory so nothing could remove it:

    TempDir RemoveAll cleanup: unlinkat ...\.task_lock:
    The process cannot access the file because it is being used by another process

Release it where the execution actually ends. The daemon now defers
ReleaseLock alongside unmarkActiveEnvRoot, which is the same lifetime: the
task run. Cleanup still releases too, and both are idempotent.

Also open the lock file with FILE_SHARE_DELETE on Windows. os.OpenFile does
not request it, and without it Windows refuses to delete a file anyone holds
open — the lock is meant to coordinate executions, not to pin a directory
against the GC. Unix already allows unlinking an open file; this makes
Windows behave the same.

Refs #7326

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): hold the env-root claim in the parent, not the prepare helper

Review found the lock never applied in production. Real daemons always
prepare through PrepareIsolated, which runs a short-lived helper process:
the lock was taken inside that helper, the kernel released it the moment the
helper exited, and Environment.lockFile is an *os.File that cannot cross the
helper's JSON response. The parent got back a nil lock, so ReleaseLock was a
no-op and the agent ran with no protection at all — the exact re-dispatch
window this was meant to close.

Every test added with the lock went through the in-process Prepare, where
the "helper" never exits, so none of them could see it.

Move the claim to the daemon parent, whose lifetime IS the task run:

- ClaimEnvRoot / EnvRootClaim.Release are now exported, and runTask takes
  the claim alongside markActiveEnvRoot and releases it on every exit.
- PrepareParams.EnvRootPreclaimed tells preparation the caller already holds
  the claim and performed its reset, so it does not try to take a lock the
  parent is holding. Direct in-process callers still claim for themselves.

Also cover the reuse path, which took no claim at all: a task continuing in
a PRIOR task's env root now excludes on that root through
LockEnvRootForReuse. Identity there belongs to the earlier task, so this is
the exclusion half of a claim only — without it two continuations of one
task could refresh and run the same workdir concurrently. Guarded to roots
under the workspaces root, since a local_directory task's PriorWorkDir is
the user's own directory.

Tests now exercise the production path: one drives PrepareIsolated through
the real helper harness and asserts the parent still holds the claim after
the helper exits, the other covers reuse exclusion. Against the previous
arrangement the first fails with "a second execution claimed the env root of
a task that just prepared".

Refs #7326

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): lock a prior env root only after proving it is a managed one

Review found the reuse guard wrote its lock file before it had established
where it was writing. runTask locked filepath.Dir(task.PriorWorkDir) up
front, gated only by a lexical filepath.Rel containment check — which does
not resolve symlinks. A link inside the workspaces root pointing anywhere
else passed that gate, and .task_lock was created in the target: a user's
own directory, or a path outside the root entirely. Eligibility was not
settled until much later, in shouldReusePriorWorkdir.

Reorder so the proof comes first. shouldReusePriorWorkdir already resolves
symlinks and verifies the {workspace}/{task}/workdir shape, managed
provenance and the task-context marker; it now returns the canonical path it
validated, and that is the only path ever opened. The lock is re-confirmed
against the same check while held, closing the gap between proving
eligibility and acting on it.

Declining is not fatal on this path: a busy root, a vanished directory or a
failed re-check falls back to a fresh Prepare, which costs session
continuity rather than correctness. ErrEnvRootBusy makes that distinguish-
able from a real I/O failure. The lexical isUnderWorkspacesRoot helper is
gone — it promised containment it could not deliver.

The canonical path deliberately stays internal. Reuse still receives
task.PriorWorkDir, so the workdir reported to the server and handed to the
next task keeps its existing shape.

Tests: negative cases for a symlink escaping the root, a plain outside path
and a managed-looking path without provenance, each asserting no .task_lock
appears outside — against the previous ordering the symlink case fails with
"prior-workdir guard wrote the lock outside workspaces root". A positive
case pins that a provenanced root IS locked, in the right directory, and
excludes a concurrent continuation. And a helper-process test pins that a
caller who holds the claim but forgets EnvRootPreclaimed fails immediately
and says so, instead of preparing while believing it is protected.

Refs #7326

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): claim a prior env root by identity, not by name

Review found the remaining TOCTOU: the canonical path validation returns is
a string, not a handle. Between proving eligibility and opening the lock,
the validated env root can be renamed away and a symlink to somewhere
outside dropped in its place — an ordinary open follows it and creates
.task_lock out there, and no later re-check can undo a write that already
happened. A swap to a different but equally provenanced directory at the
same path is worse still: every name-based check agrees, and the daemon ends
up holding a lock on one directory while running in another.

Resolve the lock through an os.Root pinned at the workspaces root. Root
refuses any component that resolves outside its tree, so the lock file
cannot land outside no matter when the swap arrives — containment stops
being a check that races and becomes a property of the open.

Root still follows symlinks that stay inside the tree, so containment alone
cannot answer "is this the same directory?". Chain identity instead: stat
the directory validation approved, require the lock to have landed on that
same inode, and require the directory handed to Reuse to be it as well.
os.SameFile compares identity; comparing canonical path strings cannot tell
a directory apart from its replacement at the same name.

Reuse now receives the canonical path that was locked, so the object locked
and the object used are the same one. Two consequences that fell out:

- The workspaces root has to be resolved before measuring the env root
  against it, or a symlinked root (macOS /tmp, a home on a linked volume)
  makes them look unrelated and every reuse is refused.
- gateResumeToReusedWorkdir compared workdirs textually, so a canonical
  env.WorkDir silently dropped the prior session. It now compares directory
  identity, which is what it meant all along.

Tests use a deterministic hook to swap the directory between validation and
the lock rather than racing it. Against the previous code they fail with
"validated canonical root was retargeted and the lock was written outside"
and "accepted reuse after the validated directory was replaced at the same
path".

Refs #7326

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): pin the workspaces root before validating, and lock from one handle

Three findings from review, all in how os.Root was being used rather than in
the choice of it.

Pin the workspaces root BEFORE validating anything. Opening it afterwards,
from a name validation had just approved, re-resolved that name: rename the
whole root aside, leave a symlink to a look-alike tree, and os.Root
faithfully pins the replacement. Root promises you cannot escape the tree it
opened — not that it opened the tree you meant. Holding the handle first
makes that ordering impossible.

Take the directory's identity and create its lock file from ONE sub-Root
pinned on the env root. Resolving the env root's relative path twice from
the workspaces Root — once to stat, once to create — allowed A on the first
resolution and B on the second, reporting A's identity while holding B's
lock.

Close the last window by detection rather than by claiming it is closed. A
file descriptor cannot cross into the preparation helper process, so Reuse
can only be handed a name, and the gap between the final identity check and
Reuse's own opens cannot be pinned away. The environment Reuse produces is
now checked back against the directory the lock is held on, so a
substitution ends the run in a fresh environment instead of executing in a
directory nothing holds. What remains uncovered is stated in the code: the
writes Reuse itself performs inside that window.

Also drop sameExistingDir's string fast path, which returned true without a
stat and contradicted the identity contract the function documents. Its
table test used synthetic paths that never existed; they are real
directories now, which is what the comparison was always about.

Tests, each verified to fail without its fix: a workspaces-root swap must
not let the lock be written into the replacement tree; reported identity
must be the directory the lock file is actually in; and a swap after the
claim, before Reuse, must end in a fresh environment rather than the
substitute.

Refs #7326

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 14:49:27 +08:00

404 lines
14 KiB
Go

package execenv
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const preparationHelperTestMode = "execenv-preparation-helper"
func preparationHelperTestCommand() []string {
return []string{
os.Args[0],
"-test.run=^TestPreparationHelperProcess$",
"--",
preparationHelperTestMode,
}
}
// TestPreparationHelperProcess is both a no-op parent-side test and the child
// entry point used by isolation tests. Keeping it in the package test binary
// exercises the same stdin/stdout protocol as the real multica helper.
func TestPreparationHelperProcess(t *testing.T) {
if len(os.Args) == 0 || os.Args[len(os.Args)-1] != preparationHelperTestMode {
return
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := RunPreparationHelper(os.Stdin, os.Stdout, logger); err != nil {
os.Exit(2)
}
os.Exit(0)
}
func TestPreparationHelperRoundTripsReuse(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
params := PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-helper-reuse",
TaskID: "99999999-8888-7777-6666-555555555555",
Provider: "claude",
Task: TaskContextForEnv{IssueID: "issue-helper-reuse"},
}
env, err := PrepareIsolated(ctx, preparationHelperTestCommand(), params, logger)
if err != nil {
t.Fatalf("PrepareIsolated: %v", err)
}
reused, err := ReuseIsolated(ctx, preparationHelperTestCommand(), ReuseParams{
WorkspacesRoot: params.WorkspacesRoot,
WorkDir: env.WorkDir,
Provider: params.Provider,
Task: TaskContextForEnv{
IssueID: "issue-helper-reuse",
NewCommentCount: 1,
ProjectID: "project-helper-reuse",
ProjectResources: []ProjectResourceForEnv{
{
ID: "resource-helper-reuse",
ResourceType: "github_repo",
ResourceRef: json.RawMessage(`{"url":"https://github.com/multica-ai/multica"}`),
},
},
},
}, logger)
if err != nil {
t.Fatalf("ReuseIsolated: %v", err)
}
if reused == nil || reused.RootDir != env.RootDir || reused.WorkDir != env.WorkDir {
t.Fatalf("reused environment = %#v, want root %q workdir %q", reused, env.RootDir, env.WorkDir)
}
}
func TestPreparationHelperRoundTripsProjectResources(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
params := PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-helper-project-resource",
TaskID: "88888888-7777-6666-5555-444444444444",
Provider: "claude",
Task: TaskContextForEnv{
IssueID: "issue-helper-project-resource",
ProjectID: "project-helper-project-resource",
ProjectResources: []ProjectResourceForEnv{
{
ID: "resource-helper-project-resource",
ResourceType: "github_repo",
ResourceRef: json.RawMessage(`{"url":"https://github.com/multica-ai/multica"}`),
Label: "Multica",
},
},
},
}
env, err := PrepareIsolated(ctx, preparationHelperTestCommand(), params, logger)
if err != nil {
t.Fatalf("PrepareIsolated: %v", err)
}
defer env.Cleanup(true)
data, err := os.ReadFile(filepath.Join(env.WorkDir, ".multica", "project", "resources.json"))
if err != nil {
t.Fatalf("read project resources: %v", err)
}
var got projectResourceFile
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("decode project resources: %v", err)
}
if len(got.Resources) != 1 {
t.Fatalf("project resources = %#v, want one resource", got.Resources)
}
resource := got.Resources[0]
var ref struct {
URL string `json:"url"`
}
if err := json.Unmarshal(resource.ResourceRef, &ref); err != nil {
t.Fatalf("decode resource ref: %v", err)
}
if resource.ID != "resource-helper-project-resource" ||
resource.ResourceType != "github_repo" ||
ref.URL != "https://github.com/multica-ai/multica" ||
resource.Label != "Multica" {
t.Fatalf("project resource = %#v, want all fields preserved", resource)
}
}
func TestPreparationRequestPreservesOpenclawGatewayForHelper(t *testing.T) {
t.Parallel()
want := OpenclawGatewayPin{
Host: "gw.internal",
Port: 18789,
Token: "real-secret",
TLS: true,
}
tests := []struct {
name string
request preparationRequest
pin func(preparationRequest) OpenclawGatewayPin
}{
{
name: "prepare",
request: preparationRequest{
Action: preparationActionPrepare,
Prepare: &PrepareParams{OpenclawGateway: want},
},
pin: func(request preparationRequest) OpenclawGatewayPin {
return request.Prepare.OpenclawGateway
},
},
{
name: "reuse",
request: preparationRequest{
Action: preparationActionReuse,
Reuse: &ReuseParams{OpenclawGateway: want},
},
pin: func(request preparationRequest) OpenclawGatewayPin {
return request.Reuse.OpenclawGateway
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
redacted, err := json.Marshal(tt.request)
if err != nil {
t.Fatalf("marshal redacted request: %v", err)
}
if bytes.Contains(redacted, []byte(want.Token)) {
t.Fatalf("ordinary JSON leaked gateway token: %s", redacted)
}
payload, err := marshalPreparationRequest(tt.request)
if err != nil {
t.Fatalf("marshal preparation request: %v", err)
}
got, err := decodePreparationRequest(bytes.NewReader(payload))
if err != nil {
t.Fatalf("decode preparation request: %v", err)
}
if got := tt.pin(got); got != want {
t.Fatal("gateway pin fields did not survive the helper protocol")
}
})
}
}
// TestPreparationHelperPreservesOpenclawTimeoutKind covers the boundary that
// makes structural classification possible. Prepare runs in a one-shot helper
// process and its error reaches the daemon as JSON text, so an
// errors.Is-based check would silently degrade to "unknown error" — and the
// daemon would fall back to matching "deadline exceeded" in the message, which
// is exactly the misclassification this change removes (#7112).
func TestPreparationHelperPreservesOpenclawTimeoutKind(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell shim shape is covered by the windows-tagged tests")
}
sleepBin, err := exec.LookPath("sleep")
if err != nil {
t.Skipf("no sleep binary available to build a slow shim: %v", err)
}
// A CLI slower than the deadline, which the child process inherits through
// the environment. openclawCLIMinTimeout is the floor, so the shim has to
// outlast a full second.
shim := writeShim(t, t.TempDir(), "#!/bin/sh\n"+sleepBin+" 5\n", "")
t.Setenv(OpenclawCLITimeoutEnv, "1s")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err = PrepareIsolated(ctx, preparationHelperTestCommand(), PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-helper-openclaw-timeout",
TaskID: "11111111-2222-3333-4444-555555555555",
Provider: "openclaw",
OpenclawBin: shim,
Task: TaskContextForEnv{IssueID: "issue-helper-openclaw-timeout"},
}, logger)
if err == nil {
t.Fatal("expected preparation to fail when the openclaw CLI outlasts its deadline")
}
if !errors.Is(err, ErrOpenclawCLITimeout) {
t.Errorf("the timeout sentinel must survive the helper boundary\ngot: %s", err)
}
if !strings.Contains(err.Error(), "openclaw config file") {
t.Errorf("the original diagnostic must survive too\ngot: %s", err)
}
}
// TestRehydratePreparationErrorUnknownKind pins the mixed-version direction of
// the same wire: a helper newer than the daemon may name a kind this build has
// never heard of, and the error must still arrive with its message intact
// rather than being dropped or mislabelled.
func TestRehydratePreparationErrorUnknownKind(t *testing.T) {
err := rehydratePreparationError("prepare failed somehow", "some_future_kind")
if err == nil || err.Error() != "prepare failed somehow" {
t.Fatalf("rehydratePreparationError dropped the message: %v", err)
}
if errors.Is(err, ErrOpenclawCLITimeout) {
t.Error("an unknown kind must not be reported as an openclaw CLI timeout")
}
if kind := preparationErrorKind(errors.New("plain failure")); kind != "" {
t.Errorf("preparationErrorKind(plain) = %q, want empty", kind)
}
if kind := preparationErrorKind(fmt.Errorf("wrapped: %w", ErrOpenclawCLITimeout)); kind != preparationErrorKindOpenclawCLITimeout {
t.Errorf("preparationErrorKind(timeout) = %q, want %q", kind, preparationErrorKindOpenclawCLITimeout)
}
}
// TestPrepareIsolatedKeepsTheClaimWithTheParent is the production-path
// regression. Every real daemon prepares through PrepareIsolated, and the
// helper is a short-lived child process: a lock taken inside it is released by
// the kernel the instant it exits, and *os.File cannot travel back through the
// helper's JSON response. An env-root claim taken during preparation therefore
// protects nothing by the time the agent runs — the in-process Prepare tests
// cannot see this, because there the "helper" never exits.
//
// The parent takes the claim and keeps it, so a second execution of the same
// task must still be refused after PrepareIsolated has returned.
func TestPrepareIsolatedKeepsTheClaimWithTheParent(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
const (
workspaceID = "ws-isolated"
taskID = "01a01ec0-e69d-7000-8000-0123456789ab"
)
claim, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
if err != nil {
t.Fatalf("parent claim: %v", err)
}
defer claim.Release()
env, err := PrepareIsolated(context.Background(), preparationHelperTestCommand(), PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: workspaceID,
TaskID: taskID,
AgentName: "Isolated",
EnvRootPreclaimed: true,
Task: TaskContextForEnv{IssueID: taskID},
}, nil)
if err != nil {
t.Fatalf("PrepareIsolated: %v", err)
}
if env == nil || env.WorkDir == "" {
t.Fatal("PrepareIsolated returned no environment")
}
// The helper has exited. If the claim had been taken inside it, the lock
// would be gone and this second claim would succeed.
if second, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID); err == nil {
second.Release()
t.Fatal("production PrepareIsolated returned without retaining the execution lock")
}
// And releasing it must hand the env root back for a later dispatch.
claim.Release()
next, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
if err != nil {
t.Fatalf("env root stayed locked after release: %v", err)
}
next.Release()
}
// TestLockEnvRootForReuseExcludesConcurrentContinuations covers the lock
// primitive the reuse path is built on. The composed decision — validate the
// prior workdir, then lock only the canonical root — is pinned by
// TestLockReusablePriorEnvRoot* in the daemon package, which is where the
// ordering lives.
func TestLockEnvRootForReuseExcludesConcurrentContinuations(t *testing.T) {
t.Parallel()
priorRoot := filepath.Join(t.TempDir(), "ws", "0123456789ab")
if err := os.MkdirAll(filepath.Join(priorRoot, "workdir"), 0o755); err != nil {
t.Fatalf("seed prior root: %v", err)
}
wsRoot, err := os.OpenRoot(filepath.Dir(filepath.Dir(priorRoot)))
if err != nil {
t.Fatalf("open workspaces root: %v", err)
}
defer wsRoot.Close()
rel := filepath.Join(filepath.Base(filepath.Dir(priorRoot)), filepath.Base(priorRoot))
first, _, err := LockEnvRootForReuse(wsRoot, rel, priorRoot)
if err != nil {
t.Fatalf("first continuation: %v", err)
}
if first == nil {
t.Fatal("expected a claim for an existing prior root")
}
if second, _, err := LockEnvRootForReuse(wsRoot, rel, priorRoot); err == nil {
second.Release()
t.Fatal("two continuations locked the same prior workdir at once")
}
first.Release()
again, _, err := LockEnvRootForReuse(wsRoot, rel, priorRoot)
if err != nil {
t.Fatalf("prior root stayed locked after release: %v", err)
}
again.Release()
// A missing root is not an error — the caller falls through to a fresh
// Prepare, and there is nothing to exclude on.
base := t.TempDir()
baseRoot, err := os.OpenRoot(base)
if err != nil {
t.Fatalf("open base: %v", err)
}
defer baseRoot.Close()
missing, _, err := LockEnvRootForReuse(baseRoot, "absent", filepath.Join(base, "absent"))
if err != nil || missing != nil {
t.Fatalf("missing prior root: claim=%v err=%v, want nil/nil", missing, err)
}
}
// TestPrepareIsolatedFailsLoudlyWhenPreclaimIsNotDeclared pins what happens if
// a caller ever holds the claim but forgets EnvRootPreclaimed. Parent and
// helper then contend for the same lock, and the important property is that
// this fails immediately and says so, rather than proceeding with preparation
// that silently believes it is protected.
func TestPrepareIsolatedFailsLoudlyWhenPreclaimIsNotDeclared(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
const (
workspaceID = "ws-preclaim"
taskID = "01a01ec0-e69d-7000-8000-0123456789ab"
)
claim, err := ClaimEnvRoot(workspacesRoot, workspaceID, taskID)
if err != nil {
t.Fatalf("parent claim: %v", err)
}
defer claim.Release()
_, err = PrepareIsolated(context.Background(), preparationHelperTestCommand(), PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: workspaceID,
TaskID: taskID,
AgentName: "Forgetful",
// EnvRootPreclaimed deliberately left false while the parent holds it.
Task: TaskContextForEnv{IssueID: taskID},
}, nil)
if err == nil {
t.Fatal("preparation ran without declaring the parent's claim")
}
if !strings.Contains(err.Error(), "running execution") {
t.Fatalf("error = %v, want it to name the held claim", err)
}
}