mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +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>
476 lines
14 KiB
Go
476 lines
14 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Keep logical output paths comfortably below the strictest supported host
|
|
// path ceilings, leaving room for the captured root and publication staging.
|
|
const artifactOutputMaxPathBytes = 768
|
|
|
|
// Publish validates outputs and copies them into a sibling temporary directory
|
|
// beneath the captured caller root. One atomic no-replace rename makes the
|
|
// complete outputs tree and manifest visible together.
|
|
func (e *DelegationArtifactExchange) Publish(
|
|
ctx context.Context,
|
|
destination *DelegationArtifactRoot,
|
|
publishedAt time.Time,
|
|
) (DelegationArtifactPublication, error) {
|
|
return e.publish(ctx, destination, publishedAt, nil)
|
|
}
|
|
|
|
// publishWithPreparation records the exact sibling staging path before any
|
|
// publication data is created. The callback must durably persist that path.
|
|
func (e *DelegationArtifactExchange) publishWithPreparation(
|
|
ctx context.Context,
|
|
destination *DelegationArtifactRoot,
|
|
publishedAt time.Time,
|
|
onPrepared func(string) error,
|
|
) (DelegationArtifactPublication, error) {
|
|
return e.publish(ctx, destination, publishedAt, onPrepared)
|
|
}
|
|
|
|
func (e *DelegationArtifactExchange) publish(
|
|
ctx context.Context,
|
|
destination *DelegationArtifactRoot,
|
|
publishedAt time.Time,
|
|
onPrepared func(string) error,
|
|
) (DelegationArtifactPublication, error) {
|
|
if destination == nil || destination.root == nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_missing_destination_root", "publish", "", ErrArtifactState,
|
|
)
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if e.state != artifactExchangeOpen {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_invalid_state", "publish", "", ErrArtifactState,
|
|
)
|
|
}
|
|
|
|
publication, err := e.publishLocked(ctx, destination.root, publishedAt, onPrepared)
|
|
if err != nil {
|
|
e.retainFailureLocked(time.Now(), artifactErrorCode(err))
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
e.state = artifactExchangePublished
|
|
return publication, nil
|
|
}
|
|
|
|
func (e *DelegationArtifactExchange) publishLocked(
|
|
ctx context.Context,
|
|
destinationRoot *artifactSecureRoot,
|
|
publishedAt time.Time,
|
|
onPrepared func(string) error,
|
|
) (publication DelegationArtifactPublication, returnErr error) {
|
|
if err := destinationRoot.mkdirAll(".delegations", 0750); err != nil {
|
|
return DelegationArtifactPublication{}, classifyPublicationFilesystemError(
|
|
"open_publication_parent", "", err,
|
|
)
|
|
}
|
|
publicationParent, err := destinationRoot.openSubroot(".delegations")
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, classifyPublicationFilesystemError(
|
|
"open_publication_parent", "", err,
|
|
)
|
|
}
|
|
defer publicationParent.close()
|
|
|
|
finalName := e.delegationID.String()
|
|
if exists, err := publicationParent.exists(finalName); err != nil {
|
|
return DelegationArtifactPublication{}, wrapArtifactFilesystemError(
|
|
"check_publication_destination", "", err,
|
|
)
|
|
} else if exists {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_publish_conflict", "publish", "", ErrArtifactPublishConflict,
|
|
)
|
|
}
|
|
|
|
tempName := ".tmp-" + finalName + "-" + uuid.NewString()
|
|
tempPath := path.Join(".delegations", tempName)
|
|
e.publicationTempPath = tempPath
|
|
if onPrepared != nil {
|
|
if err := onPrepared(tempPath); err != nil {
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
}
|
|
tempRoot, err := publicationParent.createSubroot(tempName, 0700)
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, classifyPublicationFilesystemError(
|
|
"create_publication_temp", "", err,
|
|
)
|
|
}
|
|
tempClosed := false
|
|
defer func() {
|
|
if !tempClosed {
|
|
if closeErr := tempRoot.close(); returnErr == nil && closeErr != nil {
|
|
returnErr = artifactError(
|
|
"artifact_close_failed", "close_publication_temp", "", closeErr,
|
|
)
|
|
}
|
|
tempClosed = true
|
|
}
|
|
}()
|
|
if err := tempRoot.mkdirAll("outputs", 0750); err != nil {
|
|
return DelegationArtifactPublication{}, wrapArtifactFilesystemError(
|
|
"create_publication_outputs", "", err,
|
|
)
|
|
}
|
|
|
|
outputPaths, err := enumerateArtifactOutputs(
|
|
ctx,
|
|
e.root,
|
|
"outputs",
|
|
e.limits.MaxFiles-e.inputCount,
|
|
)
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
outputs := make([]DelegationArtifactManifestOutput, 0, len(outputPaths))
|
|
outputBytes := int64(0)
|
|
directories := map[string]struct{}{"outputs": {}}
|
|
for _, outputPath := range outputPaths {
|
|
if err := checkArtifactContext(ctx); err != nil {
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
if err := validateManifestOutputPath(outputPath); err != nil {
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
remaining := e.limits.MaxTotalBytes - e.inputBytes - outputBytes
|
|
destinationPath := outputPath
|
|
parent := path.Dir(destinationPath)
|
|
if parent != "." {
|
|
if err := tempRoot.mkdirAll(parent, 0750); err != nil {
|
|
return DelegationArtifactPublication{}, wrapArtifactFilesystemError(
|
|
"create_output_parent", destinationPath, err,
|
|
)
|
|
}
|
|
for dir := parent; dir != "." && dir != "/"; dir = path.Dir(dir) {
|
|
directories[dir] = struct{}{}
|
|
}
|
|
}
|
|
artifact, err := copyArtifactFile(
|
|
ctx,
|
|
e.root,
|
|
outputPath,
|
|
tempRoot,
|
|
destinationPath,
|
|
e.limits.MaxFileBytes,
|
|
remaining,
|
|
0640,
|
|
)
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, err
|
|
}
|
|
artifact.Status = DelegationArtifactPublished
|
|
outputBytes += artifact.SizeBytes
|
|
outputs = append(outputs, DelegationArtifactManifestOutput{
|
|
Path: artifact.Path,
|
|
SizeBytes: artifact.SizeBytes,
|
|
SHA256: artifact.SHA256,
|
|
MediaType: artifact.MediaType,
|
|
})
|
|
}
|
|
|
|
sort.Slice(outputs, func(i, j int) bool { return outputs[i].Path < outputs[j].Path })
|
|
manifest := DelegationArtifactManifest{
|
|
SchemaVersion: DelegationArtifactManifestVersion,
|
|
DelegationID: finalName,
|
|
PublishedAt: publishedAt.UTC(),
|
|
OutputCount: len(outputs),
|
|
OutputBytes: outputBytes,
|
|
Outputs: outputs,
|
|
}
|
|
if manifest.Outputs == nil {
|
|
manifest.Outputs = []DelegationArtifactManifestOutput{}
|
|
}
|
|
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_manifest_failed", "marshal_manifest", "", err,
|
|
)
|
|
}
|
|
manifestBytes = append(manifestBytes, '\n')
|
|
manifestFile, err := tempRoot.createFile("manifest.json", 0600)
|
|
if err != nil {
|
|
return DelegationArtifactPublication{}, wrapArtifactFilesystemError(
|
|
"create_manifest", "manifest.json", err,
|
|
)
|
|
}
|
|
if _, err := manifestFile.Write(manifestBytes); err != nil {
|
|
manifestFile.Close()
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_manifest_failed", "write_manifest", "manifest.json", err,
|
|
)
|
|
}
|
|
if err := manifestFile.Sync(); err != nil {
|
|
manifestFile.Close()
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_sync_failed", "sync_manifest", "manifest.json", err,
|
|
)
|
|
}
|
|
if err := manifestFile.Chmod(0640); err != nil {
|
|
manifestFile.Close()
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_chmod_failed", "protect_manifest", "manifest.json", err,
|
|
)
|
|
}
|
|
if err := manifestFile.Close(); err != nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_close_failed", "close_manifest", "manifest.json", err,
|
|
)
|
|
}
|
|
|
|
syncDirs := make([]string, 0, len(directories))
|
|
for dir := range directories {
|
|
syncDirs = append(syncDirs, dir)
|
|
}
|
|
sort.Slice(syncDirs, func(i, j int) bool {
|
|
return strings.Count(syncDirs[i], "/") > strings.Count(syncDirs[j], "/")
|
|
})
|
|
for _, dir := range syncDirs {
|
|
if err := tempRoot.syncDir(dir); err != nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_sync_failed", "sync_directory", dir, err,
|
|
)
|
|
}
|
|
}
|
|
if err := tempRoot.syncDir("."); err != nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_sync_failed", "sync_publication_temp", "", err,
|
|
)
|
|
}
|
|
if err := tempRoot.close(); err != nil {
|
|
return DelegationArtifactPublication{}, artifactError(
|
|
"artifact_close_failed", "close_publication_temp", "", err,
|
|
)
|
|
}
|
|
tempClosed = true
|
|
if err := publicationParent.renameNoReplace(tempName, finalName); err != nil {
|
|
return DelegationArtifactPublication{}, classifyPublicationFilesystemError(
|
|
"publish", "", err,
|
|
)
|
|
}
|
|
if err := publicationParent.syncDir("."); err != nil {
|
|
// The final publication is already atomically visible and rollback is
|
|
// unsafe. Returning failure here would invite a duplicate delegation
|
|
// even though the caller can already consume the output.
|
|
slog.Warn("delegate.artifact_publication_parent_sync_failed",
|
|
"delegation_id", e.delegationID,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
rootPath := path.Join(".delegations", finalName)
|
|
return DelegationArtifactPublication{
|
|
RootPath: rootPath,
|
|
ManifestPath: path.Join(rootPath, "manifest.json"),
|
|
Manifest: manifest,
|
|
}, nil
|
|
}
|
|
|
|
func enumerateArtifactOutputs(
|
|
ctx context.Context,
|
|
root *artifactSecureRoot,
|
|
base string,
|
|
maxFiles int,
|
|
) ([]string, error) {
|
|
if maxFiles < 0 {
|
|
return nil, artifactError(
|
|
"artifact_file_limit", "enumerate_outputs", "", ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
// The configured file budget remains authoritative. Directory-only trees
|
|
// receive a matching budget, capped at the default artifact file ceiling.
|
|
maxDirectories := min(maxFiles, DelegationArtifactMaxFiles)
|
|
maxDepth := min(maxDirectories, artifactSecureMaxDepth)
|
|
maxEntries := maxFiles
|
|
maxInt := int(^uint(0) >> 1)
|
|
if maxDirectories <= maxInt-maxEntries {
|
|
maxEntries += maxDirectories
|
|
} else {
|
|
maxEntries = maxInt
|
|
}
|
|
|
|
var outputs []string
|
|
type outputDirectory struct {
|
|
path string
|
|
depth int
|
|
}
|
|
directories := []outputDirectory{{path: base}}
|
|
entriesSeen := 0
|
|
directoriesSeen := 0
|
|
|
|
for directoryIndex := 0; directoryIndex < len(directories); directoryIndex++ {
|
|
if err := checkArtifactContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
directory := directories[directoryIndex]
|
|
err := func() error {
|
|
entry, err := root.openEntry(directory.path)
|
|
if err != nil {
|
|
return classifyArtifactOpenError("enumerate_outputs", directory.path, err)
|
|
}
|
|
defer entry.close()
|
|
if entry.kind != artifactEntryDirectory {
|
|
return artifactError(
|
|
"artifact_non_regular",
|
|
"enumerate_outputs",
|
|
directory.path,
|
|
ErrArtifactNonRegular,
|
|
)
|
|
}
|
|
|
|
for {
|
|
dirEntries, readErr := entry.readDirBatch()
|
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
|
return classifyArtifactOpenError(
|
|
"enumerate_outputs",
|
|
directory.path,
|
|
readErr,
|
|
)
|
|
}
|
|
for _, dirEntry := range dirEntries {
|
|
if err := checkArtifactContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
entriesSeen++
|
|
if entriesSeen > maxEntries {
|
|
return artifactError(
|
|
"artifact_entry_limit",
|
|
"enumerate_outputs",
|
|
directory.path,
|
|
ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
name := dirEntry.Name()
|
|
if len(directory.path)+1+len(name) > artifactOutputMaxPathBytes {
|
|
return artifactError(
|
|
"artifact_path_limit",
|
|
"enumerate_outputs",
|
|
directory.path,
|
|
ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
logicalPath := path.Join(directory.path, name)
|
|
child, err := root.openEntry(logicalPath)
|
|
if err != nil {
|
|
return classifyArtifactOpenError("open_output", logicalPath, err)
|
|
}
|
|
switch child.kind {
|
|
case artifactEntryDirectory:
|
|
if err := child.close(); err != nil {
|
|
return wrapArtifactFilesystemError(
|
|
"close_output",
|
|
logicalPath,
|
|
err,
|
|
)
|
|
}
|
|
directoriesSeen++
|
|
if directoriesSeen > maxDirectories {
|
|
return artifactError(
|
|
"artifact_directory_limit",
|
|
"enumerate_outputs",
|
|
logicalPath,
|
|
ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
childDepth := directory.depth + 1
|
|
if childDepth > maxDepth {
|
|
return artifactError(
|
|
"artifact_depth_limit",
|
|
"enumerate_outputs",
|
|
logicalPath,
|
|
ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
directories = append(directories, outputDirectory{
|
|
path: logicalPath,
|
|
depth: childDepth,
|
|
})
|
|
case artifactEntryRegular:
|
|
links := child.links
|
|
if err := child.close(); err != nil {
|
|
return wrapArtifactFilesystemError(
|
|
"close_output",
|
|
logicalPath,
|
|
err,
|
|
)
|
|
}
|
|
if links != 1 {
|
|
return artifactError(
|
|
"artifact_hardlink",
|
|
"open_output",
|
|
logicalPath,
|
|
ErrArtifactHardlink,
|
|
)
|
|
}
|
|
outputs = append(outputs, logicalPath)
|
|
if len(outputs) > maxFiles {
|
|
return artifactError(
|
|
"artifact_file_limit",
|
|
"enumerate_outputs",
|
|
logicalPath,
|
|
ErrArtifactLimitExceeded,
|
|
)
|
|
}
|
|
default:
|
|
if err := child.close(); err != nil {
|
|
return wrapArtifactFilesystemError(
|
|
"close_output",
|
|
logicalPath,
|
|
err,
|
|
)
|
|
}
|
|
return artifactError(
|
|
"artifact_non_regular",
|
|
"open_output",
|
|
logicalPath,
|
|
ErrArtifactNonRegular,
|
|
)
|
|
}
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
return nil
|
|
}
|
|
}
|
|
}()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sort.Strings(outputs)
|
|
return outputs, nil
|
|
}
|
|
|
|
func classifyPublicationFilesystemError(op, logicalPath string, err error) error {
|
|
if isArtifactAlreadyExists(err) || errors.Is(err, ErrArtifactPublishConflict) {
|
|
return artifactError("artifact_publish_conflict", op, logicalPath, ErrArtifactPublishConflict)
|
|
}
|
|
return wrapArtifactFilesystemError(op, logicalPath, err)
|
|
}
|
|
|
|
func validateManifestOutputPath(raw string) error {
|
|
clean, err := validateArtifactRelativePath(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clean == "outputs" || !strings.HasPrefix(clean, "outputs/") {
|
|
return fmt.Errorf("%w: manifest output is outside outputs", ErrArtifactInvalidPath)
|
|
}
|
|
return nil
|
|
}
|