Files
goclaw/internal/tools/sandbox_mount_test.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

438 lines
16 KiB
Go

package tools
import (
"context"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type recordingSandboxManager struct {
sandbox *recordingSandbox
key string
workspace string
cfg *sandbox.Config
getOpts sandbox.GetOpts
}
func (m *recordingSandboxManager) Get(ctx context.Context, key string, workspace string, cfg *sandbox.Config, opts ...sandbox.GetOption) (sandbox.Sandbox, error) {
m.key = key
m.workspace = workspace
m.cfg = cfg
m.getOpts = sandbox.ApplyGetOpts(opts)
if m.sandbox == nil {
m.sandbox = &recordingSandbox{}
}
return m.sandbox, nil
}
func (m *recordingSandboxManager) Release(context.Context, string) error { return nil }
func (m *recordingSandboxManager) ReleaseAll(context.Context) error { return nil }
func (m *recordingSandboxManager) Stop() {}
func (m *recordingSandboxManager) Stats() map[string]any { return nil }
type recordingSandbox struct {
workDir string
command []string
}
func (s *recordingSandbox) Exec(ctx context.Context, command []string, workDir string, opts ...sandbox.ExecOption) (*sandbox.ExecResult, error) {
s.command = append([]string(nil), command...)
s.workDir = workDir
return &sandbox.ExecResult{Stdout: "ok"}, nil
}
func (s *recordingSandbox) Destroy(context.Context) error { return nil }
func (s *recordingSandbox) ID() string { return "recording-sandbox" }
func TestEffectiveSandboxWorkspacePrefersTenantWorkspace(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
tenantWorkspace := "/srv/goclaw/workspace/tenants/acme/sessions/direct"
got, err := effectiveSandboxWorkspace(WithToolWorkspace(context.Background(), tenantWorkspace), globalWorkspace)
if err != nil {
t.Fatalf("effectiveSandboxWorkspace returned error: %v", err)
}
if got != tenantWorkspace {
t.Fatalf("effectiveSandboxWorkspace = %q, want tenant workspace %q", got, tenantWorkspace)
}
}
func TestEffectiveSandboxWorkspaceFailsClosedWithoutTenantWorkspace(t *testing.T) {
ctx := store.WithTenantID(context.Background(), uuid.New())
if got, err := effectiveSandboxWorkspace(ctx, "/srv/goclaw/workspace"); err == nil {
t.Fatalf("effectiveSandboxWorkspace = %q, want fail-closed error for tenant context without workspace", got)
}
}
func TestEffectiveSandboxWorkspaceAllowsMasterFallback(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
got, err := effectiveSandboxWorkspace(ctx, globalWorkspace)
if err != nil {
t.Fatalf("effectiveSandboxWorkspace returned error: %v", err)
}
if got != globalWorkspace {
t.Fatalf("effectiveSandboxWorkspace = %q, want global workspace %q", got, globalWorkspace)
}
}
func TestSandboxCwdForHostPathMapsMountRootToWorkspace(t *testing.T) {
mountWorkspace := "/srv/goclaw/workspace/tenants/acme"
got, err := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandbox.DefaultContainerWorkdir)
if err != nil {
t.Fatalf("sandboxCwdForHostPath returned error: %v", err)
}
if got != sandbox.DefaultContainerWorkdir {
t.Fatalf("sandboxCwdForHostPath = %q, want %q", got, sandbox.DefaultContainerWorkdir)
}
}
func TestSandboxCwdForHostPathMapsChildPathUnderWorkspace(t *testing.T) {
mountWorkspace := "/srv/goclaw/workspace/tenants/acme"
cwd := "/srv/goclaw/workspace/tenants/acme/project"
got, err := sandboxCwdForHostPath(cwd, mountWorkspace, sandbox.DefaultContainerWorkdir)
if err != nil {
t.Fatalf("sandboxCwdForHostPath returned error: %v", err)
}
if got != "/workspace/project" {
t.Fatalf("sandboxCwdForHostPath = %q, want /workspace/project", got)
}
}
func TestSandboxCwdForHostPathRejectsWorkspaceEscape(t *testing.T) {
mountWorkspace := "/srv/goclaw/workspace/tenants/acme"
cwd := "/srv/goclaw/workspace/tenants/other"
if got, err := sandboxCwdForHostPath(cwd, mountWorkspace, sandbox.DefaultContainerWorkdir); err == nil {
t.Fatalf("sandboxCwdForHostPath = %q, want escape error", got)
}
}
func TestExecSandboxUsesEffectiveWorkspaceMountAndContainerCwd(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
tenantWorkspace := "/srv/goclaw/workspace/tenants/acme"
mgr := &recordingSandboxManager{}
tool := NewSandboxedExecTool(globalWorkspace, true, mgr)
result := tool.executeInSandbox(WithToolWorkspace(context.Background(), tenantWorkspace), "pwd", tenantWorkspace, "session-1")
if result.IsError {
t.Fatalf("executeInSandbox returned error: %s", result.ForLLM)
}
if mgr.workspace != tenantWorkspace {
t.Fatalf("sandbox manager workspace = %q, want tenant workspace %q", mgr.workspace, tenantWorkspace)
}
if mgr.sandbox.workDir != sandbox.DefaultContainerWorkdir {
t.Fatalf("sandbox exec workDir = %q, want %q", mgr.sandbox.workDir, sandbox.DefaultContainerWorkdir)
}
}
func TestCredentialedExecSandboxUsesEffectiveWorkspaceMountAndContainerCwd(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
tenantWorkspace := "/srv/goclaw/workspace/tenants/acme"
mgr := &recordingSandboxManager{}
tool := NewSandboxedExecTool(globalWorkspace, true, mgr)
result := tool.executeCredentialedSandbox(WithToolWorkspace(context.Background(), tenantWorkspace), "/usr/bin/gh", []string{"api", "user"}, tenantWorkspace, "session-1", map[string]string{"GOCLAW_TEST_ENV": "value"}, 30*time.Second)
if result.IsError {
t.Fatalf("executeCredentialedSandbox returned error: %s", result.ForLLM)
}
if mgr.workspace != tenantWorkspace {
t.Fatalf("sandbox manager workspace = %q, want tenant workspace %q", mgr.workspace, tenantWorkspace)
}
if mgr.sandbox.workDir != sandbox.DefaultContainerWorkdir {
t.Fatalf("sandbox exec workDir = %q, want %q", mgr.sandbox.workDir, sandbox.DefaultContainerWorkdir)
}
}
func TestCredentialedExecSandboxMountsCurrentDelegationExchange(t *testing.T) {
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-credentialed-session")
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.executeCredentialedSandbox(
ctx,
"/usr/bin/gh",
[]string{"api", "user"},
outputs,
"delegated-credentialed-session",
map[string]string{"GH_TOKEN": "secret"},
30*time.Second,
)
if result.IsError {
t.Fatalf("credentialed delegated sandbox exec failed: %s", result.ForLLM)
}
if manager.getOpts.WorkspaceAccessOverride == nil ||
*manager.getOpts.WorkspaceAccessOverride != sandbox.AccessRW {
t.Fatalf("delegation output access override = %#v, want rw", manager.getOpts.WorkspaceAccessOverride)
}
if len(manager.getOpts.ReadOnlyMounts) != 1 {
t.Fatalf("read-only mounts = %#v, want one inputs mount", manager.getOpts.ReadOnlyMounts)
}
mount := manager.getOpts.ReadOnlyMounts[0]
if mount.Name != "inputs" ||
mount.Destination != path.Join(sandbox.DefaultContainerWorkdir, "inputs") ||
filepath.Base(mount.HostPath) != "inputs" {
t.Fatalf("delegation input mount = %#v", mount)
}
}
func TestCredentialedExecSandboxWorkingDirResolvesInsideTenantWorkspace(t *testing.T) {
globalWorkspace := t.TempDir()
tenantWorkspace := filepath.Join(globalWorkspace, "tenants", "acme")
subdir := filepath.Join(tenantWorkspace, "repo")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatalf("mkdir subdir: %v", err)
}
binaryPath := filepath.Join(tenantWorkspace, "fake-cli")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write binary: %v", err)
}
wantMountWorkspace, err := filepath.EvalSymlinks(tenantWorkspace)
if err != nil {
t.Fatalf("canonicalize tenant workspace: %v", err)
}
mgr := &recordingSandboxManager{}
tool := NewSandboxedExecTool(globalWorkspace, true, mgr)
tool.SetSecureCLIStore(&sandboxMountSecureCLIStore{binary: &store.SecureCLIBinary{
BinaryName: "fake-cli",
BinaryPath: &binaryPath,
TimeoutSeconds: 30,
Enabled: true,
IsGlobal: true,
}})
ctx := WithToolWorkspace(context.Background(), tenantWorkspace)
ctx = WithToolSandboxKey(ctx, "session-1")
result := tool.Execute(ctx, map[string]any{
"command": "fake-cli status",
"working_dir": "repo",
})
if result.IsError {
t.Fatalf("Execute returned error: %s", result.ForLLM)
}
if mgr.workspace != wantMountWorkspace {
t.Fatalf("sandbox manager workspace = %q, want tenant workspace %q", mgr.workspace, wantMountWorkspace)
}
if mgr.sandbox.workDir != "/workspace/repo" {
t.Fatalf("sandbox exec workDir = %q, want /workspace/repo", mgr.sandbox.workDir)
}
}
func TestSandboxFileToolsUseEffectiveWorkspaceMount(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
tenantWorkspace := "/srv/goclaw/workspace/tenants/acme"
ctx := WithToolWorkspace(context.Background(), tenantWorkspace)
tests := []struct {
name string
run func(*recordingSandboxManager) error
}{
{
name: "read_file",
run: func(mgr *recordingSandboxManager) error {
tool := NewSandboxedReadFileTool(globalWorkspace, true, mgr)
_, err := tool.getFsBridge(ctx, "session-1", tenantWorkspace, sandbox.DefaultContainerWorkdir)
return err
},
},
{
name: "write_file",
run: func(mgr *recordingSandboxManager) error {
tool := NewSandboxedWriteFileTool(globalWorkspace, true, mgr)
_, err := tool.getFsBridge(ctx, "session-1", tenantWorkspace, sandbox.DefaultContainerWorkdir)
return err
},
},
{
name: "list_files",
run: func(mgr *recordingSandboxManager) error {
tool := NewSandboxedListFilesTool(globalWorkspace, true, mgr)
_, err := tool.getFsBridge(ctx, "session-1", tenantWorkspace, sandbox.DefaultContainerWorkdir)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mgr := &recordingSandboxManager{}
if err := tt.run(mgr); err != nil {
t.Fatalf("getFsBridge returned error: %v", err)
}
if mgr.workspace != tenantWorkspace {
t.Fatalf("sandbox manager workspace = %q, want tenant workspace %q", mgr.workspace, tenantWorkspace)
}
})
}
}
func TestAcquireToolSandboxMountsOnlyCurrentDelegationInputs(t *testing.T) {
tenantWorkspace := t.TempDir()
delegationID := uuid.New()
root := filepath.Join(tenantWorkspace, "collaboration", "delegations", delegationID.String())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
cfg := sandbox.DefaultConfig()
cfg.Mode = sandbox.ModeAll
cfg.WorkspaceAccess = sandbox.AccessNone
ctx := WithDelegationID(context.Background(), delegationID.String())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, outputs)
ctx = WithSandboxConfig(ctx, &cfg)
mgr := &recordingSandboxManager{}
if _, err := acquireToolSandbox(ctx, mgr, "delegate-session", outputs); err != nil {
t.Fatalf("acquireToolSandbox: %v", err)
}
if len(mgr.getOpts.ReadOnlyMounts) != 1 {
t.Fatalf("read-only mounts = %#v, want one input mount", mgr.getOpts.ReadOnlyMounts)
}
if mgr.getOpts.WorkspaceAccessOverride == nil ||
*mgr.getOpts.WorkspaceAccessOverride != sandbox.AccessRW {
t.Fatalf("delegation output access override = %#v, want rw", mgr.getOpts.WorkspaceAccessOverride)
}
mount := mgr.getOpts.ReadOnlyMounts[0]
wantInputs, err := filepath.EvalSymlinks(inputs)
if err != nil {
t.Fatal(err)
}
if mount.Name != "inputs" || mount.HostPath != wantInputs || mount.Destination != "/workspace/inputs" {
t.Fatalf("input mount = %#v", mount)
}
}
func TestAcquireToolSandboxRejectsExchangePathNotBoundToDelegationID(t *testing.T) {
root := filepath.Join(t.TempDir(), "collaboration", "delegations", uuid.NewString())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
ctx := WithDelegationID(context.Background(), uuid.NewString())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, outputs)
mgr := &recordingSandboxManager{}
if _, err := acquireToolSandbox(ctx, mgr, "delegate-session", outputs); err == nil {
t.Fatal("forged exchange path unexpectedly mounted")
}
if mgr.key != "" {
t.Fatal("sandbox manager called for forged exchange path")
}
}
func TestDelegationInputMutationRejectedBeforeSandboxAcquisition(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
ctx = WithToolSandboxKey(ctx, "delegate-session")
writeManager := &recordingSandboxManager{}
writeResult := NewSandboxedWriteFileTool(outputs, true, writeManager).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "content": "mutated",
})
if !writeResult.IsError || !strings.Contains(writeResult.ForLLM, "read-only") {
t.Fatalf("write input result = %#v", writeResult)
}
if writeManager.key != "" {
t.Fatalf("write acquired sandbox before rejecting input mutation")
}
editManager := &recordingSandboxManager{}
editResult := NewSandboxedEditTool(outputs, true, editManager).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "old_string": "a", "new_string": "b",
})
if !editResult.IsError || !strings.Contains(editResult.ForLLM, "read-only") {
t.Fatalf("edit input result = %#v", editResult)
}
if editManager.key != "" {
t.Fatalf("edit acquired sandbox before rejecting input mutation")
}
}
type sandboxMountSecureCLIStore struct {
binary *store.SecureCLIBinary
}
func (s *sandboxMountSecureCLIStore) Create(context.Context, *store.SecureCLIBinary) error {
return nil
}
func (s *sandboxMountSecureCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) {
return nil, nil
}
func (s *sandboxMountSecureCLIStore) Update(context.Context, uuid.UUID, map[string]any) error {
return nil
}
func (s *sandboxMountSecureCLIStore) Delete(context.Context, uuid.UUID) error { return nil }
func (s *sandboxMountSecureCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *sandboxMountSecureCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) {
return s.binary, nil
}
func (s *sandboxMountSecureCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *sandboxMountSecureCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) {
return nil, nil
}
func (s *sandboxMountSecureCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) {
return false, nil
}
func (s *sandboxMountSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) {
return nil, nil
}
func (s *sandboxMountSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error {
return nil
}
func (s *sandboxMountSecureCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error {
return nil
}
func (s *sandboxMountSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error {
return nil
}
func (s *sandboxMountSecureCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) {
return nil, nil
}
func TestExecSandboxFailsClosedWhenTenantWorkspaceMissing(t *testing.T) {
globalWorkspace := "/srv/goclaw/workspace"
mgr := &recordingSandboxManager{}
tool := NewSandboxedExecTool(globalWorkspace, true, mgr)
ctx := store.WithTenantID(context.Background(), uuid.New())
result := tool.executeInSandbox(ctx, "pwd", globalWorkspace, "session-1")
if !result.IsError {
t.Fatalf("executeInSandbox succeeded, want fail-closed error")
}
if result.ForLLM == "" {
t.Fatalf("executeInSandbox returned empty error result")
}
if mgr.workspace != "" {
t.Fatalf("sandbox manager should not be called, got workspace %q", mgr.workspace)
}
}