mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
* 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>
190 lines
6.3 KiB
Go
190 lines
6.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestExecDelegationArtifactRejectsNativeHostRead(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX shell command fixture")
|
|
}
|
|
|
|
ctx, _, outputs := delegationArtifactToolContext(t)
|
|
outsideDir := t.TempDir()
|
|
outsideFile := filepath.Join(outsideDir, "outside.txt")
|
|
const outsideContents = "delegated-native-host-read"
|
|
if err := os.WriteFile(outsideFile, []byte(outsideContents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
result := NewExecTool(outputs, true).Execute(ctx, map[string]any{
|
|
"command": "cat " + strconv.Quote(outsideFile),
|
|
})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("delegated native exec read an outside host file: %#v", result)
|
|
}
|
|
if result.ForLLM != delegatedExecSandboxRequiredError {
|
|
t.Fatalf("error = %q, want %q", result.ForLLM, delegatedExecSandboxRequiredError)
|
|
}
|
|
if strings.Contains(result.ForLLM, outsideContents) {
|
|
t.Fatal("delegated native exec exposed outside file contents")
|
|
}
|
|
}
|
|
|
|
func TestExecDelegationArtifactRejectsCredentialedHostPathBeforeLookup(t *testing.T) {
|
|
ctx, _, outputs := delegationArtifactToolContext(t)
|
|
secureCLIStore := newStubSecureCLIStore()
|
|
secureCLIStore.byName["delegated-cli"] = &store.SecureCLIBinary{
|
|
BinaryName: "delegated-cli",
|
|
TimeoutSeconds: 30,
|
|
Enabled: true,
|
|
IsGlobal: true,
|
|
}
|
|
tool := NewExecTool(outputs, true)
|
|
tool.SetSecureCLIStore(secureCLIStore)
|
|
|
|
result := tool.Execute(ctx, map[string]any{"command": "delegated-cli status"})
|
|
|
|
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
|
|
t.Fatalf("delegated credentialed exec result = %#v, want stable sandbox-required error", result)
|
|
}
|
|
secureCLIStore.mu.Lock()
|
|
lookupCalls := secureCLIStore.lookupCalls
|
|
secureCLIStore.mu.Unlock()
|
|
if lookupCalls != 0 {
|
|
t.Fatalf("credential lookup calls = %d, want guard before credentialed routing", lookupCalls)
|
|
}
|
|
}
|
|
|
|
func TestExecDelegationArtifactRejectsMissingSandboxKey(t *testing.T) {
|
|
ctx, _, outputs := delegationArtifactToolContext(t)
|
|
manager := &recordingSandboxManager{}
|
|
tool := NewSandboxedExecTool(outputs, true, manager)
|
|
|
|
result := tool.Execute(ctx, map[string]any{"command": "echo should-not-run"})
|
|
|
|
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
|
|
t.Fatalf("delegated exec without sandbox key = %#v, want stable sandbox-required error", result)
|
|
}
|
|
if manager.key != "" {
|
|
t.Fatalf("sandbox manager called with key %q despite missing context key", manager.key)
|
|
}
|
|
}
|
|
|
|
func TestExecDelegationArtifactUsesSandboxWithManagerAndKey(t *testing.T) {
|
|
ctx, outputs := delegatedExecSandboxContext(t)
|
|
ctx = WithToolSandboxKey(ctx, "delegated-session")
|
|
manager := &recordingSandboxManager{}
|
|
tool := NewSandboxedExecTool(outputs, true, manager)
|
|
|
|
result := tool.Execute(ctx, map[string]any{"command": "echo sandboxed"})
|
|
|
|
if result.IsError {
|
|
t.Fatalf("delegated sandbox exec failed: %s", result.ForLLM)
|
|
}
|
|
if manager.key != "delegated-session" {
|
|
t.Fatalf("sandbox manager key = %q, want delegated-session", manager.key)
|
|
}
|
|
if got := strings.Join(manager.sandbox.command, " "); got != "sh -c echo sandboxed" {
|
|
t.Fatalf("sandbox command = %q, want sandbox shell path", got)
|
|
}
|
|
}
|
|
|
|
type disabledDelegationSandboxManager struct {
|
|
getCalls int
|
|
}
|
|
|
|
func (m *disabledDelegationSandboxManager) Get(context.Context, string, string, *sandbox.Config, ...sandbox.GetOption) (sandbox.Sandbox, error) {
|
|
m.getCalls++
|
|
return nil, sandbox.ErrSandboxDisabled
|
|
}
|
|
|
|
func (*disabledDelegationSandboxManager) Release(context.Context, string) error { return nil }
|
|
func (*disabledDelegationSandboxManager) ReleaseAll(context.Context) error { return nil }
|
|
func (*disabledDelegationSandboxManager) Stop() {}
|
|
func (*disabledDelegationSandboxManager) Stats() map[string]any { return nil }
|
|
|
|
func TestExecDelegationArtifactDoesNotFallbackWhenSandboxDisabled(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX touch command fixture")
|
|
}
|
|
|
|
ctx, outputs := delegatedExecSandboxContext(t)
|
|
ctx = WithToolSandboxKey(ctx, "delegated-session")
|
|
marker := filepath.Join(t.TempDir(), "process-ran")
|
|
manager := &disabledDelegationSandboxManager{}
|
|
tool := NewSandboxedExecTool(outputs, true, manager)
|
|
|
|
result := tool.Execute(ctx, map[string]any{
|
|
"command": "touch " + strconv.Quote(marker),
|
|
})
|
|
|
|
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
|
|
t.Fatalf("disabled delegated sandbox result = %#v, want stable sandbox-required error", result)
|
|
}
|
|
if manager.getCalls != 1 {
|
|
t.Fatalf("sandbox manager Get calls = %d, want 1", manager.getCalls)
|
|
}
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("delegated exec fell back to host; marker stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecNonDelegatedSandboxDisabledStillFallsBackToHost(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX echo command fixture")
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
canonicalWorkspace, err := filepath.EvalSymlinks(workspace)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager := &disabledDelegationSandboxManager{}
|
|
tool := NewSandboxedExecTool(canonicalWorkspace, true, manager)
|
|
ctx := WithToolSandboxKey(context.Background(), "ordinary-session")
|
|
|
|
result := tool.Execute(ctx, map[string]any{"command": "echo ordinary-fallback"})
|
|
|
|
if result.IsError {
|
|
t.Fatalf("non-delegated sandbox fallback changed: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "ordinary-fallback") {
|
|
t.Fatalf("non-delegated host fallback output = %q", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
func delegatedExecSandboxContext(t *testing.T) (context.Context, string) {
|
|
t.Helper()
|
|
delegationID := uuid.New()
|
|
root := filepath.Join(t.TempDir(), "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)
|
|
}
|
|
canonicalOutputs, err := filepath.EvalSymlinks(outputs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := WithDelegationID(context.Background(), delegationID.String())
|
|
ctx = WithDelegationArtifactInputs(ctx, inputs)
|
|
ctx = WithToolWorkspace(ctx, canonicalOutputs)
|
|
return ctx, canonicalOutputs
|
|
}
|