Files
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

282 lines
7.4 KiB
Go

package workspace
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolve_PersonalOpen(t *testing.T) {
base := t.TempDir()
r := NewResolver()
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-123",
AgentType: "open",
UserID: "user-456",
TenantID: "tenant-uuid-1",
TenantSlug: "acme",
PeerKind: "direct",
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
want := filepath.Join(base, "tenants", "acme", "agent-123", "user-456")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
if wc.Scope != ScopePersonal {
t.Errorf("Scope = %q, want personal", wc.Scope)
}
if wc.OwnerID != "user-456" {
t.Errorf("OwnerID = %q", wc.OwnerID)
}
if wc.MemoryScope != "user" {
t.Errorf("MemoryScope = %q, want user", wc.MemoryScope)
}
assertDirExists(t, wc.ActivePath)
}
func TestResolve_PersonalGroup(t *testing.T) {
base := t.TempDir()
r := NewResolver()
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-123",
AgentType: "open",
UserID: "user-456",
ChatID: "chat-789",
TenantID: "tenant-uuid-1",
TenantSlug: "acme",
PeerKind: "group",
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
// Group chat uses chatID for isolation
want := filepath.Join(base, "tenants", "acme", "agent-123", "chat-789")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
}
func TestResolve_PredefinedShared(t *testing.T) {
base := t.TempDir()
r := NewResolver()
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-pre",
AgentType: "predefined",
UserID: "user-1",
TenantID: "tenant-uuid-1",
TenantSlug: "acme",
PeerKind: "direct",
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
// Predefined = shared, no user subdir
want := filepath.Join(base, "tenants", "acme", "agent-pre")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
}
func TestResolve_TeamShared(t *testing.T) {
base := t.TempDir()
r := NewResolver()
teamID := "team-abc"
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
AgentType: "open",
UserID: "user-1",
ChatID: "chat-1",
TenantID: "tenant-uuid-1",
TenantSlug: "acme",
PeerKind: "direct",
TeamID: &teamID,
TeamConfig: &TeamWorkspaceConfig{WorkspaceScope: "shared"},
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
want := filepath.Join(base, "tenants", "acme", "teams", "team-abc")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
if wc.Scope != ScopeTeam {
t.Errorf("Scope = %q, want team", wc.Scope)
}
if wc.TeamPath == nil || *wc.TeamPath != want {
t.Errorf("TeamPath = %v, want %q", wc.TeamPath, want)
}
if wc.MemoryScope != "shared" {
t.Errorf("MemoryScope = %q, want shared", wc.MemoryScope)
}
}
// TestResolve_TeamShared_TenantScopedBaseDir_NoDoubleJoin pins a regression:
// callers whose BaseDir is already tenant-scoped (e.g. internal/agent's
// Loop.dataDir, built via config.TenantDataDir) must pass an empty
// TenantID/TenantSlug so resolveTeam's tenantPath() call is a no-op. An
// earlier bug passed both an already-scoped BaseDir AND a non-empty
// TenantID/TenantSlug into this same call, producing
// ".../tenants/acme/tenants/acme/teams/<id>" for the agent-facing path,
// while the web UI upload handler (config.TenantWorkspace, single join)
// correctly produced ".../tenants/acme/teams/<id>".
func TestResolve_TeamShared_TenantScopedBaseDir_NoDoubleJoin(t *testing.T) {
rawBase := t.TempDir()
alreadyTenantScopedBase := filepath.Join(rawBase, "tenants", "acme")
r := NewResolver()
teamID := "team-abc"
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
AgentType: "open",
UserID: "user-1",
ChatID: "chat-1",
TenantID: "", // intentionally empty: BaseDir is already tenant-scoped
TenantSlug: "",
PeerKind: "direct",
TeamID: &teamID,
TeamConfig: &TeamWorkspaceConfig{WorkspaceScope: "shared"},
BaseDir: alreadyTenantScopedBase,
})
if err != nil {
t.Fatal(err)
}
want := filepath.Join(rawBase, "tenants", "acme", "teams", "team-abc")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
if n := strings.Count(wc.ActivePath, filepath.Join("tenants", "acme")); n != 1 {
t.Errorf("expected tenant segment to appear exactly once, got %d occurrences in %q", n, wc.ActivePath)
}
}
func TestResolve_TeamIsolated(t *testing.T) {
base := t.TempDir()
r := NewResolver()
teamID := "team-abc"
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
AgentType: "open",
UserID: "user-1",
ChatID: "chat-1",
TenantID: "tenant-uuid-1",
TenantSlug: "acme",
PeerKind: "direct",
TeamID: &teamID,
TeamConfig: &TeamWorkspaceConfig{WorkspaceScope: "isolated"},
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
teamRoot := filepath.Join(base, "tenants", "acme", "teams", "team-abc")
want := filepath.Join(teamRoot, "chat-1")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
if wc.TeamPath == nil || *wc.TeamPath != teamRoot {
t.Errorf("TeamPath = %v, want %q", wc.TeamPath, teamRoot)
}
if wc.MemoryScope != "user" {
t.Errorf("MemoryScope = %q, want user", wc.MemoryScope)
}
}
func TestResolve_EnforcementLabel(t *testing.T) {
tests := []struct {
name string
scope Scope
shared bool
substr string
}{
{"personal", ScopePersonal, false, "personal workspace"},
{"team_shared", ScopeTeam, true, "shared team workspace"},
{"team_isolated", ScopeTeam, false, "isolated team workspace"},
{"delegate", ScopeDelegate, false, "delegated task"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
label := DefaultEnforcementLabel(tt.scope, tt.shared)
if !strings.Contains(label, tt.substr) {
t.Errorf("label = %q, missing %q", label, tt.substr)
}
})
}
}
func TestResolve_EmptyBaseDir(t *testing.T) {
r := NewResolver()
_, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
UserID: "user-1",
})
if err == nil {
t.Error("expected error for empty BaseDir")
}
}
func TestResolve_MasterTenant(t *testing.T) {
base := t.TempDir()
r := NewResolver()
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
AgentType: "open",
UserID: "user-1",
TenantID: masterTenantID,
PeerKind: "direct",
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
// Master tenant = base dir (no tenants/ prefix)
want := filepath.Join(base, "agent-1", "user-1")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
}
func TestResolve_EmptyTenantID(t *testing.T) {
base := t.TempDir()
r := NewResolver()
wc, err := r.Resolve(context.Background(), ResolveParams{
AgentID: "agent-1",
AgentType: "open",
UserID: "user-1",
PeerKind: "direct",
BaseDir: base,
})
if err != nil {
t.Fatal(err)
}
want := filepath.Join(base, "agent-1", "user-1")
if wc.ActivePath != want {
t.Errorf("ActivePath = %q, want %q", wc.ActivePath, want)
}
}
func assertDirExists(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Errorf("directory %q does not exist: %v", path, err)
} else if !info.IsDir() {
t.Errorf("%q exists but is not a directory", path)
}
}