mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +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>
425 lines
13 KiB
Go
425 lines
13 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
func newLinkStore(db *sql.DB) *pg.PGAgentLinkStore {
|
|
return pg.NewPGAgentLinkStore(db)
|
|
}
|
|
|
|
func makeLink(srcID, tgtID uuid.UUID, direction, status string) *store.AgentLinkData {
|
|
return &store.AgentLinkData{
|
|
SourceAgentID: srcID,
|
|
TargetAgentID: tgtID,
|
|
Direction: direction,
|
|
Status: status,
|
|
MaxConcurrent: 1,
|
|
CreatedBy: "test",
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_CreateAndGet(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
link := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", link.ID)
|
|
})
|
|
|
|
if link.ID == uuid.Nil {
|
|
t.Fatal("expected link.ID to be set after CreateLink")
|
|
}
|
|
|
|
got, err := ls.GetLink(ctx, link.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetLink: %v", err)
|
|
}
|
|
if got.SourceAgentID != agentA {
|
|
t.Errorf("SourceAgentID = %v, want %v", got.SourceAgentID, agentA)
|
|
}
|
|
if got.TargetAgentID != agentB {
|
|
t.Errorf("TargetAgentID = %v, want %v", got.TargetAgentID, agentB)
|
|
}
|
|
if got.Direction != store.LinkDirectionOutbound {
|
|
t.Errorf("Direction = %q, want %q", got.Direction, store.LinkDirectionOutbound)
|
|
}
|
|
if got.Status != store.LinkStatusActive {
|
|
t.Errorf("Status = %q, want %q", got.Status, store.LinkStatusActive)
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_Delete(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
link := makeLink(agentA, agentB, store.LinkDirectionBidirectional, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
|
|
if err := ls.DeleteLink(ctx, link.ID); err != nil {
|
|
t.Fatalf("DeleteLink: %v", err)
|
|
}
|
|
|
|
_, err := ls.GetLink(ctx, link.ID)
|
|
if err == nil {
|
|
t.Error("expected error after DeleteLink, got nil")
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_Update(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
link := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", link.ID)
|
|
})
|
|
|
|
if err := ls.UpdateLink(ctx, link.ID, map[string]any{"status": store.LinkStatusDisabled}); err != nil {
|
|
t.Fatalf("UpdateLink: %v", err)
|
|
}
|
|
|
|
got, err := ls.GetLink(ctx, link.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetLink after update: %v", err)
|
|
}
|
|
if got.Status != store.LinkStatusDisabled {
|
|
t.Errorf("Status after update = %q, want %q", got.Status, store.LinkStatusDisabled)
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_ListLinksFrom(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
_, agentC := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
// agentA → agentB, agentA → agentC
|
|
linkAB := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
linkAC := makeLink(agentA, agentC, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
for _, l := range []*store.AgentLinkData{linkAB, linkAC} {
|
|
if err := ls.CreateLink(ctx, l); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", linkAB.ID)
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", linkAC.ID)
|
|
})
|
|
|
|
links, err := ls.ListLinksFrom(ctx, agentA)
|
|
if err != nil {
|
|
t.Fatalf("ListLinksFrom: %v", err)
|
|
}
|
|
|
|
found := map[uuid.UUID]bool{}
|
|
for _, l := range links {
|
|
found[l.ID] = true
|
|
}
|
|
if !found[linkAB.ID] {
|
|
t.Error("ListLinksFrom: linkAB not found")
|
|
}
|
|
if !found[linkAC.ID] {
|
|
t.Error("ListLinksFrom: linkAC not found")
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_ListLinksTo(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
_, agentC := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
// agentA → agentC, agentB → agentC
|
|
linkAC := makeLink(agentA, agentC, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
linkBC := makeLink(agentB, agentC, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
for _, l := range []*store.AgentLinkData{linkAC, linkBC} {
|
|
if err := ls.CreateLink(ctx, l); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", linkAC.ID)
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", linkBC.ID)
|
|
})
|
|
|
|
links, err := ls.ListLinksTo(ctx, agentC)
|
|
if err != nil {
|
|
t.Fatalf("ListLinksTo: %v", err)
|
|
}
|
|
found := map[uuid.UUID]bool{}
|
|
for _, l := range links {
|
|
found[l.ID] = true
|
|
}
|
|
if !found[linkAC.ID] {
|
|
t.Error("ListLinksTo: linkAC not found")
|
|
}
|
|
if !found[linkBC.ID] {
|
|
t.Error("ListLinksTo: linkBC not found")
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_CanDelegate(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
_, agentC := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
// outbound A→B (A can delegate to B).
|
|
link := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agent_links WHERE id = $1", link.ID) })
|
|
|
|
t.Run("can_delegate_outbound", func(t *testing.T) {
|
|
ok, err := ls.CanDelegate(ctx, agentA, agentB)
|
|
if err != nil {
|
|
t.Fatalf("CanDelegate: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("expected A can delegate to B (outbound link)")
|
|
}
|
|
})
|
|
|
|
t.Run("no_link_returns_false", func(t *testing.T) {
|
|
ok, err := ls.CanDelegate(ctx, agentA, agentC)
|
|
if err != nil {
|
|
t.Fatalf("CanDelegate no-link: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected CanDelegate false for agents with no link")
|
|
}
|
|
})
|
|
|
|
t.Run("disabled_link_returns_false", func(t *testing.T) {
|
|
linkDis := makeLink(agentA, agentC, store.LinkDirectionOutbound, store.LinkStatusDisabled)
|
|
if err := ls.CreateLink(ctx, linkDis); err != nil {
|
|
t.Fatalf("CreateLink disabled: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agent_links WHERE id = $1", linkDis.ID) })
|
|
|
|
ok, err := ls.CanDelegate(ctx, agentA, agentC)
|
|
if err != nil {
|
|
t.Fatalf("CanDelegate disabled: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("disabled link should not permit delegation")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStoreAgentLink_GetLinkBetween(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
link := makeLink(agentA, agentB, store.LinkDirectionBidirectional, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agent_links WHERE id = $1", link.ID) })
|
|
|
|
got, err := ls.GetLinkBetween(ctx, agentA, agentB)
|
|
if err != nil {
|
|
t.Fatalf("GetLinkBetween: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("GetLinkBetween returned nil")
|
|
}
|
|
if got.ID != link.ID {
|
|
t.Errorf("ID = %v, want %v", got.ID, link.ID)
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_GetLinkBetweenContract(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
_, agentC := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
reverse := makeLink(agentB, agentA, store.LinkDirectionInbound, store.LinkStatusActive)
|
|
direct := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
for _, link := range []*store.AgentLinkData{reverse, direct} {
|
|
if err := ls.CreateLink(ctx, link); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
linkID := link.ID
|
|
t.Cleanup(func() { _, _ = db.Exec("DELETE FROM agent_links WHERE id = $1", linkID) })
|
|
}
|
|
|
|
got, err := ls.GetLinkBetween(ctx, agentA, agentB)
|
|
if err != nil {
|
|
t.Fatalf("GetLinkBetween(deterministic): %v", err)
|
|
}
|
|
if got == nil || got.ID != direct.ID {
|
|
t.Fatalf("GetLinkBetween chose %#v, want delegator-authored %s", got, direct.ID)
|
|
}
|
|
|
|
missing, err := ls.GetLinkBetween(ctx, agentA, agentC)
|
|
if err != nil {
|
|
t.Fatalf("GetLinkBetween(not found): %v", err)
|
|
}
|
|
if missing != nil {
|
|
t.Fatalf("GetLinkBetween(not found) = %#v, want nil", missing)
|
|
}
|
|
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
if _, err := ls.GetLinkBetween(cancelled, agentA, agentB); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("GetLinkBetween(cancelled) error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_TenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, agentA1 := seedTenantAgent(t, db)
|
|
tenantB, agentB1 := seedTenantAgent(t, db)
|
|
// Create a second agent in each tenant for valid links.
|
|
_, agentA2 := seedTenantAgent(t, db)
|
|
_, agentB2 := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
ctxB := tenantCtx(tenantB)
|
|
ls := newLinkStore(db)
|
|
|
|
// Link in tenant A.
|
|
linkA := makeLink(agentA1, agentA2, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctxA, linkA); err != nil {
|
|
t.Fatalf("CreateLink tenantA: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agent_links WHERE id = $1", linkA.ID) })
|
|
|
|
// Link in tenant B.
|
|
linkB := makeLink(agentB1, agentB2, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
if err := ls.CreateLink(ctxB, linkB); err != nil {
|
|
t.Fatalf("CreateLink tenantB: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agent_links WHERE id = $1", linkB.ID) })
|
|
|
|
// Tenant B cannot GetLink for tenant A's link.
|
|
_, err := ls.GetLink(ctxB, linkA.ID)
|
|
if err == nil || !errors.Is(err, sql.ErrNoRows) {
|
|
// Accept any error (ErrNoRows or wrapped) — what matters is it's not found.
|
|
if err == nil {
|
|
t.Error("tenant B can read tenant A's link — isolation broken")
|
|
}
|
|
}
|
|
|
|
// ListLinksFrom for agentA1 under tenantB scope should be empty.
|
|
fromB, err := ls.ListLinksFrom(ctxB, agentA1)
|
|
if err != nil {
|
|
t.Fatalf("ListLinksFrom cross-tenant: %v", err)
|
|
}
|
|
for _, l := range fromB {
|
|
if l.ID == linkA.ID {
|
|
t.Error("tenant B sees tenant A's link via ListLinksFrom")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStoreAgentLink_DelegateTargets(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentA := seedTenantAgent(t, db)
|
|
_, agentB := seedTenantAgent(t, db)
|
|
_, agentC := seedTenantAgent(t, db)
|
|
_, agentD := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
ls := newLinkStore(db)
|
|
|
|
// A→B outbound: A delegates to B.
|
|
// A→C inbound: C delegates to A.
|
|
// A↔D bidirectional: both delegate to each other.
|
|
lAB := makeLink(agentA, agentB, store.LinkDirectionOutbound, store.LinkStatusActive)
|
|
lAC := makeLink(agentA, agentC, store.LinkDirectionInbound, store.LinkStatusActive)
|
|
lAD := makeLink(agentA, agentD, store.LinkDirectionBidirectional, store.LinkStatusActive)
|
|
for _, l := range []*store.AgentLinkData{lAB, lAC, lAD} {
|
|
if err := ls.CreateLink(ctx, l); err != nil {
|
|
t.Fatalf("CreateLink: %v", err)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", lAB.ID)
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", lAC.ID)
|
|
db.Exec("DELETE FROM agent_links WHERE id = $1", lAD.ID)
|
|
})
|
|
|
|
assertTargets := func(from uuid.UUID, want ...uuid.UUID) {
|
|
t.Helper()
|
|
targets, err := ls.DelegateTargets(ctx, from)
|
|
if err != nil {
|
|
t.Fatalf("DelegateTargets(%s): %v", from, err)
|
|
}
|
|
got := make(map[uuid.UUID]bool, len(targets))
|
|
for _, link := range targets {
|
|
got[link.ID] = true
|
|
}
|
|
for _, id := range want {
|
|
if !got[id] {
|
|
t.Errorf("DelegateTargets(%s): link %s not found", from, id)
|
|
}
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("DelegateTargets(%s): got %d links, want %d", from, len(got), len(want))
|
|
}
|
|
}
|
|
|
|
assertTargets(agentA, lAB.ID, lAD.ID)
|
|
assertTargets(agentB)
|
|
assertTargets(agentC, lAC.ID)
|
|
assertTargets(agentD, lAD.ID)
|
|
|
|
if err := ls.UpdateLink(ctx, lAB.ID, map[string]any{"status": store.LinkStatusDisabled}); err != nil {
|
|
t.Fatalf("disable outbound link: %v", err)
|
|
}
|
|
assertTargets(agentA, lAD.ID)
|
|
|
|
if err := ls.UpdateLink(ctx, lAC.ID, map[string]any{"direction": store.LinkDirectionOutbound}); err != nil {
|
|
t.Fatalf("change inbound direction: %v", err)
|
|
}
|
|
assertTargets(agentA, lAC.ID, lAD.ID)
|
|
assertTargets(agentC)
|
|
|
|
if err := ls.DeleteLink(ctx, lAD.ID); err != nil {
|
|
t.Fatalf("delete bidirectional link: %v", err)
|
|
}
|
|
assertTargets(agentA, lAC.ID)
|
|
assertTargets(agentD)
|
|
}
|