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>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-07-30 14:17:40 +07:00
1 parent 496b7ffce6
commit bb7712a9ff
186 files changed
+18858 -1233

No files matched your search

+62 -10
View File
@@ -27,6 +27,32 @@ type contextSetupResult struct {
// values needed by the agent loop and tool execution. Also runs input guard and message
// truncation. Returns error only if input guard blocks the message.
func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetupResult, error) {
isArtifactDelegation := req.RunKind == "delegate"
// A nested run must not inherit filesystem, Team, media, or delegation
// authority from its caller. Install explicit empty values before resolving
// this run's own scope.
ctx = store.WithRunContext(ctx, nil)
ctx = tools.WithToolWorkspace(ctx, "")
ctx = tools.WithToolTeamWorkspace(ctx, "")
ctx = tools.WithToolTeamRoot(ctx, "")
ctx = tools.WithToolTeamID(ctx, "")
ctx = tools.WithTeamTaskID(ctx, "")
ctx = tools.WithLeaderAgentID(ctx, "")
ctx = tools.WithTenantAllowedPaths(ctx, nil)
ctx = tools.WithWorkspaceChannel(ctx, "")
ctx = tools.WithWorkspaceChatID(ctx, "")
ctx = tools.WithDelegationID(ctx, "")
ctx = tools.WithDelegationArtifactInputs(ctx, "")
ctx = tools.WithRunKind(ctx, "")
ctx = tools.WithRunMediaPaths(ctx, nil)
ctx = tools.WithRunMediaNames(ctx, nil)
ctx = tools.WithMediaImages(ctx, nil)
ctx = tools.WithMediaImageRefs(ctx, nil)
ctx = tools.WithMediaDocRefs(ctx, nil)
ctx = tools.WithMediaAudioRefs(ctx, nil)
ctx = tools.WithMediaVideoRefs(ctx, nil)
// Inject agent UUID + key into context for tool routing
if l.agentUUID != uuid.Nil {
ctx = store.WithAgentID(ctx, l.agentUUID)
@@ -87,7 +113,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject tenant-specific allowed paths for filesystem tools.
if len(l.tenantAllowedPaths) > 0 {
if !isArtifactDelegation && len(l.tenantAllowedPaths) > 0 {
ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths)
}
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
@@ -150,6 +176,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if req.DelegationID != "" {
ctx = tools.WithDelegationID(ctx, req.DelegationID)
}
if req.RunKind != "" {
ctx = tools.WithRunKind(ctx, req.RunKind)
}
// --- Per-user setup: file seeding + workspace resolution ---
// Uses userSetups sync.Map to track both concerns atomically per user.
@@ -163,7 +192,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
// Layer order: tenant → team → project (future) → user/chat
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
// Result is always a single folder set via WithToolWorkspace.
if l.workspace != "" && req.UserID != "" {
if !isArtifactDelegation && l.workspace != "" && req.UserID != "" {
ws := setup.workspace
if ws == "" {
ws = l.workspace
@@ -197,12 +226,21 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
effectiveWorkspace = l.workspace
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
} else if l.workspace != "" {
} else if !isArtifactDelegation && l.workspace != "" {
ctx = tools.WithToolWorkspace(ctx, l.workspace)
}
if isArtifactDelegation {
if req.TeamWorkspace != "" ||
!validateDelegationArtifactWorkspace(req.DelegationID, req.DelegateInputsPath, req.DelegateOutputsPath) {
return contextSetupResult{}, fmt.Errorf("invalid delegation artifact workspace")
}
ctx = tools.WithDelegationArtifactInputs(ctx, req.DelegateInputsPath)
ctx = tools.WithToolWorkspace(ctx, req.DelegateOutputsPath)
}
// Team workspace: dispatched task overrides default workspace.
if req.TeamWorkspace != "" {
if !isArtifactDelegation && req.TeamWorkspace != "" {
if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil {
// See note above on loop_context user workspace fallback. A broken
// req.TeamWorkspace would otherwise become cmd.Dir and surface as
@@ -214,7 +252,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
}
if req.TeamID != "" {
if !isArtifactDelegation && req.TeamID != "" {
ctx = tools.WithToolTeamID(ctx, req.TeamID)
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
// so the dispatched agent can still read peer-scoped files in the same team.
@@ -228,7 +266,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
}
}
if req.LeaderAgentID != "" {
if !isArtifactDelegation && req.LeaderAgentID != "" {
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
}
@@ -237,14 +275,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
var resolvedTeamSettings json.RawMessage
// Dispatched tasks already have TeamWorkspace set but still need team settings
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if !isArtifactDelegation && req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
resolvedTeamSettings = team.Settings
}
}
}
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if !isArtifactDelegation && req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
resolvedTeamSettings = team.Settings
wsChat := req.ChatID
@@ -285,7 +323,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
}
// V3 workspace: resolve once, set immutable context.
{
if isArtifactDelegation {
ctx = workspace.WithContext(ctx, &workspace.WorkspaceContext{
ActivePath: req.DelegateOutputsPath,
Scope: workspace.ScopeDelegate,
MemoryScope: "user",
KGScope: "user",
OwnerID: req.UserID,
EnforcementLabel: workspace.DefaultEnforcementLabel(workspace.ScopeDelegate, false),
})
} else {
var teamIDPtr *string
if req.TeamID != "" {
teamIDPtr = &req.TeamID
@@ -386,6 +433,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
}
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
credUserID := store.ExplicitCredentialUserIDFromContext(ctx)
tenantAllowedPaths := l.tenantAllowedPaths
if isArtifactDelegation {
tenantAllowedPaths = nil
}
rc := &store.RunContext{
AgentID: l.agentUUID,
AgentKey: l.id,
@@ -419,9 +470,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
WorkspaceChatID: effectiveWorkspaceChatID,
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
TeamTaskID: req.TeamTaskID,
DelegationID: req.DelegationID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
TenantAllowedPaths: l.tenantAllowedPaths,
TenantAllowedPaths: tenantAllowedPaths,
}
ctx = store.WithRunContext(ctx, rc)