Files
goclaw/internal/tools/delegation_artifacts_lifecycle.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

196 lines
7.4 KiB
Go

package tools
import (
"encoding/json"
"fmt"
"io"
"time"
"github.com/google/uuid"
)
const (
delegationArtifactLifecycleVersion = 2
delegationArtifactLifecycleFile = ".goclaw-lifecycle.json"
delegationArtifactLifecycleMaxBytes = 64 * 1024
)
type delegationArtifactLifecycleStatus string
const (
artifactLifecycleStaging delegationArtifactLifecycleStatus = "staging"
artifactLifecycleRunning delegationArtifactLifecycleStatus = "running"
artifactLifecyclePublishing delegationArtifactLifecycleStatus = "publishing"
artifactLifecyclePublished delegationArtifactLifecycleStatus = "published"
artifactLifecycleFailed delegationArtifactLifecycleStatus = "failed"
artifactLifecycleCancelled delegationArtifactLifecycleStatus = "cancelled"
)
type delegationArtifactCallerLocation struct {
Base string `json:"base"`
RelativePath string `json:"relative_path"`
}
// delegationArtifactLifecycleState contains only logical locations and stable
// identifiers. Host roots are reconstructed from runtime configuration and are
// never serialized.
type delegationArtifactLifecycleState struct {
SchemaVersion int `json:"schema_version"`
DelegationID string `json:"delegation_id"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug,omitempty"`
Status delegationArtifactLifecycleStatus `json:"status"`
StartedAt time.Time `json:"started_at"`
FailedAt *time.Time `json:"failed_at,omitempty"`
RetainUntil time.Time `json:"retain_until"`
ReasonCode string `json:"reason_code"`
CallerLocation *delegationArtifactCallerLocation `json:"caller_location,omitempty"`
PublicationTempPath string `json:"publication_temp_path,omitempty"`
}
func newStagingDelegationArtifactLifecycleState(
tenantID uuid.UUID,
delegationID uuid.UUID,
startedAt time.Time,
retainUntil time.Time,
) delegationArtifactLifecycleState {
return delegationArtifactLifecycleState{
SchemaVersion: delegationArtifactLifecycleVersion,
DelegationID: delegationID.String(),
TenantID: tenantID.String(),
Status: artifactLifecycleStaging,
StartedAt: startedAt.UTC(),
RetainUntil: retainUntil.UTC(),
ReasonCode: "artifact_staging",
}
}
func persistDelegationArtifactLifecycleState(
root *artifactSecureRoot,
delegationID uuid.UUID,
state delegationArtifactLifecycleState,
) error {
if root == nil {
return artifactError("artifact_lifecycle_state_failed", "write_lifecycle_state", "", ErrArtifactState)
}
if err := validateDelegationArtifactLifecycleState(state, delegationID); err != nil {
return err
}
encoded, err := json.Marshal(state)
if err != nil {
return artifactError("artifact_lifecycle_state_failed", "marshal_lifecycle_state", "", err)
}
encoded = append(encoded, '\n')
tempName := ".goclaw-lifecycle-" + uuid.NewString() + ".tmp"
stateFile, err := root.createFile(tempName, 0600)
if err != nil {
return wrapArtifactFilesystemError("create_lifecycle_state", "", err)
}
removeTemp := true
defer func() {
_ = stateFile.Close()
if removeTemp {
_ = root.removeTree(tempName)
}
}()
if _, err := stateFile.Write(encoded); err != nil {
return artifactError("artifact_lifecycle_state_failed", "write_lifecycle_state", "", err)
}
if err := stateFile.Sync(); err != nil {
return artifactError("artifact_lifecycle_state_failed", "sync_lifecycle_state", "", err)
}
if err := stateFile.Close(); err != nil {
return artifactError("artifact_lifecycle_state_failed", "close_lifecycle_state", "", err)
}
if err := root.renameReplace(tempName, delegationArtifactLifecycleFile); err != nil {
return wrapArtifactFilesystemError("publish_lifecycle_state", "", err)
}
removeTemp = false
if err := root.syncDir("."); err != nil {
return artifactError("artifact_lifecycle_state_failed", "sync_lifecycle_directory", "", err)
}
return nil
}
func (e *DelegationArtifactExchange) persistLifecycleState(state delegationArtifactLifecycleState) error {
if e == nil {
return artifactError("artifact_lifecycle_state_failed", "write_lifecycle_state", "", ErrArtifactState)
}
return persistDelegationArtifactLifecycleState(e.root, e.delegationID, state)
}
func readDelegationArtifactLifecycleState(
root *artifactSecureRoot,
delegationID uuid.UUID,
) (delegationArtifactLifecycleState, error) {
entry, err := root.openEntry(delegationArtifactLifecycleFile)
if err != nil {
return delegationArtifactLifecycleState{}, err
}
defer entry.close()
if entry.kind != artifactEntryRegular || entry.links != 1 ||
entry.size <= 0 || entry.size > delegationArtifactLifecycleMaxBytes {
return delegationArtifactLifecycleState{}, ErrArtifactState
}
encoded, err := io.ReadAll(io.LimitReader(entry.file, delegationArtifactLifecycleMaxBytes+1))
if err != nil {
return delegationArtifactLifecycleState{}, err
}
if len(encoded) > delegationArtifactLifecycleMaxBytes {
return delegationArtifactLifecycleState{}, ErrArtifactState
}
var state delegationArtifactLifecycleState
if err := json.Unmarshal(encoded, &state); err != nil {
return delegationArtifactLifecycleState{}, err
}
if err := validateDelegationArtifactLifecycleState(state, delegationID); err != nil {
return delegationArtifactLifecycleState{}, err
}
return state, nil
}
func validateDelegationArtifactLifecycleState(
state delegationArtifactLifecycleState,
delegationID uuid.UUID,
) error {
if state.SchemaVersion != delegationArtifactLifecycleVersion ||
state.DelegationID != delegationID.String() ||
state.TenantID == "" ||
state.StartedAt.IsZero() ||
state.RetainUntil.IsZero() ||
state.ReasonCode == "" {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
switch state.Status {
case artifactLifecycleStaging, artifactLifecycleRunning:
if state.FailedAt != nil || state.PublicationTempPath != "" {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
case artifactLifecyclePublishing, artifactLifecyclePublished:
if state.FailedAt != nil ||
validateDelegationPublicationTempPath(delegationID, state.PublicationTempPath) != nil {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
case artifactLifecycleFailed, artifactLifecycleCancelled:
if state.FailedAt == nil {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
default:
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
if state.CallerLocation != nil {
if state.CallerLocation.Base != "workspace" && state.CallerLocation.Base != "data" {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
if state.CallerLocation.RelativePath == "" {
return artifactError("artifact_lifecycle_state_invalid", "validate_lifecycle_state", "", ErrArtifactState)
}
}
if state.PublicationTempPath != "" {
if err := validateDelegationPublicationTempPath(delegationID, state.PublicationTempPath); err != nil {
return fmt.Errorf("invalid publication temp path: %w", err)
}
}
return nil
}