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

+94 -27
View File
@@ -1,6 +1,8 @@
package tools
import (
"context"
"errors"
"fmt"
"strings"
"sync"
@@ -11,17 +13,23 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
var ErrAnnounceQueueDrainTimeout = errors.New("announce_queue_drain_timeout")
// AnnounceQueueItem represents a single subagent result waiting to be announced.
type AnnounceQueueItem struct {
SubagentID string
Label string
Status string // "completed", "failed", "cancelled"
Result string
Media []bus.MediaFile // media files from tool results
Runtime time.Duration
Iterations int
InputTokens int64
OutputTokens int64
SubagentID string
CompletionID uuid.UUID
DurablyPersisted bool
ParentTaskID string
Depth int
Label string
Status string // "completed", "failed", "cancelled"
Result string
Media []bus.MediaFile // media files from tool results
Runtime time.Duration
Iterations int
InputTokens int64
OutputTokens int64
}
// AnnounceMetadata carries origin info for routing the batched announce.
@@ -31,10 +39,11 @@ type AnnounceMetadata struct {
OriginPeerKind string
OriginLocalKey string // composite key with topic/thread suffix for routing
OriginUserID string
OriginSenderID string // real acting sender; preserves permission attribution through re-ingress (#915)
OriginRole string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
OriginSessionKey string // exact parent session key (WS uses non-standard format)
OriginSenderID string // real acting sender; preserves permission attribution through re-ingress (#915)
OriginRole string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
OriginSessionKey string // exact parent session key (WS uses non-standard format)
OriginTenantID uuid.UUID // parent tenant for announce routing
RootAgentID uuid.UUID
ParentAgent string
OriginTraceID string // parent trace UUID for announce linking
OriginRootSpanID string // parent agent's root span UUID
@@ -47,7 +56,9 @@ type AnnounceQueue struct {
queues map[string]*sessionQueue // session key → queue
debounce time.Duration // default 1000ms
cap int // max items per session before immediate drain (default 20)
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata)
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata)
closed bool
drainWG sync.WaitGroup
}
type sessionQueue struct {
@@ -81,6 +92,9 @@ func NewAnnounceQueue(
func (aq *AnnounceQueue) Enqueue(sessionKey string, item AnnounceQueueItem, meta AnnounceMetadata) {
aq.mu.Lock()
defer aq.mu.Unlock()
if aq.closed {
return
}
sq, ok := aq.queues[sessionKey]
if !ok {
@@ -98,7 +112,7 @@ func (aq *AnnounceQueue) Enqueue(sessionKey string, item AnnounceQueueItem, meta
items := sq.items
sqMeta := sq.meta
delete(aq.queues, sessionKey)
go aq.drain(sessionKey, items, sqMeta)
aq.startDrainLocked(sessionKey, items, sqMeta)
return
}
@@ -116,12 +130,62 @@ func (aq *AnnounceQueue) Enqueue(sessionKey string, item AnnounceQueueItem, meta
items := sq.items
sqMeta := sq.meta
delete(aq.queues, sessionKey)
aq.startDrainLocked(sessionKey, items, sqMeta)
aq.mu.Unlock()
aq.drain(sessionKey, items, sqMeta)
})
}
func (aq *AnnounceQueue) startDrainLocked(
sessionKey string,
items []AnnounceQueueItem,
meta AnnounceMetadata,
) {
if aq.closed {
return
}
aq.drainWG.Add(1)
go func() {
defer aq.drainWG.Done()
aq.drain(sessionKey, items, meta)
}()
}
// CloseContext closes intake, drops pending debounce batches, and waits for any
// drain callback that already started. Dropping pending parent-resume messages
// during process shutdown avoids starting new agent runs against dependencies
// that are being torn down; task terminal state remains durable.
func (aq *AnnounceQueue) CloseContext(ctx context.Context) error {
if aq == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
aq.mu.Lock()
if !aq.closed {
aq.closed = true
for _, queue := range aq.queues {
if queue.timer != nil {
queue.timer.Stop()
}
}
clear(aq.queues)
}
aq.mu.Unlock()
done := make(chan struct{})
go func() {
aq.drainWG.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("%w: %v", ErrAnnounceQueueDrainTimeout, ctx.Err())
}
}
// drain merges items into a single announce message and calls onDrain.
func (aq *AnnounceQueue) drain(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata) {
if len(items) == 0 || aq.onDrain == nil {
@@ -147,11 +211,11 @@ func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) str
replyInstruction := BuildReplyInstruction(roster)
return fmt.Sprintf(
"[System Message] A subagent task %q just %s.\n\n"+
"[System Message] A subagent task %q just %s (task=%s, parent=%s, depth=%d).\n\n"+
"Result:\n%s\n\n"+
"Stats: runtime %s, iterations %d, tokens %d in / %d out\n\n"+
"%s",
item.Label, statusLabel, item.Result,
item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth, item.Result,
item.Runtime.Round(time.Millisecond), item.Iterations,
item.InputTokens, item.OutputTokens,
replyInstruction,
@@ -171,8 +235,8 @@ func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) str
}
sb.WriteString(fmt.Sprintf(
"\n---\nTask #%d: %q %s (runtime %s, iterations %d, tokens %d/%d)\nResult: %s\n",
i+1, item.Label, statusLabel,
"\n---\nTask #%d: %q %s (task=%s, parent=%s, depth=%d, runtime %s, iterations %d, tokens %d/%d)\nResult: %s\n",
i+1, item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth,
item.Runtime.Round(time.Millisecond), item.Iterations,
item.InputTokens, item.OutputTokens,
item.Result,
@@ -188,11 +252,12 @@ func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) str
// buildReplyInstruction generates the instruction block for the parent LLM,
// including a deterministic roster of all subagent tasks with their statuses.
func BuildReplyInstruction(roster SubagentRoster) string {
// Count running tasks from roster
running := 0
for _, e := range roster.Entries {
if e.Status == TaskStatusRunning {
running++
running := roster.Active
if running == 0 {
for _, entry := range roster.Entries {
if entry.Status == TaskStatusRunning {
running++
}
}
}
@@ -200,8 +265,10 @@ func BuildReplyInstruction(roster SubagentRoster) string {
var rosterBlock string
if len(roster.Entries) > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Subagent roster (%d spawned, max %d per agent):\n",
roster.Total, roster.MaxPerAgent))
sb.WriteString(fmt.Sprintf(
"Subagent roster (%d active / %d max; %d retained total):\n",
running, roster.MaxPerAgent, roster.Total,
))
for _, e := range roster.Entries {
sb.WriteString(fmt.Sprintf(" [%-9s] %s\n", e.Status, e.Label))
}
@@ -0,0 +1,50 @@
package tools
import (
"context"
"errors"
"testing"
"time"
)
func TestAnnounceQueueCloseDropsPendingDebounce(t *testing.T) {
drained := make(chan struct{}, 1)
queue := NewAnnounceQueue(20, 20, func(string, []AnnounceQueueItem, AnnounceMetadata) {
drained <- struct{}{}
})
queue.Enqueue("session", AnnounceQueueItem{SubagentID: "task"}, AnnounceMetadata{})
if err := queue.CloseContext(context.Background()); err != nil {
t.Fatal(err)
}
select {
case <-drained:
t.Fatal("pending announce drained after queue close")
case <-time.After(50 * time.Millisecond):
}
}
func TestAnnounceQueueCloseContextBoundsActiveDrain(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
queue := NewAnnounceQueue(1, 1, func(string, []AnnounceQueueItem, AnnounceMetadata) {
close(started)
<-release
})
queue.Enqueue("session", AnnounceQueueItem{SubagentID: "task"}, AnnounceMetadata{})
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("announce drain did not start")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := queue.CloseContext(ctx)
if !errors.Is(err, ErrAnnounceQueueDrainTimeout) {
t.Fatalf("CloseContext error = %v, want typed drain timeout", err)
}
close(release)
if err := queue.CloseContext(context.Background()); err != nil {
t.Fatalf("CloseContext retry: %v", err)
}
}
@@ -0,0 +1,93 @@
package tools
import (
"context"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
const asyncCompletionAttempts = 3
const (
asyncTerminalPersistenceAttempts = 12
asyncPersistenceAttemptTimeout = 2 * time.Second
asyncPersistenceMaxBackoff = time.Second
)
// PublishAsyncCompletion retries only after child execution has released its
// admission lease. The durable task row remains the fallback if every attempt
// finds the inbound queue saturated.
func PublishAsyncCompletion(
ctx context.Context,
msgBus *bus.MessageBus,
message bus.InboundMessage,
) bool {
if msgBus == nil {
return true
}
if ctx == nil {
ctx = context.Background()
}
for attempt := range asyncCompletionAttempts {
if msgBus.TryPublishInbound(message) {
return true
}
if attempt == asyncCompletionAttempts-1 {
break
}
timer := time.NewTimer(time.Duration(attempt+1) * 10 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
}
}
return false
}
func retryAsyncPersistence(
ctx context.Context,
operation func(context.Context) error,
) error {
return retryPersistence(ctx, asyncCompletionAttempts, operation)
}
func retryTerminalPersistence(
ctx context.Context,
operation func(context.Context) error,
) error {
return retryPersistence(ctx, asyncTerminalPersistenceAttempts, operation)
}
func retryPersistence(
ctx context.Context,
attempts int,
operation func(context.Context) error,
) error {
if ctx == nil {
ctx = context.Background()
}
var err error
for attempt := range attempts {
attemptCtx, cancel := context.WithTimeout(ctx, asyncPersistenceAttemptTimeout)
err = operation(attemptCtx)
cancel()
if err == nil {
return nil
}
if attempt == attempts-1 {
break
}
backoff := min(time.Duration(1<<attempt)*25*time.Millisecond, asyncPersistenceMaxBackoff)
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return err
case <-timer.C:
}
}
return err
}
@@ -0,0 +1,337 @@
package tools
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestSpawnAsyncReturnsDurableCompletionAndGetSurvivesManagerStateLoss(t *testing.T) {
manager := NewSubagentManager(
&recordingSubagentProvider{response: "durable spawn result"},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 2, MaxChildrenPerAgent: 2},
)
taskStore := newRecordingSubagentTaskStore()
manager.SetTaskStore(taskStore)
tool := NewSpawnTool(manager, "parent", 0)
ctx := subagentTestContext("parent")
accepted := tool.Execute(ctx, map[string]any{"task": "persist this", "mode": "async"})
if accepted == nil || accepted.IsError {
t.Fatalf("spawn result = %#v", accepted)
}
var receipt struct {
CompletionID string `json:"completion_id"`
TaskID string `json:"task_id"`
}
if err := json.NewDecoder(strings.NewReader(accepted.ForLLM)).Decode(&receipt); err != nil {
t.Fatalf("decode accepted result: %v\n%s", err, accepted.ForLLM)
}
completionID, err := uuid.Parse(receipt.CompletionID)
if err != nil || receipt.TaskID == "" {
t.Fatalf("receipt = %#v, parse error = %v", receipt, err)
}
manager.Close()
// A restarted manager has no in-memory tasks but can still retrieve the row.
restarted := NewSubagentManager(nil, nil, "", nil, nil, SubagentConfig{})
restarted.SetTaskStore(taskStore)
getTool := NewSpawnTool(restarted, "parent", 0)
got := getTool.Execute(ctx, map[string]any{
"action": "get",
"completion_id": completionID.String(),
})
if got == nil || got.IsError || !strings.Contains(got.ForLLM, "durable spawn result") {
t.Fatalf("durable get result = %#v", got)
}
otherRoot := store.WithAgentID(ctx, uuid.New())
if result := getTool.Execute(otherRoot, map[string]any{
"action": "get",
"completion_id": completionID.String(),
}); result == nil || !result.IsError {
t.Fatalf("other root retrieved completion: %#v", result)
}
restarted.Close()
}
func TestSpawnAsyncRejectsAcceptanceWhenDurableCreateFails(t *testing.T) {
provider := &recordingSubagentProvider{}
manager := NewSubagentManager(
provider,
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 1, MaxSpawnDepth: 1, MaxChildrenPerAgent: 1},
)
taskStore := newRecordingSubagentTaskStore()
taskStore.createErr = errors.New("database unavailable")
manager.SetTaskStore(taskStore)
_, err := manager.Spawn(
subagentTestContext("parent"),
"parent",
0,
"must not run",
"durable",
"",
"test",
"chat",
"",
nil,
)
if err == nil || !strings.Contains(err.Error(), "persist accepted subagent") {
t.Fatalf("spawn error = %v", err)
}
if provider.calls != 0 {
t.Fatalf("provider calls = %d, want 0", provider.calls)
}
manager.Close()
}
func TestSpawnAsyncDoesNotLabelAnnouncementDeliveredBeforeTerminalPersistence(t *testing.T) {
messageBus := bus.New()
manager := NewSubagentManager(
&recordingSubagentProvider{response: "terminal result"},
nil,
"model",
messageBus,
NewRegistry,
SubagentConfig{MaxConcurrent: 1, MaxSpawnDepth: 1, MaxChildrenPerAgent: 1},
)
taskStore := newRecordingSubagentTaskStore()
taskStore.updateErr = errors.New("terminal database write failed")
manager.SetTaskStore(taskStore)
_, err := manager.Spawn(
subagentTestContext("parent"),
"parent",
0,
"complete but fail persistence",
"durability-ordering",
"",
"test",
"chat",
"",
nil,
)
if err != nil {
t.Fatalf("spawn: %v", err)
}
manager.Close()
select {
case metadata := <-taskStore.metadata:
t.Fatalf("announcement metadata recorded before terminal persistence: %#v", metadata)
default:
}
}
func TestDelegateAsyncDurableGetIsSourceAgentScoped(t *testing.T) {
taskStore := newRecordingSubagentTaskStore()
tool := newDelegateTestTool(t, noopAgentLink{}, func(_ context.Context, _ DelegateRequest) (DelegateResult, error) {
return DelegateResult{Content: "durable delegation result"}, nil
})
tool.SetTaskStore(taskStore)
ctx := makeDelegateCtx(t)
accepted := tool.Execute(ctx, map[string]any{
"agent_key": "child-agent",
"task": "persist delegation",
"mode": "async",
})
if accepted == nil || accepted.IsError {
t.Fatalf("delegate result = %#v", accepted)
}
var receipt struct {
DelegationID string `json:"delegation_id"`
}
if err := json.Unmarshal([]byte(accepted.ForLLM), &receipt); err != nil {
t.Fatalf("decode delegation receipt: %v", err)
}
if _, err := uuid.Parse(receipt.DelegationID); err != nil {
t.Fatalf("delegation id = %q: %v", receipt.DelegationID, err)
}
tool.Close()
select {
case metadata := <-taskStore.metadata:
if metadata[asyncCompletionDeliveryKey] != asyncCompletionDeliveryMissed {
t.Fatalf("noninteractive delegate announcement metadata = %#v, want undelivered", metadata)
}
default:
t.Fatal("noninteractive delegate did not record get-only fallback")
}
delegationID := uuid.MustParse(receipt.DelegationID)
if err := taskStore.UpdateMetadata(
ctx,
store.AgentIDFromContext(ctx),
delegationID,
map[string]any{
asyncCompletionMediaKey: []persistedCompletionMedia{{
Path: ".delegations/" + receipt.DelegationID + "/report.pdf",
MimeType: "application/pdf",
Filename: "report.pdf",
}},
},
); err != nil {
t.Fatalf("persist logical completion media: %v", err)
}
restarted := NewDelegateTool(nil, nil, nil, nil)
restarted.SetTaskStore(taskStore)
defer restarted.Close()
got := restarted.Execute(ctx, map[string]any{
"action": "get",
"delegation_id": receipt.DelegationID,
})
if got == nil || got.IsError ||
!strings.Contains(got.ForLLM, "durable delegation result") ||
!strings.Contains(got.ForLLM, ".delegations/"+receipt.DelegationID+"/report.pdf") {
t.Fatalf("durable delegate get = %#v", got)
}
otherAgent := store.WithAgentID(ctx, uuid.New())
if result := restarted.Execute(otherAgent, map[string]any{
"action": "get",
"delegation_id": receipt.DelegationID,
}); result == nil || !result.IsError {
t.Fatalf("other agent retrieved delegation: %#v", result)
}
}
func TestDelegateAsyncRunningPersistenceDoesNotRetainAdmissionPermit(t *testing.T) {
taskStore := newRecordingSubagentTaskStore()
var runningAttempts atomic.Int32
runningDeadline := make(chan time.Duration, 1)
persistenceStarted := make(chan struct{}, 1)
persistenceRelease := make(chan struct{})
defer func() {
select {
case <-persistenceRelease:
default:
close(persistenceRelease)
}
}()
taskStore.updateHook = func(ctx context.Context, status string) error {
if status != TaskStatusRunning {
return nil
}
if runningAttempts.Add(1) != 1 {
return nil
}
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("running persistence has no deadline")
}
runningDeadline <- time.Until(deadline)
persistenceStarted <- struct{}{}
<-persistenceRelease
return errors.New("transient running persistence failure")
}
admission := orchestration.NewChildRunAdmission(1, 4)
runStarted := make(chan string, 2)
tool := NewDelegateToolWithAdmission(noopAgentLink{}, noopAgentCRUD{}, nil, func(_ context.Context, req DelegateRequest) (DelegateResult, error) {
runStarted <- req.Task
return DelegateResult{Content: "done"}, nil
}, admission)
tool.SetWorkspace(t.TempDir())
t.Cleanup(tool.Close)
tool.SetTaskStore(taskStore)
first := tool.Execute(makeDelegateCtx(t), map[string]any{
"agent_key": "child-agent",
"task": "first",
"mode": "async",
})
if first == nil || first.IsError {
t.Fatalf("first delegate result = %#v", first)
}
select {
case task := <-runStarted:
if task != "first" {
t.Fatalf("first started task = %q", task)
}
case <-time.After(2 * time.Second):
t.Fatal("first delegate run did not start")
}
select {
case <-persistenceStarted:
case <-time.After(2 * time.Second):
t.Fatal("running persistence did not start")
}
second := tool.Execute(makeDelegateCtx(t), map[string]any{
"agent_key": "child-agent",
"task": "second",
"mode": "async",
})
if second == nil || second.IsError {
t.Fatalf("second delegate result = %#v", second)
}
select {
case task := <-runStarted:
if task != "second" {
t.Fatalf("second started task = %q", task)
}
case <-time.After(2 * time.Second):
t.Fatal("running-status persistence retained the only admission permit")
}
close(persistenceRelease)
tool.Close()
select {
case remaining := <-runningDeadline:
if remaining <= 0 || remaining > 500*time.Millisecond {
t.Fatalf("running persistence deadline = %s, want at most 500ms", remaining)
}
default:
t.Fatal("running persistence was not attempted")
}
if attempts := runningAttempts.Load(); attempts != 2 {
t.Fatalf("running persistence attempts = %d, want one per accepted delegation", attempts)
}
}
func TestCompletionMediaDescriptorsNeverPersistHostPaths(t *testing.T) {
workspace := t.TempDir()
inside := filepath.Join(workspace, ".uploads", "report.pdf")
outside := filepath.Join(t.TempDir(), "secret.txt")
descriptors := completionMediaDescriptors([]bus.MediaFile{
{Path: inside, MimeType: "application/pdf", Filename: "report.pdf"},
{Path: outside, MimeType: "text/plain", Filename: "secret.txt"},
}, workspace, "")
if len(descriptors) != 1 || descriptors[0].Path != ".uploads/report.pdf" {
t.Fatalf("completion media = %#v, want one logical workspace path", descriptors)
}
encoded, err := json.Marshal(descriptors)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), workspace) || strings.Contains(string(encoded), outside) {
t.Fatalf("completion media leaked host path: %s", encoded)
}
if payload := persistedCompletionMediaPayload([]map[string]any{{
"path": outside,
}}); len(payload) != 0 {
t.Fatalf("absolute persisted media was returned: %#v", payload)
}
}
+80
View File
@@ -0,0 +1,80 @@
package tools
import (
"encoding/json"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
const asyncCompletionMediaKey = "completion_media"
type persistedCompletionMedia struct {
Path string `json:"path"`
MimeType string `json:"mime_type,omitempty"`
Filename string `json:"filename,omitempty"`
Caption string `json:"caption,omitempty"`
}
// completionMediaDescriptors converts runtime host paths into workspace-safe
// logical paths. Outside-workspace media is intentionally omitted.
func completionMediaDescriptors(
media []bus.MediaFile,
workspace, logicalPrefix string,
) []persistedCompletionMedia {
if len(media) == 0 || workspace == "" {
return nil
}
descriptors := make([]persistedCompletionMedia, 0, len(media))
for _, item := range media {
rawPath := item.Path
if !filepath.IsAbs(rawPath) {
rawPath = filepath.Join(workspace, rawPath)
}
absolutePath, err := filepath.Abs(rawPath)
if err != nil {
continue
}
relativePath, err := filepath.Rel(workspace, absolutePath)
if err != nil {
continue
}
logicalPath := filepath.ToSlash(relativePath)
if logicalPrefix != "" {
logicalPath = strings.TrimSuffix(logicalPrefix, "/") + "/" + logicalPath
}
normalized, err := validateArtifactRelativePath(logicalPath)
if err != nil {
continue
}
descriptors = append(descriptors, persistedCompletionMedia{
Path: normalized,
MimeType: item.MimeType,
Filename: item.Filename,
Caption: item.Caption,
})
}
return descriptors
}
func persistedCompletionMediaPayload(raw any) []persistedCompletionMedia {
encoded, err := json.Marshal(raw)
if err != nil {
return nil
}
var decoded []persistedCompletionMedia
if err := json.Unmarshal(encoded, &decoded); err != nil {
return nil
}
safe := decoded[:0]
for _, item := range decoded {
normalized, err := validateArtifactRelativePath(item.Path)
if err != nil {
continue
}
item.Path = normalized
safe = append(safe, item)
}
return safe
}
+113 -8
View File
@@ -2,12 +2,14 @@ package tools
import (
"context"
"fmt"
"log/slog"
"maps"
"sync"
"github.com/google/uuid"
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -33,9 +35,76 @@ const (
ctxAgentPolicy toolContextKey = "tool_agent_policy" // per-agent tool policy for MCP bridge enforcement
ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing
ctxRunKind toolContextKey = "tool_run_kind" // "notification", "announce", "delegation"
ctxSubagentScope toolContextKey = "subagent_task_scope"
ctxSubagentTaskID toolContextKey = "subagent_task_id"
ctxSubagentDepth toolContextKey = "subagent_depth"
ctxChildRunLease toolContextKey = "child_run_lease"
ctxTelegramManagerPermissions toolContextKey = "telegram_manager_permissions"
)
func withSubagentExecution(ctx context.Context, scope TaskScope, taskID string, depth int, lease *orchestration.ChildRunLease) context.Context {
ctx = context.WithValue(ctx, ctxSubagentScope, scope)
ctx = context.WithValue(ctx, ctxSubagentTaskID, taskID)
ctx = context.WithValue(ctx, ctxSubagentDepth, depth)
return context.WithValue(ctx, ctxChildRunLease, lease)
}
func childRunLeaseFromContext(ctx context.Context) *orchestration.ChildRunLease {
lease, _ := ctx.Value(ctxChildRunLease).(*orchestration.ChildRunLease)
return lease
}
// withDelegatedAgentExecution starts a fresh semantic spawn tree for the target
// agent while retaining the admission lease used by synchronous continuations.
func withDelegatedAgentExecution(ctx context.Context, lease *orchestration.ChildRunLease) context.Context {
ctx = context.WithValue(ctx, ctxSubagentScope, TaskScope{})
ctx = context.WithValue(ctx, ctxSubagentTaskID, "")
ctx = context.WithValue(ctx, ctxSubagentDepth, 0)
ctx = WithSubagentConfig(ctx, nil)
return context.WithValue(ctx, ctxChildRunLease, lease)
}
func subagentScopeFromContext(ctx context.Context) TaskScope {
if scope, ok := ctx.Value(ctxSubagentScope).(TaskScope); ok {
if scope.TenantID != uuid.Nil || scope.RootAgentID != uuid.Nil || scope.RootAgentKey != "" {
return scope
}
}
return TaskScope{
TenantID: store.TenantIDFromContext(ctx),
RootAgentID: store.AgentIDFromContext(ctx),
RootAgentKey: ToolAgentKeyFromCtx(ctx),
}
}
func subagentTaskIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(ctxSubagentTaskID).(string)
return id
}
func subagentDepthFromContext(ctx context.Context, fallback int) int {
if depth, ok := ctx.Value(ctxSubagentDepth).(int); ok {
return depth
}
return fallback
}
// childRunContinuationLineage returns structural admission lineage. It is
// intentionally separate from semantic sub-agent depth, which resets at every
// Agent Link delegation boundary.
func childRunContinuationLineage(
ctx context.Context,
fallbackParentTaskID string,
fallbackDepth int,
) (string, int) {
if lease := childRunLeaseFromContext(ctx); lease != nil {
if parentTaskID, parentDepth, ok := lease.ContinuationParent(); ok {
return parentTaskID, parentDepth + 1
}
}
return fallbackParentTaskID, fallbackDepth
}
// ctxRateLimitOverride carries a per-agent tool rate limit (calls/hour) that
// overrides the global tools.rate_limit_per_hour. 0 means "use the global".
const ctxRateLimitOverride toolContextKey = "tool_rate_limit_override"
@@ -53,6 +122,13 @@ type MediaPathLoader interface {
LoadPath(id string) (string, error)
}
// MediaPathRootProvider optionally exposes the managed root that authorizes
// legacy media returned by MediaPathLoader. Implementations must return a
// stable root; callers still validate containment and regular-file semantics.
type MediaPathRootProvider interface {
MediaRootPath() string
}
func WithToolChannel(ctx context.Context, channel string) context.Context {
return context.WithValue(ctx, ctxChannel, channel)
}
@@ -137,7 +213,7 @@ func WithToolWorkspace(ctx context.Context, ws string) context.Context {
}
func ToolWorkspaceFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxWorkspace).(string); v != "" {
if v, ok := ctx.Value(ctxWorkspace).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -374,7 +450,8 @@ func WithSubagentConfig(ctx context.Context, cfg *config.SubagentsConfig) contex
}
func SubagentConfigFromCtx(ctx context.Context) *config.SubagentsConfig {
if v, _ := ctx.Value(ctxSubagentCfg).(*config.SubagentsConfig); v != nil {
if raw := ctx.Value(ctxSubagentCfg); raw != nil {
v, _ := raw.(*config.SubagentsConfig)
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -432,7 +509,7 @@ func WithToolTeamID(ctx context.Context, teamID string) context.Context {
// ToolTeamIDFromCtx returns the dispatching team's ID from context.
func ToolTeamIDFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxTeamID).(string); v != "" {
if v, ok := ctx.Value(ctxTeamID).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -453,7 +530,7 @@ func WithToolTeamWorkspace(ctx context.Context, dir string) context.Context {
// ToolTeamWorkspaceFromCtx returns the team shared workspace directory path.
func ToolTeamWorkspaceFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxTeamWorkspace).(string); v != "" {
if v, ok := ctx.Value(ctxTeamWorkspace).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -495,7 +572,7 @@ func WithTeamTaskID(ctx context.Context, taskID string) context.Context {
// TeamTaskIDFromCtx returns the delegation's team task ID from context.
func TeamTaskIDFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxTeamTaskID).(string); v != "" {
if v, ok := ctx.Value(ctxTeamTaskID).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -518,7 +595,7 @@ func WithDelegationID(ctx context.Context, id string) context.Context {
// DelegationIDFromCtx returns the active delegation ID. Falls back to
// RunContext when no explicit context key is present.
func DelegationIDFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxDelegationID).(string); v != "" {
if v, ok := ctx.Value(ctxDelegationID).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -539,7 +616,7 @@ func WithLeaderAgentID(ctx context.Context, id string) context.Context {
// LeaderAgentIDFromCtx returns the leader's agent UUID string from context.
func LeaderAgentIDFromCtx(ctx context.Context) string {
if v, _ := ctx.Value(ctxLeaderAgentID).(string); v != "" {
if v, ok := ctx.Value(ctxLeaderAgentID).(string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -820,7 +897,7 @@ func WithTenantAllowedPaths(ctx context.Context, paths []string) context.Context
// TenantAllowedPathsFromCtx returns tenant-specific allowed paths from context.
// Falls back to RunContext for subagent inheritance.
func TenantAllowedPathsFromCtx(ctx context.Context) []string {
if v, _ := ctx.Value(ctxTenantAllowedPaths).([]string); len(v) > 0 {
if v, ok := ctx.Value(ctxTenantAllowedPaths).([]string); ok {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
@@ -828,3 +905,31 @@ func TenantAllowedPathsFromCtx(ctx context.Context) []string {
}
return nil
}
const ctxDelegationArtifactInputs toolContextKey = "tool_delegation_artifact_inputs"
// WithDelegationArtifactInputs installs the runtime-only host root backing the
// logical read-only inputs/ alias for an admitted Agent Link run.
func WithDelegationArtifactInputs(ctx context.Context, root string) context.Context {
return context.WithValue(ctx, ctxDelegationArtifactInputs, root)
}
// DelegationArtifactInputsFromCtx returns the runtime-only staged-input root.
// It is deliberately not copied into RunContext, prompts, traces, or results.
func DelegationArtifactInputsFromCtx(ctx context.Context) string {
root, _ := ctx.Value(ctxDelegationArtifactInputs).(string)
return root
}
// IsDelegationArtifactRun reports whether filesystem/media tools are executing
// inside an isolated Agent Link artifact exchange.
func IsDelegationArtifactRun(ctx context.Context) bool {
return DelegationIDFromCtx(ctx) != "" && DelegationArtifactInputsFromCtx(ctx) != ""
}
func validateDelegationChildRunMode(ctx context.Context, operation, mode string) error {
if IsDelegationArtifactRun(ctx) && mode != "sync" {
return fmt.Errorf("%s mode %q is not allowed inside an Agent Link artifact run; use mode=\"sync\"", operation, mode)
}
return nil
}
+22 -18
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -78,7 +79,11 @@ func (t *CreateImageTool) resolveReferenceImages(ctx context.Context, args map[s
}
seenPaths[path] = true
ext := strings.ToLower(filepath.Ext(path))
resolved, err := resolveStructuredMediaPath(ctx, path, "reference image")
if err != nil {
return nil, err
}
ext := strings.ToLower(filepath.Ext(resolved))
mimeTypes := map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif",
@@ -88,14 +93,6 @@ func (t *CreateImageTool) resolveReferenceImages(ctx context.Context, args map[s
if !ok {
mime = "image/png"
}
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
if err != nil {
return nil, fmt.Errorf("invalid reference image path: %w", err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return nil, err
}
data, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("failed to read reference image file: %w", err)
@@ -136,18 +133,25 @@ func (t *CreateImageTool) resolveReferenceImages(ctx context.Context, args map[s
seenIDs[id] = true
images := MediaImagesFromCtx(ctx)
if len(images) == 0 {
return nil, fmt.Errorf("no images available in conversation context")
}
var img providers.ImageContent
if id == "latest" {
ref, refErr := resolveImageMediaRef(ctx, id)
if refErr == nil && ref.Path != "" {
fileImages, err := (&ReadImageTool{}).loadImageFromPath(ctx, ref.Path)
if err != nil {
return nil, fmt.Errorf("failed to resolve reference image media_id %q: %w", id, err)
}
img = fileImages[0]
} else if id == "latest" && len(images) > 0 {
img = images[len(images)-1]
} else {
var idx int
if _, err := fmt.Sscanf(id, "%d", &idx); err == nil && idx >= 0 && idx < len(images) {
idx, err := strconv.Atoi(id)
if err == nil && idx >= 0 && idx < len(images) {
img = images[idx]
} else {
img = images[len(images)-1]
if refErr != nil {
return nil, refErr
}
return nil, fmt.Errorf("image media_id %q has no accessible workspace path", id)
}
}
dataBytes, _ := base64.StdEncoding.DecodeString(img.Data)
@@ -226,9 +230,9 @@ func (t *CreateImageTool) Parameters() map[string]any {
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Workspace file path to a reference image."},
"path": map[string]any{"type": "string", "description": "Logical workspace path to a reference image, such as .uploads/photo.jpg or delegation input inputs/photo.jpg. Never guess an absolute path."},
"url": map[string]any{"type": "string", "description": "HTTP/HTTPS URL of a reference image."},
"id": map[string]any{"type": "string", "description": "Media ID of a reference image from the chat."},
"id": map[string]any{"type": "string", "description": "Exact image ID from a <media:image id=\"...\"> tag, or 'latest', for in-process tool execution. Claude CLI/MCP callers should use path."},
"strength": map[string]any{"type": "number", "description": "Reference strength (0.0 to 1.0) specific to this image."},
"description": map[string]any{"type": "string", "description": "Description of the role or content of this reference image (e.g. 'Lâm', 'Quân')."},
},
@@ -396,6 +396,59 @@ func TestCreateImageTool_ResolveReferenceImage_ID(t *testing.T) {
}
}
func TestCreateImageTool_ResolveReferenceImage_ExactID(t *testing.T) {
workspace := t.TempDir()
firstPath := filepath.Join(workspace, "first.png")
secondPath := filepath.Join(workspace, "second.png")
firstData := []byte("first-image")
secondData := []byte("second-image")
if err := os.WriteFile(firstPath, firstData, 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(secondPath, secondData, 0644); err != nil {
t.Fatal(err)
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaImageRefs(ctx, []providers.MediaRef{
{ID: "image-first", Kind: "image", MimeType: "image/png", Path: firstPath},
{ID: "image-second", Kind: "image", MimeType: "image/png", Path: secondPath},
})
got, err := NewCreateImageTool(nil).resolveReferenceImages(ctx, map[string]any{
"ref_images": []any{map[string]any{"id": "image-first"}},
})
if err != nil {
t.Fatalf("resolve exact media ID: %v", err)
}
if len(got) != 1 || string(got[0].Data) != string(firstData) {
t.Fatalf("resolved images = %#v, want exact first image", got)
}
}
func TestCreateImageTool_ResolveReferenceImage_UnknownIDFails(t *testing.T) {
workspace := t.TempDir()
imagePath := filepath.Join(workspace, "known.png")
if err := os.WriteFile(imagePath, []byte("known-image"), 0644); err != nil {
t.Fatal(err)
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaImages(ctx, []providers.ImageContent{{
MimeType: "image/png",
Data: base64.StdEncoding.EncodeToString([]byte("fallback-must-not-be-used")),
}})
ctx = WithMediaImageRefs(ctx, []providers.MediaRef{{
ID: "known-id", Kind: "image", MimeType: "image/png", Path: imagePath,
}})
_, err := NewCreateImageTool(nil).resolveReferenceImages(ctx, map[string]any{
"ref_images": []any{map[string]any{"id": "missing-id"}},
})
if err == nil || !strings.Contains(err.Error(), `media_id "missing-id" not found`) {
t.Fatalf("unknown media ID error = %v", err)
}
}
func TestCreateImageTool_MultipleReferenceImages_MixedSources(t *testing.T) {
tmpDir := t.TempDir()
refFile := filepath.Join(tmpDir, "ref.png")
+1 -1
View File
@@ -727,7 +727,7 @@ func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath strin
return ErrorResult(fmt.Sprintf("credentialed sandbox path mapping: %v", cwdErr))
}
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
slog.Warn("security.credentialed_exec_sandbox_unavailable",
"binary", absPath, "error", err)
@@ -0,0 +1,238 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const delegateCompletionTargetKey = "target_agent_key"
const delegateRunningPersistenceTimeout = 250 * time.Millisecond
func (t *DelegateTool) createDelegateCompletion(ctx context.Context, req DelegateRequest) error {
if t.taskStore == nil {
return nil
}
delegationID, err := uuid.Parse(req.DelegationID)
if err != nil {
return fmt.Errorf("invalid delegation ID: %w", err)
}
tenantID := parseUUIDOrNil(req.TenantID)
if tenantID == uuid.Nil || req.FromAgentID == uuid.Nil {
return fmt.Errorf("delegation completion requires tenant and source agent")
}
var sessionKey, originChannel, originChatID, originPeerKind, originUserID *string
if req.SessionKey != "" {
sessionKey = &req.SessionKey
}
if req.Channel != "" {
originChannel = &req.Channel
}
if req.ChatID != "" {
originChatID = &req.ChatID
}
if req.PeerKind != "" {
originPeerKind = &req.PeerKind
}
if req.UserID != "" {
originUserID = &req.UserID
}
data := &store.SubagentTaskData{
BaseModel: store.BaseModel{ID: delegationID},
TenantID: tenantID,
RootAgentID: req.FromAgentID,
ParentAgentKey: req.FromAgentKey,
SessionKey: sessionKey,
Subject: "Delegate to " + req.ToAgentKey,
Description: req.Task,
Status: TaskStatusQueued,
Depth: subagentDepthFromContext(ctx, 0) + 1,
OriginChannel: originChannel,
OriginChatID: originChatID,
OriginPeerKind: originPeerKind,
OriginUserID: originUserID,
Metadata: map[string]any{
asyncCompletionKindKey: asyncCompletionKindDelegate,
delegateCompletionTargetKey: req.ToAgentKey,
asyncCompletionDeliveryKey: asyncCompletionDeliveryPending,
},
}
dbCtx := store.WithTenantID(context.Background(), tenantID)
if err := retryAsyncPersistence(dbCtx, func(attemptCtx context.Context) error {
return t.taskStore.Create(attemptCtx, data)
}); err != nil {
return fmt.Errorf("persist accepted delegation %s: %w", req.DelegationID, err)
}
return nil
}
func (t *DelegateTool) updateDelegateCompletion(
req DelegateRequest,
status string,
result *string,
) error {
if t.taskStore == nil {
return nil
}
delegationID := parseUUIDOrNil(req.DelegationID)
tenantID := parseUUIDOrNil(req.TenantID)
if delegationID == uuid.Nil || tenantID == uuid.Nil || req.FromAgentID == uuid.Nil {
return fmt.Errorf("delegation completion requires valid tenant, source agent, and delegation ID")
}
dbCtx := store.WithTenantID(context.Background(), tenantID)
if err := retryTerminalPersistence(dbCtx, func(attemptCtx context.Context) error {
return t.taskStore.UpdateStatus(
attemptCtx,
req.FromAgentID,
delegationID,
status,
result,
0,
0,
0,
)
}); err != nil {
slog.Error("delegate.async.completion_persist_failed",
"delegation_id", req.DelegationID,
"from_agent_id", req.FromAgentID,
"status", status,
"error", err,
)
return err
}
return nil
}
// updateDelegateRunning is observability-only: execution may proceed even when
// this transition cannot be persisted. Keep it to one short attempt outside
// the admission callback so a database outage cannot monopolize a child-run
// permit.
func (t *DelegateTool) updateDelegateRunning(req DelegateRequest) error {
if t.taskStore == nil {
return nil
}
delegationID := parseUUIDOrNil(req.DelegationID)
tenantID := parseUUIDOrNil(req.TenantID)
if delegationID == uuid.Nil || tenantID == uuid.Nil || req.FromAgentID == uuid.Nil {
return fmt.Errorf("delegation completion requires valid tenant, source agent, and delegation ID")
}
dbCtx, cancel := context.WithTimeout(
store.WithTenantID(context.Background(), tenantID),
delegateRunningPersistenceTimeout,
)
defer cancel()
err := t.taskStore.UpdateStatus(
dbCtx,
req.FromAgentID,
delegationID,
TaskStatusRunning,
nil,
0,
0,
0,
)
if err != nil {
slog.Warn("delegate.async.running_persist_failed",
"delegation_id", req.DelegationID,
"from_agent_id", req.FromAgentID,
"error", err,
)
}
return err
}
func (t *DelegateTool) updateDelegateCompletionMedia(
req DelegateRequest,
media []persistedCompletionMedia,
) error {
if t.taskStore == nil || len(media) == 0 {
return nil
}
delegationID := parseUUIDOrNil(req.DelegationID)
tenantID := parseUUIDOrNil(req.TenantID)
if delegationID == uuid.Nil || tenantID == uuid.Nil || req.FromAgentID == uuid.Nil {
return fmt.Errorf("delegation completion requires valid tenant, source agent, and delegation ID")
}
dbCtx := store.WithTenantID(context.Background(), tenantID)
if err := retryTerminalPersistence(dbCtx, func(attemptCtx context.Context) error {
return t.taskStore.UpdateMetadata(attemptCtx, req.FromAgentID, delegationID, map[string]any{
asyncCompletionMediaKey: media,
})
}); err != nil {
slog.Error("delegate.async.completion_media_persist_failed",
"delegation_id", req.DelegationID,
"from_agent_id", req.FromAgentID,
"error", err,
)
return err
}
return nil
}
func (t *DelegateTool) updateDelegateAnnouncement(req DelegateRequest, delivered bool) {
if t.taskStore == nil {
return
}
delegationID := parseUUIDOrNil(req.DelegationID)
tenantID := parseUUIDOrNil(req.TenantID)
if delegationID == uuid.Nil || tenantID == uuid.Nil || req.FromAgentID == uuid.Nil {
return
}
status := asyncCompletionDeliveryMissed
if delivered {
status = asyncCompletionDeliveryDone
}
dbCtx := store.WithTenantID(context.Background(), tenantID)
if err := retryAsyncPersistence(dbCtx, func(attemptCtx context.Context) error {
return t.taskStore.UpdateMetadata(attemptCtx, req.FromAgentID, delegationID, map[string]any{
asyncCompletionDeliveryKey: status,
})
}); err != nil {
slog.Warn("delegate.async.announcement_status_failed",
"delegation_id", req.DelegationID,
"error", err,
)
}
}
func (t *DelegateTool) executeGetCompletion(ctx context.Context, args map[string]any) *Result {
rawID, _ := args["delegation_id"].(string)
delegationID, err := uuid.Parse(rawID)
if err != nil {
return ErrorResult("delegation_id must be a valid UUID")
}
tenantID := store.TenantIDFromContext(ctx)
fromAgentID := store.AgentIDFromContext(ctx)
if tenantID == uuid.Nil || fromAgentID == uuid.Nil {
return ErrorResult("delegate result lookup requires tenant and agent context")
}
if t.taskStore == nil {
return ErrorResult("durable delegation task tracking is unavailable")
}
dbCtx := store.WithTenantID(context.WithoutCancel(ctx), tenantID)
task, err := t.taskStore.Get(dbCtx, fromAgentID, delegationID)
if err != nil {
return ErrorResult("failed to load delegation result")
}
if task == nil || completionKind(task.Metadata) != asyncCompletionKindDelegate {
return ErrorResult("delegation result not found")
}
payload := persistedCompletionPayload(task)
payload["delegation_id"] = task.ID.String()
delete(payload, "completion_id")
if target, ok := task.Metadata[delegateCompletionTargetKey].(string); ok && target != "" {
payload["agent"] = target
}
encoded, err := json.Marshal(payload)
if err != nil {
return ErrorResult("failed to encode delegation result")
}
return NewResult(string(encoded))
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,708 @@
package tools
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const delegationArtifactManifestMaxBytes = 1024 * 1024
type recoveredPublicationStatus uint8
const (
recoveredPublicationMissing recoveredPublicationStatus = iota
recoveredPublicationInvalid
recoveredPublicationValid
)
func isArtifactSymlinkError(err error) bool {
return errors.Is(err, errArtifactSymlink) || errors.Is(err, errArtifactReparsePoint)
}
type retainedDelegationArtifact struct {
tenantWorkspace string
tenantID uuid.UUID
tenantSlug string
delegationID uuid.UUID
retainUntil time.Time
callerLocation *delegationArtifactCallerLocation
publicationTempPath string
publicationDurable bool
onCleaned func()
}
func (t *DelegateTool) resolveDelegationCallerLocation(
job *delegateArtifactJob,
) *delegationArtifactCallerLocation {
type candidate struct {
name string
root string
}
candidates := []candidate{{name: "workspace", root: job.tenantWorkspace}}
if t.dataDir != "" {
candidates = append(candidates, candidate{
name: "data",
root: config.TenantDataDir(t.dataDir, job.tenantID, job.tenantSlug),
})
}
for _, candidate := range candidates {
relativePath, ok := artifactRelativeToRoot(candidate.root, job.callerWorkspace)
if !ok {
continue
}
return &delegationArtifactCallerLocation{
Base: candidate.name,
RelativePath: relativePath,
}
}
return nil
}
func artifactRelativeToRoot(root, target string) (string, bool) {
if root == "" || target == "" {
return "", false
}
absoluteRoot, err := filepath.Abs(root)
if err != nil {
return "", false
}
absoluteTarget, err := filepath.Abs(target)
if err != nil {
return "", false
}
relativePath, err := filepath.Rel(absoluteRoot, absoluteTarget)
if err != nil || relativePath == ".." ||
strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
return "", false
}
relativePath = filepath.ToSlash(relativePath)
if relativePath == "." {
return relativePath, true
}
validated, err := validateArtifactRelativePath(relativePath)
return validated, err == nil
}
func (t *DelegateTool) updateActiveDelegationLifecycle(
exchange *DelegationArtifactExchange,
job *delegateArtifactJob,
) error {
state, err := readDelegationArtifactLifecycleState(exchange.root, job.delegationID)
if err != nil {
return err
}
if state.Status != artifactLifecycleStaging {
return ErrArtifactState
}
state.TenantSlug = job.tenantSlug
state.CallerLocation = job.callerLocation
return exchange.persistLifecycleState(state)
}
func (t *DelegateTool) markDelegationRunning(
exchange *DelegationArtifactExchange,
job *delegateArtifactJob,
now time.Time,
) error {
state, err := readDelegationArtifactLifecycleState(exchange.root, job.delegationID)
if err != nil {
return err
}
if state.Status != artifactLifecycleStaging {
return ErrArtifactState
}
state.TenantSlug = job.tenantSlug
state.Status = artifactLifecycleRunning
state.FailedAt = nil
state.RetainUntil = now.UTC().Add(delegationArtifactFailureTTL)
state.ReasonCode = "artifact_running"
state.CallerLocation = job.callerLocation
state.PublicationTempPath = ""
return exchange.persistLifecycleState(state)
}
func (t *DelegateTool) markDelegationPublishing(
exchange *DelegationArtifactExchange,
job *delegateArtifactJob,
tempPath string,
now time.Time,
) error {
if err := validateDelegationPublicationTempPath(job.delegationID, tempPath); err != nil {
return err
}
state, err := readDelegationArtifactLifecycleState(exchange.root, job.delegationID)
if err != nil {
return err
}
if state.Status != artifactLifecycleRunning {
return ErrArtifactState
}
state.TenantSlug = job.tenantSlug
state.Status = artifactLifecyclePublishing
state.FailedAt = nil
state.RetainUntil = now.UTC().Add(delegationArtifactFailureTTL)
state.ReasonCode = "artifact_publishing"
state.CallerLocation = job.callerLocation
state.PublicationTempPath = tempPath
return exchange.persistLifecycleState(state)
}
func (t *DelegateTool) markDelegationPublished(
exchange *DelegationArtifactExchange,
job *delegateArtifactJob,
now time.Time,
) error {
state, err := readDelegationArtifactLifecycleState(exchange.root, job.delegationID)
if err != nil {
return err
}
if state.Status != artifactLifecyclePublishing {
return ErrArtifactState
}
state.Status = artifactLifecyclePublished
state.FailedAt = nil
state.RetainUntil = now.UTC()
state.ReasonCode = "artifact_published"
return exchange.persistLifecycleState(state)
}
func (t *DelegateTool) registerRetainedDelegationExchange(
exchange *DelegationArtifactExchange,
job *delegateArtifactJob,
status delegationArtifactLifecycleStatus,
) error {
if exchange == nil || job == nil {
return ErrArtifactState
}
retention, ok := exchange.FailureRetention()
if !ok {
return ErrArtifactState
}
state, err := readDelegationArtifactLifecycleState(exchange.root, job.delegationID)
if err != nil {
return err
}
failedAt := retention.FailedAt.UTC()
state.TenantSlug = job.tenantSlug
if status != artifactLifecycleCancelled {
status = artifactLifecycleFailed
}
state.Status = status
state.FailedAt = &failedAt
state.RetainUntil = retention.RetainUntil.UTC()
state.ReasonCode = retention.ReasonCode
state.CallerLocation = job.callerLocation
if err := exchange.persistLifecycleState(state); err != nil {
return err
}
t.addRetainedDelegationArtifact(retainedDelegationArtifact{
tenantWorkspace: job.tenantWorkspace,
tenantID: job.tenantID,
tenantSlug: job.tenantSlug,
delegationID: job.delegationID,
retainUntil: retention.RetainUntil,
callerLocation: job.callerLocation,
publicationTempPath: state.PublicationTempPath,
})
return nil
}
func (t *DelegateTool) registerPublishedDelegationCleanup(
job *delegateArtifactJob,
publicationTempPath string,
onCleaned func(),
) {
t.addRetainedDelegationArtifact(retainedDelegationArtifact{
tenantWorkspace: job.tenantWorkspace,
tenantID: job.tenantID,
tenantSlug: job.tenantSlug,
delegationID: job.delegationID,
retainUntil: time.Time{},
callerLocation: job.callerLocation,
publicationTempPath: publicationTempPath,
publicationDurable: true,
onCleaned: onCleaned,
})
}
func (t *DelegateTool) addRetainedDelegationArtifact(item retainedDelegationArtifact) {
key := retainedDelegationArtifactKey(item.tenantWorkspace, item.delegationID)
t.retainedMu.Lock()
defer t.retainedMu.Unlock()
if t.sweeperClosed {
return
}
if existing, ok := t.retained[key]; ok && item.onCleaned == nil {
item.onCleaned = existing.onCleaned
}
t.retained[key] = item
if t.sweeperStarted {
return
}
t.sweeperStarted = true
go t.runDelegationArtifactSweeper()
}
func retainedDelegationArtifactKey(tenantWorkspace string, delegationID uuid.UUID) string {
return tenantWorkspace + "\x00" + delegationID.String()
}
func (t *DelegateTool) runDelegationArtifactSweeper() {
ticker := time.NewTicker(delegationArtifactSweepInterval)
defer func() {
ticker.Stop()
close(t.sweeperDone)
}()
for {
select {
case now := <-ticker.C:
t.recoverRetainedDelegationExchanges()
t.sweepRetainedDelegationExchanges(now)
case <-t.sweeperStop:
return
}
}
}
func (t *DelegateTool) recoverRetainedDelegationExchanges() {
for _, tenantWorkspace := range t.delegationTenantWorkspaces() {
if err := t.recoverTenantDelegationExchanges(tenantWorkspace); err != nil {
slog.Warn("delegate.artifact_recovery_scan_failed")
}
}
}
func (t *DelegateTool) delegationTenantWorkspaces() []string {
if t.workspace == "" {
return nil
}
roots := make([]string, 0, 8)
if info, err := os.Lstat(t.workspace); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
roots = append(roots, t.workspace)
}
tenantParent := filepath.Join(t.workspace, "tenants")
entries, err := os.ReadDir(tenantParent)
if err != nil {
return roots
}
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 {
continue
}
info, err := entry.Info()
if err != nil || !info.IsDir() {
continue
}
roots = append(roots, filepath.Join(tenantParent, entry.Name()))
}
sort.Strings(roots)
return roots
}
func (t *DelegateTool) recoverTenantDelegationExchanges(tenantWorkspace string) error {
tenantRoot, err := openArtifactSecureRoot(tenantWorkspace)
if err != nil {
return err
}
defer tenantRoot.close()
delegationsRoot, err := tenantRoot.openSubroot("collaboration/delegations")
if err != nil {
if isArtifactNotExist(err) {
return nil
}
return err
}
defer delegationsRoot.close()
names, err := tenantRoot.readDir("collaboration/delegations")
if err != nil {
return err
}
sort.Strings(names)
recoveredAt := time.Now().UTC()
for _, name := range names {
delegationID, err := uuid.Parse(name)
if err != nil || delegationID == uuid.Nil || delegationID.String() != name {
if cleanupErr := delegationsRoot.removeTree(name); cleanupErr != nil {
slog.Warn("delegate.artifact_invalid_exchange_cleanup_failed",
"entry", name,
"error", cleanupErr,
)
} else {
slog.Warn("security.delegation_artifact_invalid_exchange_removed",
"entry", name,
)
}
continue
}
exchangeRoot, err := delegationsRoot.openSubroot(name)
if err != nil {
slog.Warn("delegate.artifact_exchange_recovery_open_failed",
"delegation_id", delegationID,
"error", err,
)
continue
}
state, stateErr := readDelegationArtifactLifecycleState(exchangeRoot, delegationID)
if stateErr != nil {
_ = exchangeRoot.close()
t.addRetainedDelegationArtifact(retainedDelegationArtifact{
tenantWorkspace: tenantWorkspace,
delegationID: delegationID,
retainUntil: recoveredAt.Add(delegationArtifactFailureTTL),
})
slog.Warn("delegate.artifact_corrupt_exchange_retained",
"delegation_id", delegationID,
"error", stateErr,
)
continue
}
tenantID, err := uuid.Parse(state.TenantID)
if err != nil || tenantID == uuid.Nil {
_ = exchangeRoot.close()
continue
}
if !t.lifecycleStateMatchesTenantWorkspace(state, tenantWorkspace, tenantID) {
_ = exchangeRoot.close()
continue
}
retainUntil := state.RetainUntil
publicationDurable := false
switch state.Status {
case artifactLifecyclePublishing:
item := retainedDelegationArtifact{
tenantWorkspace: tenantWorkspace,
tenantID: tenantID,
tenantSlug: state.TenantSlug,
delegationID: delegationID,
retainUntil: state.RetainUntil,
callerLocation: state.CallerLocation,
publicationTempPath: state.PublicationTempPath,
}
publicationStatus, inspectErr := t.inspectRecoveredPublication(item)
if inspectErr != nil {
_ = exchangeRoot.close()
t.addRetainedDelegationArtifact(item)
slog.Warn("delegate.artifact_publication_recovery_deferred",
"delegation_id", delegationID,
"error", inspectErr,
)
continue
}
if publicationStatus == recoveredPublicationValid {
state.Status = artifactLifecyclePublished
state.FailedAt = nil
state.RetainUntil = recoveredAt
state.ReasonCode = "artifact_published"
retainUntil = time.Time{}
publicationDurable = true
if err := persistDelegationArtifactLifecycleState(exchangeRoot, delegationID, state); err != nil {
slog.Warn("delegate.artifact_publication_recovery_state_failed",
"delegation_id", delegationID,
"error", err,
)
}
break
}
fallthrough
case artifactLifecycleStaging, artifactLifecycleRunning:
failedAt := recoveredAt
state.Status = artifactLifecycleFailed
state.FailedAt = &failedAt
state.RetainUntil = recoveredAt.Add(delegationArtifactFailureTTL)
state.ReasonCode = "artifact_recovered_stale"
if err := persistDelegationArtifactLifecycleState(exchangeRoot, delegationID, state); err != nil {
_ = exchangeRoot.close()
continue
}
retainUntil = state.RetainUntil
case artifactLifecyclePublished:
retainUntil = time.Time{}
publicationDurable = true
}
_ = exchangeRoot.close()
t.addRetainedDelegationArtifact(retainedDelegationArtifact{
tenantWorkspace: tenantWorkspace,
tenantID: tenantID,
tenantSlug: state.TenantSlug,
delegationID: delegationID,
retainUntil: retainUntil,
callerLocation: state.CallerLocation,
publicationTempPath: state.PublicationTempPath,
publicationDurable: publicationDurable,
})
}
return nil
}
func (t *DelegateTool) inspectRecoveredPublication(
item retainedDelegationArtifact,
) (recoveredPublicationStatus, error) {
callerRootPath, err := t.resolveRetainedCallerRoot(item)
if err != nil {
return recoveredPublicationInvalid, nil
}
callerRoot, err := openArtifactSecureRoot(callerRootPath)
if err != nil {
if isArtifactNotExist(err) {
return recoveredPublicationMissing, nil
}
return recoveredPublicationMissing, err
}
defer callerRoot.close()
finalPath := path.Join(".delegations", item.delegationID.String())
publicationRoot, err := callerRoot.openSubroot(finalPath)
if err != nil {
switch {
case isArtifactNotExist(err):
return recoveredPublicationMissing, nil
case isArtifactSymlinkError(err):
return recoveredPublicationInvalid, nil
default:
return recoveredPublicationMissing, err
}
}
defer publicationRoot.close()
entry, err := publicationRoot.openEntry("manifest.json")
if err != nil {
if isArtifactNotExist(err) || isArtifactSymlinkError(err) {
return recoveredPublicationInvalid, nil
}
return recoveredPublicationMissing, err
}
defer entry.close()
if entry.kind != artifactEntryRegular || entry.links != 1 ||
entry.size <= 0 || entry.size > delegationArtifactManifestMaxBytes {
return recoveredPublicationInvalid, nil
}
encoded, err := io.ReadAll(io.LimitReader(entry.file, delegationArtifactManifestMaxBytes+1))
if err != nil {
return recoveredPublicationMissing, err
}
if len(encoded) > delegationArtifactManifestMaxBytes {
return recoveredPublicationInvalid, nil
}
var manifest DelegationArtifactManifest
if err := json.Unmarshal(encoded, &manifest); err != nil {
return recoveredPublicationInvalid, nil
}
if manifest.SchemaVersion != DelegationArtifactManifestVersion ||
manifest.DelegationID != item.delegationID.String() ||
manifest.OutputCount != len(manifest.Outputs) ||
manifest.OutputCount < 0 ||
manifest.OutputCount > DelegationArtifactMaxFiles ||
manifest.OutputBytes < 0 ||
manifest.OutputBytes > DelegationArtifactMaxTotalBytes {
return recoveredPublicationInvalid, nil
}
seen := make(map[string]struct{}, len(manifest.Outputs))
var totalBytes int64
for _, output := range manifest.Outputs {
if err := validateManifestOutputPath(output.Path); err != nil ||
output.SizeBytes < 0 ||
output.SizeBytes > DelegationArtifactMaxFileBytes ||
output.SHA256 == "" {
return recoveredPublicationInvalid, nil
}
if _, exists := seen[output.Path]; exists {
return recoveredPublicationInvalid, nil
}
seen[output.Path] = struct{}{}
outputEntry, err := publicationRoot.openEntry(output.Path)
if err != nil {
if isArtifactNotExist(err) || isArtifactSymlinkError(err) {
return recoveredPublicationInvalid, nil
}
return recoveredPublicationMissing, err
}
valid := outputEntry.kind == artifactEntryRegular &&
outputEntry.links == 1 &&
outputEntry.size == output.SizeBytes
_ = outputEntry.close()
if !valid {
return recoveredPublicationInvalid, nil
}
totalBytes += output.SizeBytes
if totalBytes > DelegationArtifactMaxTotalBytes {
return recoveredPublicationInvalid, nil
}
}
if totalBytes != manifest.OutputBytes {
return recoveredPublicationInvalid, nil
}
return recoveredPublicationValid, nil
}
func (t *DelegateTool) lifecycleStateMatchesTenantWorkspace(
state delegationArtifactLifecycleState,
tenantWorkspace string,
tenantID uuid.UUID,
) bool {
if tenantID == store.MasterTenantID {
expected, err := filepath.Abs(t.workspace)
if err != nil {
return false
}
actual, err := filepath.Abs(tenantWorkspace)
return err == nil && actual == expected
}
if state.TenantSlug == "" {
// A staging state can be created immediately before its tenant slug is
// added. It has no caller publication path and can only delete its scanned
// exchange directory.
return state.Status == artifactLifecycleStaging &&
state.CallerLocation == nil &&
state.PublicationTempPath == ""
}
expected := config.TenantWorkspace(t.workspace, tenantID, state.TenantSlug)
expectedAbs, err := filepath.Abs(expected)
if err != nil {
return false
}
actualAbs, err := filepath.Abs(tenantWorkspace)
return err == nil && actualAbs == expectedAbs
}
func (t *DelegateTool) sweepRetainedDelegationExchanges(now time.Time) {
due := make([]retainedDelegationArtifact, 0, delegationArtifactSweepBatch)
t.retainedMu.Lock()
for _, item := range t.retained {
if !now.Before(item.retainUntil) {
due = append(due, item)
if len(due) == delegationArtifactSweepBatch {
break
}
}
}
t.retainedMu.Unlock()
for _, item := range due {
if err := t.cleanupRetainedDelegationArtifact(item); err != nil {
slog.Warn("delegate.artifact_retention_cleanup_failed")
continue
}
t.retainedMu.Lock()
delete(t.retained, retainedDelegationArtifactKey(item.tenantWorkspace, item.delegationID))
t.retainedMu.Unlock()
if item.onCleaned != nil {
item.onCleaned()
}
}
}
func (t *DelegateTool) cleanupRetainedDelegationArtifact(item retainedDelegationArtifact) error {
if item.publicationTempPath != "" && !item.publicationDurable {
if item.callerLocation == nil {
return ErrArtifactState
}
if err := validateDelegationPublicationTempPath(item.delegationID, item.publicationTempPath); err != nil {
return err
}
callerRootPath, err := t.resolveRetainedCallerRoot(item)
if err != nil {
return err
}
callerRoot, err := openArtifactSecureRoot(callerRootPath)
if err != nil {
return err
}
err = callerRoot.removeTree(item.publicationTempPath)
_ = callerRoot.close()
if err != nil && !isArtifactNotExist(err) {
return err
}
}
return t.tryRemoveDelegationExchange(item.tenantWorkspace, item.delegationID)
}
func (t *DelegateTool) resolveRetainedCallerRoot(item retainedDelegationArtifact) (string, error) {
if item.callerLocation == nil {
return "", ErrArtifactState
}
var base string
switch item.callerLocation.Base {
case "workspace":
base = item.tenantWorkspace
case "data":
if t.dataDir == "" {
return "", ErrArtifactState
}
base = config.TenantDataDir(t.dataDir, item.tenantID, item.tenantSlug)
default:
return "", ErrArtifactState
}
relativePath := item.callerLocation.RelativePath
if relativePath == "." {
return base, nil
}
validated, err := validateArtifactRelativePath(relativePath)
if err != nil {
return "", err
}
return filepath.Join(base, filepath.FromSlash(validated)), nil
}
func validateDelegationPublicationTempPath(delegationID uuid.UUID, tempPath string) error {
validated, err := validateArtifactRelativePath(tempPath)
if err != nil {
return err
}
prefix := ".tmp-" + delegationID.String() + "-"
if path.Dir(validated) != ".delegations" ||
!strings.HasPrefix(path.Base(validated), prefix) {
return ErrArtifactInvalidPath
}
suffix := strings.TrimPrefix(path.Base(validated), prefix)
parsed, err := uuid.Parse(suffix)
if err != nil || parsed == uuid.Nil || parsed.String() != suffix {
return ErrArtifactInvalidPath
}
return nil
}
func (t *DelegateTool) removeDelegationExchange(
tenantWorkspace string,
delegationID uuid.UUID,
) error {
if tenantWorkspace == "" || delegationID == uuid.Nil {
return ErrArtifactState
}
tenantRoot, err := openArtifactSecureRoot(tenantWorkspace)
if err != nil {
return err
}
defer tenantRoot.close()
relativePath := path.Join("collaboration", "delegations", delegationID.String())
if err := tenantRoot.removeTree(relativePath); err != nil && !isArtifactNotExist(err) {
return fmt.Errorf("remove delegation exchange: %w", err)
}
return nil
}
func (t *DelegateTool) tryRemoveDelegationExchange(
tenantWorkspace string,
delegationID uuid.UUID,
) error {
if t.removeExchange != nil {
return t.removeExchange(tenantWorkspace, delegationID)
}
return t.removeDelegationExchange(tenantWorkspace, delegationID)
}
@@ -0,0 +1,118 @@
package tools
import (
"context"
"fmt"
"path"
"path/filepath"
"strings"
)
// resolveDelegationInputPath maps the logical inputs/ alias to the captured
// exchange input directory. The host root never needs to be disclosed to the
// delegated model.
func resolveDelegationInputPath(ctx context.Context, raw string) (string, bool, error) {
root := DelegationArtifactInputsFromCtx(ctx)
if root == "" || filepath.IsAbs(raw) {
return "", false, nil
}
logical := path.Clean(filepath.ToSlash(strings.TrimSpace(raw)))
if logical == "inputs" {
return filepath.Clean(root), true, nil
}
if !strings.HasPrefix(logical, "inputs/") {
return "", false, nil
}
relative, err := validateArtifactRelativePath(strings.TrimPrefix(logical, "inputs/"))
if err != nil {
return "", true, err
}
resolved, err := resolvePathWithAllowed(
filepath.FromSlash(relative),
root,
true,
nil,
)
if err != nil {
return "", true, fmt.Errorf("cannot access delegation input")
}
return resolved, true, nil
}
func rejectDelegationInputMutation(ctx context.Context, raw string) error {
if _, handled, err := resolveDelegationInputPath(ctx, raw); handled {
if err != nil {
return err
}
return fmt.Errorf("delegation inputs are read-only")
}
return nil
}
// resolveStructuredMediaPath resolves logical delegation inputs for media
// readers without widening filesystem-tool authority. Outside an artifact run
// it preserves the normal workspace and Agent Team read rules.
func resolveStructuredMediaPath(ctx context.Context, raw, kind string) (string, error) {
return resolveStructuredMediaPathWithAllowed(ctx, raw, kind, allowedWithTeamWorkspace(ctx, nil))
}
// resolveStructuredMediaRefPath resolves paths sourced from conversation
// MediaRefs. Unlike explicit user/tool paths, a ref is authoritative only for
// the active workspace or the current delegation's staged inputs.
func resolveStructuredMediaRefPath(ctx context.Context, raw, kind string) (string, error) {
if inputRoot := DelegationArtifactInputsFromCtx(ctx); inputRoot != "" && filepath.IsAbs(raw) {
if resolved, err := resolvePathWithAllowed(raw, inputRoot, true, nil); err == nil {
if err := ValidateRegularFileForRead(resolved); err != nil {
return "", fmt.Errorf("%s delegation input is not a safe regular file", kind)
}
return filepath.Clean(resolved), nil
}
}
return resolveStructuredMediaPathWithAllowed(ctx, raw, kind, nil)
}
// resolveLoadedMediaRefPath preserves legacy media-store compatibility without
// treating an arbitrary loader return value as filesystem authority.
func resolveLoadedMediaRefPath(
ctx context.Context,
loader MediaPathLoader,
raw, kind string,
) (string, error) {
if rooted, ok := loader.(MediaPathRootProvider); ok {
root := rooted.MediaRootPath()
if root != "" {
if resolved, err := resolvePathWithAllowed(raw, root, true, nil); err == nil {
if err := ValidateRegularFileForRead(resolved); err != nil {
return "", fmt.Errorf("%s legacy media is not a safe regular file", kind)
}
return filepath.Clean(resolved), nil
}
}
}
return resolveStructuredMediaRefPath(ctx, raw, kind)
}
func resolveStructuredMediaPathWithAllowed(ctx context.Context, raw, kind string, allowedPrefixes []string) (string, error) {
if resolved, handled, err := resolveDelegationInputPath(ctx, raw); handled {
if err != nil {
return "", fmt.Errorf("invalid %s delegation input path", kind)
}
if err := ValidateRegularFileForRead(resolved); err != nil {
return "", fmt.Errorf("%s delegation input is not a safe regular file", kind)
}
return resolved, nil
}
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(raw, workspace, effectiveRestrict(ctx, true), allowedPrefixes)
if err != nil {
return "", fmt.Errorf("invalid %s path: %w", kind, err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return "", err
}
if err := ValidateRegularFileForRead(resolved); err != nil {
return "", fmt.Errorf("%s path is not a safe regular file: %w", kind, err)
}
return filepath.Clean(resolved), nil
}
@@ -0,0 +1,217 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func delegationArtifactToolContext(t *testing.T) (context.Context, string, string) {
t.Helper()
root := t.TempDir()
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0750); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(outputs, 0750); err != nil {
t.Fatal(err)
}
ctx := WithDelegationID(context.Background(), uuid.NewString())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, outputs)
return ctx, inputs, outputs
}
func TestDelegationArtifactFilePolicy(t *testing.T) {
ctx, inputs, outputs := delegationArtifactToolContext(t)
if err := os.WriteFile(filepath.Join(inputs, "source.txt"), []byte("staged"), 0440); err != nil {
t.Fatal(err)
}
read := NewReadFileTool(outputs, true).Execute(ctx, map[string]any{"path": "inputs/source.txt"})
if read.IsError || !strings.Contains(read.ForLLM, "staged") {
t.Fatalf("read staged input = %#v", read)
}
listed := NewListFilesTool(outputs, true).Execute(ctx, map[string]any{"path": "inputs"})
if listed.IsError || !strings.Contains(listed.ForLLM, "source.txt") {
t.Fatalf("list staged inputs = %#v", listed)
}
writeInput := NewWriteFileTool(outputs, true).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "content": "mutated",
})
if !writeInput.IsError || !strings.Contains(writeInput.ForLLM, "read-only") {
t.Fatalf("input mutation = %#v, want read-only error", writeInput)
}
editInput := NewEditTool(outputs, true).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "old_string": "staged", "new_string": "mutated",
})
if !editInput.IsError || !strings.Contains(editInput.ForLLM, "read-only") {
t.Fatalf("input edit = %#v, want read-only error", editInput)
}
written := NewWriteFileTool(outputs, true).Execute(ctx, map[string]any{
"path": "report.txt", "content": "result", "deliver": true,
})
if written.IsError || len(written.Media) != 0 {
t.Fatalf("prepublication write delivery = %#v", written)
}
if got, err := os.ReadFile(filepath.Join(outputs, "report.txt")); err != nil || string(got) != "result" {
t.Fatalf("output file = %q, %v", got, err)
}
if result := NewSendFileTool(outputs, true).Execute(ctx, map[string]any{"path": "report.txt"}); !result.IsError {
t.Fatalf("send_file published prepublication output: %#v", result)
}
if result := (&MessageTool{}).Execute(ctx, map[string]any{
"action": "send", "message": "MEDIA:report.txt",
}); !result.IsError {
t.Fatalf("message published prepublication output: %#v", result)
}
}
func TestReadDocumentArchiveUsesLogicalDelegationInputPath(t *testing.T) {
ctx, inputs, outputs := delegationArtifactToolContext(t)
archive := filepath.Join(inputs, "archive.zip")
if err := os.WriteFile(archive, []byte("not a real zip"), 0440); err != nil {
t.Fatal(err)
}
result := NewReadDocumentTool(nil, nil).Execute(ctx, map[string]any{
"path": "inputs/archive.zip",
"prompt": "inspect archive",
})
if result.IsError {
t.Fatalf("read_document archive = %#v", result)
}
if strings.Contains(result.ForLLM, inputs) || strings.Contains(result.ForLLM, outputs) {
t.Fatalf("archive result leaked host path: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "inputs/archive.zip") {
t.Fatalf("archive result omitted logical path: %s", result.ForLLM)
}
}
func TestDelegationArtifactDisablesVirtualMemoryRouting(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
agentID := uuid.New()
ctx = store.WithAgentID(ctx, agentID)
ctx = store.WithUserID(ctx, "user-1")
memStore := newMockMemoryStore()
memStore.docs[docKey(agentID.String(), "user-1", "MEMORY.md")] = "database-secret"
interceptor := NewMemoryInterceptor(memStore, outputs)
if err := os.WriteFile(filepath.Join(outputs, "MEMORY.md"), []byte("physical-output"), 0640); err != nil {
t.Fatal(err)
}
reader := NewReadFileTool(outputs, true)
reader.SetMemoryInterceptor(interceptor)
result := reader.Execute(ctx, map[string]any{"path": "MEMORY.md"})
if result.IsError || strings.Contains(result.ForLLM, "database-secret") ||
!strings.Contains(result.ForLLM, "physical-output") {
t.Fatalf("delegation memory read routed virtually: %#v", result)
}
writer := NewWriteFileTool(outputs, true)
writer.SetMemoryInterceptor(interceptor)
result = writer.Execute(ctx, map[string]any{"path": "MEMORY.md", "content": "new-output"})
if result.IsError {
t.Fatalf("physical MEMORY.md write failed: %#v", result)
}
if got := memStore.docs[docKey(agentID.String(), "user-1", "MEMORY.md")]; got != "database-secret" {
t.Fatalf("virtual memory mutated: %q", got)
}
}
func TestDelegationStructuredMediaPathsResolveOnlyStagedInputs(t *testing.T) {
ctx, inputs, _ := delegationArtifactToolContext(t)
staged := filepath.Join(inputs, "clip.mp4")
if err := os.WriteFile(staged, []byte("video"), 0440); err != nil {
t.Fatal(err)
}
got, err := resolveStructuredMediaPath(ctx, "inputs/clip.mp4", "video")
if err != nil {
t.Fatalf("resolve staged media: %v", err)
}
want, err := filepath.EvalSymlinks(staged)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("resolved path = %q, want %q", got, want)
}
_, err = resolveStructuredMediaPath(ctx, "inputs/missing.mp4", "video")
if err == nil {
t.Fatal("missing staged media unexpectedly resolved")
}
if strings.Contains(err.Error(), filepath.Dir(inputs)) {
t.Fatalf("error leaked exchange host root: %v", err)
}
}
func TestCreateImageReferenceUsesLogicalDelegationInputPath(t *testing.T) {
ctx, inputs, _ := delegationArtifactToolContext(t)
staged := filepath.Join(inputs, "reference.png")
want := []byte("staged-reference")
if err := os.WriteFile(staged, want, 0440); err != nil {
t.Fatal(err)
}
refs, err := NewCreateImageTool(nil).resolveReferenceImages(ctx, map[string]any{
"ref_images": []any{map[string]any{"path": "inputs/reference.png"}},
})
if err != nil {
t.Fatalf("resolve staged create_image reference: %v", err)
}
if len(refs) != 1 || string(refs[0].Data) != string(want) {
t.Fatalf("resolved refs = %#v, want staged reference", refs)
}
}
func TestDelegationArtifactResultPolicySuppressesUnpublishedMedia(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
result := &Result{
ForLLM: "created\nMEDIA:" + filepath.Join(outputs, "generated.png") + "\nkeep this",
Media: []bus.MediaFile{{
Path: filepath.Join(outputs, "generated.png"),
}},
}
ApplyDelegationArtifactResultPolicy(ctx, result)
if len(result.Media) != 0 {
t.Fatalf("unpublished media remained: %#v", result.Media)
}
if strings.Contains(result.ForLLM, "MEDIA:") || !strings.Contains(result.ForLLM, "keep this") {
t.Fatalf("artifact result policy = %q", result.ForLLM)
}
}
func TestDelegationArtifactResultPolicyPreservesMediaDiscussion(t *testing.T) {
ctx := delegationArtifactTestContext()
result := &Result{
ForLLM: "The literal marker MEDIA: is documented here.\nGenerated MEDIA:outputs/report.pdf successfully.",
}
ApplyDelegationArtifactResultPolicy(ctx, result)
if !strings.Contains(result.ForLLM, "The literal marker MEDIA: is documented here.") {
t.Fatalf("legitimate MEDIA discussion was removed: %q", result.ForLLM)
}
if strings.Contains(result.ForLLM, "MEDIA:outputs/report.pdf") {
t.Fatalf("unpublished media path leaked: %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Generated successfully.") {
t.Fatalf("surrounding tool result text was removed: %q", result.ForLLM)
}
}
@@ -0,0 +1,36 @@
package tools
import (
"context"
"strings"
)
// ApplyDelegationArtifactResultPolicy keeps unvalidated delegate outputs inside
// the exchange. Publication is owned by DelegateTool after the child run
// succeeds and the manifest has been validated.
func ApplyDelegationArtifactResultPolicy(ctx context.Context, result *Result) {
if result == nil || !IsDelegationArtifactRun(ctx) {
return
}
result.Media = nil
result.ForLLM = stripArtifactMediaLines(result.ForLLM)
}
func stripArtifactMediaLines(content string) string {
if !strings.Contains(content, "MEDIA:") {
return content
}
lines := strings.Split(content, "\n")
kept := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[[audio_as_voice]]") {
continue
}
cleaned := strings.TrimRight(embeddedMediaPattern.ReplaceAllString(line, ""), " \t")
if strings.TrimSpace(cleaned) != "" {
kept = append(kept, cleaned)
}
}
return strings.TrimSpace(strings.Join(kept, "\n"))
}
+323
View File
@@ -0,0 +1,323 @@
package tools
import (
"context"
"errors"
"fmt"
"path"
"strings"
"sync"
"time"
"unicode"
"github.com/google/uuid"
)
const (
DelegationArtifactManifestVersion = 1
DelegationArtifactMaxFileBytes = int64(50 * 1024 * 1024)
DelegationArtifactMaxTotalBytes = int64(100 * 1024 * 1024)
DelegationArtifactMaxFiles = 100
)
var (
ErrArtifactInvalidPath = errors.New("invalid delegation artifact path")
ErrArtifactNonRegular = errors.New("delegation artifact is not a regular file")
ErrArtifactHardlink = errors.New("delegation artifact has multiple hard links")
ErrArtifactLimitExceeded = errors.New("delegation artifact limit exceeded")
ErrArtifactPublishConflict = errors.New("delegation artifact publication conflict")
ErrArtifactSecureUnavailable = errors.New("secure delegation artifact filesystem unavailable")
ErrArtifactState = errors.New("invalid delegation artifact state")
)
// DelegationArtifactError is safe to return across runtime boundaries: Path is
// always a logical relative path and never a host workspace path.
type DelegationArtifactError struct {
Code string
Op string
Path string
Err error
}
func (e *DelegationArtifactError) Error() string {
switch {
case e.Path != "":
return fmt.Sprintf("%s %q: %v", e.Op, e.Path, e.Err)
case e.Op != "":
return fmt.Sprintf("%s: %v", e.Op, e.Err)
default:
return e.Err.Error()
}
}
func (e *DelegationArtifactError) Unwrap() error { return e.Err }
type DelegationArtifactLimits struct {
MaxFileBytes int64
MaxTotalBytes int64
MaxFiles int
}
func DefaultDelegationArtifactLimits() DelegationArtifactLimits {
return DelegationArtifactLimits{
MaxFileBytes: DelegationArtifactMaxFileBytes,
MaxTotalBytes: DelegationArtifactMaxTotalBytes,
MaxFiles: DelegationArtifactMaxFiles,
}
}
func (l DelegationArtifactLimits) validate() (DelegationArtifactLimits, error) {
if l == (DelegationArtifactLimits{}) {
return DefaultDelegationArtifactLimits(), nil
}
if l.MaxFileBytes <= 0 || l.MaxTotalBytes <= 0 || l.MaxFiles <= 0 {
return DelegationArtifactLimits{}, artifactError(
"artifact_invalid_limits", "configure", "", ErrArtifactLimitExceeded,
)
}
if l.MaxFileBytes > l.MaxTotalBytes {
return DelegationArtifactLimits{}, artifactError(
"artifact_invalid_limits", "configure", "", ErrArtifactLimitExceeded,
)
}
return l, nil
}
type DelegationArtifactStatus string
const (
DelegationArtifactStaged DelegationArtifactStatus = "staged"
DelegationArtifactPublished DelegationArtifactStatus = "published"
)
type DelegationArtifact struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
MediaType string `json:"media_type"`
Status DelegationArtifactStatus `json:"status"`
}
type DelegationArtifactManifestOutput struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
MediaType string `json:"media_type"`
}
// DelegationArtifactManifest is the durable, workspace-safe manifest. It
// deliberately excludes tenant IDs, agent IDs, host paths, prompts, and traces.
type DelegationArtifactManifest struct {
SchemaVersion int `json:"schema_version"`
DelegationID string `json:"delegation_id"`
PublishedAt time.Time `json:"published_at"`
OutputCount int `json:"output_count"`
OutputBytes int64 `json:"output_bytes"`
Outputs []DelegationArtifactManifestOutput `json:"outputs"`
}
type DelegationArtifactPublication struct {
RootPath string
ManifestPath string
Manifest DelegationArtifactManifest
}
type DelegationArtifactMount struct {
LogicalAlias string
HostRoot string
ContainerPath string
ReadOnly bool
}
type DelegationArtifactFailureRetention struct {
FailedAt time.Time
RetainUntil time.Time
ReasonCode string
PublicationTempPath string
}
type delegationArtifactExchangeState string
const (
artifactExchangeOpen delegationArtifactExchangeState = "open"
artifactExchangeFailed delegationArtifactExchangeState = "failed_retained"
artifactExchangePublished delegationArtifactExchangeState = "published"
)
// DelegationArtifactRoot retains a secure directory handle so later renames or
// replacements of the path used to open it cannot redirect artifact access.
type DelegationArtifactRoot struct {
root *artifactSecureRoot
}
func OpenDelegationArtifactRoot(hostPath string) (*DelegationArtifactRoot, error) {
root, err := openArtifactSecureRoot(hostPath)
if err != nil {
return nil, wrapArtifactFilesystemError("capture_root", "", err)
}
return &DelegationArtifactRoot{root: root}, nil
}
func (r *DelegationArtifactRoot) Close() error {
if r == nil || r.root == nil {
return nil
}
return r.root.close()
}
// DelegationArtifactExchange owns one tenant-scoped, delegation-scoped
// inputs/outputs exchange. Host paths are runtime-only and never serialized.
type DelegationArtifactExchange struct {
tenantID uuid.UUID
delegationID uuid.UUID
hostRoot string
root *artifactSecureRoot
limits DelegationArtifactLimits
failureTTL time.Duration
mu sync.Mutex
state delegationArtifactExchangeState
inputCount int
inputBytes int64
inputs []DelegationArtifact
failure *DelegationArtifactFailureRetention
publicationTempPath string
}
func (e *DelegationArtifactExchange) DelegationID() uuid.UUID { return e.delegationID }
func (e *DelegationArtifactExchange) TenantID() uuid.UUID { return e.tenantID }
// InputsMount is runtime wiring for the delegated sandbox. Do not include its
// HostRoot in prompts, tool results, logs, traces, or durable manifests.
func (e *DelegationArtifactExchange) InputsMount() DelegationArtifactMount {
return DelegationArtifactMount{
LogicalAlias: "inputs",
HostRoot: e.inputsHostPath(),
ContainerPath: "/workspace/inputs",
ReadOnly: true,
}
}
// OutputsHostPath is the delegated run's ephemeral read/write workspace.
func (e *DelegationArtifactExchange) OutputsHostPath() string {
return e.outputsHostPath()
}
func (e *DelegationArtifactExchange) Close() error {
if e == nil || e.root == nil {
return nil
}
return e.root.close()
}
func (e *DelegationArtifactExchange) FailureRetention() (DelegationArtifactFailureRetention, bool) {
e.mu.Lock()
defer e.mu.Unlock()
if e.failure == nil {
return DelegationArtifactFailureRetention{}, false
}
return *e.failure, true
}
func (e *DelegationArtifactExchange) ReadyForCleanup(now time.Time) bool {
retention, ok := e.FailureRetention()
return ok && !now.Before(retention.RetainUntil)
}
func (e *DelegationArtifactExchange) RetainFailure(now time.Time, reasonCode string) {
e.mu.Lock()
defer e.mu.Unlock()
e.retainFailureLocked(now, reasonCode)
}
func (e *DelegationArtifactExchange) retainFailureLocked(now time.Time, reasonCode string) {
if e.state == artifactExchangePublished {
return
}
if e.failure != nil {
return
}
if reasonCode == "" {
reasonCode = "artifact_exchange_failed"
}
e.state = artifactExchangeFailed
e.failure = &DelegationArtifactFailureRetention{
FailedAt: now.UTC(),
RetainUntil: now.UTC().Add(e.failureTTL),
ReasonCode: reasonCode,
PublicationTempPath: e.publicationTempPath,
}
}
func artifactError(code, op, logicalPath string, err error) error {
return &DelegationArtifactError{
Code: code,
Op: op,
Path: logicalPath,
Err: err,
}
}
func wrapArtifactFilesystemError(op, logicalPath string, err error) error {
if err == nil {
return nil
}
if errors.Is(err, ErrArtifactSecureUnavailable) {
return artifactError("artifact_secure_open_unavailable", op, logicalPath, err)
}
if errors.Is(err, ErrArtifactPublishConflict) {
return artifactError("artifact_publish_conflict", op, logicalPath, err)
}
return artifactError("artifact_filesystem_error", op, logicalPath, err)
}
func validateArtifactRelativePath(raw string) (string, error) {
if raw == "" ||
strings.Contains(raw, `\`) ||
strings.Contains(raw, ":") ||
strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", artifactError("artifact_invalid_path", "validate_path", raw, ErrArtifactInvalidPath)
}
if strings.HasPrefix(raw, "/") {
return "", artifactError("artifact_invalid_path", "validate_path", raw, ErrArtifactInvalidPath)
}
clean := path.Clean(raw)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != raw {
return "", artifactError("artifact_invalid_path", "validate_path", raw, ErrArtifactInvalidPath)
}
for component := range strings.SplitSeq(clean, "/") {
if component == "" ||
component == "." ||
component == ".." ||
strings.HasSuffix(component, ".") ||
strings.HasSuffix(component, " ") ||
isReservedArtifactComponent(component) {
return "", artifactError("artifact_invalid_path", "validate_path", raw, ErrArtifactInvalidPath)
}
}
return clean, nil
}
func isReservedArtifactComponent(component string) bool {
base := component
if dot := strings.IndexByte(base, '.'); dot >= 0 {
base = base[:dot]
}
switch strings.ToUpper(base) {
case "CON", "PRN", "AUX", "NUL", "CLOCK$",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return true
default:
return false
}
}
func checkArtifactContext(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
+136
View File
@@ -0,0 +1,136 @@
package tools
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
)
type artifactSniffWriter struct {
buf []byte
}
func (w *artifactSniffWriter) Write(p []byte) (int, error) {
if len(w.buf) < 512 {
remaining := min(512-len(w.buf), len(p))
w.buf = append(w.buf, p[:remaining]...)
}
return len(p), nil
}
func copyArtifactFile(
ctx context.Context,
sourceRoot *artifactSecureRoot,
sourcePath string,
destinationRoot *artifactSecureRoot,
destinationPath string,
maxFileBytes int64,
maxRemainingBytes int64,
destinationMode os.FileMode,
) (DelegationArtifact, error) {
if maxRemainingBytes < 0 {
return DelegationArtifact{}, artifactError(
"artifact_total_byte_limit", "copy", destinationPath, ErrArtifactLimitExceeded,
)
}
source, err := sourceRoot.openEntry(sourcePath)
if err != nil {
return DelegationArtifact{}, classifyArtifactOpenError("open_source", sourcePath, err)
}
defer source.close()
if source.kind != artifactEntryRegular {
return DelegationArtifact{}, artifactError(
"artifact_non_regular", "open_source", sourcePath, ErrArtifactNonRegular,
)
}
if source.links != 1 {
return DelegationArtifact{}, artifactError(
"artifact_hardlink", "open_source", sourcePath, ErrArtifactHardlink,
)
}
effectiveLimit := min(maxRemainingBytes, maxFileBytes)
if source.size > effectiveLimit {
return DelegationArtifact{}, artifactError(
"artifact_byte_limit", "copy", destinationPath, ErrArtifactLimitExceeded,
)
}
destination, err := destinationRoot.createFile(destinationPath, 0600)
if err != nil {
return DelegationArtifact{}, wrapArtifactFilesystemError("create_destination", destinationPath, err)
}
closeDestination := true
defer func() {
if closeDestination {
_ = destination.Close()
}
}()
hash := sha256.New()
sniff := &artifactSniffWriter{}
writer := io.MultiWriter(destination, hash, sniff)
reader := io.LimitReader(&contextArtifactReader{ctx: ctx, reader: source.file}, effectiveLimit+1)
written, err := io.Copy(writer, reader)
if err != nil {
return DelegationArtifact{}, artifactError("artifact_copy_failed", "copy", destinationPath, err)
}
if written > effectiveLimit {
return DelegationArtifact{}, artifactError(
"artifact_byte_limit", "copy", destinationPath, ErrArtifactLimitExceeded,
)
}
if err := destination.Sync(); err != nil {
return DelegationArtifact{}, artifactError(
"artifact_sync_failed", "sync_file", destinationPath, err,
)
}
if err := destination.Chmod(destinationMode); err != nil {
return DelegationArtifact{}, artifactError(
"artifact_chmod_failed", "protect_file", destinationPath, err,
)
}
if err := destination.Close(); err != nil {
return DelegationArtifact{}, artifactError(
"artifact_close_failed", "close_file", destinationPath, err,
)
}
closeDestination = false
mediaType := "application/octet-stream"
if len(sniff.buf) > 0 {
mediaType = http.DetectContentType(sniff.buf)
}
return DelegationArtifact{
Path: destinationPath,
SizeBytes: written,
SHA256: hex.EncodeToString(hash.Sum(nil)),
MediaType: mediaType,
}, nil
}
type contextArtifactReader struct {
ctx context.Context
reader io.Reader
}
func (r *contextArtifactReader) Read(p []byte) (int, error) {
if err := checkArtifactContext(r.ctx); err != nil {
return 0, err
}
return r.reader.Read(p)
}
func classifyArtifactOpenError(op, logicalPath string, err error) error {
switch {
case errors.Is(err, errArtifactSymlink):
return artifactError("artifact_symlink", op, logicalPath, ErrArtifactNonRegular)
case errors.Is(err, errArtifactReparsePoint):
return artifactError("artifact_reparse_point", op, logicalPath, ErrArtifactNonRegular)
default:
return wrapArtifactFilesystemError(op, logicalPath, err)
}
}
@@ -0,0 +1,32 @@
//go:build darwin
package tools
import (
"errors"
"golang.org/x/sys/unix"
)
func (r *artifactSecureRoot) renameNoReplace(oldName, newName string) error {
oldPath, err := validateArtifactRelativePath(oldName)
if err != nil {
return err
}
newPath, err := validateArtifactRelativePath(newName)
if err != nil {
return err
}
err = unix.RenameatxNp(r.fd, oldPath, r.fd, newPath, unix.RENAME_EXCL)
if errors.Is(err, unix.EEXIST) {
return errors.Join(ErrArtifactPublishConflict, err)
}
if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
return errors.Join(ErrArtifactSecureUnavailable, err)
}
return err
}
func isArtifactNotExist(err error) bool {
return errors.Is(err, unix.ENOENT)
}
@@ -0,0 +1,193 @@
package tools
import (
"context"
"errors"
"fmt"
"path"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
const defaultDelegationArtifactFailureTTL = 60 * time.Minute
// NewDelegationArtifactExchange creates:
//
// <tenant-workspace>/collaboration/delegations/<delegation-id>/{inputs,outputs}
//
// tenantWorkspace must already be resolved through the canonical tenant path
// helper. Both IDs are mandatory non-zero UUIDs.
func NewDelegationArtifactExchange(
tenantWorkspace string,
tenantID uuid.UUID,
delegationID uuid.UUID,
limits DelegationArtifactLimits,
failureTTL time.Duration,
) (*DelegationArtifactExchange, error) {
if tenantWorkspace == "" || tenantID == uuid.Nil || delegationID == uuid.Nil {
return nil, artifactError(
"artifact_invalid_identity", "create_exchange", "", ErrArtifactInvalidPath,
)
}
validLimits, err := limits.validate()
if err != nil {
return nil, err
}
if failureTTL <= 0 {
failureTTL = defaultDelegationArtifactFailureTTL
}
tenantRoot, err := openArtifactSecureRoot(tenantWorkspace)
if err != nil {
return nil, wrapArtifactFilesystemError("capture_tenant_root", "", err)
}
defer tenantRoot.close()
const exchangeParent = "collaboration/delegations"
if err := tenantRoot.mkdirAll(exchangeParent, 0750); err != nil {
return nil, wrapArtifactFilesystemError("create_exchange_parent", "", err)
}
exchangeRelative := path.Join(exchangeParent, delegationID.String())
exchangeRoot, err := tenantRoot.createSubroot(exchangeRelative, 0700)
if err != nil {
if isArtifactAlreadyExists(err) {
return nil, artifactError(
"artifact_exchange_conflict", "create_exchange", "", ErrArtifactPublishConflict,
)
}
return nil, wrapArtifactFilesystemError("create_exchange", "", err)
}
if err := exchangeRoot.mkdirAll("inputs", 0750); err != nil {
exchangeRoot.close()
return nil, wrapArtifactFilesystemError("create_inputs", "", err)
}
if err := exchangeRoot.mkdirAll("outputs", 0700); err != nil {
exchangeRoot.close()
return nil, wrapArtifactFilesystemError("create_outputs", "", err)
}
exchange := &DelegationArtifactExchange{
tenantID: tenantID,
delegationID: delegationID,
hostRoot: filepath.Join(tenantWorkspace, filepath.FromSlash(exchangeRelative)),
root: exchangeRoot,
limits: validLimits,
failureTTL: failureTTL,
state: artifactExchangeOpen,
}
startedAt := time.Now().UTC()
lifecycleState := newStagingDelegationArtifactLifecycleState(
tenantID,
delegationID,
startedAt,
startedAt.Add(failureTTL),
)
if err := exchange.persistLifecycleState(lifecycleState); err != nil {
_ = exchange.Close()
_ = tenantRoot.removeTree(exchangeRelative)
return nil, err
}
return exchange, nil
}
func (e *DelegationArtifactExchange) inputsHostPath() string {
return filepath.Join(e.hostRoot, "inputs")
}
func (e *DelegationArtifactExchange) outputsHostPath() string {
return filepath.Join(e.hostRoot, "outputs")
}
// StageInputs copies already-authorized relative files from the captured caller
// workspace. Duplicate basenames receive deterministic suffixes.
func (e *DelegationArtifactExchange) StageInputs(
ctx context.Context,
source *DelegationArtifactRoot,
relativePaths []string,
) ([]DelegationArtifact, error) {
if source == nil || source.root == nil {
return nil, artifactError("artifact_missing_source_root", "stage_inputs", "", ErrArtifactState)
}
e.mu.Lock()
defer e.mu.Unlock()
if e.state != artifactExchangeOpen {
return nil, artifactError("artifact_invalid_state", "stage_inputs", "", ErrArtifactState)
}
if len(relativePaths) > e.limits.MaxFiles-e.inputCount {
err := artifactError("artifact_file_limit", "stage_inputs", "", ErrArtifactLimitExceeded)
e.retainFailureLocked(time.Now(), artifactErrorCode(err))
return nil, err
}
usedNames := make(map[string]struct{}, len(e.inputs)+len(relativePaths))
for _, input := range e.inputs {
usedNames[strings.TrimPrefix(input.Path, "inputs/")] = struct{}{}
}
staged := make([]DelegationArtifact, 0, len(relativePaths))
for _, rawPath := range relativePaths {
if err := checkArtifactContext(ctx); err != nil {
e.retainFailureLocked(time.Now(), "artifact_context_cancelled")
return nil, err
}
sourcePath, err := validateArtifactRelativePath(rawPath)
if err != nil {
e.retainFailureLocked(time.Now(), artifactErrorCode(err))
return nil, err
}
name := nextArtifactInputName(path.Base(sourcePath), usedNames)
logicalPath := path.Join("inputs", name)
remaining := e.limits.MaxTotalBytes - e.inputBytes
artifact, err := copyArtifactFile(
ctx,
source.root,
sourcePath,
e.root,
logicalPath,
e.limits.MaxFileBytes,
remaining,
0440,
)
if err != nil {
e.retainFailureLocked(time.Now(), artifactErrorCode(err))
return nil, err
}
artifact.Status = DelegationArtifactStaged
e.inputCount++
e.inputBytes += artifact.SizeBytes
e.inputs = append(e.inputs, artifact)
staged = append(staged, artifact)
usedNames[name] = struct{}{}
}
return append([]DelegationArtifact(nil), staged...), nil
}
func (e *DelegationArtifactExchange) StagedInputs() []DelegationArtifact {
e.mu.Lock()
defer e.mu.Unlock()
return append([]DelegationArtifact(nil), e.inputs...)
}
func nextArtifactInputName(base string, used map[string]struct{}) string {
if _, exists := used[base]; !exists {
return base
}
ext := path.Ext(base)
stem := strings.TrimSuffix(base, ext)
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d%s", stem, i, ext)
if _, exists := used[candidate]; !exists {
return candidate
}
}
}
func artifactErrorCode(err error) string {
var artifactErr *DelegationArtifactError
if errors.As(err, &artifactErr) && artifactErr.Code != "" {
return artifactErr.Code
}
return "artifact_exchange_failed"
}
@@ -0,0 +1,37 @@
//go:build linux || darwin
package tools
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"golang.org/x/sys/unix"
)
func TestDelegationArtifactPublishRejectsFIFOWithoutBlocking(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
fifoPath := filepath.Join(exchange.OutputsHostPath(), "pipe")
if err := unix.Mkfifo(fifoPath, 0600); err != nil {
t.Skipf("mkfifo unavailable: %v", err)
}
destinationPath := t.TempDir()
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, ErrArtifactNonRegular) {
t.Fatalf("Publish() error = %v, want non-regular rejection", err)
}
}
@@ -0,0 +1,195 @@
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
}
@@ -0,0 +1,32 @@
//go:build linux
package tools
import (
"errors"
"golang.org/x/sys/unix"
)
func (r *artifactSecureRoot) renameNoReplace(oldName, newName string) error {
oldPath, err := validateArtifactRelativePath(oldName)
if err != nil {
return err
}
newPath, err := validateArtifactRelativePath(newName)
if err != nil {
return err
}
err = unix.Renameat2(r.fd, oldPath, r.fd, newPath, unix.RENAME_NOREPLACE)
if errors.Is(err, unix.EEXIST) {
return errors.Join(ErrArtifactPublishConflict, err)
}
if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
return errors.Join(ErrArtifactSecureUnavailable, err)
}
return err
}
func isArtifactNotExist(err error) bool {
return errors.Is(err, unix.ENOENT)
}
@@ -0,0 +1,390 @@
//go:build linux || darwin
package tools
import (
"errors"
"io"
"io/fs"
"os"
"path"
"strings"
"golang.org/x/sys/unix"
)
type artifactSecureRoot struct {
fd int
}
func openArtifactSecureRoot(hostPath string) (*artifactSecureRoot, error) {
fd, err := unix.Open(hostPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return nil, translateArtifactPOSIXError(err)
}
return &artifactSecureRoot{fd: fd}, nil
}
func (r *artifactSecureRoot) close() error {
if r == nil || r.fd < 0 {
return nil
}
fd := r.fd
r.fd = -1
return unix.Close(fd)
}
func (r *artifactSecureRoot) mkdirAll(relativePath string, mode fs.FileMode) error {
components, err := artifactPathComponents(relativePath)
if err != nil {
return err
}
current, err := unix.Dup(r.fd)
if err != nil {
return err
}
unix.CloseOnExec(current)
defer func() {
_ = unix.Close(current)
}()
for _, component := range components {
if err := unix.Mkdirat(current, component, uint32(mode.Perm())); err != nil && !errors.Is(err, unix.EEXIST) {
return translateArtifactPOSIXError(err)
}
next, err := unix.Openat(
current,
component,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
return translateArtifactPOSIXError(err)
}
unix.Close(current)
current = next
}
return nil
}
func (r *artifactSecureRoot) createSubroot(relativePath string, mode fs.FileMode) (*artifactSecureRoot, error) {
parent, name, err := r.openParent(relativePath, false)
if err != nil {
return nil, err
}
defer unix.Close(parent)
if err := unix.Mkdirat(parent, name, uint32(mode.Perm())); err != nil {
return nil, translateArtifactPOSIXError(err)
}
fd, err := unix.Openat(
parent,
name,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
return nil, translateArtifactPOSIXError(err)
}
return &artifactSecureRoot{fd: fd}, nil
}
func (r *artifactSecureRoot) openSubroot(relativePath string) (*artifactSecureRoot, error) {
fd, err := r.openDir(relativePath)
if err != nil {
return nil, err
}
return &artifactSecureRoot{fd: fd}, nil
}
func (r *artifactSecureRoot) createFile(
relativePath string,
mode fs.FileMode,
) (*os.File, error) {
parent, name, err := r.openParent(relativePath, false)
if err != nil {
return nil, err
}
defer unix.Close(parent)
fd, err := unix.Openat(
parent,
name,
unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC,
uint32(mode.Perm()),
)
if err != nil {
return nil, translateArtifactPOSIXError(err)
}
return os.NewFile(uintptr(fd), name), nil
}
func (r *artifactSecureRoot) openEntry(relativePath string) (*artifactSecureEntry, error) {
parent, name, err := r.openParent(relativePath, false)
if err != nil {
return nil, err
}
defer unix.Close(parent)
fd, err := unix.Openat(
parent,
name,
unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
return nil, translateArtifactPOSIXError(err)
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil {
unix.Close(fd)
return nil, err
}
kind := artifactEntryOther
switch stat.Mode & unix.S_IFMT {
case unix.S_IFREG:
kind = artifactEntryRegular
case unix.S_IFDIR:
kind = artifactEntryDirectory
}
return &artifactSecureEntry{
file: os.NewFile(uintptr(fd), name),
kind: kind,
size: stat.Size,
links: uint64(stat.Nlink),
}, nil
}
func (r *artifactSecureRoot) readDir(relativePath string) ([]string, error) {
entry, err := r.openEntry(relativePath)
if err != nil {
return nil, err
}
defer entry.close()
if entry.kind != artifactEntryDirectory {
return nil, ErrArtifactNonRegular
}
names := make([]string, 0, artifactSecureReadBatchSize)
for {
dirEntries, readErr := entry.readDirBatch()
if readErr != nil && !errors.Is(readErr, io.EOF) {
return nil, readErr
}
if len(dirEntries) > artifactSecureMaxDirectoryEntries-len(names) {
return nil, ErrArtifactLimitExceeded
}
for _, dirEntry := range dirEntries {
names = append(names, dirEntry.Name())
}
if errors.Is(readErr, io.EOF) {
return names, nil
}
}
}
func (r *artifactSecureRoot) exists(relativePath string) (bool, error) {
parent, name, err := r.openParent(relativePath, false)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}
defer unix.Close(parent)
var stat unix.Stat_t
err = unix.Fstatat(parent, name, &stat, unix.AT_SYMLINK_NOFOLLOW)
switch {
case err == nil:
return true, nil
case errors.Is(err, unix.ENOENT):
return false, nil
default:
return false, translateArtifactPOSIXError(err)
}
}
func (r *artifactSecureRoot) syncDir(relativePath string) error {
fd, err := r.openDir(relativePath)
if err != nil {
return err
}
defer unix.Close(fd)
err = unix.Fsync(fd)
if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOTSUP) {
return nil
}
return err
}
func (r *artifactSecureRoot) removeTree(relativePath string) error {
parent, name, err := r.openParent(relativePath, false)
if err != nil {
return err
}
defer unix.Close(parent)
remaining := artifactSecureCleanupEntryBudget
return removeArtifactTreeAt(parent, name, 0, &remaining)
}
func removeArtifactTreeAt(parent int, name string, depth int, remaining *int) error {
if depth > artifactSecureMaxDepth {
return ErrArtifactLimitExceeded
}
if *remaining <= 0 {
return ErrArtifactLimitExceeded
}
*remaining--
var stat unix.Stat_t
if err := unix.Fstatat(parent, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil {
if errors.Is(err, unix.ENOENT) {
return nil
}
return translateArtifactPOSIXError(err)
}
if stat.Mode&unix.S_IFMT != unix.S_IFDIR {
return translateArtifactPOSIXError(unix.Unlinkat(parent, name, 0))
}
fd, err := unix.Openat(
parent,
name,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
return translateArtifactPOSIXError(err)
}
dir := os.NewFile(uintptr(fd), name)
for {
entries, readErr := dir.ReadDir(artifactSecureReadBatchSize)
if readErr != nil && !errors.Is(readErr, io.EOF) {
_ = dir.Close()
return readErr
}
for _, entry := range entries {
if err := removeArtifactTreeAt(fd, entry.Name(), depth+1, remaining); err != nil {
_ = dir.Close()
return err
}
}
if errors.Is(readErr, io.EOF) {
break
}
}
if err := dir.Close(); err != nil {
return err
}
return translateArtifactPOSIXError(unix.Unlinkat(parent, name, unix.AT_REMOVEDIR))
}
func (r *artifactSecureRoot) renameReplace(oldName, newName string) error {
oldPath, err := validateArtifactRelativePath(oldName)
if err != nil {
return err
}
newPath, err := validateArtifactRelativePath(newName)
if err != nil {
return err
}
if path.Dir(oldPath) != "." || path.Dir(newPath) != "." {
return ErrArtifactInvalidPath
}
return translateArtifactPOSIXError(unix.Renameat(r.fd, oldPath, r.fd, newPath))
}
func (r *artifactSecureRoot) openParent(
relativePath string,
create bool,
) (int, string, error) {
components, err := artifactPathComponents(relativePath)
if err != nil {
return -1, "", err
}
if len(components) == 0 {
return -1, "", ErrArtifactInvalidPath
}
parentComponents := components[:len(components)-1]
current, err := unix.Dup(r.fd)
if err != nil {
return -1, "", err
}
unix.CloseOnExec(current)
for _, component := range parentComponents {
if create {
if err := unix.Mkdirat(current, component, 0750); err != nil && !errors.Is(err, unix.EEXIST) {
unix.Close(current)
return -1, "", translateArtifactPOSIXError(err)
}
}
next, err := unix.Openat(
current,
component,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
unix.Close(current)
return -1, "", translateArtifactPOSIXError(err)
}
unix.Close(current)
current = next
}
return current, components[len(components)-1], nil
}
func (r *artifactSecureRoot) openDir(relativePath string) (int, error) {
if relativePath == "." {
fd, err := unix.Dup(r.fd)
if err == nil {
unix.CloseOnExec(fd)
}
return fd, err
}
components, err := artifactPathComponents(relativePath)
if err != nil {
return -1, err
}
current, err := unix.Dup(r.fd)
if err != nil {
return -1, err
}
unix.CloseOnExec(current)
for _, component := range components {
next, err := unix.Openat(
current,
component,
unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC,
0,
)
if err != nil {
unix.Close(current)
return -1, translateArtifactPOSIXError(err)
}
unix.Close(current)
current = next
}
return current, nil
}
func artifactPathComponents(relativePath string) ([]string, error) {
if relativePath == "." {
return nil, nil
}
clean, err := validateArtifactRelativePath(relativePath)
if err != nil {
return nil, err
}
return strings.Split(clean, "/"), nil
}
func translateArtifactPOSIXError(err error) error {
if errors.Is(err, unix.ELOOP) {
return fmtArtifactJoined(errArtifactSymlink, err)
}
return err
}
func fmtArtifactJoined(classification, cause error) error {
return errors.Join(classification, cause)
}
func isArtifactAlreadyExists(err error) bool {
return errors.Is(err, unix.EEXIST) || errors.Is(err, ErrArtifactPublishConflict)
}
@@ -0,0 +1,475 @@
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
}
@@ -0,0 +1,48 @@
package tools
import (
"errors"
"io/fs"
"os"
)
const (
artifactSecureReadBatchSize = 64
artifactSecureMaxDirectoryEntries = 4096
artifactSecureMaxDepth = 64
artifactSecureCleanupEntryBudget = 4096
)
var (
errArtifactSymlink = errors.New("symlink rejected")
errArtifactReparsePoint = errors.New("reparse point rejected")
)
type artifactEntryKind uint8
const (
artifactEntryOther artifactEntryKind = iota
artifactEntryRegular
artifactEntryDirectory
)
type artifactSecureEntry struct {
file *os.File
kind artifactEntryKind
size int64
links uint64
}
func (e *artifactSecureEntry) close() error {
if e == nil || e.file == nil {
return nil
}
return e.file.Close()
}
func (e *artifactSecureEntry) readDirBatch() ([]fs.DirEntry, error) {
if e == nil || e.file == nil || e.kind != artifactEntryDirectory {
return nil, ErrArtifactNonRegular
}
return e.file.ReadDir(artifactSecureReadBatchSize)
}
+751
View File
@@ -0,0 +1,751 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestDelegationArtifactExchangeTenantLayoutAndInputContract(t *testing.T) {
tenantRoot := t.TempDir()
sourcePath := t.TempDir()
if err := os.MkdirAll(filepath.Join(sourcePath, "one"), 0750); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(sourcePath, "two"), 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourcePath, "one", "report.txt"), []byte("first"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourcePath, "two", "report.txt"), []byte("second"), 0600); err != nil {
t.Fatal(err)
}
tenantID := uuid.New()
delegationID := uuid.New()
exchange := newTestDelegationExchange(t, tenantRoot, tenantID, delegationID, DelegationArtifactLimits{})
source := openTestArtifactRoot(t, sourcePath)
staged, err := exchange.StageInputs(
context.Background(),
source,
[]string{"one/report.txt", "two/report.txt"},
)
if err != nil {
t.Fatalf("StageInputs() error = %v", err)
}
if got, want := len(staged), 2; got != want {
t.Fatalf("staged count = %d, want %d", got, want)
}
if staged[0].Path != "inputs/report.txt" || staged[1].Path != "inputs/report-2.txt" {
t.Fatalf("staged paths = %#v", []string{staged[0].Path, staged[1].Path})
}
wantExchangeRoot := filepath.Join(
tenantRoot,
"collaboration",
"delegations",
delegationID.String(),
)
if got := exchange.OutputsHostPath(); got != filepath.Join(wantExchangeRoot, "outputs") {
t.Fatalf("OutputsHostPath() = %q, want %q", got, filepath.Join(wantExchangeRoot, "outputs"))
}
mount := exchange.InputsMount()
if mount.LogicalAlias != "inputs" || mount.ContainerPath != "/workspace/inputs" || !mount.ReadOnly {
t.Fatalf("InputsMount() = %#v", mount)
}
if mount.HostRoot != filepath.Join(wantExchangeRoot, "inputs") {
t.Fatalf("input host root = %q", mount.HostRoot)
}
for _, name := range []string{"report.txt", "report-2.txt"} {
info, err := os.Stat(filepath.Join(mount.HostRoot, name))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0222 != 0 {
t.Fatalf("%s mode = %o, want read-only", name, info.Mode().Perm())
}
}
}
func TestDelegationArtifactStageRejectsTraversalSymlinksAndHardlinks(t *testing.T) {
testCases := []struct {
name string
prepare func(t *testing.T, source string) string
wantErr error
}{
{
name: "traversal",
prepare: func(t *testing.T, source string) string {
return "../outside.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "absolute",
prepare: func(t *testing.T, source string) string {
return filepath.Join(source, "outside.txt")
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "alternate data stream",
prepare: func(t *testing.T, source string) string {
return "report.txt:secret"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "reserved device",
prepare: func(t *testing.T, source string) string {
return "NUL.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "control character",
prepare: func(t *testing.T, source string) string {
return "report\n.txt"
},
wantErr: ErrArtifactInvalidPath,
},
{
name: "symlink",
prepare: func(t *testing.T, source string) string {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("real.txt", filepath.Join(source, "link.txt")); err != nil {
t.Fatal(err)
}
return "link.txt"
},
wantErr: ErrArtifactNonRegular,
},
{
name: "hardlink",
prepare: func(t *testing.T, source string) string {
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Link(filepath.Join(source, "real.txt"), filepath.Join(source, "hard.txt")); err != nil {
t.Skipf("hardlinks unavailable: %v", err)
}
return "hard.txt"
},
wantErr: ErrArtifactHardlink,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sourcePath := t.TempDir()
input := testCase.prepare(t, sourcePath)
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
source := openTestArtifactRoot(t, sourcePath)
_, err := exchange.StageInputs(context.Background(), source, []string{input})
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("StageInputs() error = %v, want errors.Is(%v)", err, testCase.wantErr)
}
retention, retained := exchange.FailureRetention()
if !retained || retention.ReasonCode == "" || !retention.RetainUntil.After(retention.FailedAt) {
t.Fatalf("failure retention = %#v, %v", retention, retained)
}
})
}
}
func TestDelegationArtifactPublishManifestV1IsSortedAndPathSafe(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.MkdirAll(filepath.Join(exchange.OutputsHostPath(), "nested"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "z.txt"), []byte("z"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "nested", "a.txt"), []byte("alpha"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
destination := openTestArtifactRoot(t, destinationPath)
publishedAt := time.Date(2026, 7, 29, 8, 30, 0, 0, time.FixedZone("ICT", 7*60*60))
publication, err := exchange.Publish(context.Background(), destination, publishedAt)
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if publication.RootPath != filepath.ToSlash(filepath.Join(".delegations", exchange.DelegationID().String())) {
t.Fatalf("publication root = %q", publication.RootPath)
}
if publication.Manifest.SchemaVersion != 1 ||
publication.Manifest.DelegationID != exchange.DelegationID().String() ||
publication.Manifest.PublishedAt.Location() != time.UTC ||
publication.Manifest.OutputCount != 2 ||
publication.Manifest.OutputBytes != 6 {
t.Fatalf("manifest = %#v", publication.Manifest)
}
if got := []string{
publication.Manifest.Outputs[0].Path,
publication.Manifest.Outputs[1].Path,
}; got[0] != "outputs/nested/a.txt" || got[1] != "outputs/z.txt" {
t.Fatalf("manifest output order = %#v", got)
}
for _, output := range publication.Manifest.Outputs {
if err := validateManifestOutputPath(output.Path); err != nil {
t.Fatalf("manifest output path %q invalid: %v", output.Path, err)
}
if len(output.SHA256) != 64 || output.MediaType == "" {
t.Fatalf("manifest output = %#v", output)
}
}
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(manifestBytes), destinationPath) ||
strings.Contains(string(manifestBytes), exchange.OutputsHostPath()) {
t.Fatalf("manifest leaked host path: %s", manifestBytes)
}
var durable DelegationArtifactManifest
if err := json.Unmarshal(manifestBytes, &durable); err != nil {
t.Fatalf("decode manifest: %v", err)
}
if durable.Outputs == nil || durable.OutputCount != len(durable.Outputs) {
t.Fatalf("durable outputs = %#v", durable.Outputs)
}
var rawManifest map[string]json.RawMessage
if err := json.Unmarshal(manifestBytes, &rawManifest); err != nil {
t.Fatal(err)
}
if len(rawManifest) != 6 {
t.Fatalf("manifest persisted unexpected fields: %v", rawManifest)
}
for _, forbidden := range []string{"tenant_id", "agent_id", "workspace", "trace_id", "status"} {
if _, exists := rawManifest[forbidden]; exists {
t.Fatalf("manifest persisted forbidden field %q", forbidden)
}
}
}
func TestDelegationArtifactPublishEmptyManifest(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
destinationPath := t.TempDir()
publication, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Unix(123, 0),
)
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if publication.Manifest.OutputCount != 0 ||
publication.Manifest.OutputBytes != 0 ||
publication.Manifest.Outputs == nil ||
len(publication.Manifest.Outputs) != 0 {
t.Fatalf("empty manifest = %#v", publication.Manifest)
}
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifestBytes), `"outputs": []`) {
t.Fatalf("empty outputs must encode as [], manifest: %s", manifestBytes)
}
}
func TestDelegationArtifactPublishRejectsExcessEmptyDirectories(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
for index := 0; index <= DelegationArtifactMaxFiles; index++ {
name := fmt.Sprintf("empty-%03d", index)
if err := os.Mkdir(filepath.Join(exchange.OutputsHostPath(), name), 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_directory_limit" {
t.Fatalf("Publish() error = %v, want artifact_directory_limit", err)
}
}
func TestDelegationArtifactPublishRejectsDeepOutputTree(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
current := exchange.OutputsHostPath()
for depth := 0; depth <= artifactSecureMaxDepth; depth++ {
current = filepath.Join(current, "d")
if err := os.Mkdir(current, 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_depth_limit" {
t.Fatalf("Publish() error = %v, want artifact_depth_limit", err)
}
}
func TestDelegationArtifactPublishRejectsLongLogicalOutputPath(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
currentHostPath := exchange.OutputsHostPath()
logicalPath := "outputs"
for depth := 0; len(logicalPath) <= artifactOutputMaxPathBytes; depth++ {
if depth >= artifactSecureMaxDepth {
t.Fatal("test path reached depth limit before path-length limit")
}
component := fmt.Sprintf("%02d-%s", depth, strings.Repeat("x", 47))
currentHostPath = filepath.Join(currentHostPath, component)
logicalPath += "/" + component
if err := os.Mkdir(currentHostPath, 0700); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_path_limit" {
t.Fatalf("Publish() error = %v, want artifact_path_limit", err)
}
}
func TestDelegationArtifactPublishPreservesRegularOutputFileLimit(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 8,
MaxTotalBytes: 32,
MaxFiles: 2,
}
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
limits,
)
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, t.TempDir()),
time.Now(),
)
var artifactErr *DelegationArtifactError
if !errors.Is(err, ErrArtifactLimitExceeded) ||
!errors.As(err, &artifactErr) ||
artifactErr.Code != "artifact_file_limit" {
t.Fatalf("Publish() error = %v, want artifact_file_limit", err)
}
}
func TestDelegationArtifactPublishRejectsSymlinkAndHardlinkOutputs(t *testing.T) {
testCases := []struct {
name string
prepare func(t *testing.T, exchange *DelegationArtifactExchange)
wantErr error
}{
{
name: "symlink",
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(exchange.OutputsHostPath(), "link.txt")); err != nil {
t.Fatal(err)
}
},
wantErr: ErrArtifactNonRegular,
},
{
name: "hardlink",
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Link(outside, filepath.Join(exchange.OutputsHostPath(), "hard.txt")); err != nil {
t.Skipf("hardlinks unavailable: %v", err)
}
},
wantErr: ErrArtifactHardlink,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
testCase.prepare(t, exchange)
destinationPath := t.TempDir()
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("Publish() error = %v, want errors.Is(%v)", err, testCase.wantErr)
}
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("final destination visible after rejection: %v", statErr)
}
retention, retained := exchange.FailureRetention()
if !retained || !strings.HasPrefix(
retention.PublicationTempPath,
".delegations/.tmp-"+exchange.DelegationID().String()+"-",
) {
t.Fatalf("publication temp path not retained for janitor: %#v, %v", retention, retained)
}
parentEntries, readErr := os.ReadDir(filepath.Join(destinationPath, ".delegations"))
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range parentEntries {
if !strings.HasPrefix(entry.Name(), ".tmp-") {
t.Fatalf("unexpected publication entry remains: %s", entry.Name())
}
}
})
}
}
func TestDelegationArtifactPublishNoReplaceCollision(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "new.txt"), []byte("new"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
finalPath := filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())
if err := os.MkdirAll(finalPath, 0700); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(finalPath, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, ErrArtifactPublishConflict) {
t.Fatalf("Publish() error = %v, want conflict", err)
}
content, readErr := os.ReadFile(sentinel)
if readErr != nil || string(content) != "original" {
t.Fatalf("pre-existing destination changed: %q, %v", content, readErr)
}
}
func TestDelegationArtifactSecureRenameNeverReplaces(t *testing.T) {
root := openTestArtifactRoot(t, t.TempDir())
source, err := root.root.createSubroot("source", 0700)
if err != nil {
t.Fatal(err)
}
if err := source.close(); err != nil {
t.Fatal(err)
}
destination, err := root.root.createSubroot("destination", 0700)
if err != nil {
t.Fatal(err)
}
if err := destination.close(); err != nil {
t.Fatal(err)
}
err = root.root.renameNoReplace("source", "destination")
if !errors.Is(err, ErrArtifactPublishConflict) {
t.Fatalf("renameNoReplace() error = %v, want conflict", err)
}
for _, name := range []string{"source", "destination"} {
exists, statErr := root.root.exists(name)
if statErr != nil || !exists {
t.Fatalf("%s existence = %v, %v; rename replaced an entry", name, exists, statErr)
}
}
}
func TestDelegationArtifactPublishUsesCapturedDestinationRoot(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "result.txt"), []byte("result"), 0600); err != nil {
t.Fatal(err)
}
parent := t.TempDir()
originalPath := filepath.Join(parent, "workspace")
movedPath := filepath.Join(parent, "workspace-captured")
if err := os.Mkdir(originalPath, 0700); err != nil {
t.Fatal(err)
}
destination := openTestArtifactRoot(t, originalPath)
if err := os.Rename(originalPath, movedPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(originalPath, 0700); err != nil {
t.Fatal(err)
}
publication, err := exchange.Publish(context.Background(), destination, time.Now())
if err != nil {
t.Fatalf("Publish() error = %v", err)
}
if _, err := os.Stat(filepath.Join(movedPath, filepath.FromSlash(publication.ManifestPath))); err != nil {
t.Fatalf("artifact not published to captured root: %v", err)
}
if _, err := os.Stat(filepath.Join(originalPath, filepath.FromSlash(publication.ManifestPath))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("replacement path received publication: %v", err)
}
}
func TestDelegationArtifactBudgetsAreSharedAcrossInputsAndOutputs(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 5,
MaxTotalBytes: 8,
MaxFiles: 2,
}
sourcePath := t.TempDir()
if err := os.WriteFile(filepath.Join(sourcePath, "input.bin"), []byte("1234"), 0600); err != nil {
t.Fatal(err)
}
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
if _, err := exchange.StageInputs(
context.Background(),
openTestArtifactRoot(t, sourcePath),
[]string{"input.bin"},
); err != nil {
t.Fatalf("StageInputs() error = %v", err)
}
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "output.bin"), []byte("12345"), 0600); err != nil {
t.Fatal(err)
}
destinationPath := t.TempDir()
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if !errors.Is(err, ErrArtifactLimitExceeded) {
t.Fatalf("Publish() error = %v, want shared total-byte limit", err)
}
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("final destination visible after budget failure: %v", statErr)
}
}
func TestDelegationArtifactInputFileLimitRejectsBeforeStaging(t *testing.T) {
limits := DelegationArtifactLimits{
MaxFileBytes: 8,
MaxTotalBytes: 16,
MaxFiles: 1,
}
sourcePath := t.TempDir()
for _, name := range []string{"one.txt", "two.txt"} {
if err := os.WriteFile(filepath.Join(sourcePath, name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
_, err := exchange.StageInputs(
context.Background(),
openTestArtifactRoot(t, sourcePath),
[]string{"one.txt", "two.txt"},
)
if !errors.Is(err, ErrArtifactLimitExceeded) {
t.Fatalf("StageInputs() error = %v, want file limit", err)
}
entries, readErr := os.ReadDir(exchange.InputsMount().HostRoot)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("staging began before structured input count rejection: %v", entries)
}
}
func TestDelegationArtifactPublishRejectsSymlinkPublicationParent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on some Windows hosts")
}
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
destinationPath := t.TempDir()
outsidePath := t.TempDir()
if err := os.Symlink(outsidePath, filepath.Join(destinationPath, ".delegations")); err != nil {
t.Fatal(err)
}
_, err := exchange.Publish(
context.Background(),
openTestArtifactRoot(t, destinationPath),
time.Now(),
)
if err == nil {
t.Fatal("Publish() accepted symlink publication parent")
}
entries, readErr := os.ReadDir(outsidePath)
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("publication escaped through symlink: %v", entries)
}
}
func TestDelegationArtifactFailureRetentionCleanupHook(t *testing.T) {
exchange := newTestDelegationExchange(
t,
t.TempDir(),
uuid.New(),
uuid.New(),
DelegationArtifactLimits{},
)
failedAt := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
exchange.RetainFailure(failedAt, "validation_failed")
exchange.RetainFailure(failedAt.Add(time.Hour), "must_not_extend_ttl")
retention, ok := exchange.FailureRetention()
if !ok || retention.FailedAt != failedAt || retention.ReasonCode != "validation_failed" {
t.Fatalf("retention = %#v, %v", retention, ok)
}
if exchange.ReadyForCleanup(failedAt.Add(59 * time.Minute)) {
t.Fatal("exchange ready for cleanup before TTL")
}
if !exchange.ReadyForCleanup(failedAt.Add(60 * time.Minute)) {
t.Fatal("exchange not ready for cleanup at TTL")
}
}
func newTestDelegationExchange(
t *testing.T,
tenantRoot string,
tenantID uuid.UUID,
delegationID uuid.UUID,
limits DelegationArtifactLimits,
) *DelegationArtifactExchange {
t.Helper()
exchange, err := NewDelegationArtifactExchange(
tenantRoot,
tenantID,
delegationID,
limits,
0,
)
if err != nil {
t.Fatalf("NewDelegationArtifactExchange() error = %v", err)
}
t.Cleanup(func() {
if err := exchange.Close(); err != nil {
t.Errorf("close exchange: %v", err)
}
})
return exchange
}
func openTestArtifactRoot(t *testing.T, hostPath string) *DelegationArtifactRoot {
t.Helper()
root, err := OpenDelegationArtifactRoot(hostPath)
if err != nil {
t.Fatalf("OpenDelegationArtifactRoot() error = %v", err)
}
t.Cleanup(func() {
if err := root.Close(); err != nil {
t.Errorf("close root: %v", err)
}
})
return root
}
@@ -0,0 +1,63 @@
//go:build !linux && !darwin && !windows
package tools
import (
"io/fs"
"os"
)
type artifactSecureRoot struct{}
func openArtifactSecureRoot(string) (*artifactSecureRoot, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) close() error { return nil }
func (r *artifactSecureRoot) mkdirAll(string, fs.FileMode) error {
return ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) createSubroot(string, fs.FileMode) (*artifactSecureRoot, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) openSubroot(string) (*artifactSecureRoot, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) createFile(string, fs.FileMode) (*os.File, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) openEntry(string) (*artifactSecureEntry, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) readDir(string) ([]string, error) {
return nil, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) exists(string) (bool, error) {
return false, ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) syncDir(string) error {
return ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) removeTree(string) error {
return ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) renameNoReplace(string, string) error {
return ErrArtifactSecureUnavailable
}
func (r *artifactSecureRoot) renameReplace(string, string) error {
return ErrArtifactSecureUnavailable
}
func isArtifactAlreadyExists(error) bool { return false }
func isArtifactNotExist(error) bool { return false }
@@ -0,0 +1,574 @@
//go:build windows
package tools
import (
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
type artifactSecureRoot struct {
handle windows.Handle
}
type artifactFileRenameInformation struct {
ReplaceIfExists uint32
RootDirectory windows.Handle
FileNameLength uint32
FileName [1]uint16
}
func openArtifactSecureRoot(hostPath string) (*artifactSecureRoot, error) {
absolute, err := filepath.Abs(hostPath)
if err != nil {
return nil, err
}
handle, err := artifactNtOpen(
0,
artifactNTAbsolutePath(absolute),
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return nil, translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(handle); err != nil {
windows.CloseHandle(handle)
return nil, err
}
return &artifactSecureRoot{handle: handle}, nil
}
func (r *artifactSecureRoot) close() error {
if r == nil || r.handle == 0 || r.handle == windows.InvalidHandle {
return nil
}
handle := r.handle
r.handle = windows.InvalidHandle
return windows.CloseHandle(handle)
}
func (r *artifactSecureRoot) mkdirAll(relativePath string, _ fs.FileMode) error {
components, err := artifactPathComponentsWindows(relativePath)
if err != nil {
return err
}
current, err := duplicateArtifactHandle(r.handle)
if err != nil {
return err
}
defer windows.CloseHandle(current)
for _, component := range components {
next, err := artifactNtOpen(
current,
component,
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN_IF,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
windows.FILE_ATTRIBUTE_NORMAL,
)
if err != nil {
return translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(next); err != nil {
windows.CloseHandle(next)
return err
}
windows.CloseHandle(current)
current = next
}
return nil
}
func (r *artifactSecureRoot) createSubroot(relativePath string, _ fs.FileMode) (*artifactSecureRoot, error) {
parent, name, err := r.openParent(relativePath)
if err != nil {
return nil, err
}
defer windows.CloseHandle(parent)
handle, err := artifactNtOpen(
parent,
name,
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
windows.FILE_ATTRIBUTE_NORMAL,
)
if err != nil {
return nil, translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(handle); err != nil {
windows.CloseHandle(handle)
return nil, err
}
return &artifactSecureRoot{handle: handle}, nil
}
func (r *artifactSecureRoot) openSubroot(relativePath string) (*artifactSecureRoot, error) {
handle, err := r.openDir(relativePath)
if err != nil {
return nil, err
}
return &artifactSecureRoot{handle: handle}, nil
}
func (r *artifactSecureRoot) createFile(relativePath string, _ fs.FileMode) (*os.File, error) {
parent, name, err := r.openParent(relativePath)
if err != nil {
return nil, err
}
defer windows.CloseHandle(parent)
handle, err := artifactNtOpen(
parent,
name,
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_CREATE,
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
windows.FILE_ATTRIBUTE_NORMAL,
)
if err != nil {
return nil, translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(handle); err != nil {
windows.CloseHandle(handle)
return nil, err
}
return os.NewFile(uintptr(handle), name), nil
}
func (r *artifactSecureRoot) openEntry(relativePath string) (*artifactSecureEntry, error) {
parent, name, err := r.openParent(relativePath)
if err != nil {
return nil, err
}
defer windows.CloseHandle(parent)
handle, err := artifactNtOpen(
parent,
name,
windows.FILE_GENERIC_READ|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return nil, translateArtifactWindowsError(err)
}
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
windows.CloseHandle(handle)
return nil, err
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
windows.CloseHandle(handle)
return nil, errArtifactReparsePoint
}
kind := artifactEntryRegular
if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
kind = artifactEntryDirectory
}
size := int64(uint64(info.FileSizeHigh)<<32 | uint64(info.FileSizeLow))
return &artifactSecureEntry{
file: os.NewFile(uintptr(handle), name),
kind: kind,
size: size,
links: uint64(info.NumberOfLinks),
}, nil
}
func (r *artifactSecureRoot) readDir(relativePath string) ([]string, error) {
entry, err := r.openEntry(relativePath)
if err != nil {
return nil, err
}
defer entry.close()
if entry.kind != artifactEntryDirectory {
return nil, ErrArtifactNonRegular
}
names := make([]string, 0, artifactSecureReadBatchSize)
for {
dirEntries, readErr := entry.readDirBatch()
if readErr != nil && !errors.Is(readErr, io.EOF) {
return nil, readErr
}
if len(dirEntries) > artifactSecureMaxDirectoryEntries-len(names) {
return nil, ErrArtifactLimitExceeded
}
for _, dirEntry := range dirEntries {
names = append(names, dirEntry.Name())
}
if errors.Is(readErr, io.EOF) {
return names, nil
}
}
}
func (r *artifactSecureRoot) exists(relativePath string) (bool, error) {
parent, name, err := r.openParent(relativePath)
if err != nil {
if isArtifactWindowsNotExist(err) {
return false, nil
}
return false, err
}
defer windows.CloseHandle(parent)
handle, err := artifactNtOpen(
parent,
name,
windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
if isArtifactWindowsNotExist(err) {
return false, nil
}
return false, translateArtifactWindowsError(err)
}
windows.CloseHandle(handle)
return true, nil
}
func (r *artifactSecureRoot) syncDir(relativePath string) error {
handle, err := r.openDir(relativePath)
if err != nil {
return err
}
defer windows.CloseHandle(handle)
err = windows.FlushFileBuffers(handle)
if errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_INVALID_HANDLE) {
return nil
}
return err
}
func (r *artifactSecureRoot) removeTree(relativePath string) error {
parent, name, err := r.openParent(relativePath)
if err != nil {
return err
}
defer windows.CloseHandle(parent)
remaining := artifactSecureCleanupEntryBudget
return removeArtifactTreeAt(parent, name, 0, &remaining)
}
func removeArtifactTreeAt(parent windows.Handle, name string, depth int, remaining *int) error {
if depth > artifactSecureMaxDepth {
return ErrArtifactLimitExceeded
}
if *remaining <= 0 {
return ErrArtifactLimitExceeded
}
*remaining--
handle, err := artifactNtOpen(
parent,
name,
windows.FILE_GENERIC_READ|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
if isArtifactWindowsNotExist(err) {
return nil
}
return translateArtifactWindowsError(err)
}
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return err
}
isDirectory := info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0
isReparsePoint := info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0
if isDirectory && !isReparsePoint {
duplicate, err := duplicateArtifactHandle(handle)
if err != nil {
return err
}
dir := os.NewFile(uintptr(duplicate), name)
for {
entries, readErr := dir.ReadDir(artifactSecureReadBatchSize)
if readErr != nil && !errors.Is(readErr, io.EOF) {
_ = dir.Close()
return readErr
}
for _, entry := range entries {
if err := removeArtifactTreeAt(handle, entry.Name(), depth+1, remaining); err != nil {
_ = dir.Close()
return err
}
}
if errors.Is(readErr, io.EOF) {
break
}
}
if err := dir.Close(); err != nil {
return err
}
}
deleteFlag := byte(1)
return windows.SetFileInformationByHandle(
handle,
windows.FileDispositionInfo,
&deleteFlag,
1,
)
}
func (r *artifactSecureRoot) renameNoReplace(oldName, newName string) error {
return r.rename(oldName, newName, false)
}
func (r *artifactSecureRoot) renameReplace(oldName, newName string) error {
return r.rename(oldName, newName, true)
}
func (r *artifactSecureRoot) rename(oldName, newName string, replace bool) error {
oldPath, err := validateArtifactRelativePath(oldName)
if err != nil {
return err
}
newPath, err := validateArtifactRelativePath(newName)
if err != nil {
return err
}
if path.Dir(oldPath) != "." || path.Dir(newPath) != "." {
return ErrArtifactInvalidPath
}
source, err := artifactNtOpen(
r.handle,
oldPath,
windows.FILE_GENERIC_READ|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
return translateArtifactWindowsError(err)
}
defer windows.CloseHandle(source)
if err := rejectArtifactReparsePoint(source); err != nil {
return err
}
newNameUTF16, err := windows.UTF16FromString(newPath)
if err != nil {
return err
}
nameBytes := (len(newNameUTF16) - 1) * 2
var layout artifactFileRenameInformation
bufferSize := int(unsafe.Offsetof(layout.FileName)) + nameBytes
buffer := make([]byte, bufferSize)
info := (*artifactFileRenameInformation)(unsafe.Pointer(&buffer[0]))
if replace {
info.ReplaceIfExists = 1
}
info.RootDirectory = r.handle
info.FileNameLength = uint32(nameBytes)
copy(
(*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.FileName[0]))[:nameBytes/2:nameBytes/2],
newNameUTF16[:len(newNameUTF16)-1],
)
var iosb windows.IO_STATUS_BLOCK
err = windows.NtSetInformationFile(
source,
&iosb,
&buffer[0],
uint32(bufferSize),
windows.FileRenameInformation,
)
if !replace && isArtifactAlreadyExists(err) {
return errors.Join(ErrArtifactPublishConflict, err)
}
return translateArtifactWindowsError(err)
}
func (r *artifactSecureRoot) openParent(relativePath string) (windows.Handle, string, error) {
components, err := artifactPathComponentsWindows(relativePath)
if err != nil {
return windows.InvalidHandle, "", err
}
if len(components) == 0 {
return windows.InvalidHandle, "", ErrArtifactInvalidPath
}
current, err := duplicateArtifactHandle(r.handle)
if err != nil {
return windows.InvalidHandle, "", err
}
for _, component := range components[:len(components)-1] {
next, err := artifactNtOpen(
current,
component,
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
windows.CloseHandle(current)
return windows.InvalidHandle, "", translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(next); err != nil {
windows.CloseHandle(next)
windows.CloseHandle(current)
return windows.InvalidHandle, "", err
}
windows.CloseHandle(current)
current = next
}
return current, components[len(components)-1], nil
}
func (r *artifactSecureRoot) openDir(relativePath string) (windows.Handle, error) {
if relativePath == "." {
return duplicateArtifactHandle(r.handle)
}
components, err := artifactPathComponentsWindows(relativePath)
if err != nil {
return windows.InvalidHandle, err
}
current, err := duplicateArtifactHandle(r.handle)
if err != nil {
return windows.InvalidHandle, err
}
for _, component := range components {
next, err := artifactNtOpen(
current,
component,
windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE|windows.DELETE|windows.SYNCHRONIZE,
windows.FILE_OPEN,
windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
0,
)
if err != nil {
windows.CloseHandle(current)
return windows.InvalidHandle, translateArtifactWindowsError(err)
}
if err := rejectArtifactReparsePoint(next); err != nil {
windows.CloseHandle(next)
windows.CloseHandle(current)
return windows.InvalidHandle, err
}
windows.CloseHandle(current)
current = next
}
return current, nil
}
func artifactNtOpen(
root windows.Handle,
name string,
access uint32,
disposition uint32,
options uint32,
attributes uint32,
) (windows.Handle, error) {
objectName, err := windows.NewNTUnicodeString(name)
if err != nil {
return windows.InvalidHandle, err
}
oa := &windows.OBJECT_ATTRIBUTES{
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
RootDirectory: root,
ObjectName: objectName,
Attributes: windows.OBJ_CASE_INSENSITIVE,
}
var handle windows.Handle
var iosb windows.IO_STATUS_BLOCK
var allocationSize int64
err = windows.NtCreateFile(
&handle,
access,
oa,
&iosb,
&allocationSize,
attributes,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
disposition,
options,
0,
0,
)
return handle, err
}
func duplicateArtifactHandle(source windows.Handle) (windows.Handle, error) {
var target windows.Handle
err := windows.DuplicateHandle(
windows.CurrentProcess(),
source,
windows.CurrentProcess(),
&target,
0,
false,
windows.DUPLICATE_SAME_ACCESS,
)
return target, err
}
func rejectArtifactReparsePoint(handle windows.Handle) error {
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
return err
}
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return errArtifactReparsePoint
}
return nil
}
func artifactNTAbsolutePath(absolute string) string {
if strings.HasPrefix(absolute, `\\`) {
return `\??\UNC\` + strings.TrimPrefix(absolute, `\\`)
}
return `\??\` + absolute
}
func artifactPathComponentsWindows(relativePath string) ([]string, error) {
if relativePath == "." {
return nil, nil
}
clean, err := validateArtifactRelativePath(relativePath)
if err != nil {
return nil, err
}
return strings.Split(clean, "/"), nil
}
func translateArtifactWindowsError(err error) error {
if err == nil {
return nil
}
return err
}
func isArtifactWindowsNotExist(err error) bool {
return errors.Is(err, windows.ERROR_FILE_NOT_FOUND) ||
errors.Is(err, windows.ERROR_PATH_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
}
func isArtifactAlreadyExists(err error) bool {
return errors.Is(err, windows.ERROR_ALREADY_EXISTS) ||
errors.Is(err, windows.ERROR_FILE_EXISTS) ||
errors.Is(err, windows.STATUS_OBJECT_NAME_COLLISION) ||
errors.Is(err, ErrArtifactPublishConflict)
}
func isArtifactNotExist(err error) bool {
return isArtifactWindowsNotExist(err)
}
@@ -0,0 +1,80 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func delegationArtifactTestContext() context.Context {
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
ctx = WithDelegationID(ctx, "11111111-1111-1111-1111-111111111111")
return WithDelegationArtifactInputs(ctx, "/runtime/delegations/input")
}
func TestDelegationArtifactRunRejectsAsyncChildModes(t *testing.T) {
ctx := delegationArtifactTestContext()
for _, mode := range []string{"async", "spawn", "background"} {
if err := validateDelegationChildRunMode(ctx, "delegate", mode); err == nil {
t.Fatalf("mode %q was accepted", mode)
}
}
if err := validateDelegationChildRunMode(ctx, "delegate", "sync"); err != nil {
t.Fatalf("sync mode rejected: %v", err)
}
if err := validateDelegationChildRunMode(context.Background(), "delegate", "async"); err != nil {
t.Fatalf("normal async run rejected: %v", err)
}
}
func TestSpawnToolRejectsDefaultAsyncInsideDelegationArtifactRun(t *testing.T) {
tool := NewSpawnTool(nil, "parent", 0)
result := tool.Execute(delegationArtifactTestContext(), map[string]any{
"task": "must not escape the delegation lifetime",
})
if result == nil || !strings.Contains(result.ForLLM, "mode=\"sync\"") {
t.Fatalf("unexpected result: %#v", result)
}
}
func TestDelegateToolRejectsAsyncInsideDelegationArtifactRunBeforeLookup(t *testing.T) {
tool := &DelegateTool{}
result := tool.Execute(delegationArtifactTestContext(), map[string]any{
"agent_key": "child",
"task": "must not escape the delegation lifetime",
"mode": "async",
})
if result == nil || !strings.Contains(result.ForLLM, "mode=\"sync\"") {
t.Fatalf("unexpected result: %#v", result)
}
}
func TestDelegateToolSchemaKeepsBoundedInputsOptional(t *testing.T) {
schema := (&DelegateTool{}).Parameters()
properties, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("properties = %#v", schema["properties"])
}
inputs, ok := properties["inputs"].(map[string]any)
if !ok {
t.Fatalf("inputs schema = %#v", properties["inputs"])
}
if got := inputs["type"]; got != "array" {
t.Fatalf("inputs type = %#v, want array", got)
}
if got := inputs["maxItems"]; got != DelegationArtifactMaxFiles {
t.Fatalf("inputs maxItems = %#v, want %d", got, DelegationArtifactMaxFiles)
}
if required, ok := schema["required"].([]string); ok {
for _, name := range required {
if name == "inputs" {
t.Fatal("inputs unexpectedly became required")
}
}
}
if _, ok := properties["delegation_id"]; !ok {
t.Fatal("schema omitted durable delegation result lookup")
}
}
+7 -5
View File
@@ -106,6 +106,9 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
if oldStr == newStr {
return ErrorResult("old_string and new_string are identical")
}
if err := rejectDelegationInputMutation(ctx, path); err != nil {
return ErrorResult(err.Error())
}
// Group write permission check
if t.permStore != nil {
@@ -115,7 +118,7 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
}
// Virtual FS: context files
if t.contextFileIntc != nil {
if !IsDelegationArtifactRun(ctx) && t.contextFileIntc != nil {
if content, handled, err := t.contextFileIntc.ReadFile(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read context file: %v", err))
@@ -135,7 +138,7 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
}
// Virtual FS: memory files
if t.memIntc != nil {
if !IsDelegationArtifactRun(ctx) && t.memIntc != nil {
if content, handled, err := t.memIntc.ReadFile(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read memory file: %v", err))
@@ -178,7 +181,6 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
return ErrorResult(err.Error())
}
data, err := os.ReadFile(resolved)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
@@ -211,12 +213,12 @@ func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr st
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandbox.DefaultContainerWorkdir)
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
+63 -14
View File
@@ -117,8 +117,10 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
}
}
// Virtual FS: route context files to DB
if t.contextFileIntc != nil {
// Agent Link artifact runs are a physical exchange boundary. Virtual
// context files belong to the delegate's persistent identity and must not
// masquerade as staged inputs.
if !IsDelegationArtifactRun(ctx) && t.contextFileIntc != nil {
if content, handled, err := t.contextFileIntc.ReadFile(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read context file: %v", err))
@@ -132,13 +134,16 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
// Virtual system files: TEAM.md, DELEGATION.md, AVAILABILITY.md are injected
// into the system prompt and don't exist on disk. Return a helpful hint.
baseName := filepath.Base(path)
if hint, ok := virtualSystemFiles[baseName]; ok {
return SilentResult(hint)
if !IsDelegationArtifactRun(ctx) {
baseName := filepath.Base(path)
if hint, ok := virtualSystemFiles[baseName]; ok {
return SilentResult(hint)
}
}
// Virtual FS: route memory files to DB
if t.memIntc != nil {
// Delegated runs use physical exchange files only. Context and memory
// virtual-file routing must not cross the Agent Link boundary.
if !IsDelegationArtifactRun(ctx) && t.memIntc != nil {
if content, handled, err := t.memIntc.ReadFile(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read memory file: %v", err))
@@ -150,12 +155,31 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
}
}
// Sandbox routing (sandboxKey from ctx — thread-safe)
// Sandboxed delegation inputs are addressed by their logical alias. The
// acquisition helper mounts only this exchange at /workspace/inputs:ro.
sandboxKey := ToolSandboxKeyFromCtx(ctx)
if t.sandboxMgr != nil && sandboxKey != "" {
return t.executeInSandbox(ctx, path, sandboxKey, args)
}
if resolved, handled, err := resolveDelegationInputPath(ctx, path); handled {
if err != nil {
return ErrorResult("cannot access delegation input")
}
if err := ValidateRegularFileForRead(resolved); err != nil {
return ErrorResult("delegation input is not a safe regular file")
}
if isBinaryFileExt(resolved) {
ext := strings.ToLower(filepath.Ext(resolved))
return ErrorResult(fmt.Sprintf("cannot read binary file (%s). Use the appropriate tool: read_image for images, read_document for documents, read_audio for audio, read_video for video.", ext))
}
data, err := os.ReadFile(resolved)
if err != nil {
return ErrorResult("failed to read delegation input")
}
return t.paginateOutput(string(data), args)
}
// Host execution — use per-user workspace from context if available
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
@@ -169,6 +193,9 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
return ErrorResult(err.Error())
}
if err := ValidateRegularFileForRead(resolved); err != nil {
return ErrorResult(fmt.Sprintf("cannot read path: %v", err))
}
// Block binary files — reading them wastes context with garbled data.
if isBinaryFileExt(resolved) {
@@ -200,7 +227,7 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandbox.DefaultContainerWorkdir)
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
@@ -219,7 +246,7 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st
}
func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey, mountWorkspace, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
return nil, err
}
@@ -332,9 +359,8 @@ func allowedWriteWithTeamWorkspace(ctx context.Context, base []string) []string
return buildAllowedPrefixes(ctx, base, false)
}
// buildAllowedPrefixes merges base + tenant paths + team workspace, optionally
// including team root. Extracted to share the slice-building logic between read
// and write variants without duplication.
// buildAllowedPrefixes merges base + tenant paths + team workspace and,
// for read operations, the explicit Agent Team root.
func buildAllowedPrefixes(ctx context.Context, base []string, includeTeamRoot bool) []string {
tenantPaths := TenantAllowedPathsFromCtx(ctx)
teamWs := ToolTeamWorkspaceFromCtx(ctx)
@@ -342,7 +368,6 @@ func buildAllowedPrefixes(ctx context.Context, base []string, includeTeamRoot bo
if includeTeamRoot {
teamRoot = ToolTeamRootFromCtx(ctx)
}
if len(tenantPaths) == 0 && teamWs == "" && teamRoot == "" {
return base
}
@@ -393,6 +418,16 @@ func resolvePathWithAllowed(path, workspace string, restrict bool, allowedPrefix
prefixReal = absPrefix
}
if isPathInside(real, prefixReal) {
// Preserve the same filesystem hardening as resolvePath. Allowed
// prefixes widen the containment boundary only; they must not
// weaken symlink or hardlink protections.
if hasMutableSymlinkParent(real) {
slog.Warn("security.mutable_symlink_parent", "path", path, "resolved", real)
return "", fmt.Errorf("access denied: path contains mutable symlink component")
}
if err := checkHardlink(real); err != nil {
return "", err
}
slog.Debug("read_file: allowed by prefix", "path", real, "prefix", prefixReal)
return real, nil
}
@@ -401,6 +436,20 @@ func resolvePathWithAllowed(path, workspace string, restrict bool, allowedPrefix
return "", err
}
// ValidateRegularFileForRead enforces the final leaf-file boundary used by
// outbound media paths. It rejects missing paths, directories, devices,
// sockets, FIFOs, symlinks, and hardlinked regular files.
func ValidateRegularFileForRead(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("path is not a regular file")
}
return checkHardlink(path)
}
// checkDeniedPath returns an error if the resolved path falls under any denied prefix.
// Denied prefixes are relative to the workspace (e.g. ".goclaw" denies workspace/.goclaw/).
// The resolved path should already be canonical (from resolvePath with restrict=true).
+24 -5
View File
@@ -72,7 +72,7 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul
}
// Virtual FS: route memory directory listing to DB
if t.memIntc != nil {
if !IsDelegationArtifactRun(ctx) && t.memIntc != nil {
if listing, handled, err := t.memIntc.ListFiles(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to list memory files: %v", err))
@@ -90,6 +90,13 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul
return t.executeInSandbox(ctx, path, sandboxKey)
}
if resolved, handled, err := resolveDelegationInputPath(ctx, path); handled {
if err != nil {
return ErrorResult("cannot access delegation input")
}
return t.executeDelegationHostList(ctx, resolved, path)
}
// Host execution — use per-user workspace from context if available
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
@@ -104,12 +111,24 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul
return ErrorResult(err.Error())
}
return t.executeHostList(ctx, resolved, path)
}
func (t *ListFilesTool) executeDelegationHostList(ctx context.Context, resolved, displayPath string) *Result {
result := t.executeHostList(ctx, resolved, displayPath)
if result.IsError {
return ErrorResult("failed to list delegation input directory")
}
return result
}
func (t *ListFilesTool) executeHostList(ctx context.Context, resolved, displayPath string) *Result {
entries, err := os.ReadDir(resolved)
if err != nil {
if os.IsNotExist(err) {
msg := fmt.Sprintf("Directory does not exist: %s", path)
msg := fmt.Sprintf("Directory does not exist: %s", displayPath)
if teamWs := ToolTeamWorkspaceFromCtx(ctx); teamWs != "" && !strings.HasPrefix(resolved, teamWs) {
msg += fmt.Sprintf("\nHint: try the team workspace path: list_files(path=\"%s/%s\")", teamWs, path)
msg += fmt.Sprintf("\nHint: try the team workspace path: list_files(path=\"%s/%s\")", teamWs, displayPath)
}
return SilentResult(msg)
}
@@ -144,7 +163,7 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandbox.DefaultContainerWorkdir)
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
@@ -163,7 +182,7 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s
}
func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey, mountWorkspace, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
return nil, err
}
+10 -4
View File
@@ -114,6 +114,12 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
if path == "" {
return ErrorResult("path is required")
}
if err := rejectDelegationInputMutation(ctx, path); err != nil {
return ErrorResult(err.Error())
}
if IsDelegationArtifactRun(ctx) {
deliver = false
}
// Group write permission check
if t.permStore != nil {
@@ -123,7 +129,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
}
// Virtual FS: route context files to DB
if t.contextFileIntc != nil {
if !IsDelegationArtifactRun(ctx) && t.contextFileIntc != nil {
if handled, err := t.contextFileIntc.WriteFile(ctx, path, content); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write context file: %v", err))
@@ -133,7 +139,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
}
// Virtual FS: route memory files to DB
if t.memIntc != nil {
if !IsDelegationArtifactRun(ctx) && t.memIntc != nil {
if mwr, err := t.memIntc.WriteFile(ctx, path, content, appendMode); mwr.Handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write memory file: %v", err))
@@ -244,7 +250,7 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandbox.DefaultContainerWorkdir)
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
@@ -288,7 +294,7 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san
}
func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey, mountWorkspace, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
return nil, err
}
+13 -15
View File
@@ -118,6 +118,9 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result
if message == "" {
return ErrorResult("message is required")
}
if IsDelegationArtifactRun(ctx) && strings.Contains(message, "MEDIA:") {
return ErrorResult("delegation files are published only after the delegated run completes")
}
channel := argString(args, "channel")
if channel == "" {
@@ -470,8 +473,8 @@ func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target
// sendMedia sends a file as a media attachment via the outbound message bus.
func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath string) *Result {
if _, err := os.Stat(filePath); err != nil {
return ErrorResult(fmt.Sprintf("file not found: %s", filePath))
if err := ValidateRegularFileForRead(filePath); err != nil {
return ErrorResult(fmt.Sprintf("media path is not a safe regular file: %v", err))
}
if t.msgBus == nil {
return ErrorResult("media sending requires message bus")
@@ -530,6 +533,9 @@ func (t *MessageTool) extractEmbeddedMedia(ctx context.Context, message string)
// Extract each MEDIA: path and resolve via security-checked path resolution.
for _, raw := range matches {
if resolved, ok := t.resolveMediaPath(ctx, raw); ok {
if err := ValidateRegularFileForRead(resolved); err != nil {
continue
}
media = append(media, bus.MediaAttachment{
URL: resolved,
ContentType: mimeFromPath(resolved),
@@ -622,11 +628,10 @@ func isGroupContext(ctx context.Context) bool {
// resolveMediaPath extracts and validates a file path from a "MEDIA:path" string.
// Uses the same workspace-aware path resolution as other filesystem tools.
// Multi-tenant isolation forces MEDIA: paths through restricted resolution
// first, with one explicit fallback for generated media artifacts under /tmp/.
// Multi-tenant isolation forces MEDIA: paths through restricted resolution.
// In practice MEDIA: paths may resolve to:
// - files inside the agent workspace
// - absolute paths under /tmp/ for generated media artifacts
// - files inside an explicitly authorized team/collaboration read root
//
// Relative paths are resolved against the agent's workspace.
func (t *MessageTool) resolveMediaPath(ctx context.Context, s string) (string, bool) {
@@ -645,17 +650,10 @@ func (t *MessageTool) resolveMediaPath(ctx context.Context, s string) (string, b
}
restrict := effectiveRestrict(ctx, t.restrict)
// resolvePath handles relative→absolute, symlink, hardlink, boundary checks.
resolved, err := resolvePath(raw, workspace, restrict)
// Use the read-only prefix set so orchestrators can publish media produced
// by linked agents or team members without granting mutation access.
resolved, err := resolvePathWithAllowed(raw, workspace, restrict, allowedWithTeamWorkspace(ctx, nil))
if err != nil {
// When restricted, also allow /tmp/ paths (used by create_image, create_audio, etc.)
// But reject paths that are siblings of the workspace — these are likely traversal
// attacks where workspace/../X resolves inside /tmp/ because workspace itself is in /tmp/.
cleaned := filepath.Clean(raw)
wsParent := filepath.Dir(filepath.Clean(workspace))
if restrict && isInTempDir(cleaned) && !isPathInside(cleaned, wsParent) {
return cleaned, true
}
return "", false
}
+54 -12
View File
@@ -56,9 +56,9 @@ func TestResolveMediaPath(t *testing.T) {
want string
wantOK bool
}{
// /tmp/ always allowed
{"valid temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), filepath.Join(tmpDir, "test.png"), true},
{"valid nested temp", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), filepath.Join(tmpDir, "sub", "file.txt"), true},
// Unscoped temp files are not tenant/run-owned.
{"unscoped temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), "", false},
{"unscoped nested temp", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), "", false},
// Workspace files allowed
{"workspace absolute", "MEDIA:" + testFileCanonical, testFileCanonical, true},
@@ -104,8 +104,8 @@ func TestResolveMediaPath(t *testing.T) {
{"absolute outside workspace", "MEDIA:" + outsidePath(workspaceCanonical, "etc/hostname"), false},
// Workspace-relative → allowed
{"workspace relative", "MEDIA:docs/report.pdf", true},
// /tmp/ → allowed (temp dir exception in restricted mode)
{"temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), true},
// /tmp/ is not an implicit cross-tenant read root.
{"temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), false},
}
for _, tt := range tests {
@@ -131,6 +131,29 @@ func TestResolveMediaPath(t *testing.T) {
t.Errorf("got %q, want %q", got, testFileCanonical)
}
})
}
func TestMessageMediaRejectsNonRegularFiles(t *testing.T) {
workspace := t.TempDir()
tool := NewMessageTool(workspace, true)
tool.SetMessageBus(bus.New())
if result := tool.sendMedia(context.Background(), "telegram", "chat-1", workspace); !result.IsError {
t.Fatal("sendMedia allowed a directory")
}
original := filepath.Join(workspace, "original.png")
if err := os.WriteFile(original, []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
hardlink := filepath.Join(workspace, "hardlink.png")
if err := os.Link(original, hardlink); err != nil {
t.Skipf("hardlinks not supported: %v", err)
}
if result := tool.sendMedia(context.Background(), "telegram", "chat-1", hardlink); !result.IsError {
t.Fatal("sendMedia allowed a hardlinked file")
}
}
func TestIsInTempDir(t *testing.T) {
@@ -157,8 +180,6 @@ func TestIsInTempDir(t *testing.T) {
}
func TestExtractEmbeddedMedia(t *testing.T) {
tmpDir := os.TempDir()
workspace := t.TempDir()
workspaceCanonical, _ := filepath.EvalSymlinks(workspace)
@@ -215,7 +236,10 @@ func TestExtractEmbeddedMedia(t *testing.T) {
})
t.Run("multiple MEDIA: on same line", func(t *testing.T) {
img := filepath.Join(tmpDir, "photo.png")
img := filepath.Join(workspaceCanonical, "photo.png")
if err := os.WriteFile(img, []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
msg := "MEDIA:" + reportCanonical + " MEDIA:" + img
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
@@ -227,6 +251,17 @@ func TestExtractEmbeddedMedia(t *testing.T) {
}
})
t.Run("non-regular MEDIA path is stripped without attachment", func(t *testing.T) {
msg := "Directory:\nMEDIA:" + docsDir + "\nDone"
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
if cleaned != "Directory:\nDone" {
t.Fatalf("unexpected cleaned text: %q", cleaned)
}
if len(media) != 0 {
t.Fatalf("non-regular path produced attachments: %+v", media)
}
})
t.Run("MEDIA: path outside workspace is stripped but no attachment", func(t *testing.T) {
msg := "File:\nMEDIA:" + outsidePath(workspaceCanonical, "etc/passwd") + "\nDone"
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
@@ -252,7 +287,11 @@ func TestExtractEmbeddedMedia(t *testing.T) {
})
t.Run("audio_as_voice tag stripped", func(t *testing.T) {
msg := "[[audio_as_voice]]\nMEDIA:" + filepath.Join(tmpDir, "voice.ogg") + "\nExtra text"
voice := filepath.Join(workspaceCanonical, "voice.ogg")
if err := os.WriteFile(voice, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
msg := "[[audio_as_voice]]\nMEDIA:" + voice + "\nExtra text"
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
if cleaned != "Extra text" {
@@ -264,7 +303,10 @@ func TestExtractEmbeddedMedia(t *testing.T) {
})
t.Run("multiple MEDIA: paths", func(t *testing.T) {
img := filepath.Join(tmpDir, "photo.png")
img := filepath.Join(workspaceCanonical, "photo-2.png")
if err := os.WriteFile(img, []byte("image"), 0o644); err != nil {
t.Fatal(err)
}
msg := "Files:\nMEDIA:" + reportCanonical + "\nMEDIA:" + img + "\nEnjoy!"
cleaned, media := tool.extractEmbeddedMedia(ctx, msg)
@@ -317,8 +359,8 @@ func TestValidateChannelTenant(t *testing.T) {
// Wire a mock checker.
channels := map[string]uuid.UUID{
"telegram": tenantA,
"tenant-b-tg": tenantB,
"telegram": tenantA,
"tenant-b-tg": tenantB,
}
tool.SetChannelTenantChecker(func(name string) (uuid.UUID, bool) {
tid, ok := channels[name]
+22 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -62,7 +63,7 @@ func (t *ReadAudioTool) Name() string { return "read_audio" }
func (t *ReadAudioTool) Description() string {
return "Analyze audio files (speech, music, sounds) attached to the conversation. " +
"Use when you see <media:audio> tags and need to transcribe, summarize, or analyze audio content. " +
"Specify what you want to extract or analyze."
"A workspace-relative path such as inputs/recording.mp3 may also be provided. Specify what you want to extract or analyze."
}
func (t *ReadAudioTool) Parameters() map[string]any {
@@ -77,6 +78,10 @@ func (t *ReadAudioTool) Parameters() map[string]any {
"type": "string",
"description": "Optional: specific media_id from <media:audio> tag. If omitted, uses most recent audio.",
},
"path": map[string]any{
"type": "string",
"description": "Optional workspace-relative audio path. Delegated inputs use inputs/<name>.",
},
},
"required": []string{"prompt"},
}
@@ -88,16 +93,30 @@ func (t *ReadAudioTool) Execute(ctx context.Context, args map[string]any) *Resul
prompt = "Analyze this audio and describe its contents."
}
mediaID, _ := args["media_id"].(string)
audioArg, _ := args["path"].(string)
if mediaID != "" && audioArg != "" {
return ErrorResult("Both 'media_id' and 'path' parameters cannot be specified. Choose only one.")
}
audioPath, audioMime, err := t.resolveAudioFile(ctx, mediaID)
var audioPath, audioMime string
var err error
if audioArg != "" {
audioPath, err = resolveStructuredMediaPath(ctx, audioArg, "audio")
audioMime = mimeFromAudioExt(filepath.Ext(audioPath))
} else {
audioPath, audioMime, err = t.resolveAudioFile(ctx, mediaID)
}
if err != nil {
return ErrorResult(err.Error())
}
slog.Info("read_audio: resolved file", "path", audioPath, "mime", audioMime, "media_id", mediaID)
slog.Info("read_audio: resolved file", "mime", audioMime, "media_id", mediaID, "logical_path", audioArg)
data, err := os.ReadFile(audioPath)
if err != nil {
if audioArg != "" && IsDelegationArtifactRun(ctx) {
return ErrorResult("Failed to read delegation audio input")
}
return ErrorResult(fmt.Sprintf("Failed to read audio file: %v", err))
}
slog.Info("read_audio: file loaded", "size_bytes", len(data))
+12 -15
View File
@@ -14,22 +14,11 @@ import (
// resolveAudioFile finds the audio file path from context MediaRefs.
func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (path, mime string, err error) {
if t.mediaLoader == nil {
return "", "", fmt.Errorf("no media storage configured — cannot access audio files")
}
refs := MediaAudioRefsFromCtx(ctx)
if len(refs) == 0 {
return "", "", fmt.Errorf("no audio files available in this conversation. The user may not have sent an audio file.")
}
// Sanitize media_id: LLM may pass the literal tag string (e.g. "<media:audio>")
// instead of a UUID. Treat tag-like values as empty to fall back to most recent.
if strings.Contains(mediaID, "<") || strings.Contains(mediaID, "media:") {
slog.Debug("read_audio: sanitizing tag-like media_id", "raw", mediaID)
mediaID = ""
}
var ref *providers.MediaRef
if mediaID != "" {
for i := range refs {
@@ -39,10 +28,7 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p
}
}
if ref == nil {
// Fallback to most recent audio instead of hard error,
// since LLM may generate invalid IDs.
slog.Warn("read_audio: media_id not found, falling back to most recent", "media_id", mediaID)
ref = &refs[len(refs)-1]
return "", "", fmt.Errorf("audio media_id %q not found in this conversation", mediaID)
}
} else {
ref = &refs[len(refs)-1]
@@ -50,6 +36,7 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p
// Prefer persisted workspace path; fall back to legacy .media/ lookup.
p := ref.Path
loadedLegacy := false
if p == "" {
var err error
if t.mediaLoader == nil {
@@ -59,6 +46,16 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p
if err != nil {
return "", "", fmt.Errorf("audio file not found: %v", err)
}
loadedLegacy = true
}
if loadedLegacy {
p, err = resolveLoadedMediaRefPath(ctx, t.mediaLoader, p, "audio")
} else {
p, err = resolveStructuredMediaRefPath(ctx, p, "audio")
}
if err != nil {
return "", "", err
}
mime = ref.MimeType
+216
View File
@@ -2,12 +2,29 @@ package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type testMediaPathLoader struct {
path string
root string
}
func (l testMediaPathLoader) LoadPath(string) (string, error) {
return l.path, nil
}
func (l testMediaPathLoader) MediaRootPath() string {
return l.root
}
type readAudioUnsupportedProvider struct {
name string
chatCalls int
@@ -122,3 +139,202 @@ func TestReadAudioCallProvider_UnsupportedProviderDoesNotSendAudioAsImage(t *tes
t.Fatalf("unsupported audio route called chat fallback: calls=%d images=%d", fake.chatCalls, fake.images)
}
}
func TestResolveAudioFileRequiresExactMediaID(t *testing.T) {
workspace := t.TempDir()
audioPath := filepath.Join(workspace, ".uploads", "recording.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: audioPath,
MimeType: "audio/mpeg",
}
tool := NewReadAudioTool(nil, nil)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
if gotPath, _, err := tool.resolveAudioFile(ctx, "recording.mp3"); err == nil {
t.Fatalf("non-ID value resolved to %q, want exact media_id error", gotPath)
}
gotPath, gotMime, err := tool.resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("exact media_id returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath || gotMime != "audio/mpeg" {
t.Fatalf("resolved (%q, %q), want (%q, audio/mpeg)", gotPath, gotMime, wantPath)
}
}
func TestResolveAudioFileOmittedMediaIDUsesNewestRef(t *testing.T) {
workspace := t.TempDir()
oldPath := filepath.Join(workspace, ".uploads", "old.mp3")
latestPath := filepath.Join(workspace, ".uploads", "latest.mp3")
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{oldPath, latestPath} {
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
}
refs := []providers.MediaRef{
{ID: uuid.NewString(), Kind: "audio", Path: oldPath, MimeType: "audio/mpeg"},
{ID: uuid.NewString(), Kind: "audio", Path: latestPath, MimeType: "audio/mpeg"},
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, refs)
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, "")
if err != nil {
t.Fatalf("omitted media_id returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(latestPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want newest %q", gotPath, wantPath)
}
}
func TestResolveAudioFileRejectsRefPathOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
outsidePath := filepath.Join(t.TempDir(), "secret.mp3")
if err := os.WriteFile(outsidePath, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: outsidePath,
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
if err == nil {
t.Fatalf("outside ref path resolved to %q, want containment error", gotPath)
}
}
func TestResolveAudioFileRejectsLegacyLoaderPathOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
outsidePath := filepath.Join(t.TempDir(), "legacy.mp3")
if err := os.WriteFile(outsidePath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: outsidePath}).resolveAudioFile(ctx, ref.ID)
if err == nil {
t.Fatalf("outside legacy loader path resolved to %q, want containment error", gotPath)
}
}
func TestResolveAudioFileAcceptsConfiguredLegacyMediaRoot(t *testing.T) {
workspace := t.TempDir()
mediaRoot := filepath.Join(t.TempDir(), ".media")
audioPath := filepath.Join(mediaRoot, "session-hash", "legacy.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{
path: audioPath,
root: mediaRoot,
}).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("configured legacy media path returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
}
func TestResolveAudioFileAcceptsLegacyLoaderPathInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
audioPath := filepath.Join(workspace, ".uploads", "legacy.mp3")
if err := os.MkdirAll(filepath.Dir(audioPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(audioPath, []byte("audio"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
MimeType: "audio/mpeg",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, testMediaPathLoader{path: audioPath}).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("inside legacy loader path returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
}
func TestResolveAudioFileAcceptsDelegationInputRef(t *testing.T) {
ctx, inputs, _ := delegationArtifactToolContext(t)
audioPath := filepath.Join(inputs, "recording.mp3")
if err := os.WriteFile(audioPath, []byte("audio"), 0o440); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "audio",
Path: audioPath,
MimeType: "audio/mpeg",
}
ctx = WithMediaAudioRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadAudioTool(nil, nil).resolveAudioFile(ctx, ref.ID)
if err != nil {
t.Fatalf("delegation input ref returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(audioPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want staged input %q", gotPath, wantPath)
}
}
+51 -4
View File
@@ -5,9 +5,12 @@ import (
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
@@ -123,6 +126,9 @@ func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Re
}
mediaID, _ := args["media_id"].(string)
docPathArg, _ := args["path"].(string)
if strings.TrimSpace(mediaID) != "" && strings.TrimSpace(docPathArg) != "" {
return ErrorResult("Specify either media_id or path, not both.")
}
// Resolve document file path from MediaRefs in context.
docPath, docMime, err := t.resolveDocumentFile(ctx, mediaID, docPathArg)
@@ -130,19 +136,24 @@ func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Re
return ErrorResult(err.Error())
}
slog.Info("read_document: resolved file", "path", docPath, "mime", docMime, "media_id", mediaID)
displayPath := logicalDocumentDisplayPath(ctx, docPath, docPathArg)
slog.Info("read_document: resolved file", "path", displayPath, "mime", docMime, "media_id", mediaID)
if isArchiveDocumentPath(docPath) {
return NewResult(fmt.Sprintf(
"Archive file available at %s. read_document does not analyze archive containers directly. Use exec to inspect or extract it, for example: unzip -l %q or unzip -q %q -d <output-dir>, then use list_files/read_file on extracted files.",
docPath, docPath, docPath,
displayPath, displayPath, displayPath,
))
}
// Read document file.
data, err := os.ReadFile(docPath)
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to read document file: %v", err))
slog.Warn("read_document: file read failed",
"path", displayPath,
"error", tracing.RedactText(ctx, err.Error()),
)
return ErrorResult(fmt.Sprintf("Failed to read document file %s.", displayPath))
}
slog.Info("read_document: file loaded", "size_bytes", len(data))
if len(data) > documentMaxBytes {
@@ -169,7 +180,11 @@ func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Re
// handing it to a subprocess. A rejection routes to vision rather than
// erroring, preserving today's behavior for MediaRef-derived paths.
if safePath, verr := t.validateExecPath(ctx, docPath); verr != nil {
slog.Warn("security.read_document_local_path_rejected", "path", docPath, "reason", verr.Error())
slog.Warn(
"security.read_document_local_path_rejected",
"path", displayPath,
"reason", tracing.RedactText(ctx, verr.Error()),
)
} else if text, err := t.localParser.Extract(ctx, safePath, docMime); err == nil {
slog.Info("read_document: local extraction hit", "mime", docMime, "bytes", len(text))
return NewResult(text) // no Provider/Model/Usage => no LLM spend
@@ -203,6 +218,35 @@ func (t *ReadDocumentTool) Execute(ctx context.Context, args map[string]any) *Re
return result
}
// logicalDocumentDisplayPath returns only model-safe paths. Resolved host
// paths remain runtime-only even when tracing redaction is not configured.
func logicalDocumentDisplayPath(ctx context.Context, resolvedPath, requestedPath string) string {
requested := path.Clean(strings.ReplaceAll(strings.TrimSpace(requestedPath), "\\", "/"))
if requestedPath != "" && requested != "." && requested != ".." &&
!strings.HasPrefix(requested, "../") && !path.IsAbs(requested) {
return requested
}
if inputRoot := DelegationArtifactInputsFromCtx(ctx); inputRoot != "" {
if relative, ok := artifactRelativeToRoot(inputRoot, resolvedPath); ok &&
relative != "." {
return path.Join("inputs", relative)
}
}
if workspace := ToolWorkspaceFromCtx(ctx); workspace != "" {
if relative, ok := artifactRelativeToRoot(workspace, resolvedPath); ok &&
relative != "." {
return relative
}
}
name := filepath.Base(resolvedPath)
if name == "." || name == string(filepath.Separator) || name == "" {
return "document"
}
return name
}
// validateExecPath confirms a resolved document path is workspace-confined
// before it is passed to a local extractor subprocess. It reuses the same
// allow/deny resolution as the explicit-path argument branch so MediaRef-derived
@@ -217,5 +261,8 @@ func (t *ReadDocumentTool) validateExecPath(ctx context.Context, path string) (s
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return "", err
}
if err := ValidateRegularFileForRead(resolved); err != nil {
return "", fmt.Errorf("document path is not a safe regular file: %w", err)
}
return resolved, nil
}
+16 -51
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
@@ -15,6 +14,9 @@ import (
// resolveDocumentFile finds the document file path from an explicit workspace
// path or from context MediaRefs.
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, docPath string) (path, mime string, err error) {
if strings.TrimSpace(mediaID) != "" && strings.TrimSpace(docPath) != "" {
return "", "", fmt.Errorf("specify either media_id or path, not both")
}
if docPath != "" {
p, err := resolveDocumentPathArg(ctx, docPath)
if err != nil {
@@ -28,16 +30,11 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc
return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.")
}
if strings.Contains(mediaID, "<") || strings.Contains(mediaID, "media:") {
slog.Debug("read_document: sanitizing tag-like media_id", "raw", mediaID)
mediaID = ""
}
// Find specific media_id or use most recent document.
var ref *providers.MediaRef
if mediaID != "" {
for i := range refs {
if documentRefMatches(refs[i], mediaID) {
if refs[i].ID == mediaID {
ref = &refs[i]
break
}
@@ -52,6 +49,7 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc
// Prefer persisted workspace path; fall back to legacy .media/ lookup.
p := ref.Path
loadedLegacy := false
if p == "" {
var err error
if t.mediaLoader == nil {
@@ -61,6 +59,16 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc
if err != nil {
return "", "", fmt.Errorf("document file not found: %v", err)
}
loadedLegacy = true
}
if loadedLegacy {
p, err = resolveLoadedMediaRefPath(ctx, t.mediaLoader, p, "document")
} else {
p, err = resolveStructuredMediaRefPath(ctx, p, "document")
}
if err != nil {
return "", "", err
}
// Determine MIME type: prefer ref's stored MIME, fall back to extension.
@@ -73,50 +81,7 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID, doc
}
func resolveDocumentPathArg(ctx context.Context, path string) (string, error) {
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
if err != nil {
return "", fmt.Errorf("invalid document path: %w", err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return "", err
}
if info, err := os.Stat(resolved); err != nil {
return "", fmt.Errorf("failed to stat document file: %w", err)
} else if info.IsDir() {
return "", fmt.Errorf("document path is a directory: %s", path)
}
return resolved, nil
}
func documentRefMatches(ref providers.MediaRef, mediaID string) bool {
if ref.ID == mediaID {
return true
}
if ref.Path == "" {
return false
}
want := strings.ToLower(filepath.Base(mediaID))
got := strings.ToLower(filepath.Base(ref.Path))
if want == got {
return true
}
return strings.EqualFold(stripUploadShortID(got), want)
}
func stripUploadShortID(name string) string {
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
idx := strings.LastIndexByte(stem, '-')
if idx < 0 || len(stem)-idx-1 != 8 {
return name
}
for _, r := range stem[idx+1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return name
}
}
return stem[:idx] + ext
return resolveStructuredMediaPath(ctx, path, "document")
}
func isArchiveDocumentPath(path string) bool {
+112 -14
View File
@@ -42,9 +42,15 @@ func TestResolveDocumentFileAcceptsWorkspacePath(t *testing.T) {
}
}
func TestResolveDocumentFileMatchesUploadedFilenameAlias(t *testing.T) {
func TestResolveDocumentFileRequiresExactMediaID(t *testing.T) {
workspace := t.TempDir()
docPath := filepath.Join(workspace, ".uploads", "codex-9c8914a5.zip")
if err := os.MkdirAll(filepath.Dir(docPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(docPath, []byte("zip bytes"), 0o644); err != nil {
t.Fatal(err)
}
refs := []providers.MediaRef{{
ID: uuid.NewString(),
Kind: "document",
@@ -53,13 +59,22 @@ func TestResolveDocumentFileMatchesUploadedFilenameAlias(t *testing.T) {
}}
tool := NewReadDocumentTool(nil, nil)
ctx := WithMediaDocRefs(context.Background(), refs)
gotPath, gotMime, err := tool.resolveDocumentFile(ctx, "codex.zip", "")
if err != nil {
t.Fatalf("resolveDocumentFile returned error: %v", err)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaDocRefs(ctx, refs)
if gotPath, _, err := tool.resolveDocumentFile(ctx, "codex.zip", ""); err == nil {
t.Fatalf("filename alias resolved to %q, want exact media_id error", gotPath)
}
if gotPath != docPath {
t.Fatalf("path = %q, want %q", gotPath, docPath)
gotPath, gotMime, err := tool.resolveDocumentFile(ctx, refs[0].ID, "")
if err != nil {
t.Fatalf("exact media_id returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(docPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want %q", gotPath, wantPath)
}
if gotMime != "application/zip" {
t.Fatalf("mime = %q, want application/zip", gotMime)
@@ -84,19 +99,85 @@ func TestResolveDocumentFileInvalidMediaIDReturnsError(t *testing.T) {
}
func TestResolveDocumentFileOmittedMediaIDUsesLastRef(t *testing.T) {
workspace := t.TempDir()
oldPath := filepath.Join(workspace, ".uploads", "old.pdf")
latestPath := filepath.Join(workspace, ".uploads", "latest.pdf")
if err := os.MkdirAll(filepath.Dir(oldPath), 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{oldPath, latestPath} {
if err := os.WriteFile(path, []byte("pdf"), 0o644); err != nil {
t.Fatal(err)
}
}
refs := []providers.MediaRef{
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/old.pdf", MimeType: "application/pdf"},
{ID: uuid.NewString(), Kind: "document", Path: "/workspace/.uploads/latest.pdf", MimeType: "application/pdf"},
{ID: uuid.NewString(), Kind: "document", Path: oldPath, MimeType: "application/pdf"},
{ID: uuid.NewString(), Kind: "document", Path: latestPath, MimeType: "application/pdf"},
}
tool := NewReadDocumentTool(nil, nil)
ctx := WithMediaDocRefs(context.Background(), refs)
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithMediaDocRefs(ctx, refs)
gotPath, _, err := tool.resolveDocumentFile(ctx, "", "")
if err != nil {
t.Fatalf("resolveDocumentFile returned error: %v", err)
}
if gotPath != refs[1].Path {
t.Fatalf("path = %q, want most recent %q", gotPath, refs[1].Path)
wantPath, err := filepath.EvalSymlinks(refs[1].Path)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want most recent %q", gotPath, wantPath)
}
}
func TestResolveDocumentFileRejectsRefPathOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
outsideDir := t.TempDir()
outsidePath := filepath.Join(outsideDir, "secret.pdf")
if err := os.WriteFile(outsidePath, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "document",
Path: outsidePath,
MimeType: "application/pdf",
}
ctx := WithToolWorkspace(context.Background(), workspace)
ctx = WithToolTeamWorkspace(ctx, outsideDir)
ctx = WithMediaDocRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadDocumentTool(nil, nil).resolveDocumentFile(ctx, ref.ID, "")
if err == nil {
t.Fatalf("outside ref path resolved to %q, want containment error", gotPath)
}
}
func TestResolveDocumentFileAcceptsDelegationInputRef(t *testing.T) {
ctx, inputs, _ := delegationArtifactToolContext(t)
docPath := filepath.Join(inputs, "brief.pdf")
if err := os.WriteFile(docPath, []byte("pdf"), 0o440); err != nil {
t.Fatal(err)
}
ref := providers.MediaRef{
ID: uuid.NewString(),
Kind: "document",
Path: "inputs/brief.pdf",
MimeType: "application/pdf",
}
ctx = WithMediaDocRefs(ctx, []providers.MediaRef{ref})
gotPath, _, err := NewReadDocumentTool(nil, nil).resolveDocumentFile(ctx, ref.ID, "")
if err != nil {
t.Fatalf("delegation input ref returned error: %v", err)
}
wantPath, err := filepath.EvalSymlinks(docPath)
if err != nil {
t.Fatal(err)
}
if gotPath != wantPath {
t.Fatalf("path = %q, want staged input %q", gotPath, wantPath)
}
}
@@ -121,7 +202,24 @@ func TestReadDocumentArchiveReturnsExecHint(t *testing.T) {
if result.IsError {
t.Fatalf("expected archive hint, got error: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "unzip -l") || !strings.Contains(result.ForLLM, docPath) {
t.Fatalf("expected unzip hint with path, got: %s", result.ForLLM)
if !strings.Contains(result.ForLLM, "unzip -l") ||
!strings.Contains(result.ForLLM, ".uploads/codex-9c8914a5.zip") {
t.Fatalf("expected unzip hint with logical path, got: %s", result.ForLLM)
}
if strings.Contains(result.ForLLM, workspace) || strings.Contains(result.ForLLM, docPath) {
t.Fatalf("archive hint leaked physical workspace path: %s", result.ForLLM)
}
}
func TestReadDocumentRejectsMediaIDAndPathTogether(t *testing.T) {
tool := NewReadDocumentTool(nil, nil)
result := tool.Execute(context.Background(), map[string]any{
"prompt": "Inspect",
"media_id": uuid.NewString(),
"path": ".uploads/document.pdf",
})
if !result.IsError || !strings.Contains(result.ForLLM, "either media_id or path") {
t.Fatalf("result = %#v, want mutually exclusive argument error", result)
}
}
+53 -8
View File
@@ -17,6 +17,7 @@ import (
// --- Context helpers for media images ---
const ctxMediaImages toolContextKey = "tool_media_images"
const ctxMediaImageRefs toolContextKey = "tool_media_image_refs"
// WithMediaImages stores base64-encoded images in context for read_image tool access.
func WithMediaImages(ctx context.Context, images []providers.ImageContent) context.Context {
@@ -29,6 +30,18 @@ func MediaImagesFromCtx(ctx context.Context) []providers.ImageContent {
return v
}
// WithMediaImageRefs stores image MediaRefs in chronological order for exact
// media_id resolution by image tools.
func WithMediaImageRefs(ctx context.Context, refs []providers.MediaRef) context.Context {
return context.WithValue(ctx, ctxMediaImageRefs, refs)
}
// MediaImageRefsFromCtx retrieves image MediaRefs available to the current run.
func MediaImageRefsFromCtx(ctx context.Context) []providers.MediaRef {
v, _ := ctx.Value(ctxMediaImageRefs).([]providers.MediaRef)
return v
}
// --- ReadImageTool ---
// visionProviderPriority is the order in which providers are tried for vision.
@@ -76,7 +89,11 @@ func (t *ReadImageTool) Parameters() map[string]any {
},
"path": map[string]any{
"type": "string",
"description": "Optional file path to an image in the workspace. Use this for generated images or attachments. If omitted, analyzes images from the conversation.",
"description": "Optional logical file path to an image in the workspace (for example .uploads/photo.jpg or inputs/photo.jpg). Never reconstruct an absolute workspace path.",
},
"media_id": map[string]any{
"type": "string",
"description": "Optional exact media ID from a <media:image id=\"...\"> tag for in-process tool execution. Claude CLI/MCP callers should use the tag's logical path.",
},
"url": map[string]any{
"type": "string",
@@ -98,10 +115,14 @@ func (t *ReadImageTool) Execute(ctx context.Context, args map[string]any) *Resul
imgPath, _ := args["path"].(string)
imgURL, _ := args["url"].(string)
mediaID, _ := args["media_id"].(string)
if imgPath != "" && imgURL != "" {
if imgPath != "" && imgURL != "" && mediaID == "" {
return ErrorResult("Both 'path' and 'url' parameters cannot be specified. Choose only one.")
}
if mediaID != "" && (imgPath != "" || imgURL != "") {
return ErrorResult("Only one of 'path', 'url', or 'media_id' may be specified.")
}
// If path is provided, load image from workspace file
images := MediaImagesFromCtx(ctx)
@@ -111,6 +132,19 @@ func (t *ReadImageTool) Execute(ctx context.Context, args map[string]any) *Resul
return ErrorResult(err.Error())
}
images = fileImages
} else if mediaID != "" {
ref, err := resolveImageMediaRef(ctx, mediaID)
if err != nil {
return ErrorResult(err.Error())
}
if ref.Path == "" {
return ErrorResult(fmt.Sprintf("image media_id %q has no accessible workspace path", mediaID))
}
fileImages, err := t.loadImageFromPath(ctx, ref.Path)
if err != nil {
return ErrorResult(err.Error())
}
images = fileImages
} else if imgURL != "" {
if _, _, err := security.Validate(imgURL); err != nil {
return ErrorResult(fmt.Sprintf("Invalid image URL: %v", err))
@@ -152,6 +186,22 @@ func (t *ReadImageTool) Execute(ctx context.Context, args map[string]any) *Resul
return result
}
func resolveImageMediaRef(ctx context.Context, mediaID string) (providers.MediaRef, error) {
refs := MediaImageRefsFromCtx(ctx)
if len(refs) == 0 {
return providers.MediaRef{}, fmt.Errorf("no image media references available in this conversation")
}
if mediaID == "latest" {
return refs[len(refs)-1], nil
}
for _, ref := range refs {
if ref.ID == mediaID {
return ref, nil
}
}
return providers.MediaRef{}, fmt.Errorf("image media_id %q not found in this conversation", mediaID)
}
// callProvider dispatches the vision call using provider.Chat().
func (t *ReadImageTool) callProvider(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error) {
prompt := GetParamString(params, "prompt", "Describe this image in detail.")
@@ -235,13 +285,8 @@ func (t *ReadImageTool) loadImageFromPath(ctx context.Context, path string) ([]p
return nil, fmt.Errorf("unsupported image format: %s (supported: jpg, png, gif, webp, bmp)", ext)
}
// Resolve path within workspace (respect workspace restriction).
workspace := ToolWorkspaceFromCtx(ctx)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, true), allowedWithTeamWorkspace(ctx, nil))
resolved, err := resolveStructuredMediaPath(ctx, path, "image")
if err != nil {
return nil, fmt.Errorf("invalid image path: %w", err)
}
if err := checkDeniedPath(resolved, workspace, nil); err != nil {
return nil, err
}
+19
View File
@@ -42,6 +42,25 @@ func TestReadImage_PrivateURL_Error(t *testing.T) {
}
}
func TestResolveImageMediaRefRequiresExactID(t *testing.T) {
ctx := WithMediaImageRefs(context.Background(), []providers.MediaRef{
{ID: "first-id", Kind: "image", Path: ".uploads/first.png"},
{ID: "second-id", Kind: "image", Path: ".uploads/second.png"},
})
got, err := resolveImageMediaRef(ctx, "first-id")
if err != nil || got.ID != "first-id" {
t.Fatalf("exact media ID = %#v, %v", got, err)
}
latest, err := resolveImageMediaRef(ctx, "latest")
if err != nil || latest.ID != "second-id" {
t.Fatalf("latest media ID = %#v, %v", latest, err)
}
if _, err := resolveImageMediaRef(ctx, "missing-id"); err == nil {
t.Fatal("unknown media ID unexpectedly resolved")
}
}
func TestReadImage_AnthropicURL_Error(t *testing.T) {
tool := NewReadImageTool(nil)
+29 -5
View File
@@ -67,7 +67,7 @@ func (t *ReadVideoTool) Name() string { return "read_video" }
func (t *ReadVideoTool) Description() string {
return "Analyze video files attached to the conversation. " +
"Use when you see <media:video> tags and need to describe, summarize, or analyze video content. " +
"Specify what you want to extract or analyze."
"A workspace-relative path such as inputs/clip.mp4 may also be provided. Specify what you want to extract or analyze."
}
func (t *ReadVideoTool) Parameters() map[string]any {
@@ -86,6 +86,10 @@ func (t *ReadVideoTool) Parameters() map[string]any {
"type": "string",
"description": "Optional URL to a video file. Use this to analyze videos hosted online.",
},
"path": map[string]any{
"type": "string",
"description": "Optional workspace-relative video path. Delegated inputs use inputs/<name>.",
},
},
"required": []string{"prompt"},
}
@@ -98,9 +102,19 @@ func (t *ReadVideoTool) Execute(ctx context.Context, args map[string]any) *Resul
}
mediaID, _ := args["media_id"].(string)
videoURL, _ := args["url"].(string)
videoArg, _ := args["path"].(string)
if mediaID != "" && videoURL != "" {
return ErrorResult("Both 'media_id' and 'url' parameters cannot be specified. Choose only one.")
sourceCount := 0
for _, source := range []string{mediaID, videoURL, videoArg} {
if source != "" {
sourceCount++
}
}
if sourceCount > 1 {
if videoArg == "" && mediaID != "" && videoURL != "" {
return ErrorResult("Both 'media_id' and 'url' parameters cannot be specified. Choose only one.")
}
return ErrorResult("Only one of 'media_id', 'url', or 'path' may be specified.")
}
var data []byte
@@ -118,15 +132,25 @@ func (t *ReadVideoTool) Execute(ctx context.Context, args map[string]any) *Resul
ext := filepath.Ext(validatedURL.Path)
videoMime = mimeFromVideoExt(ext)
} else {
videoPath, mime, err := t.resolveVideoFile(ctx, mediaID)
var videoPath, mime string
var err error
if videoArg != "" {
videoPath, err = resolveStructuredMediaPath(ctx, videoArg, "video")
mime = mimeFromVideoExt(filepath.Ext(videoPath))
} else {
videoPath, mime, err = t.resolveVideoFile(ctx, mediaID)
}
if err != nil {
return ErrorResult(err.Error())
}
videoMime = mime
slog.Info("read_video: resolved file", "path", videoPath, "mime", videoMime, "media_id", mediaID)
slog.Info("read_video: resolved file", "mime", videoMime, "media_id", mediaID, "logical_path", videoArg)
fileData, err := os.ReadFile(videoPath)
if err != nil {
if videoArg != "" && IsDelegationArtifactRun(ctx) {
return ErrorResult("Failed to read delegation video input")
}
return ErrorResult(fmt.Sprintf("Failed to read video file: %v", err))
}
slog.Info("read_video: file loaded", "size_bytes", len(fileData))
+11
View File
@@ -44,6 +44,7 @@ func (t *ReadVideoTool) resolveVideoFile(ctx context.Context, mediaID string) (p
// Prefer persisted workspace path; fall back to legacy .media/ lookup.
p := ref.Path
loadedLegacy := false
if p == "" {
var err error
if t.mediaLoader == nil {
@@ -53,6 +54,16 @@ func (t *ReadVideoTool) resolveVideoFile(ctx context.Context, mediaID string) (p
if err != nil {
return "", "", fmt.Errorf("video file not found: %v", err)
}
loadedLegacy = true
}
if loadedLegacy {
p, err = resolveLoadedMediaRefPath(ctx, t.mediaLoader, p, "video")
} else {
p, err = resolveStructuredMediaRefPath(ctx, p, "video")
}
if err != nil {
return "", "", err
}
mime = ref.MimeType
+130 -1
View File
@@ -3,7 +3,9 @@ package tools
import (
"context"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"
@@ -18,12 +20,14 @@ type recordingSandboxManager struct {
key string
workspace string
cfg *sandbox.Config
getOpts sandbox.GetOpts
}
func (m *recordingSandboxManager) Get(ctx context.Context, key string, workspace string, cfg *sandbox.Config) (sandbox.Sandbox, error) {
func (m *recordingSandboxManager) Get(ctx context.Context, key string, workspace string, cfg *sandbox.Config, opts ...sandbox.GetOption) (sandbox.Sandbox, error) {
m.key = key
m.workspace = workspace
m.cfg = cfg
m.getOpts = sandbox.ApplyGetOpts(opts)
if m.sandbox == nil {
m.sandbox = &recordingSandbox{}
}
@@ -153,6 +157,40 @@ func TestCredentialedExecSandboxUsesEffectiveWorkspaceMountAndContainerCwd(t *te
}
}
func TestCredentialedExecSandboxMountsCurrentDelegationExchange(t *testing.T) {
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-credentialed-session")
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.executeCredentialedSandbox(
ctx,
"/usr/bin/gh",
[]string{"api", "user"},
outputs,
"delegated-credentialed-session",
map[string]string{"GH_TOKEN": "secret"},
30*time.Second,
)
if result.IsError {
t.Fatalf("credentialed delegated sandbox exec failed: %s", result.ForLLM)
}
if manager.getOpts.WorkspaceAccessOverride == nil ||
*manager.getOpts.WorkspaceAccessOverride != sandbox.AccessRW {
t.Fatalf("delegation output access override = %#v, want rw", manager.getOpts.WorkspaceAccessOverride)
}
if len(manager.getOpts.ReadOnlyMounts) != 1 {
t.Fatalf("read-only mounts = %#v, want one inputs mount", manager.getOpts.ReadOnlyMounts)
}
mount := manager.getOpts.ReadOnlyMounts[0]
if mount.Name != "inputs" ||
mount.Destination != path.Join(sandbox.DefaultContainerWorkdir, "inputs") ||
filepath.Base(mount.HostPath) != "inputs" {
t.Fatalf("delegation input mount = %#v", mount)
}
}
func TestCredentialedExecSandboxWorkingDirResolvesInsideTenantWorkspace(t *testing.T) {
globalWorkspace := t.TempDir()
tenantWorkspace := filepath.Join(globalWorkspace, "tenants", "acme")
@@ -244,6 +282,97 @@ func TestSandboxFileToolsUseEffectiveWorkspaceMount(t *testing.T) {
}
}
func TestAcquireToolSandboxMountsOnlyCurrentDelegationInputs(t *testing.T) {
tenantWorkspace := t.TempDir()
delegationID := uuid.New()
root := filepath.Join(tenantWorkspace, "collaboration", "delegations", delegationID.String())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
cfg := sandbox.DefaultConfig()
cfg.Mode = sandbox.ModeAll
cfg.WorkspaceAccess = sandbox.AccessNone
ctx := WithDelegationID(context.Background(), delegationID.String())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, outputs)
ctx = WithSandboxConfig(ctx, &cfg)
mgr := &recordingSandboxManager{}
if _, err := acquireToolSandbox(ctx, mgr, "delegate-session", outputs); err != nil {
t.Fatalf("acquireToolSandbox: %v", err)
}
if len(mgr.getOpts.ReadOnlyMounts) != 1 {
t.Fatalf("read-only mounts = %#v, want one input mount", mgr.getOpts.ReadOnlyMounts)
}
if mgr.getOpts.WorkspaceAccessOverride == nil ||
*mgr.getOpts.WorkspaceAccessOverride != sandbox.AccessRW {
t.Fatalf("delegation output access override = %#v, want rw", mgr.getOpts.WorkspaceAccessOverride)
}
mount := mgr.getOpts.ReadOnlyMounts[0]
wantInputs, err := filepath.EvalSymlinks(inputs)
if err != nil {
t.Fatal(err)
}
if mount.Name != "inputs" || mount.HostPath != wantInputs || mount.Destination != "/workspace/inputs" {
t.Fatalf("input mount = %#v", mount)
}
}
func TestAcquireToolSandboxRejectsExchangePathNotBoundToDelegationID(t *testing.T) {
root := filepath.Join(t.TempDir(), "collaboration", "delegations", uuid.NewString())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
ctx := WithDelegationID(context.Background(), uuid.NewString())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, outputs)
mgr := &recordingSandboxManager{}
if _, err := acquireToolSandbox(ctx, mgr, "delegate-session", outputs); err == nil {
t.Fatal("forged exchange path unexpectedly mounted")
}
if mgr.key != "" {
t.Fatal("sandbox manager called for forged exchange path")
}
}
func TestDelegationInputMutationRejectedBeforeSandboxAcquisition(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
ctx = WithToolSandboxKey(ctx, "delegate-session")
writeManager := &recordingSandboxManager{}
writeResult := NewSandboxedWriteFileTool(outputs, true, writeManager).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "content": "mutated",
})
if !writeResult.IsError || !strings.Contains(writeResult.ForLLM, "read-only") {
t.Fatalf("write input result = %#v", writeResult)
}
if writeManager.key != "" {
t.Fatalf("write acquired sandbox before rejecting input mutation")
}
editManager := &recordingSandboxManager{}
editResult := NewSandboxedEditTool(outputs, true, editManager).Execute(ctx, map[string]any{
"path": "inputs/source.txt", "old_string": "a", "new_string": "b",
})
if !editResult.IsError || !strings.Contains(editResult.ForLLM, "read-only") {
t.Fatalf("edit input result = %#v", editResult)
}
if editManager.key != "" {
t.Fatalf("edit acquired sandbox before rejecting input mutation")
}
}
type sandboxMountSecureCLIStore struct {
binary *store.SecureCLIBinary
}
+86
View File
@@ -4,13 +4,22 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
delegationArtifactInputsDirName = "inputs"
delegationArtifactOutputsDirName = "outputs"
)
// SandboxCwd maps the current effective workspace (from context) to its
// corresponding path inside the sandbox container. The sandbox mounts the
// global workspace root at containerBase (usually "/workspace"). This function
@@ -59,6 +68,83 @@ func canonicalSandboxWorkspace(workspace string) string {
return clean
}
func sandboxContainerWorkdir(ctx context.Context) string {
if cfg := SandboxConfigFromCtx(ctx); cfg != nil {
return cfg.ContainerWorkdir()
}
return sandbox.DefaultContainerWorkdir
}
// acquireToolSandbox attaches the delegation input exchange only to the exact
// delegated run that owns it. The input/output sibling check prevents a forged
// context from turning this runtime-only option into general host mounting.
func acquireToolSandbox(
ctx context.Context,
manager sandbox.Manager,
key, workspace string,
) (sandbox.Sandbox, error) {
cfg := SandboxConfigFromCtx(ctx)
inputRoot := DelegationArtifactInputsFromCtx(ctx)
if inputRoot == "" {
return manager.Get(ctx, key, workspace, cfg)
}
delegationID, err := uuid.Parse(DelegationIDFromCtx(ctx))
if err != nil {
return nil, fmt.Errorf("delegation artifact sandbox context is invalid")
}
outputRoot := ToolWorkspaceFromCtx(ctx)
inputCanonical, err := canonicalRealDirectory(inputRoot)
if err != nil {
return nil, fmt.Errorf("delegation artifact input mount is unavailable")
}
outputCanonical, err := canonicalRealDirectory(outputRoot)
if err != nil {
return nil, fmt.Errorf("delegation artifact output mount is unavailable")
}
if filepath.Base(inputCanonical) != delegationArtifactInputsDirName ||
filepath.Base(outputCanonical) != delegationArtifactOutputsDirName ||
filepath.Dir(inputCanonical) != filepath.Dir(outputCanonical) {
return nil, fmt.Errorf("delegation artifact sandbox context is invalid")
}
exchangeRoot := filepath.Dir(inputCanonical)
if filepath.Base(exchangeRoot) != delegationID.String() ||
filepath.Base(filepath.Dir(exchangeRoot)) != "delegations" ||
filepath.Base(filepath.Dir(filepath.Dir(exchangeRoot))) != "collaboration" {
return nil, fmt.Errorf("delegation artifact sandbox context is invalid")
}
mount := sandbox.ReadOnlyMount{
Name: "inputs",
HostPath: inputCanonical,
Destination: path.Join(sandboxContainerWorkdir(ctx), "inputs"),
}
return manager.Get(
ctx,
key,
workspace,
cfg,
sandbox.WithWorkspaceAccessOverride(sandbox.AccessRW),
sandbox.WithReadOnlyMounts(mount),
)
}
func canonicalRealDirectory(raw string) (string, error) {
if raw == "" || !filepath.IsAbs(raw) {
return "", fmt.Errorf("directory path must be absolute")
}
clean := filepath.Clean(raw)
info, err := os.Lstat(clean)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("directory path is not a real directory")
}
real, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", fmt.Errorf("directory path is not canonical")
}
return filepath.Clean(real), nil
}
func sandboxCwdForHostPath(hostCwd, mountWorkspace, containerBase string) (string, error) {
if hostCwd == "" {
hostCwd = mountWorkspace
+3
View File
@@ -80,6 +80,9 @@ func (t *SendFileTool) Parameters() map[string]any {
// Execute resolves and validates the path, checks for duplicate delivery, then
// returns a Result with Media populated for downstream pipeline delivery.
func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *Result {
if IsDelegationArtifactRun(ctx) {
return ErrorResult("delegation files are published only after the delegated run completes")
}
requests, err := parseSendFileRequests(args)
if err != nil {
return ErrorResult(err.Error())
+15 -2
View File
@@ -27,6 +27,8 @@ const (
ExecDefaultTimeoutSeconds = 60
ExecMinTimeoutSeconds = 1
ExecMaxTimeoutSeconds = 3600
delegatedExecSandboxRequiredError = "delegated artifact exec requires an active sandbox manager and sandbox key; host execution is not allowed"
)
// Dangerous command patterns organized into configurable deny groups.
@@ -246,6 +248,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
return ErrorResult("command contains invalid NUL byte")
}
// Delegation artifact runs are a physical isolation boundary. Reject before
// credential lookup, adapter preparation, approval prompts, or ordinary
// command handling unless execution can be routed into an identified sandbox.
if IsDelegationArtifactRun(ctx) &&
(t.sandboxMgr == nil || ToolSandboxKeyFromCtx(ctx) == "") {
return ErrorResult(delegatedExecSandboxRequiredError)
}
// Normalize command before all deny checks: NFKC + zero-width strip prevents
// Unicode-based pattern bypass while preserving functional command content.
normalizedCommand := normalizeCommand(command)
@@ -655,14 +665,17 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(cwd, mountWorkspace, sandbox.DefaultContainerWorkdir)
containerCwd, cwdErr := sandboxCwdForHostPath(cwd, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mountWorkspace, SandboxConfigFromCtx(ctx))
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
if errors.Is(err, sandbox.ErrSandboxDisabled) {
if IsDelegationArtifactRun(ctx) {
return ErrorResult(delegatedExecSandboxRequiredError)
}
return t.executeOnHost(ctx, command, cwd)
}
// Docker unavailable (binary missing, daemon down) → fail closed.
@@ -0,0 +1,189 @@
package tools
import (
"context"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestExecDelegationArtifactRejectsNativeHostRead(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell command fixture")
}
ctx, _, outputs := delegationArtifactToolContext(t)
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "outside.txt")
const outsideContents = "delegated-native-host-read"
if err := os.WriteFile(outsideFile, []byte(outsideContents), 0o600); err != nil {
t.Fatal(err)
}
result := NewExecTool(outputs, true).Execute(ctx, map[string]any{
"command": "cat " + strconv.Quote(outsideFile),
})
if !result.IsError {
t.Fatalf("delegated native exec read an outside host file: %#v", result)
}
if result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("error = %q, want %q", result.ForLLM, delegatedExecSandboxRequiredError)
}
if strings.Contains(result.ForLLM, outsideContents) {
t.Fatal("delegated native exec exposed outside file contents")
}
}
func TestExecDelegationArtifactRejectsCredentialedHostPathBeforeLookup(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
secureCLIStore := newStubSecureCLIStore()
secureCLIStore.byName["delegated-cli"] = &store.SecureCLIBinary{
BinaryName: "delegated-cli",
TimeoutSeconds: 30,
Enabled: true,
IsGlobal: true,
}
tool := NewExecTool(outputs, true)
tool.SetSecureCLIStore(secureCLIStore)
result := tool.Execute(ctx, map[string]any{"command": "delegated-cli status"})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("delegated credentialed exec result = %#v, want stable sandbox-required error", result)
}
secureCLIStore.mu.Lock()
lookupCalls := secureCLIStore.lookupCalls
secureCLIStore.mu.Unlock()
if lookupCalls != 0 {
t.Fatalf("credential lookup calls = %d, want guard before credentialed routing", lookupCalls)
}
}
func TestExecDelegationArtifactRejectsMissingSandboxKey(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{"command": "echo should-not-run"})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("delegated exec without sandbox key = %#v, want stable sandbox-required error", result)
}
if manager.key != "" {
t.Fatalf("sandbox manager called with key %q despite missing context key", manager.key)
}
}
func TestExecDelegationArtifactUsesSandboxWithManagerAndKey(t *testing.T) {
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-session")
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{"command": "echo sandboxed"})
if result.IsError {
t.Fatalf("delegated sandbox exec failed: %s", result.ForLLM)
}
if manager.key != "delegated-session" {
t.Fatalf("sandbox manager key = %q, want delegated-session", manager.key)
}
if got := strings.Join(manager.sandbox.command, " "); got != "sh -c echo sandboxed" {
t.Fatalf("sandbox command = %q, want sandbox shell path", got)
}
}
type disabledDelegationSandboxManager struct {
getCalls int
}
func (m *disabledDelegationSandboxManager) Get(context.Context, string, string, *sandbox.Config, ...sandbox.GetOption) (sandbox.Sandbox, error) {
m.getCalls++
return nil, sandbox.ErrSandboxDisabled
}
func (*disabledDelegationSandboxManager) Release(context.Context, string) error { return nil }
func (*disabledDelegationSandboxManager) ReleaseAll(context.Context) error { return nil }
func (*disabledDelegationSandboxManager) Stop() {}
func (*disabledDelegationSandboxManager) Stats() map[string]any { return nil }
func TestExecDelegationArtifactDoesNotFallbackWhenSandboxDisabled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX touch command fixture")
}
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-session")
marker := filepath.Join(t.TempDir(), "process-ran")
manager := &disabledDelegationSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{
"command": "touch " + strconv.Quote(marker),
})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("disabled delegated sandbox result = %#v, want stable sandbox-required error", result)
}
if manager.getCalls != 1 {
t.Fatalf("sandbox manager Get calls = %d, want 1", manager.getCalls)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("delegated exec fell back to host; marker stat error = %v", err)
}
}
func TestExecNonDelegatedSandboxDisabledStillFallsBackToHost(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX echo command fixture")
}
workspace := t.TempDir()
canonicalWorkspace, err := filepath.EvalSymlinks(workspace)
if err != nil {
t.Fatal(err)
}
manager := &disabledDelegationSandboxManager{}
tool := NewSandboxedExecTool(canonicalWorkspace, true, manager)
ctx := WithToolSandboxKey(context.Background(), "ordinary-session")
result := tool.Execute(ctx, map[string]any{"command": "echo ordinary-fallback"})
if result.IsError {
t.Fatalf("non-delegated sandbox fallback changed: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "ordinary-fallback") {
t.Fatalf("non-delegated host fallback output = %q", result.ForLLM)
}
}
func delegatedExecSandboxContext(t *testing.T) (context.Context, string) {
t.Helper()
delegationID := uuid.New()
root := filepath.Join(t.TempDir(), "collaboration", "delegations", delegationID.String())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
canonicalOutputs, err := filepath.EvalSymlinks(outputs)
if err != nil {
t.Fatal(err)
}
ctx := WithDelegationID(context.Background(), delegationID.String())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, canonicalOutputs)
return ctx, canonicalOutputs
}
+78 -16
View File
@@ -1,39 +1,47 @@
// Package tools provides the subagent system for spawning child agent instances.
//
// Subagents run in background goroutines with restricted tool access.
// Key constraints from OpenClaw spec:
// - Depth limit: configurable maxSpawnDepth (default 3)
// - Max children per parent: configurable (default 8)
// - Auto-archive after configurable TTL (default 30 min)
// Key GoClaw constraints:
// - Depth limit: configurable maxSpawnDepth (default 1)
// - Max children per parent: configurable (default 5)
// - Max executing descendants per root agent: configurable (default 20)
// - Auto-archive after configurable TTL (default 60 min)
// - Tool deny lists: ALWAYS_DENY + LEAF_DENY at max depth
// - Results announced back to parent via message bus
package tools
import (
"context"
"errors"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
var ErrSubagentLifecycleDrainTimeout = errors.New("subagent_lifecycle_drain_timeout")
// SubagentConfig configures the subagent system.
type SubagentConfig struct {
MaxConcurrent int // max concurrent subagents (default 4)
MaxSpawnDepth int // max nesting depth (default 3)
MaxChildrenPerAgent int // max children per parent (default 8)
ArchiveAfterMinutes int // auto-archive completed tasks (default 30)
MaxConcurrent int // executing descendants per root agent (default 20)
MaxSpawnDepth int // max nesting depth (default 1)
MaxChildrenPerAgent int // max children per parent (default 5)
ArchiveAfterMinutes int // auto-archive completed tasks (default 60)
MaxRetries int // max LLM call retries on error (default 2)
Model string // model override for subagents (empty = inherit)
}
// Subagent task status constants.
const (
TaskStatusQueued = "queued"
TaskStatusRunning = "running"
TaskStatusWaiting = string(orchestration.ChildRunWaitingChild)
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusCancelled = "cancelled"
@@ -62,17 +70,30 @@ type SubagentTask struct {
CreatedAt int64 `json:"createdAt"`
CompletedAt int64 `json:"completedAt,omitempty"`
Media []bus.MediaFile `json:"-"` // media files from tool results
Workspace string `json:"-"` // physical root used only to derive safe completion media paths
MediaPathPrefix string `json:"-"` // logical prefix, e.g. outputs/ in a delegation exchange
OriginAgentID uuid.UUID `json:"-"` // parent agent UUID for usage caps and scoped tools
OriginTenantID uuid.UUID `json:"-"` // parent's tenant for announce routing
RootAgentID uuid.UUID `json:"-"`
RootAgentKey string `json:"-"`
ParentTaskID string `json:"parentTaskId,omitempty"`
OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking
OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID
// OriginContextWindow and OriginMaxTokens are captured from the caller at
// spawn so a shared manager cannot mix budgets between agents.
// Capture caller-specific budgets because one process manager serves many agents.
OriginContextWindow int `json:"-"`
OriginMaxTokens int `json:"-"`
cancelFunc context.CancelFunc `json:"-"` // per-task context cancel
spawnConfig SubagentConfig `json:"-"` // resolved config at spawn time (per-agent override merged)
dbID uuid.UUID `json:"-"` // persistent DB UUID (zero if not persisted)
admissionTicket *orchestration.ChildRunTicket
}
// TaskScope is the authorization boundary for every in-memory task action.
// A task ID alone never authorizes lookup, cancellation, steering, or cleanup.
type TaskScope struct {
TenantID uuid.UUID
RootAgentID uuid.UUID
RootAgentKey string
}
// SubagentManager manages the lifecycle of spawned subagents.
@@ -86,11 +107,22 @@ type SubagentManager struct {
msgBus *bus.MessageBus
// createTools builds a tool registry for subagents (without spawn/subagent tools).
createTools func() *Registry
announceQueue *AnnounceQueue // optional: batches announces with debounce
taskStore store.SubagentTaskStore // optional: persists tasks to DB (fire-and-forget)
usageCaps *usagecaps.Service
// Default agent budget used only when a task was created without caller context.
createTools func() *Registry
announceQueue *AnnounceQueue // optional: batches announces with debounce
taskStore store.SubagentTaskStore // optional: durable async completion ledger
usageCaps *usagecaps.Service
admission *orchestration.ChildRunAdmission
sweeperOnce sync.Once
sweeperStop chan struct{}
sweeperDone chan struct{}
sweeperStarted bool
lifecycleMu sync.Mutex
lifecycleClosed bool
lifecycleWG sync.WaitGroup
closeOnce sync.Once
closeDone chan struct{}
now func() time.Time
// Defaults used only when a task was created without caller-specific context.
contextWindow int
maxTokens int
}
@@ -104,6 +136,31 @@ func NewSubagentManager(
createTools func() *Registry,
cfg SubagentConfig,
) *SubagentManager {
return NewSubagentManagerWithAdmission(
provider,
providerReg,
model,
msgBus,
createTools,
cfg,
orchestration.NewChildRunAdmission(32, 128),
)
}
// NewSubagentManagerWithAdmission creates a manager sharing the process-owned
// child-run admission controller with other child execution paths.
func NewSubagentManagerWithAdmission(
provider providers.Provider,
providerReg *providers.Registry,
model string,
msgBus *bus.MessageBus,
createTools func() *Registry,
cfg SubagentConfig,
admission *orchestration.ChildRunAdmission,
) *SubagentManager {
if admission == nil {
admission = orchestration.NewChildRunAdmission(32, 128)
}
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
config: cfg,
@@ -112,6 +169,11 @@ func NewSubagentManager(
model: model,
msgBus: msgBus,
createTools: createTools,
admission: admission,
sweeperStop: make(chan struct{}),
sweeperDone: make(chan struct{}),
closeDone: make(chan struct{}),
now: time.Now,
}
}
@@ -121,7 +183,7 @@ func (sm *SubagentManager) SetAnnounceQueue(q *AnnounceQueue) {
sm.announceQueue = q
}
// SetTaskStore sets the persistent store for subagent tasks (write-through, fire-and-forget).
// SetTaskStore sets the persistent store for task lifecycle and async retrieval.
func (sm *SubagentManager) SetTaskStore(s store.SubagentTaskStore) {
sm.taskStore = s
}
@@ -0,0 +1,55 @@
package tools
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
func TestSubagentDirectAnnouncementDoesNotBlockOnFullInboundBus(t *testing.T) {
messageBus := bus.New()
for range 1000 {
messageBus.PublishInbound(bus.InboundMessage{Content: "fill"})
}
manager := NewSubagentManager(nil, nil, "", messageBus, nil, SubagentConfig{})
taskStore := newRecordingSubagentTaskStore()
manager.SetTaskStore(taskStore)
task := &SubagentTask{
ID: "task-1",
Label: "probe",
Status: TaskStatusCompleted,
Result: "done",
CreatedAt: time.Now().Add(-time.Second).UnixMilli(),
OriginChannel: "test",
OriginChatID: "chat-1",
OriginTenantID: uuid.New(),
RootAgentID: uuid.New(),
RootAgentKey: "root",
OriginSessionKey: "session-1",
dbID: uuid.New(),
}
done := make(chan struct{})
go func() {
manager.announceTask(context.Background(), task, nil, 1, true)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("subagent announcement blocked on a full inbound bus")
}
select {
case metadata := <-taskStore.metadata:
if metadata[asyncCompletionDeliveryKey] != asyncCompletionDeliveryMissed {
t.Fatalf("announcement metadata = %#v, want undelivered", metadata)
}
case <-time.After(time.Second):
t.Fatal("subagent missed announcement was not recorded after bus saturation")
}
}
@@ -0,0 +1,47 @@
package tools
import (
"testing"
"github.com/google/uuid"
)
func TestSubagentAnnounceBatchKeyScopesRoutingAndAuthority(t *testing.T) {
base := SubagentTask{
OriginTenantID: uuid.New(),
RootAgentID: uuid.New(),
RootAgentKey: "root",
OriginSessionKey: "session",
OriginChannel: "telegram",
OriginChatID: "chat",
OriginPeerKind: "group",
OriginLocalKey: "topic",
OriginUserID: "user",
OriginSenderID: "sender",
OriginRole: "operator",
}
want := subagentAnnounceBatchKey(&base)
cases := map[string]func(*SubagentTask){
"tenant": func(v *SubagentTask) { v.OriginTenantID = uuid.New() },
"root id": func(v *SubagentTask) { v.RootAgentID = uuid.New() },
"root key": func(v *SubagentTask) { v.RootAgentKey += "-other" },
"session": func(v *SubagentTask) { v.OriginSessionKey += "-other" },
"channel": func(v *SubagentTask) { v.OriginChannel += "-other" },
"chat": func(v *SubagentTask) { v.OriginChatID += "-other" },
"peer kind": func(v *SubagentTask) { v.OriginPeerKind += "-other" },
"local key": func(v *SubagentTask) { v.OriginLocalKey += "-other" },
"user": func(v *SubagentTask) { v.OriginUserID += "-other" },
"sender": func(v *SubagentTask) { v.OriginSenderID += "-other" },
"role": func(v *SubagentTask) { v.OriginRole += "-other" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
got := base
mutate(&got)
if key := subagentAnnounceBatchKey(&got); key == want {
t.Fatalf("batch key did not change when %s changed", name)
}
})
}
}
+11 -9
View File
@@ -1,15 +1,17 @@
package tools
import "fmt"
import (
"fmt"
)
// DefaultSubagentConfig returns sensible defaults matching OpenClaw TS spec.
// TS sources: agent-limits.ts, sessions-spawn-tool.ts, subagent-registry.ts.
// DefaultSubagentConfig returns GoClaw's runtime defaults. Per-root admission is
// independent from the Standard/Lite process safety cap.
func DefaultSubagentConfig() SubagentConfig {
return SubagentConfig{
MaxConcurrent: 8, // TS: DEFAULT_SUBAGENT_MAX_CONCURRENT = 8
MaxSpawnDepth: 1, // TS: maxSpawnDepth ?? 1
MaxChildrenPerAgent: 5, // TS: maxChildrenPerAgent ?? 5
ArchiveAfterMinutes: 60, // TS: archiveAfterMinutes ?? 60
MaxConcurrent: 20,
MaxSpawnDepth: 1,
MaxChildrenPerAgent: 5,
ArchiveAfterMinutes: 60,
MaxRetries: 2,
}
}
@@ -74,8 +76,8 @@ Your final response IS the deliverable — it will be forwarded to the user.
## Sub-Agent Spawning
You CAN spawn your own sub-agents for parallel or complex work using the spawn tool.
Your sub-agents will announce their results back to you automatically (not to the main agent).
Coordinate their work and synthesize results before reporting back.`
Asynchronous descendants report to the root orchestrator with their direct-parent lineage.
Synchronous descendants return their result directly to you.`
} else if task.Depth >= 2 {
prompt += `
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
@@ -46,8 +48,11 @@ func TestSubagentSpawn_CapturesOriginContextWindow(t *testing.T) {
// Calling agent configured at 128k/8192, propagated via ctx (as injectContext does).
ctx := store.WithAgentContextWindow(context.Background(), 128_000)
ctx = store.WithAgentMaxTokens(ctx, 8_192)
ctx = store.WithTenantID(ctx, uuid.New())
ctx = store.WithAgentID(ctx, uuid.New())
ctx = WithToolAgentKey(ctx, "parent")
_, _, err := manager.RunSync(ctx, "parent", 0, "task", "label", "", "chan", "chat")
_, _, _, err := manager.RunSync(ctx, "parent", 0, "task", "label", "", "chan", "chat")
if err != nil {
t.Fatalf("RunSync error: %v", err)
}
+300 -36
View File
@@ -7,37 +7,180 @@ import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// scheduleArchive removes a task after the archive TTL.
func (sm *SubagentManager) scheduleArchive(taskID string, after time.Duration) {
time.Sleep(after)
const (
subagentArchiveSweepInterval = time.Minute
subagentArchiveBatchSize = 256
)
func (sm *SubagentManager) startArchiveSweeper() {
sm.lifecycleMu.Lock()
defer sm.lifecycleMu.Unlock()
if sm.lifecycleClosed {
return
}
sm.sweeperOnce.Do(func() {
sm.mu.Lock()
sm.sweeperStarted = true
sm.mu.Unlock()
go func() {
ticker := time.NewTicker(subagentArchiveSweepInterval)
defer ticker.Stop()
defer close(sm.sweeperDone)
for {
select {
case <-ticker.C:
sm.sweepArchivedTasks()
case <-sm.sweeperStop:
return
}
}
}()
})
}
func (sm *SubagentManager) sweepArchivedTasks() {
now := sm.now()
type persistentSweep struct {
scope TaskScope
ttl time.Duration
}
persistent := make(map[string]persistentSweep)
sm.mu.Lock()
defer sm.mu.Unlock()
if t, ok := sm.tasks[taskID]; ok && t.Status != TaskStatusRunning {
delete(sm.tasks, taskID)
slog.Debug("subagent archived", "id", taskID)
removed := 0
for id, task := range sm.tasks {
if removed >= subagentArchiveBatchSize || !isTerminalTaskStatus(task.Status) ||
task.CompletedAt == 0 || task.spawnConfig.ArchiveAfterMinutes <= 0 ||
now.Before(time.UnixMilli(task.CompletedAt).Add(time.Duration(task.spawnConfig.ArchiveAfterMinutes)*time.Minute)) ||
!taskExecutionDone(task) {
continue
}
delete(sm.tasks, id)
removed++
scope := TaskScope{
TenantID: task.OriginTenantID, RootAgentID: task.RootAgentID, RootAgentKey: task.RootAgentKey,
}
persistent[scope.TenantID.String()+":"+scope.RootAgentID.String()] = persistentSweep{
scope: scope, ttl: time.Duration(task.spawnConfig.ArchiveAfterMinutes) * time.Minute,
}
}
sm.mu.Unlock()
for _, sweep := range persistent {
if sm.taskStore == nil || sweep.scope.RootAgentID == uuid.Nil {
continue
}
ctx := store.WithTenantID(context.Background(), sweep.scope.TenantID)
if _, err := sm.taskStore.Archive(ctx, sweep.scope.RootAgentID, sweep.ttl, subagentArchiveBatchSize); err != nil {
slog.Warn("subagent archive sweep failed", "root_agent", sweep.scope.RootAgentKey, "error", err)
}
}
}
// GetTask returns a task by ID.
func (sm *SubagentManager) GetTask(id string) (*SubagentTask, bool) {
// Close stops the manager-owned archive sweeper and waits for accepted task
// lifecycle work. Gateway owners should use CloseContext so dependency teardown
// remains bounded.
func (sm *SubagentManager) Close() {
_ = sm.CloseContext(context.Background())
}
func (sm *SubagentManager) CloseContext(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
sm.closeOnce.Do(func() {
sm.lifecycleMu.Lock()
sm.lifecycleClosed = true
sm.lifecycleMu.Unlock()
sm.mu.RLock()
started := sm.sweeperStarted
sm.mu.RUnlock()
close(sm.sweeperStop)
go func() {
if started {
<-sm.sweeperDone
}
sm.lifecycleWG.Wait()
close(sm.closeDone)
}()
})
if sm.announceQueue != nil {
if err := sm.announceQueue.CloseContext(ctx); err != nil {
return fmt.Errorf("%w: %v", ErrSubagentLifecycleDrainTimeout, err)
}
}
select {
case <-sm.closeDone:
return nil
case <-ctx.Done():
return fmt.Errorf("%w: %v", ErrSubagentLifecycleDrainTimeout, ctx.Err())
}
}
// beginLifecycleOperation registers task ownership before admission or durable
// persistence begins. Gateway shutdown closes this gate, drains admission, and
// then waits for every registered operation before stores and message-bus
// dependencies are closed.
func (sm *SubagentManager) beginLifecycleOperation() (func(), bool) {
finishes, ok := sm.beginLifecycleOperations(1)
if !ok {
return nil, false
}
return finishes[0], true
}
func (sm *SubagentManager) beginLifecycleOperations(count int) ([]func(), bool) {
if count < 1 {
return nil, false
}
sm.lifecycleMu.Lock()
defer sm.lifecycleMu.Unlock()
if sm.lifecycleClosed {
return nil, false
}
sm.lifecycleWG.Add(count)
finishes := make([]func(), count)
for i := range finishes {
var once sync.Once
finishes[i] = func() {
once.Do(sm.lifecycleWG.Done)
}
}
return finishes, true
}
// GetTask returns an immutable snapshot when the caller owns the root tree.
func (sm *SubagentManager) GetTask(scope TaskScope, id string) (*SubagentTask, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
t, ok := sm.tasks[id]
return t, ok
if !ok || !taskMatchesScope(t, scope) {
return nil, false
}
return cloneSubagentTask(t), true
}
// ListTasks returns all tasks, optionally filtered by parent.
func (sm *SubagentManager) ListTasks(parentID string) []*SubagentTask {
// ListTasks returns immutable snapshots from one tenant/root-agent tree.
func (sm *SubagentManager) ListTasks(scope TaskScope, parentTaskID string) []*SubagentTask {
sm.mu.RLock()
defer sm.mu.RUnlock()
var result []*SubagentTask
for _, t := range sm.tasks {
if parentID == "" || t.ParentID == parentID {
result = append(result, t)
if !taskMatchesScope(t, scope) {
continue
}
if parentTaskID == "" || t.ParentTaskID == parentTaskID {
result = append(result, cloneSubagentTask(t))
}
}
return result
@@ -46,57 +189,107 @@ func (sm *SubagentManager) ListTasks(parentID string) []*SubagentTask {
// CancelTask cancels a running task by ID.
// Special IDs: "all" cancels all running tasks for any parent,
// "last" cancels the most recently created running task.
func (sm *SubagentManager) CancelTask(id string) bool {
func (sm *SubagentManager) CancelTask(scope TaskScope, id string) bool {
finish, ok := sm.beginLifecycleOperation()
if !ok {
return false
}
transferred := false
defer func() {
if !transferred {
finish()
}
}()
sm.mu.Lock()
defer sm.mu.Unlock()
var cancelled []*SubagentTask
if id == "all" {
cancelled := false
for _, t := range sm.tasks {
if t.Status == TaskStatusRunning {
if taskMatchesScope(t, scope) && isActiveTaskStatus(t.Status) {
sm.cancelTaskLocked(t)
cancelled = true
cancelled = append(cancelled, t)
}
}
return cancelled
sm.mu.Unlock()
if len(cancelled) > 0 {
transferred = true
go func() {
defer finish()
sm.persistCancelledTasks(cancelled)
}()
}
return len(cancelled) > 0
}
if id == "last" {
var latest *SubagentTask
for _, t := range sm.tasks {
if t.Status == TaskStatusRunning {
if taskMatchesScope(t, scope) && isActiveTaskStatus(t.Status) {
if latest == nil || t.CreatedAt > latest.CreatedAt {
latest = t
}
}
}
if latest == nil {
sm.mu.Unlock()
return false
}
sm.cancelTaskLocked(latest)
sm.mu.Unlock()
transferred = true
go func() {
defer finish()
sm.persistCancelledTasks([]*SubagentTask{latest})
}()
return true
}
t, ok := sm.tasks[id]
if !ok || t.Status != TaskStatusRunning {
if !ok || !taskMatchesScope(t, scope) || !isActiveTaskStatus(t.Status) {
sm.mu.Unlock()
return false
}
sm.cancelTaskLocked(t)
sm.mu.Unlock()
transferred = true
go func() {
defer finish()
sm.persistCancelledTasks([]*SubagentTask{t})
}()
return true
}
// CancelTasksForParent cancels all running tasks for a specific parent.
func (sm *SubagentManager) CancelTasksForParent(parentID string) int {
func (sm *SubagentManager) CancelTasksForParent(scope TaskScope, parentTaskID string) int {
finish, ok := sm.beginLifecycleOperation()
if !ok {
return 0
}
transferred := false
defer func() {
if !transferred {
finish()
}
}()
sm.mu.Lock()
defer sm.mu.Unlock()
count := 0
var cancelled []*SubagentTask
for _, t := range sm.tasks {
if t.ParentID == parentID && t.Status == TaskStatusRunning {
if taskMatchesScope(t, scope) && t.ParentTaskID == parentTaskID && isActiveTaskStatus(t.Status) {
sm.cancelTaskLocked(t)
count++
cancelled = append(cancelled, t)
}
}
return count
sm.mu.Unlock()
if len(cancelled) > 0 {
transferred = true
go func() {
defer finish()
sm.persistCancelledTasks(cancelled)
}()
}
return len(cancelled)
}
// cancelTaskLocked sets a task to cancelled and fires its context cancel.
@@ -108,28 +301,53 @@ func (sm *SubagentManager) cancelTaskLocked(t *SubagentTask) {
if t.cancelFunc != nil {
t.cancelFunc()
}
if t.admissionTicket != nil {
t.admissionTicket.Cancel()
}
}
func (sm *SubagentManager) persistCancelledTasks(tasks []*SubagentTask) {
for _, task := range tasks {
ctx := store.WithTenantID(context.Background(), task.OriginTenantID)
sm.persistStatus(ctx, task, 0)
}
}
// Steer cancels a running subagent and restarts it with a new message.
// Matching TS subagents-tool.ts steer action: cancel → settle → spawn replacement.
func (sm *SubagentManager) Steer(
ctx context.Context,
scope TaskScope,
taskID, newMessage string,
callback AsyncCallback,
) (string, error) {
finishes, ok := sm.beginLifecycleOperations(2)
if !ok {
return "", fmt.Errorf("subagent manager is closed")
}
finishSteer := finishes[0]
finishPersistence := finishes[1]
persistenceTransferred := false
defer finishSteer()
defer func() {
if !persistenceTransferred {
finishPersistence()
}
}()
sm.mu.Lock()
t, ok := sm.tasks[taskID]
if !ok {
if !ok || !taskMatchesScope(t, scope) {
sm.mu.Unlock()
return "", fmt.Errorf("subagent %q not found", taskID)
}
if t.Status != TaskStatusRunning {
if !isActiveTaskStatus(t.Status) {
sm.mu.Unlock()
return "", fmt.Errorf("subagent %q is not running (status=%s)", taskID, t.Status)
}
// Capture origin metadata before cancelling
parentID := t.ParentID
parentID := t.RootAgentKey
depth := t.Depth - 1 // Spawn increments depth, so use original
label := t.Label + " (steered)"
model := t.Model
@@ -140,6 +358,11 @@ func (sm *SubagentManager) Steer(
// Cancel old task (suppress announce by marking cancelled before unlock)
sm.cancelTaskLocked(t)
sm.mu.Unlock()
persistenceTransferred = true
go func() {
defer finishPersistence()
sm.persistCancelledTasks([]*SubagentTask{t})
}()
// Brief settle period (matching TS 500ms settle)
time.Sleep(500 * time.Millisecond)
@@ -160,7 +383,7 @@ func (sm *SubagentManager) Steer(
}
// WaitForChildren blocks until all running tasks for parentID complete or timeout.
func (sm *SubagentManager) WaitForChildren(ctx context.Context, parentID string, timeoutSec int) ([]*SubagentTask, error) {
func (sm *SubagentManager) WaitForChildren(ctx context.Context, scope TaskScope, parentTaskID string, timeoutSec int) ([]*SubagentTask, error) {
if timeoutSec <= 0 {
timeoutSec = 300
}
@@ -171,14 +394,14 @@ func (sm *SubagentManager) WaitForChildren(ctx context.Context, parentID string,
for {
select {
case <-ctx.Done():
return sm.ListTasks(parentID), ctx.Err()
return sm.ListTasks(scope, parentTaskID), ctx.Err()
case <-deadline:
return sm.ListTasks(parentID), fmt.Errorf("timeout after %ds waiting for children", timeoutSec)
return sm.ListTasks(scope, parentTaskID), fmt.Errorf("timeout after %ds waiting for children", timeoutSec)
case <-ticker.C:
tasks := sm.ListTasks(parentID)
tasks := sm.ListTasks(scope, parentTaskID)
allDone := true
for _, t := range tasks {
if t.Status == TaskStatusRunning {
if isActiveTaskStatus(t.Status) {
allDone = false
break
}
@@ -190,6 +413,47 @@ func (sm *SubagentManager) WaitForChildren(ctx context.Context, parentID string,
}
}
func taskMatchesScope(task *SubagentTask, scope TaskScope) bool {
return task != nil &&
scope.TenantID != uuid.Nil &&
scope.RootAgentID != uuid.Nil &&
scope.RootAgentKey != "" &&
task.OriginTenantID == scope.TenantID &&
task.RootAgentID == scope.RootAgentID &&
task.RootAgentKey == scope.RootAgentKey
}
func isActiveTaskStatus(status string) bool {
return status == TaskStatusQueued || status == TaskStatusRunning || status == TaskStatusWaiting
}
func isTerminalTaskStatus(status string) bool {
return status == TaskStatusCompleted || status == TaskStatusFailed || status == TaskStatusCancelled
}
func taskExecutionDone(task *SubagentTask) bool {
if task.admissionTicket == nil {
return true
}
select {
case <-task.admissionTicket.Done():
return true
default:
return false
}
}
func cloneSubagentTask(task *SubagentTask) *SubagentTask {
if task == nil {
return nil
}
snapshot := *task
snapshot.Media = append([]bus.MediaFile(nil), task.Media...)
snapshot.cancelFunc = nil
snapshot.admissionTicket = nil
return &snapshot
}
func generateSubagentID() string {
b := make([]byte, 8)
rand.Read(b)
+103 -56
View File
@@ -16,10 +16,15 @@ import (
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
// runTask executes the subagent in a goroutine.
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
iterations := sm.executeTask(ctx, task)
// announceTask publishes terminal state after child-run execution capacity has
// been released. Slow bus consumers and callbacks must never hold admission.
func (sm *SubagentManager) announceTask(
ctx context.Context,
task *SubagentTask,
callback AsyncCallback,
iterations int,
terminalPersisted bool,
) {
// Announce result to parent via bus (matching TS subagent-announce.ts pattern).
// The announce goes through the parent agent's session so the agent can
// reformulate the result for the user.
@@ -27,15 +32,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
elapsed := time.Since(time.UnixMilli(task.CreatedAt))
item := AnnounceQueueItem{
SubagentID: task.ID,
Label: task.Label,
Status: task.Status,
Result: task.Result,
Media: task.Media,
Runtime: elapsed,
Iterations: iterations,
InputTokens: task.TotalInputTokens,
OutputTokens: task.TotalOutputTokens,
SubagentID: task.ID,
CompletionID: task.dbID,
DurablyPersisted: terminalPersisted,
ParentTaskID: task.ParentTaskID,
Depth: task.Depth,
Label: task.Label,
Status: task.Status,
Result: task.Result,
Media: task.Media,
Runtime: elapsed,
Iterations: iterations,
InputTokens: task.TotalInputTokens,
OutputTokens: task.TotalOutputTokens,
}
meta := AnnounceMetadata{
OriginChannel: task.OriginChannel,
@@ -47,34 +56,40 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
OriginRole: task.OriginRole,
OriginSessionKey: task.OriginSessionKey,
OriginTenantID: task.OriginTenantID,
ParentAgent: task.ParentID,
RootAgentID: task.RootAgentID,
ParentAgent: task.RootAgentKey,
OriginTraceID: task.OriginTraceID.String(),
OriginRootSpanID: task.OriginRootSpanID.String(),
}
if sm.announceQueue != nil {
// Use batched announce queue (matching TS debounce pattern)
sessionKey := fmt.Sprintf("announce:%s:%s", task.ParentID, task.OriginChatID)
sessionKey := subagentAnnounceBatchKey(task)
sm.announceQueue.Enqueue(sessionKey, item, meta)
} else {
// Direct publish (no batching)
roster := sm.RosterForParent(task.ParentID)
roster := sm.RosterForParent(TaskScope{
TenantID: task.OriginTenantID, RootAgentID: task.RootAgentID, RootAgentKey: task.RootAgentKey,
})
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, roster)
announceMeta := map[string]string{
MetaOriginChannel: task.OriginChannel,
MetaOriginPeerKind: task.OriginPeerKind,
MetaParentAgent: task.ParentID,
"subagent_id": task.ID,
MetaSubagentLabel: task.Label,
MetaSubagentStatus: task.Status,
MetaSubagentResult: task.Result,
MetaSubagentRuntime: fmt.Sprintf("%d", elapsed.Milliseconds()),
MetaSubagentIterations: fmt.Sprintf("%d", iterations),
MetaSubagentInputToks: fmt.Sprintf("%d", task.TotalInputTokens),
MetaSubagentOutputToks: fmt.Sprintf("%d", task.TotalOutputTokens),
MetaOriginTraceID: task.OriginTraceID.String(),
MetaOriginRootSpanID: task.OriginRootSpanID.String(),
MetaOriginChannel: task.OriginChannel,
MetaOriginPeerKind: task.OriginPeerKind,
MetaParentAgent: task.RootAgentKey,
MetaSubagentRootAgentID: task.RootAgentID.String(),
"subagent_id": task.ID,
MetaSubagentLabel: task.Label,
MetaSubagentStatus: task.Status,
MetaSubagentResult: task.Result,
MetaSubagentRuntime: fmt.Sprintf("%d", elapsed.Milliseconds()),
MetaSubagentIterations: fmt.Sprintf("%d", iterations),
MetaSubagentInputToks: fmt.Sprintf("%d", task.TotalInputTokens),
MetaSubagentOutputToks: fmt.Sprintf("%d", task.TotalOutputTokens),
MetaSubagentParentTask: task.ParentTaskID,
MetaSubagentDepth: fmt.Sprintf("%d", task.Depth),
MetaOriginTraceID: task.OriginTraceID.String(),
MetaOriginRootSpanID: task.OriginRootSpanID.String(),
}
if task.OriginLocalKey != "" {
announceMeta[MetaOriginLocalKey] = task.OriginLocalKey
@@ -91,7 +106,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
if task.OriginUserID != "" {
announceMeta[MetaOriginUserID] = task.OriginUserID
}
sm.msgBus.PublishInbound(bus.InboundMessage{
delivered := PublishAsyncCompletion(ctx, sm.msgBus, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
ChatID: task.OriginChatID,
@@ -101,6 +116,24 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
Metadata: announceMeta,
Media: task.Media,
})
if terminalPersisted {
sm.UpdateAnnouncementStatus(ctx, task.RootAgentID, task.dbID, delivered)
} else {
slog.Error("subagent.announce_without_durable_terminal",
"task_id", task.ID,
"completion_id", task.dbID,
"root_agent_id", task.RootAgentID,
"delivered", delivered,
)
}
if !delivered {
slog.Warn("subagent.announce_deferred_to_ledger",
"task_id", task.ID,
"completion_id", task.dbID,
"root_agent_id", task.RootAgentID,
"reason", "inbound_bus_full",
)
}
}
}
@@ -112,6 +145,23 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
}
}
func subagentAnnounceBatchKey(task *SubagentTask) string {
return strings.Join([]string{
"announce",
task.OriginTenantID.String(),
task.RootAgentID.String(),
task.RootAgentKey,
task.OriginSessionKey,
task.OriginChannel,
task.OriginChatID,
task.OriginPeerKind,
task.OriginLocalKey,
task.OriginUserID,
task.OriginSenderID,
task.OriginRole,
}, "\x00")
}
// executeTask runs the LLM tool loop for a subagent. Returns iteration count.
func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) int {
// Tracing: generate a root span ID for this subagent execution.
@@ -120,19 +170,15 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
subRootSpanID := store.GenNewID()
taskStart := time.Now().UTC()
// Use a detached context for tracing so spans are emitted even if parent ctx is cancelled.
// We copy tracing values but remove the cancellation chain.
traceCtx := context.Background()
if collector := tracing.CollectorFromContext(ctx); collector != nil {
traceCtx = tracing.WithCollector(traceCtx, collector)
traceCtx = tracing.WithTraceID(traceCtx, tracing.TraceIDFromContext(ctx))
// Keep original parent_span_id (parent agent's root span) for the subagent root span.
traceCtx = tracing.WithParentSpanID(traceCtx, tracing.ParentSpanIDFromContext(ctx))
}
// Detach cancellation while preserving the delegation redactor and the
// original trace parent. Rebuilding from Background would silently drop the
// artifact confidentiality boundary on nested synchronous work.
traceCtx := context.WithoutCancel(ctx)
// subCtx overrides parent_span_id so child spans nest under subRootSpanID.
// traceCtx retains the original parent_span_id for the root subagent span.
subTraceCtx := tracing.WithParentSpanID(traceCtx, subRootSpanID)
toolCtx := tracing.WithParentSpanID(ctx, subRootSpanID)
var model string
var finalContent string
@@ -150,10 +196,6 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
"trace_id", tracing.TraceIDFromContext(traceCtx),
"status", task.Status, "iterations", iteration)
// Schedule auto-archive
if task.spawnConfig.ArchiveAfterMinutes > 0 {
go sm.scheduleArchive(task.ID, time.Duration(task.spawnConfig.ArchiveAfterMinutes)*time.Minute)
}
}()
if ctx.Err() != nil {
@@ -166,6 +208,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
// Build tools for subagent (no spawn/subagent tools to prevent recursion)
toolsReg := sm.createTools()
toolsReg.Register(NewSpawnTool(sm, task.RootAgentKey, task.Depth))
sm.applyDenyList(toolsReg, task.Depth, task.spawnConfig)
// Determine model (cascading priority):
@@ -201,11 +244,15 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
// Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern).
workspace := ToolWorkspaceFromCtx(ctx)
systemPrompt := sm.buildSubagentSystemPrompt(task, task.spawnConfig, workspace)
promptWorkspace := workspace
if IsDelegationArtifactRun(ctx) {
promptWorkspace = "outputs/"
}
systemPrompt := tracing.RedactText(ctx, sm.buildSubagentSystemPrompt(task, task.spawnConfig, promptWorkspace))
messages := []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: task.Task},
{Role: "user", Content: tracing.RedactText(ctx, task.Task)},
}
// Run LLM iteration loop (similar to agent loop but simplified)
@@ -273,10 +320,9 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
if err != nil {
sm.mu.Lock()
task.Status = TaskStatusFailed
task.Result = fmt.Sprintf("LLM error at iteration %d: %v", iteration, err)
task.Result = tracing.RedactText(ctx, fmt.Sprintf("LLM error at iteration %d: %v", iteration, err))
sm.mu.Unlock()
slog.Warn("subagent LLM error", "id", task.ID, "iteration", iteration, "error", err)
go sm.persistStatus(ctx, task, iteration)
return iteration
}
@@ -301,7 +347,8 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
argsJSON, _ := json.Marshal(tc.Arguments)
toolStart := time.Now().UTC()
toolSpanID := sm.emitToolSpanStart(subTraceCtx, toolStart, tc.Name, tc.ID, string(argsJSON))
result := toolsReg.Execute(ctx, tc.Name, tc.Arguments)
result := toolsReg.Execute(toolCtx, tc.Name, tc.Arguments)
result.ForLLM = tracing.RedactText(ctx, result.ForLLM)
sm.emitToolSpanEnd(subTraceCtx, toolSpanID, toolStart, result.ForLLM, result.IsError)
// Capture media file paths from tool results (e.g. image generation).
@@ -327,19 +374,19 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
}
sm.mu.Lock()
if finalContent == "" {
finalContent = "Task completed but no final response was generated."
if task.Status != TaskStatusCancelled {
if finalContent == "" {
finalContent = "Task completed but no final response was generated."
}
finalContent = tracing.RedactText(ctx, finalContent)
task.Status = TaskStatusCompleted
task.Result = finalContent
task.Media = mediaFiles
}
task.Status = TaskStatusCompleted
task.Result = finalContent
task.Media = mediaFiles
sm.mu.Unlock()
slog.Info("subagent completed", "id", task.ID, "iterations", iteration)
// Persist final status to DB (fire-and-forget).
go sm.persistStatus(ctx, task, iteration)
return iteration
}
+142 -16
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
@@ -9,6 +10,17 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
asyncCompletionKindKey = "completion_kind"
asyncCompletionKindSubagent = "subagent"
asyncCompletionKindDelegate = "delegate"
asyncCompletionRuntimeIDKey = "runtime_task_id"
asyncCompletionDeliveryKey = "announcement_status"
asyncCompletionDeliveryPending = "pending"
asyncCompletionDeliveryDone = "delivered"
asyncCompletionDeliveryMissed = "undelivered"
)
// detachedCtx creates a context that won't be cancelled but preserves tenant ID.
// Used for fire-and-forget DB writes that must succeed even after the parent ctx is cancelled.
func detachedCtx(ctx context.Context) context.Context {
@@ -19,10 +31,12 @@ func detachedCtx(ctx context.Context) context.Context {
return bg
}
// persistCreate writes a new subagent task to the DB (fire-and-forget).
func (sm *SubagentManager) persistCreate(ctx context.Context, task *SubagentTask) {
// persistCreate writes the accepted queued task before its admission ticket is
// activated. When a store is configured, execution is not accepted unless this
// write succeeds so every returned completion ID remains retrievable.
func (sm *SubagentManager) persistCreate(ctx context.Context, task *SubagentTask) error {
if sm.taskStore == nil {
return
return nil
}
dbCtx := detachedCtx(ctx)
@@ -52,10 +66,24 @@ func (sm *SubagentManager) persistCreate(ctx context.Context, task *SubagentTask
originUserID = &task.OriginUserID
}
var spawnedBy *uuid.UUID
if task.ParentTaskID != "" {
sm.mu.RLock()
if parent := sm.tasks[task.ParentTaskID]; parent != nil && parent.dbID != uuid.Nil &&
taskMatchesScope(parent, TaskScope{
TenantID: task.OriginTenantID, RootAgentID: task.RootAgentID, RootAgentKey: task.RootAgentKey,
}) {
parentID := parent.dbID
spawnedBy = &parentID
}
sm.mu.RUnlock()
}
data := &store.SubagentTaskData{
BaseModel: store.BaseModel{ID: task.dbID},
TenantID: task.OriginTenantID,
ParentAgentKey: task.ParentID,
RootAgentID: task.RootAgentID,
ParentAgentKey: task.RootAgentKey,
SessionKey: sessionKey,
Subject: task.Label,
Description: task.Task,
@@ -67,31 +95,129 @@ func (sm *SubagentManager) persistCreate(ctx context.Context, task *SubagentTask
OriginChatID: originChatID,
OriginPeerKind: originPeerKind,
OriginUserID: originUserID,
SpawnedBy: spawnedBy,
Metadata: map[string]any{
"root_agent_id": task.RootAgentID.String(),
"parent_task_id": task.ParentTaskID,
"depth": task.Depth,
asyncCompletionKindKey: asyncCompletionKindSubagent,
asyncCompletionRuntimeIDKey: task.ID,
asyncCompletionDeliveryKey: asyncCompletionDeliveryPending,
},
}
if err := sm.taskStore.Create(dbCtx, data); err != nil {
slog.Warn("subagent_persist: create failed", "id", task.ID, "error", err)
if err := retryAsyncPersistence(dbCtx, func(attemptCtx context.Context) error {
return sm.taskStore.Create(attemptCtx, data)
}); err != nil {
return fmt.Errorf("persist accepted subagent %s: %w", task.ID, err)
}
return nil
}
// persistStatus updates status, result, iterations, and token counts in the DB (fire-and-forget).
func (sm *SubagentManager) persistStatus(ctx context.Context, task *SubagentTask, iterations int) {
// persistStatus synchronously updates status, result, iterations, and token
// counts. Callers that announce a terminal result must check the returned error
// before recording an announcement delivery state.
func (sm *SubagentManager) persistStatus(ctx context.Context, task *SubagentTask, iterations int) error {
if sm.taskStore == nil || task.dbID == uuid.Nil {
return
return nil
}
dbCtx := detachedCtx(ctx)
sm.mu.RLock()
snapshot := cloneSubagentTask(task)
sm.mu.RUnlock()
var result *string
if task.Result != "" {
result = &task.Result
if snapshot.Result != "" {
result = &snapshot.Result
}
if err := sm.taskStore.UpdateStatus(
dbCtx, task.dbID,
task.Status, result, iterations,
task.TotalInputTokens, task.TotalOutputTokens,
); err != nil {
if media := completionMediaDescriptors(
snapshot.Media,
snapshot.Workspace,
snapshot.MediaPathPrefix,
); len(media) > 0 {
if err := retryTerminalPersistence(dbCtx, func(attemptCtx context.Context) error {
return sm.taskStore.UpdateMetadata(attemptCtx, snapshot.RootAgentID, snapshot.dbID, map[string]any{
asyncCompletionMediaKey: media,
})
}); err != nil {
slog.Warn("subagent_persist: update completion media failed", "id", task.ID, "error", err)
return err
}
}
if err := retryTerminalPersistence(dbCtx, func(attemptCtx context.Context) error {
return sm.taskStore.UpdateStatus(
attemptCtx, snapshot.RootAgentID, snapshot.dbID,
snapshot.Status, result, iterations,
snapshot.TotalInputTokens, snapshot.TotalOutputTokens,
)
}); err != nil {
slog.Warn("subagent_persist: update status failed", "id", task.ID, "error", err)
return err
}
return nil
}
// UpdateAnnouncementStatus records whether the automatic parent resume message
// was delivered. A missed delivery is recoverable through spawn(action="get").
func (sm *SubagentManager) UpdateAnnouncementStatus(
ctx context.Context,
rootAgentID, completionID uuid.UUID,
delivered bool,
) {
if sm.taskStore == nil || rootAgentID == uuid.Nil || completionID == uuid.Nil {
return
}
status := asyncCompletionDeliveryMissed
if delivered {
status = asyncCompletionDeliveryDone
}
dbCtx := detachedCtx(ctx)
if err := retryAsyncPersistence(dbCtx, func(attemptCtx context.Context) error {
return sm.taskStore.UpdateMetadata(attemptCtx, rootAgentID, completionID, map[string]any{
asyncCompletionDeliveryKey: status,
})
}); err != nil {
slog.Warn("subagent_persist: update announcement status failed",
"completion_id", completionID,
"root_agent_id", rootAgentID,
"error", err,
)
}
}
// GetPersistedTask loads one durable self-clone result within the caller's
// tenant and root-agent scope. Delegate rows intentionally do not satisfy this
// contract.
func (sm *SubagentManager) GetPersistedTask(
ctx context.Context,
scope TaskScope,
completionID uuid.UUID,
) (*store.SubagentTaskData, error) {
if sm.taskStore == nil {
return nil, fmt.Errorf("durable subagent task tracking is unavailable")
}
if scope.TenantID == uuid.Nil || scope.RootAgentID == uuid.Nil || completionID == uuid.Nil {
return nil, fmt.Errorf("subagent completion lookup requires tenant, root agent, and completion ID")
}
dbCtx := store.WithTenantID(context.WithoutCancel(ctx), scope.TenantID)
task, err := sm.taskStore.Get(dbCtx, scope.RootAgentID, completionID)
if err != nil || task == nil {
return task, err
}
if completionKind(task.Metadata) == asyncCompletionKindDelegate {
return nil, nil
}
return task, nil
}
func completionKind(metadata map[string]any) string {
if metadata == nil {
return ""
}
value, _ := metadata[asyncCompletionKindKey].(string)
return value
}
+16 -6
View File
@@ -15,27 +15,36 @@ type SubagentRosterEntry struct {
// including per-agent config limits for deterministic LLM context.
type SubagentRoster struct {
Entries []SubagentRosterEntry
Total int // total tasks for this parent
Active int // currently running tasks for this parent
Total int // retained tasks for this parent
MaxPerAgent int // from spawnConfig.MaxChildrenPerAgent
}
// RosterForParent returns the full roster of tasks for a parent.
// RosterForParent returns the full roster for one tenant/root-agent tree.
// Sorted: completed/failed/cancelled first, then running (deterministic output).
func (sm *SubagentManager) RosterForParent(parentID string) SubagentRoster {
func (sm *SubagentManager) RosterForParent(scope TaskScope) SubagentRoster {
sm.mu.RLock()
defer sm.mu.RUnlock()
var entries []SubagentRosterEntry
maxPerAgent := 0
active := 0
maxPerAgent := sm.config.MaxChildrenPerAgent
var latestCreatedAt int64
var latestID string
for _, t := range sm.tasks {
if t.ParentID != parentID {
if !taskMatchesScope(t, scope) {
continue
}
entries = append(entries, SubagentRosterEntry{
Label: t.Label,
Status: t.Status,
})
if maxPerAgent == 0 {
if isActiveTaskStatus(t.Status) {
active++
}
if t.CreatedAt > latestCreatedAt || (t.CreatedAt == latestCreatedAt && t.ID > latestID) {
latestCreatedAt = t.CreatedAt
latestID = t.ID
maxPerAgent = t.spawnConfig.MaxChildrenPerAgent
}
}
@@ -56,6 +65,7 @@ func (sm *SubagentManager) RosterForParent(parentID string) SubagentRoster {
return SubagentRoster{
Entries: entries,
Active: active,
Total: len(entries),
MaxPerAgent: maxPerAgent,
}
+64
View File
@@ -0,0 +1,64 @@
package tools
import (
"strings"
"testing"
"github.com/google/uuid"
)
func TestRosterForParentSeparatesActiveFromRetainedTasks(t *testing.T) {
tenantID := uuid.New()
rootID := uuid.New()
manager := NewSubagentManager(nil, nil, "", nil, nil, SubagentConfig{
MaxChildrenPerAgent: 5,
})
manager.tasks = map[string]*SubagentTask{
"completed": {
ID: "completed",
ParentID: "parent",
RootAgentID: rootID,
RootAgentKey: "parent",
OriginTenantID: tenantID,
Label: "completed task",
Status: TaskStatusCompleted,
CreatedAt: 1,
spawnConfig: SubagentConfig{MaxChildrenPerAgent: 3},
},
"running": {
ID: "running",
ParentID: "parent",
RootAgentID: rootID,
RootAgentKey: "parent",
OriginTenantID: tenantID,
Label: "running task",
Status: TaskStatusRunning,
CreatedAt: 2,
spawnConfig: SubagentConfig{MaxChildrenPerAgent: 7},
},
"failed": {
ID: "failed",
ParentID: "parent",
RootAgentID: rootID,
RootAgentKey: "parent",
OriginTenantID: tenantID,
Label: "failed task",
Status: TaskStatusFailed,
CreatedAt: 3,
spawnConfig: SubagentConfig{MaxChildrenPerAgent: 9},
},
}
roster := manager.RosterForParent(TaskScope{TenantID: tenantID, RootAgentID: rootID, RootAgentKey: "parent"})
if roster.Active != 1 || roster.Total != 3 || roster.MaxPerAgent != 9 {
t.Fatalf("roster counts = %#v, want 1 active / 3 retained / max 9", roster)
}
instruction := BuildReplyInstruction(roster)
if !strings.Contains(instruction, "1 active / 9 max; 3 retained total") {
t.Fatalf("instruction missing active/retained counts:\n%s", instruction)
}
if !strings.Contains(instruction, "1 subagent(s) still running") {
t.Fatalf("instruction missing running state:\n%s", instruction)
}
}
+360 -137
View File
@@ -6,14 +6,134 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// Spawn creates a new subagent task that runs asynchronously.
// Returns immediately with a status message. The subagent runs in a goroutine.
// modelOverride optionally overrides the LLM model for this subagent (matching TS sessions-spawn-tool.ts).
// SpawnReceipt identifies both the in-memory runtime task and its durable
// completion row. CompletionID is empty only when no task store is configured.
type SpawnReceipt struct {
TaskID string
CompletionID uuid.UUID
Message string
}
// SpawnWithReceipt creates an asynchronous self-clone and returns identifiers
// that remain useful if the automatic parent announcement cannot be delivered.
func (sm *SubagentManager) SpawnWithReceipt(
ctx context.Context,
parentID string,
depth int,
task, label, modelOverride string,
channel, chatID, peerKind string,
callback AsyncCallback,
) (SpawnReceipt, error) {
finishLifecycle, ok := sm.beginLifecycleOperation()
if !ok {
return SpawnReceipt{}, fmt.Errorf("subagent manager is closed")
}
lifecycleTransferred := false
defer func() {
if !lifecycleTransferred {
finishLifecycle()
}
}()
if err := validateDelegationChildRunMode(ctx, "spawn", "async"); err != nil {
return SpawnReceipt{}, err
}
cfg := sm.effectiveSpawnConfig(ctx)
depth = subagentDepthFromContext(ctx, depth)
if depth >= cfg.MaxSpawnDepth {
return SpawnReceipt{}, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
}
scope := subagentScopeFromContext(ctx)
if scope.RootAgentKey == "" {
scope.RootAgentKey = parentID
}
if scope.TenantID == uuid.Nil || scope.RootAgentID == uuid.Nil || scope.RootAgentKey == "" {
return SpawnReceipt{}, fmt.Errorf("spawn requires tenant and root-agent context")
}
parentTaskID := subagentTaskIDFromContext(ctx)
admissionParentID := subagentAdmissionParentID(parentTaskID, scope)
subTask := newSubagentTask(ctx, scope, parentTaskID, depth, task, label, modelOverride, channel, chatID, peerKind, cfg)
admissionParentID, admissionDepth := childRunContinuationLineage(
ctx,
admissionParentID,
subTask.Depth,
)
// Detach from parent's cancellation chain so subagent survives after parent run completes.
// WithoutCancel preserves all context values (agent ID, workspace, trace info, etc.)
// but parent Done() no longer propagates. Manual cancel via taskCancel() still works.
detached := context.WithoutCancel(ctx)
taskCtx, taskCancel := context.WithCancel(detached)
subTask.cancelFunc = taskCancel
if sm.taskStore != nil {
subTask.dbID = store.GenNewID()
}
var ticket *orchestration.ChildRunTicket
var err error
var iterations int
ticket, err = sm.admission.Enqueue(taskCtx, orchestration.ChildRunConstraints{
TenantID: scope.TenantID,
RootAgentID: scope.RootAgentID,
RootLimit: cfg.MaxConcurrent,
TaskID: subTask.ID,
ParentTaskID: admissionParentID,
ParentFanout: cfg.MaxChildrenPerAgent,
Depth: admissionDepth,
}, func(runCtx context.Context, lease *orchestration.ChildRunLease) {
sm.markTaskRunning(subTask)
iterations = sm.executeTask(withSubagentExecution(runCtx, scope, subTask.ID, subTask.Depth, lease), subTask)
lease.Release()
})
if err != nil {
taskCancel()
return SpawnReceipt{}, err
}
subTask.admissionTicket = ticket
if _, err := sm.acceptTask(taskCtx, subTask); err != nil {
ticket.Cancel()
taskCancel()
return SpawnReceipt{}, err
}
if err := ticket.Activate(); err != nil {
sm.rollbackRejectedTask(taskCtx, subTask, err)
taskCancel()
return SpawnReceipt{}, err
}
announceCtx := context.WithoutCancel(taskCtx)
completion := func() {
defer finishLifecycle()
<-ticket.Done()
sm.finishTicketTask(announceCtx, subTask, ticket, iterations)
terminalPersisted := sm.persistStatus(announceCtx, subTask, iterations) == nil
taskCancel()
sm.announceTask(announceCtx, subTask, callback, iterations, terminalPersisted)
}
lifecycleTransferred = true
go completion()
slog.Info("subagent queued", "id", subTask.ID, "parent_task", parentTaskID, "depth", subTask.Depth, "label", subTask.Label)
message := fmt.Sprintf("Spawned subagent '%s' (id=%s, depth=%d) for task: %s",
subTask.Label, subTask.ID, subTask.Depth, truncate(task, 100))
return SpawnReceipt{
TaskID: subTask.ID,
CompletionID: subTask.dbID,
Message: message,
}, nil
}
// Spawn preserves the existing manager API for internal control flows.
func (sm *SubagentManager) Spawn(
ctx context.Context,
parentID string,
@@ -22,61 +142,160 @@ func (sm *SubagentManager) Spawn(
channel, chatID, peerKind string,
callback AsyncCallback,
) (string, error) {
cfg := sm.effectiveConfig(ctx)
receipt, err := sm.SpawnWithReceipt(
ctx, parentID, depth, task, label, modelOverride,
channel, chatID, peerKind, callback,
)
return receipt.Message, err
}
// Apply edition ceilings (Lite edition enforces lower limits).
ed := edition.Current()
if ed.MaxSubagentConcurrent > 0 && cfg.MaxConcurrent > ed.MaxSubagentConcurrent {
cfg.MaxConcurrent = ed.MaxSubagentConcurrent
// RunSync executes a subagent task synchronously, blocking until completion.
func (sm *SubagentManager) RunSync(
ctx context.Context,
parentID string,
depth int,
task, label, modelOverride string,
channel, chatID string,
) (string, []bus.MediaFile, int, error) {
finishLifecycle, ok := sm.beginLifecycleOperation()
if !ok {
return "", nil, 0, fmt.Errorf("subagent manager is closed")
}
defer finishLifecycle()
cfg := sm.effectiveSpawnConfig(ctx)
depth = subagentDepthFromContext(ctx, depth)
if depth >= cfg.MaxSpawnDepth {
return "", nil, 0, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
}
scope := subagentScopeFromContext(ctx)
if scope.RootAgentKey == "" {
scope.RootAgentKey = parentID
}
if scope.TenantID == uuid.Nil || scope.RootAgentID == uuid.Nil || scope.RootAgentKey == "" {
return "", nil, 0, fmt.Errorf("spawn requires tenant and root-agent context")
}
parentTaskID := subagentTaskIDFromContext(ctx)
admissionParentID := subagentAdmissionParentID(parentTaskID, scope)
subTask := newSubagentTask(ctx, scope, parentTaskID, depth, task, label, modelOverride, channel, chatID, "", cfg)
admissionParentID, admissionDepth := childRunContinuationLineage(
ctx,
admissionParentID,
subTask.Depth,
)
if sm.taskStore != nil {
subTask.dbID = store.GenNewID()
}
taskCtx, taskCancel := context.WithCancel(ctx)
subTask.cancelFunc = taskCancel
constraints := orchestration.ChildRunConstraints{
TenantID: scope.TenantID,
RootAgentID: scope.RootAgentID,
RootLimit: cfg.MaxConcurrent,
TaskID: subTask.ID,
ParentTaskID: admissionParentID,
ParentFanout: cfg.MaxChildrenPerAgent,
Depth: admissionDepth,
}
var iterations int
var acceptErr error
acceptedBeforeActivate := false
run := func(runCtx context.Context, lease *orchestration.ChildRunLease) {
if !acceptedBeforeActivate {
if _, acceptErr = sm.acceptTask(runCtx, subTask); acceptErr != nil {
lease.Release()
return
}
}
sm.markTaskRunning(subTask)
iterations = sm.executeTask(withSubagentExecution(runCtx, scope, subTask.ID, subTask.Depth, lease), subTask)
lease.Release()
sm.persistStatus(runCtx, subTask, iterations)
}
slog.Info("subagent sync queued", "id", subTask.ID, "parent_task", parentTaskID, "depth", subTask.Depth, "label", subTask.Label)
if parentLease := childRunLeaseFromContext(ctx); parentLease != nil {
sm.setTaskWaiting(parentTaskID, true)
err := parentLease.Continue(taskCtx, constraints, run)
sm.setTaskWaiting(parentTaskID, false)
if err != nil {
if sm.taskAccepted(subTask.ID) && !isTerminalTaskStatus(subTask.Status) {
sm.markTaskFailed(subTask, err)
sm.persistStatus(taskCtx, subTask, iterations)
}
taskCancel()
return "", nil, iterations, err
}
if acceptErr != nil {
taskCancel()
return "", nil, iterations, acceptErr
}
} else {
ticket, err := sm.admission.Enqueue(taskCtx, constraints, run)
if err != nil {
taskCancel()
return "", nil, 0, err
}
subTask.admissionTicket = ticket
if _, err := sm.acceptTask(taskCtx, subTask); err != nil {
ticket.Cancel()
taskCancel()
return "", nil, 0, err
}
acceptedBeforeActivate = true
if err := ticket.Activate(); err != nil {
sm.rollbackRejectedTask(taskCtx, subTask, err)
taskCancel()
return "", nil, 0, err
}
<-ticket.Done()
sm.finishTicketTask(taskCtx, subTask, ticket, iterations)
}
taskCancel()
if subTask.Status == TaskStatusFailed {
return subTask.Result, nil, iterations, fmt.Errorf("subagent failed: %s", subTask.Result)
}
media := append([]bus.MediaFile(nil), subTask.Media...)
return subTask.Result, media, iterations, nil
}
// effectiveSpawnConfig applies per-agent overrides and edition ceilings once so
// async admission, sync admission, and pipeline batching use the same limits.
func (sm *SubagentManager) effectiveSpawnConfig(ctx context.Context) SubagentConfig {
cfg := sm.effectiveConfig(ctx)
ed := edition.Current()
if ed.MaxSubagentDepth > 0 && cfg.MaxSpawnDepth > ed.MaxSubagentDepth {
cfg.MaxSpawnDepth = ed.MaxSubagentDepth
}
return cfg
}
sm.mu.Lock()
// Check depth limit
if depth >= cfg.MaxSpawnDepth {
sm.mu.Unlock()
return "", fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
}
// Check concurrent limit (scoped per tenant for isolation).
tenantID := store.TenantIDFromContext(ctx)
running := 0
for _, t := range sm.tasks {
if t.Status == TaskStatusRunning && t.OriginTenantID == tenantID {
running++
}
}
if running >= cfg.MaxConcurrent {
sm.mu.Unlock()
return "", fmt.Errorf("max concurrent subagents reached (%d/%d)", running, cfg.MaxConcurrent)
}
// Check per-parent children limit
childCount := 0
for _, t := range sm.tasks {
if t.ParentID == parentID {
childCount++
}
}
if childCount >= cfg.MaxChildrenPerAgent {
sm.mu.Unlock()
return "", fmt.Errorf("max children per agent reached (%d/%d)", childCount, cfg.MaxChildrenPerAgent)
}
id := generateSubagentID()
func newSubagentTask(
ctx context.Context,
scope TaskScope,
parentTaskID string,
depth int,
task, label, modelOverride string,
channel, chatID, peerKind string,
cfg SubagentConfig,
) *SubagentTask {
if label == "" {
label = truncate(task, 50)
}
subTask := &SubagentTask{
ID: id,
ParentID: parentID,
mediaPathPrefix := ""
if IsDelegationArtifactRun(ctx) {
mediaPathPrefix = "outputs"
}
return &SubagentTask{
ID: generateSubagentID(),
ParentID: scope.RootAgentKey,
ParentTaskID: parentTaskID,
Task: task,
Label: label,
Status: "running",
Status: TaskStatusQueued,
Depth: depth + 1,
Model: modelOverride,
OriginChannel: channel,
@@ -87,115 +306,119 @@ func (sm *SubagentManager) Spawn(
OriginSenderID: store.SenderIDFromContext(ctx),
OriginRole: store.RoleFromContext(ctx),
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
OriginAgentID: store.AgentIDFromContext(ctx),
OriginTenantID: store.TenantIDFromContext(ctx),
OriginAgentID: scope.RootAgentID,
OriginTenantID: scope.TenantID,
RootAgentID: scope.RootAgentID,
RootAgentKey: scope.RootAgentKey,
OriginTraceID: tracing.TraceIDFromContext(ctx),
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
OriginContextWindow: store.AgentContextWindowFromContext(ctx),
OriginMaxTokens: store.AgentMaxTokensFromContext(ctx),
Workspace: ToolWorkspaceFromCtx(ctx),
MediaPathPrefix: mediaPathPrefix,
CreatedAt: time.Now().UnixMilli(),
spawnConfig: cfg,
}
// Detach from parent's cancellation chain so subagent survives after parent run completes.
// WithoutCancel preserves all context values (agent ID, workspace, trace info, etc.)
// but parent Done() no longer propagates. Manual cancel via taskCancel() still works.
detached := context.WithoutCancel(ctx)
taskCtx, taskCancel := context.WithCancel(detached)
subTask.cancelFunc = taskCancel
// Assign DB UUID inside lock to avoid race with runTask goroutine.
if sm.taskStore != nil {
subTask.dbID = store.GenNewID()
}
sm.tasks[id] = subTask
sm.mu.Unlock()
slog.Info("subagent spawned", "id", id, "parent", parentID, "depth", subTask.Depth, "label", label)
// Persist to DB (fire-and-forget).
if sm.taskStore != nil {
go sm.persistCreate(taskCtx, subTask)
}
go sm.runTask(taskCtx, subTask, callback)
return fmt.Sprintf("Spawned subagent '%s' (id=%s, depth=%d) for task: %s",
label, id, subTask.Depth, truncate(task, 100)), nil
}
// RunSync executes a subagent task synchronously, blocking until completion.
func (sm *SubagentManager) RunSync(
ctx context.Context,
parentID string,
depth int,
task, label, modelOverride string,
channel, chatID string,
) (string, int, error) {
cfg := sm.effectiveConfig(ctx)
// Apply edition ceilings (same as Spawn).
ed := edition.Current()
if ed.MaxSubagentConcurrent > 0 && cfg.MaxConcurrent > ed.MaxSubagentConcurrent {
cfg.MaxConcurrent = ed.MaxSubagentConcurrent
}
if ed.MaxSubagentDepth > 0 && cfg.MaxSpawnDepth > ed.MaxSubagentDepth {
cfg.MaxSpawnDepth = ed.MaxSubagentDepth
}
func (sm *SubagentManager) markTaskRunning(task *SubagentTask) {
sm.mu.Lock()
if depth >= cfg.MaxSpawnDepth {
sm.mu.Unlock()
return "", 0, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
defer sm.mu.Unlock()
if task.Status == TaskStatusCancelled {
return
}
task.Status = TaskStatusRunning
}
id := generateSubagentID()
if label == "" {
label = truncate(task, 50)
func (sm *SubagentManager) setTaskWaiting(taskID string, waiting bool) {
if taskID == "" {
return
}
sm.mu.Lock()
defer sm.mu.Unlock()
task := sm.tasks[taskID]
if task == nil || isTerminalTaskStatus(task.Status) {
return
}
if waiting {
task.Status = TaskStatusWaiting
} else {
task.Status = TaskStatusRunning
}
}
func (sm *SubagentManager) markTaskFailed(task *SubagentTask, err error) {
sm.mu.Lock()
defer sm.mu.Unlock()
if !isTerminalTaskStatus(task.Status) {
task.Status = TaskStatusFailed
task.Result = err.Error()
task.CompletedAt = time.Now().UnixMilli()
}
}
subTask := &SubagentTask{
ID: id,
ParentID: parentID,
Task: task,
Label: label,
Status: "running",
Depth: depth + 1,
Model: modelOverride,
OriginChannel: channel,
OriginChatID: chatID,
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
OriginUserID: store.UserIDFromContext(ctx),
OriginSenderID: store.SenderIDFromContext(ctx),
OriginRole: store.RoleFromContext(ctx),
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
OriginAgentID: store.AgentIDFromContext(ctx),
OriginTenantID: store.TenantIDFromContext(ctx),
OriginTraceID: tracing.TraceIDFromContext(ctx),
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
OriginContextWindow: store.AgentContextWindowFromContext(ctx),
OriginMaxTokens: store.AgentMaxTokensFromContext(ctx),
CreatedAt: time.Now().UnixMilli(),
spawnConfig: cfg,
}
if sm.taskStore != nil {
subTask.dbID = store.GenNewID()
}
sm.tasks[id] = subTask
func (sm *SubagentManager) acceptTask(ctx context.Context, task *SubagentTask) (bool, error) {
sm.mu.Lock()
if _, exists := sm.tasks[task.ID]; exists {
sm.mu.Unlock()
return false, fmt.Errorf("subagent task ID collision: %s", task.ID)
}
sm.tasks[task.ID] = task
sm.mu.Unlock()
if err := sm.persistCreate(ctx, task); err != nil {
sm.mu.Lock()
if sm.tasks[task.ID] == task {
delete(sm.tasks, task.ID)
}
sm.mu.Unlock()
return false, err
}
if task.spawnConfig.ArchiveAfterMinutes > 0 {
sm.startArchiveSweeper()
}
return true, nil
}
slog.Info("subagent sync started", "id", id, "parent", parentID, "depth", subTask.Depth, "label", label)
func (sm *SubagentManager) taskAccepted(taskID string) bool {
sm.mu.RLock()
defer sm.mu.RUnlock()
_, exists := sm.tasks[taskID]
return exists
}
if sm.taskStore != nil {
sm.persistCreate(ctx, subTask)
func (sm *SubagentManager) rollbackRejectedTask(ctx context.Context, task *SubagentTask, err error) {
sm.markTaskFailed(task, err)
sm.persistStatus(ctx, task, 0)
sm.mu.Lock()
if sm.tasks[task.ID] == task {
delete(sm.tasks, task.ID)
}
sm.mu.Unlock()
}
iterations := sm.executeTask(ctx, subTask)
func subagentAdmissionParentID(parentTaskID string, scope TaskScope) string {
if parentTaskID != "" {
return parentTaskID
}
return "root:" + scope.RootAgentID.String()
}
if subTask.Status == TaskStatusFailed {
return subTask.Result, iterations, fmt.Errorf("subagent failed: %s", subTask.Result)
func (sm *SubagentManager) finishTicketTask(
ctx context.Context,
task *SubagentTask,
ticket *orchestration.ChildRunTicket,
iterations int,
) {
sm.mu.RLock()
terminal := isTerminalTaskStatus(task.Status)
sm.mu.RUnlock()
if terminal {
return
}
return subTask.Result, iterations, nil
err := ticket.Err()
if err == nil {
err = fmt.Errorf("child run ended without terminal task state")
}
sm.markTaskFailed(task, err)
sm.persistStatus(ctx, task, iterations)
}
+275 -4
View File
@@ -2,22 +2,149 @@ package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
type recordingSubagentProvider struct {
model string
model string
systemPrompt string
response string
calls int
}
func (p *recordingSubagentProvider) Name() string { return "recording" }
func (p *recordingSubagentProvider) DefaultModel() string { return "provider-default" }
func (p *recordingSubagentProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.calls++
p.model = req.Model
return &providers.ChatResponse{Content: "done", FinishReason: "stop"}, nil
if len(req.Messages) > 0 {
p.systemPrompt = req.Messages[0].Content
}
content := p.response
if content == "" {
content = "done"
}
return &providers.ChatResponse{Content: content, FinishReason: "stop"}, nil
}
func TestRunSyncKeepsParentAgentWorkspace(t *testing.T) {
provider := &recordingSubagentProvider{}
manager := NewSubagentManager(provider, nil, "manager-default", nil, NewRegistry, SubagentConfig{
MaxConcurrent: 20,
MaxSpawnDepth: 1,
MaxChildrenPerAgent: 5,
})
workspace := t.TempDir()
ctx := WithToolWorkspace(subagentTestContext("parent"), workspace)
if _, _, _, err := manager.RunSync(
ctx, "parent", 0, "test workspace", "workspace", "", "test", "chat",
); err != nil {
t.Fatalf("RunSync() error = %v", err)
}
if !strings.Contains(provider.systemPrompt, workspace) {
t.Fatalf("subagent prompt omitted parent workspace %q: %s", workspace, provider.systemPrompt)
}
}
func TestDelegatedAgentStartsOwnSpawnTree(t *testing.T) {
provider := &recordingSubagentProvider{}
manager := NewSubagentManager(provider, nil, "manager-default", nil, NewRegistry, SubagentConfig{
MaxConcurrent: 20,
MaxSpawnDepth: 1,
MaxChildrenPerAgent: 5,
})
tenantID := uuid.New()
agentA := uuid.New()
agentB := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
ctx = store.WithAgentID(ctx, agentA)
ctx = store.WithAgentKey(ctx, "agent-a")
ctx = WithToolAgentKey(ctx, "agent-a")
ctx = withSubagentExecution(ctx, TaskScope{
TenantID: tenantID, RootAgentID: agentA, RootAgentKey: "agent-a",
}, "agent-a-leaf", 1, nil)
ctx = WithSubagentConfig(ctx, &config.SubagentsConfig{
MaxConcurrent: 2,
MaxSpawnDepth: 4,
MaxChildrenPerAgent: 3,
})
ctx = withDelegatedAgentExecution(ctx, nil)
if inherited := SubagentConfigFromCtx(ctx); inherited != nil {
t.Fatalf("delegated agent inherited source config: %#v", inherited)
}
ctx = store.WithAgentID(ctx, agentB)
ctx = store.WithAgentKey(ctx, "agent-b")
ctx = store.WithAgentContextWindow(ctx, 200_000)
ctx = store.WithAgentMaxTokens(ctx, 32_000)
ctx = WithToolAgentKey(ctx, "agent-b")
ctx = WithSubagentConfig(ctx, &config.SubagentsConfig{
MaxConcurrent: 20,
MaxSpawnDepth: 1,
MaxChildrenPerAgent: 5,
})
if _, _, _, err := manager.RunSync(
ctx, "agent-b", 0, "run as B child", "b-child", "", "delegate", "chat",
); err != nil {
t.Fatalf("B spawn failed after delegation boundary: %v", err)
}
scope := TaskScope{TenantID: tenantID, RootAgentID: agentB, RootAgentKey: "agent-b"}
tasks := manager.ListTasks(scope, "")
if len(tasks) != 1 {
t.Fatalf("B task count = %d, want 1", len(tasks))
}
if task := tasks[0]; task.Depth != 1 || task.ParentTaskID != "" ||
task.RootAgentID != agentB || task.RootAgentKey != "agent-b" {
t.Fatalf("B spawn tree task = %#v", task)
}
}
func TestRunSyncRedactsDelegationArtifactWorkspace(t *testing.T) {
physicalOutputs := filepath.Join(t.TempDir(), "collaboration", "delegations", uuid.NewString(), "outputs")
if err := os.MkdirAll(physicalOutputs, 0750); err != nil {
t.Fatal(err)
}
provider := &recordingSubagentProvider{response: "result from " + physicalOutputs}
manager := NewSubagentManager(provider, nil, "manager-default", nil, NewRegistry, SubagentConfig{
MaxConcurrent: 20,
MaxSpawnDepth: 2,
MaxChildrenPerAgent: 5,
})
ctx := delegationArtifactTestContext()
ctx = store.WithAgentID(ctx, uuid.New())
ctx = store.WithAgentContextWindow(ctx, 200_000)
ctx = store.WithAgentMaxTokens(ctx, 32_000)
ctx = WithToolWorkspace(ctx, physicalOutputs)
ctx = WithToolAgentKey(ctx, "parent")
ctx = tracing.WithTextRedactor(ctx, strings.NewReplacer(physicalOutputs, "outputs").Replace)
result, _, _, err := manager.RunSync(
ctx, "parent", 0, "inspect outputs", "workspace", "", "test", "chat",
)
if err != nil {
t.Fatalf("RunSync() error = %v", err)
}
if strings.Contains(provider.systemPrompt, physicalOutputs) ||
!strings.Contains(provider.systemPrompt, "outputs/") {
t.Fatalf("artifact prompt leaked physical workspace: %s", provider.systemPrompt)
}
if strings.Contains(result, physicalOutputs) || !strings.Contains(result, "outputs") {
t.Fatalf("artifact result leaked physical workspace: %q", result)
}
}
func (p *recordingSubagentProvider) ChatStream(ctx context.Context, req providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return p.Chat(ctx, req)
@@ -32,14 +159,14 @@ func TestRunSyncHonorsPerTaskModelOverride(t *testing.T) {
Model: "configured-model",
})
ctx := store.WithTenantID(context.Background(), uuid.New())
ctx := subagentTestContext("parent")
ctx = WithParentModel(ctx, "parent-model")
// The subagent's internal LLM call is agent-scoped; the guard requires a
// budget in ctx (propagated from the calling agent via injectContext).
ctx = store.WithAgentContextWindow(ctx, 200_000)
ctx = store.WithAgentMaxTokens(ctx, 32_000)
result, _, err := manager.RunSync(ctx, "parent", 0, "test task", "test", "requested-model", "test", "chat")
result, _, _, err := manager.RunSync(ctx, "parent", 0, "test task", "test", "requested-model", "test", "chat")
if err != nil {
t.Fatalf("RunSync() error = %v", err)
}
@@ -50,3 +177,147 @@ func TestRunSyncHonorsPerTaskModelOverride(t *testing.T) {
t.Fatalf("provider model = %q, want per-task override %q", provider.model, "requested-model")
}
}
func TestRunSyncRetainedCompletedTaskDoesNotConsumeActiveQuota(t *testing.T) {
provider := &recordingSubagentProvider{}
manager := NewSubagentManager(provider, nil, "manager-default", nil, NewRegistry, SubagentConfig{
MaxConcurrent: 1,
MaxSpawnDepth: 3,
MaxChildrenPerAgent: 1,
})
ctx := subagentTestContext("parent")
for i := range 2 {
if _, _, _, err := manager.RunSync(
ctx, "parent", 0, "test task", "test", "", "test", "chat",
); err != nil {
t.Fatalf("RunSync() attempt %d error = %v", i+1, err)
}
}
tasks := manager.ListTasks(subagentScopeFromContext(ctx), "")
if len(tasks) != 2 {
t.Fatalf("retained tasks = %d, want 2", len(tasks))
}
for _, task := range tasks {
if task.Status != TaskStatusCompleted {
t.Fatalf("retained task status = %q, want completed", task.Status)
}
}
}
func TestSubagentTaskScopeRejectsOtherRootTree(t *testing.T) {
tenantID := uuid.New()
agentA := uuid.New()
agentB := uuid.New()
manager := NewSubagentManager(nil, nil, "", nil, nil, SubagentConfig{})
manager.tasks["running-a"] = &SubagentTask{
ID: "running-a", Status: TaskStatusRunning, RootAgentID: agentA,
RootAgentKey: "agent-a", OriginTenantID: tenantID,
}
if _, ok := manager.GetTask(TaskScope{
TenantID: tenantID, RootAgentID: agentB, RootAgentKey: "agent-b",
}, "running-a"); ok {
t.Fatal("other root tree read task by ID")
}
if manager.CancelTask(TaskScope{
TenantID: tenantID, RootAgentID: agentB, RootAgentKey: "agent-b",
}, "running-a") {
t.Fatal("other root tree cancelled task")
}
}
type mediaSubagentProvider struct {
calls int
}
func (p *mediaSubagentProvider) Name() string { return "media-test" }
func (p *mediaSubagentProvider) DefaultModel() string { return "media-test-model" }
func (p *mediaSubagentProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) {
p.calls++
if p.calls == 1 {
return &providers.ChatResponse{
ToolCalls: []providers.ToolCall{{
ID: "create-1",
Name: "create_test_media",
Arguments: map[string]any{},
}},
}, nil
}
return &providers.ChatResponse{Content: "media created", FinishReason: "stop"}, nil
}
func (p *mediaSubagentProvider) ChatStream(ctx context.Context, req providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return p.Chat(ctx, req)
}
type subagentMediaTool struct{}
func (subagentMediaTool) Name() string { return "create_test_media" }
func (subagentMediaTool) Description() string { return "Create test media" }
func (subagentMediaTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (subagentMediaTool) Execute(context.Context, map[string]any) *Result {
return &Result{
ForLLM: "created",
Media: []bus.MediaFile{{
Path: "/tmp/subagent-generated.png",
MimeType: "image/png",
Filename: "subagent-generated.png",
}},
}
}
func TestRunSyncReturnsGeneratedMedia(t *testing.T) {
provider := &mediaSubagentProvider{}
manager := newMediaSubagentManager(provider)
ctx := subagentTestContext("parent")
result, media, _, err := manager.RunSync(ctx, "parent", 0, "create media", "media", "", "delegate", "chat")
if err != nil {
t.Fatalf("RunSync() error = %v", err)
}
if result != "media created" {
t.Fatalf("RunSync() result = %q, want %q", result, "media created")
}
if len(media) != 1 || media[0].Path != "/tmp/subagent-generated.png" {
t.Fatalf("RunSync() media = %#v, want generated media", media)
}
}
func TestSpawnToolSyncForwardsGeneratedMedia(t *testing.T) {
provider := &mediaSubagentProvider{}
tool := NewSpawnTool(newMediaSubagentManager(provider), "parent", 0)
ctx := subagentTestContext("parent")
result := tool.Execute(ctx, map[string]any{
"task": "create media",
"mode": "sync",
})
if result.IsError {
t.Fatalf("SpawnTool.Execute() error = %s", result.ForLLM)
}
if len(result.Media) != 1 || result.Media[0].Path != "/tmp/subagent-generated.png" {
t.Fatalf("SpawnTool.Execute() media = %#v, want generated media", result.Media)
}
}
func subagentTestContext(rootKey string) context.Context {
ctx := store.WithTenantID(context.Background(), uuid.New())
ctx = store.WithAgentID(ctx, uuid.New())
ctx = store.WithAgentContextWindow(ctx, 200_000)
ctx = store.WithAgentMaxTokens(ctx, 32_000)
return WithToolAgentKey(ctx, rootKey)
}
func newMediaSubagentManager(provider providers.Provider) *SubagentManager {
return NewSubagentManager(provider, nil, "media-test-model", nil, func() *Registry {
registry := NewRegistry()
registry.Register(subagentMediaTool{})
return registry
}, SubagentConfig{
MaxConcurrent: 4,
MaxSpawnDepth: 3,
MaxChildrenPerAgent: 8,
})
}
+88 -8
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
@@ -39,7 +40,7 @@ func (t *SpawnTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"description": "'spawn' (default), 'list', 'cancel', 'steer', or 'wait'",
"description": "'spawn' (default), 'get', 'list', 'cancel', 'steer', or 'wait'",
},
"task": map[string]any{
"type": "string",
@@ -61,6 +62,10 @@ func (t *SpawnTool) Parameters() map[string]any {
"type": "string",
"description": "Task ID for cancel/steer. For cancel: use 'all' to cancel all or 'last' for most recent",
},
"completion_id": map[string]any{
"type": "string",
"description": "Durable completion UUID returned by async spawn (required for action=get)",
},
"message": map[string]any{
"type": "string",
"description": "New instructions (required for action=steer)",
@@ -70,7 +75,6 @@ func (t *SpawnTool) Parameters() map[string]any {
"description": "Timeout in seconds for action=wait (default 300)",
},
},
"required": []string{"task"},
}
}
@@ -81,6 +85,8 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result {
}
switch action {
case "get":
return t.executeGet(ctx, args)
case "list":
return t.executeList(ctx)
case "cancel":
@@ -89,11 +95,34 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result {
return t.executeSteer(ctx, args)
case "wait":
return t.executeWait(ctx, args)
default:
case "spawn":
return t.executeSpawn(ctx, args)
default:
return ErrorResult(fmt.Sprintf("unknown spawn action %q", action))
}
}
func (t *SpawnTool) executeGet(ctx context.Context, args map[string]any) *Result {
rawID, _ := args["completion_id"].(string)
completionID, err := uuid.Parse(rawID)
if err != nil {
return ErrorResult("completion_id must be a valid UUID")
}
scope := subagentScopeFromContext(ctx)
task, err := t.subagentMgr.GetPersistedTask(ctx, scope, completionID)
if err != nil {
return ErrorResult(err.Error())
}
if task == nil {
return ErrorResult("subagent completion not found")
}
payload, err := json.Marshal(persistedCompletionPayload(task))
if err != nil {
return ErrorResult("failed to encode subagent completion")
}
return NewResult(string(payload))
}
func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Result {
// Reject legacy "agent" parameter — delegation was removed.
// Guide the LLM to use team_tasks for team coordination.
@@ -116,6 +145,12 @@ func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Resu
}
mode, _ := args["mode"].(string)
if mode == "" {
mode = "async"
}
if err := validateDelegationChildRunMode(ctx, "spawn", mode); err != nil {
return ErrorResult(err.Error())
}
if mode == "sync" {
return t.executeSubagentSync(ctx, args, task)
}
@@ -137,19 +172,56 @@ func (t *SpawnTool) executeSubagentAsync(ctx context.Context, args map[string]an
parentID = t.parentID
}
msg, err := t.subagentMgr.Spawn(ctx, parentID, t.depth, task, label, modelOverride,
receipt, err := t.subagentMgr.SpawnWithReceipt(ctx, parentID, t.depth, task, label, modelOverride,
channel, chatID, peerKind, callback)
if err != nil {
return ErrorResult(err.Error())
}
forLLM := fmt.Sprintf(`{"status":"accepted","label":%q}
accepted := map[string]any{
"status": "accepted",
"label": label,
"task_id": receipt.TaskID,
}
if receipt.CompletionID != uuid.Nil {
accepted["completion_id"] = receipt.CompletionID.String()
}
acceptedJSON, _ := json.Marshal(accepted)
forLLM := fmt.Sprintf(`%s
%s
After all spawn tool calls in this turn are complete, briefly tell the user what tasks you've started. Subagents will announce results when done — do NOT wait or poll.`, label, msg)
After all spawn tool calls in this turn are complete, briefly tell the user what tasks you've started. Subagents will announce results when done. If an announcement is missed, retrieve the durable result with spawn(action="get", completion_id="..."). Do NOT wait or poll while the task is running.`, acceptedJSON, receipt.Message)
return AsyncResult(forLLM)
}
func persistedCompletionPayload(task *store.SubagentTaskData) map[string]any {
payload := map[string]any{
"completion_id": task.ID.String(),
"status": task.Status,
"subject": task.Subject,
"created_at": task.CreatedAt,
"updated_at": task.UpdatedAt,
}
if task.Result != nil {
payload["result"] = *task.Result
}
if task.CompletedAt != nil {
payload["completed_at"] = *task.CompletedAt
}
if task.Metadata != nil {
if runtimeID, ok := task.Metadata[asyncCompletionRuntimeIDKey].(string); ok && runtimeID != "" {
payload["task_id"] = runtimeID
}
if delivery, ok := task.Metadata[asyncCompletionDeliveryKey].(string); ok && delivery != "" {
payload[asyncCompletionDeliveryKey] = delivery
}
if media := persistedCompletionMediaPayload(task.Metadata[asyncCompletionMediaKey]); len(media) > 0 {
payload["media"] = media
}
}
return payload
}
// executeSubagentSync runs a sync self-clone.
func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any, task string) *Result {
label, _ := args["label"].(string)
@@ -166,7 +238,7 @@ func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any
parentID = t.parentID
}
result, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label, modelOverride,
result, media, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label, modelOverride,
channel, chatID)
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent '%s' failed: %v", label, err))
@@ -182,7 +254,7 @@ func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any
forLLM := fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nFull result:\n%s",
label, iterations, result)
return &Result{ForLLM: forLLM, ForUser: forUser}
return &Result{ForLLM: forLLM, ForUser: forUser, Media: media}
}
// SetContext is a no-op; channel/chatID are now read from ctx (thread-safe).
@@ -193,3 +265,11 @@ func (t *SpawnTool) SetPeerKind(peerKind string) {}
// SetCallback is a no-op; callback is now read from ctx (thread-safe).
func (t *SpawnTool) SetCallback(cb AsyncCallback) {}
func (t *SpawnTool) scopeFromContext(ctx context.Context) TaskScope {
scope := subagentScopeFromContext(ctx)
if scope.RootAgentKey == "" {
scope.RootAgentKey = t.parentID
}
return scope
}
+21 -21
View File
@@ -7,26 +7,29 @@ import (
"time"
)
// executeList shows active subagent tasks.
// executeList shows running and retained subagent tasks.
func (t *SpawnTool) executeList(ctx context.Context) *Result {
parentID := ToolAgentKeyFromCtx(ctx)
if parentID == "" {
parentID = t.parentID
}
tasks := t.subagentMgr.ListTasks(parentID)
scope := t.scopeFromContext(ctx)
tasks := t.subagentMgr.ListTasks(scope, "")
if len(tasks) == 0 {
return &Result{ForLLM: "No active tasks found."}
return &Result{ForLLM: "No subagent tasks found."}
}
var lines []string
running, completed, cancelled := 0, 0, 0
queued, running, completed, failed, cancelled := 0, 0, 0, 0, 0
for _, task := range tasks {
switch task.Status {
case "running":
case TaskStatusQueued:
queued++
case TaskStatusRunning:
running++
case "completed":
case TaskStatusWaiting:
running++
case TaskStatusCompleted:
completed++
case "cancelled":
case TaskStatusFailed:
failed++
case TaskStatusCancelled:
cancelled++
}
line := fmt.Sprintf("- [%s] %s (id=%s, status=%s)", task.Label, truncate(task.Task, 60), task.ID, task.Status)
@@ -37,8 +40,10 @@ func (t *SpawnTool) executeList(ctx context.Context) *Result {
lines = append(lines, line)
}
return &Result{ForLLM: fmt.Sprintf("Subagent tasks: %d running, %d completed, %d cancelled\n%s",
running, completed, cancelled, strings.Join(lines, "\n"))}
return &Result{ForLLM: fmt.Sprintf(
"Subagent tasks: %d queued, %d active, %d completed, %d failed, %d cancelled (%d retained total)\n%s",
queued, running, completed, failed, cancelled, len(tasks), strings.Join(lines, "\n"),
)}
}
// executeCancel cancels a subagent task by ID.
@@ -48,7 +53,7 @@ func (t *SpawnTool) executeCancel(ctx context.Context, args map[string]any) *Res
return ErrorResult("id is required for action=cancel")
}
if t.subagentMgr.CancelTask(id) {
if t.subagentMgr.CancelTask(t.scopeFromContext(ctx), id) {
return &Result{ForLLM: fmt.Sprintf("Task '%s' cancelled.", id)}
}
@@ -66,7 +71,7 @@ func (t *SpawnTool) executeSteer(ctx context.Context, args map[string]any) *Resu
return ErrorResult("message is required for action=steer")
}
msg, err := t.subagentMgr.Steer(ctx, id, message, nil)
msg, err := t.subagentMgr.Steer(ctx, t.scopeFromContext(ctx), id, message, nil)
if err != nil {
return ErrorResult(err.Error())
}
@@ -75,17 +80,12 @@ func (t *SpawnTool) executeSteer(ctx context.Context, args map[string]any) *Resu
// executeWait blocks until all children of the calling agent complete or timeout.
func (t *SpawnTool) executeWait(ctx context.Context, args map[string]any) *Result {
parentID := ToolAgentKeyFromCtx(ctx)
if parentID == "" {
parentID = t.parentID
}
timeout := 300
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeout = int(v)
}
tasks, err := t.subagentMgr.WaitForChildren(ctx, parentID, timeout)
tasks, err := t.subagentMgr.WaitForChildren(ctx, t.scopeFromContext(ctx), "", timeout)
return t.formatWaitResult(tasks, err)
}
@@ -0,0 +1,552 @@
package tools
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/google/uuid"
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type taskLifecycleUpdate struct {
rootAgentID uuid.UUID
id uuid.UUID
status string
result string
}
type recordingSubagentTaskStore struct {
mu sync.Mutex
creates []store.SubagentTaskData
rows map[uuid.UUID]store.SubagentTaskData
updates chan taskLifecycleUpdate
metadata chan map[string]any
createStarted chan struct{}
createRelease chan struct{}
createErr error
updateErr error
updateHook func(context.Context, string) error
updateStarted chan struct{}
updateRelease chan struct{}
updateOnce sync.Once
}
func newRecordingSubagentTaskStore() *recordingSubagentTaskStore {
return &recordingSubagentTaskStore{
rows: make(map[uuid.UUID]store.SubagentTaskData),
updates: make(chan taskLifecycleUpdate, 16),
metadata: make(chan map[string]any, 16),
}
}
func TestSubagentTaskStatusesFitPersistentStore(t *testing.T) {
statuses := []string{
TaskStatusQueued,
TaskStatusRunning,
TaskStatusWaiting,
TaskStatusCompleted,
TaskStatusFailed,
TaskStatusCancelled,
}
for _, status := range statuses {
if len(status) > store.SubagentTaskStatusMaxLength {
t.Fatalf(
"subagent status %q is %d characters, exceeds persistent limit %d",
status,
len(status),
store.SubagentTaskStatusMaxLength,
)
}
}
if TaskStatusWaiting != string(orchestration.ChildRunWaitingChild) {
t.Fatalf(
"task waiting status %q differs from child-run state %q",
TaskStatusWaiting,
orchestration.ChildRunWaitingChild,
)
}
}
func (s *recordingSubagentTaskStore) Create(_ context.Context, task *store.SubagentTaskData) error {
if s.createErr != nil {
return s.createErr
}
s.mu.Lock()
s.creates = append(s.creates, *task)
s.rows[task.ID] = *task
s.mu.Unlock()
if s.createStarted != nil {
close(s.createStarted)
<-s.createRelease
}
return nil
}
func (s *recordingSubagentTaskStore) Get(_ context.Context, rootAgentID, id uuid.UUID) (*store.SubagentTaskData, error) {
s.mu.Lock()
defer s.mu.Unlock()
task, ok := s.rows[id]
if !ok || task.RootAgentID != rootAgentID {
return nil, nil
}
copy := task
return &copy, nil
}
func (s *recordingSubagentTaskStore) UpdateStatus(
ctx context.Context,
rootAgentID uuid.UUID,
id uuid.UUID,
status string,
result *string,
_ int,
_, _ int64,
) error {
value := ""
if result != nil {
value = *result
}
s.updates <- taskLifecycleUpdate{
rootAgentID: rootAgentID,
id: id,
status: status,
result: value,
}
if s.updateHook != nil {
if err := s.updateHook(ctx, status); err != nil {
return err
}
}
if s.updateErr != nil {
return s.updateErr
}
s.mu.Lock()
if task, ok := s.rows[id]; ok && task.RootAgentID == rootAgentID {
task.Status = status
task.Result = result
s.rows[id] = task
}
s.mu.Unlock()
if s.updateStarted != nil {
s.updateOnce.Do(func() { close(s.updateStarted) })
<-s.updateRelease
}
return nil
}
func (s *recordingSubagentTaskStore) ListByParent(context.Context, uuid.UUID, string) ([]store.SubagentTaskData, error) {
return nil, nil
}
func (s *recordingSubagentTaskStore) ListBySession(context.Context, uuid.UUID, string) ([]store.SubagentTaskData, error) {
return nil, nil
}
func (s *recordingSubagentTaskStore) Archive(context.Context, uuid.UUID, time.Duration, int) (int64, error) {
return 0, nil
}
func (s *recordingSubagentTaskStore) UpdateMetadata(_ context.Context, rootAgentID, id uuid.UUID, metadata map[string]any) error {
s.mu.Lock()
if task, ok := s.rows[id]; ok && task.RootAgentID == rootAgentID {
if task.Metadata == nil {
task.Metadata = make(map[string]any)
}
for key, value := range metadata {
task.Metadata[key] = value
}
s.rows[id] = task
}
s.mu.Unlock()
s.metadata <- metadata
return nil
}
type blockingSubagentProvider struct {
started chan struct{}
release chan struct{}
}
func (*blockingSubagentProvider) Name() string { return "blocking" }
func (*blockingSubagentProvider) DefaultModel() string { return "blocking" }
func (p *blockingSubagentProvider) Chat(
context.Context,
providers.ChatRequest,
) (*providers.ChatResponse, error) {
p.started <- struct{}{}
<-p.release
return &providers.ChatResponse{Content: "done", FinishReason: "stop"}, nil
}
func (p *blockingSubagentProvider) ChatStream(
ctx context.Context,
req providers.ChatRequest,
_ func(providers.StreamChunk),
) (*providers.ChatResponse, error) {
return p.Chat(ctx, req)
}
func TestRootSubagentsHonorMaxChildrenFanout(t *testing.T) {
provider := &blockingSubagentProvider{
started: make(chan struct{}, 2),
release: make(chan struct{}),
}
admission := orchestration.NewChildRunAdmission(2, 4)
manager := NewSubagentManagerWithAdmission(
provider,
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 3, MaxChildrenPerAgent: 1},
admission,
)
ctx := subagentTestContext("parent")
if _, err := manager.Spawn(ctx, "parent", 0, "first", "first", "", "test", "chat", "", nil); err != nil {
t.Fatalf("spawn first: %v", err)
}
select {
case <-provider.started:
case <-time.After(time.Second):
t.Fatal("first root child did not start")
}
if _, err := manager.Spawn(ctx, "parent", 0, "second", "second", "", "test", "chat", "", nil); err != nil {
t.Fatalf("spawn second: %v", err)
}
select {
case <-provider.started:
t.Fatal("second root child bypassed maxChildren fanout")
case <-time.After(100 * time.Millisecond):
}
close(provider.release)
select {
case <-provider.started:
case <-time.After(time.Second):
t.Fatal("queued root child did not start after capacity released")
}
if err := admission.Close(context.Background()); err != nil {
t.Fatal(err)
}
manager.Close()
}
func TestRunSyncAdmissionRejectionCreatesNoGhostTask(t *testing.T) {
admission := orchestration.NewChildRunAdmission(1, 1)
if err := admission.Close(context.Background()); err != nil {
t.Fatal(err)
}
provider := &recordingSubagentProvider{}
manager := NewSubagentManagerWithAdmission(
provider,
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 3, MaxChildrenPerAgent: 2},
admission,
)
taskStore := newRecordingSubagentTaskStore()
manager.SetTaskStore(taskStore)
ctx := subagentTestContext("parent")
if _, _, _, err := manager.RunSync(ctx, "parent", 0, "task", "label", "", "test", "chat"); err == nil {
t.Fatal("closed admission unexpectedly accepted sync task")
}
select {
case update := <-taskStore.updates:
t.Fatalf("rejected task persisted an update: %#v", update)
default:
}
taskStore.mu.Lock()
if len(taskStore.creates) != 0 {
t.Fatalf("rejected task persisted creates: %#v", taskStore.creates)
}
taskStore.mu.Unlock()
if got := manager.ListTasks(subagentScopeFromContext(ctx), ""); len(got) != 0 {
t.Fatalf("rejected task remained in memory: %#v", got)
}
if provider.calls != 0 {
t.Fatalf("provider calls = %d, want 0", provider.calls)
}
}
func TestCancelQueuedTaskPersistsCancelledStatus(t *testing.T) {
admission := orchestration.NewChildRunAdmission(1, 4)
blockerStarted := make(chan struct{})
releaseBlocker := make(chan struct{})
blocker, err := admission.Enqueue(context.Background(), orchestration.ChildRunConstraints{
TenantID: store.MasterTenantID,
TaskID: "blocker",
}, func(context.Context, *orchestration.ChildRunLease) {
close(blockerStarted)
<-releaseBlocker
})
if err != nil {
t.Fatal(err)
}
if err := blocker.Activate(); err != nil {
t.Fatal(err)
}
select {
case <-blockerStarted:
case <-time.After(time.Second):
t.Fatal("blocker did not start")
}
manager := NewSubagentManagerWithAdmission(
&recordingSubagentProvider{},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 3, MaxChildrenPerAgent: 2},
admission,
)
taskStore := newRecordingSubagentTaskStore()
manager.SetTaskStore(taskStore)
ctx := subagentTestContext("parent")
if _, err := manager.Spawn(ctx, "parent", 0, "queued task", "queued", "", "test", "chat", "", nil); err != nil {
t.Fatalf("spawn queued task: %v", err)
}
scope := subagentScopeFromContext(ctx)
tasks := manager.ListTasks(scope, "")
if len(tasks) != 1 || tasks[0].Status != TaskStatusQueued {
t.Fatalf("queued tasks = %#v", tasks)
}
taskStore.mu.Lock()
if len(taskStore.creates) != 1 || taskStore.creates[0].RootAgentID != scope.RootAgentID {
t.Fatalf("persisted create root ownership = %#v, want %s", taskStore.creates, scope.RootAgentID)
}
taskStore.mu.Unlock()
if !manager.CancelTask(scope, tasks[0].ID) {
t.Fatal("queued task was not cancelled")
}
select {
case update := <-taskStore.updates:
if update.rootAgentID != scope.RootAgentID ||
update.status != TaskStatusCancelled || update.result != "cancelled by user" {
t.Fatalf("cancel update = %#v", update)
}
case <-time.After(time.Second):
t.Fatal("queued cancellation was not persisted")
}
close(releaseBlocker)
<-blocker.Done()
if err := admission.Close(context.Background()); err != nil {
t.Fatal(err)
}
manager.Close()
}
func TestManagerCloseWaitsForQueuedCancellationPersistence(t *testing.T) {
admission := orchestration.NewChildRunAdmission(1, 4)
blockerStarted := make(chan struct{})
releaseBlocker := make(chan struct{})
blocker, err := admission.Enqueue(context.Background(), orchestration.ChildRunConstraints{
TenantID: store.MasterTenantID,
TaskID: "blocker",
}, func(context.Context, *orchestration.ChildRunLease) {
close(blockerStarted)
<-releaseBlocker
})
if err != nil {
t.Fatal(err)
}
if err := blocker.Activate(); err != nil {
t.Fatal(err)
}
select {
case <-blockerStarted:
case <-time.After(time.Second):
t.Fatal("blocker did not start")
}
manager := NewSubagentManagerWithAdmission(
&recordingSubagentProvider{},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 3, MaxChildrenPerAgent: 2},
admission,
)
taskStore := newRecordingSubagentTaskStore()
taskStore.updateStarted = make(chan struct{})
taskStore.updateRelease = make(chan struct{})
manager.SetTaskStore(taskStore)
ctx := subagentTestContext("parent")
if _, err := manager.Spawn(ctx, "parent", 0, "queued task", "queued", "", "test", "chat", "", nil); err != nil {
t.Fatalf("spawn queued task: %v", err)
}
scope := subagentScopeFromContext(ctx)
tasks := manager.ListTasks(scope, "")
if len(tasks) != 1 {
t.Fatalf("queued tasks = %#v", tasks)
}
if !manager.CancelTask(scope, tasks[0].ID) {
t.Fatal("queued task was not cancelled")
}
select {
case <-taskStore.updateStarted:
case <-time.After(time.Second):
t.Fatal("terminal persistence did not start")
}
close(releaseBlocker)
<-blocker.Done()
if err := admission.Close(context.Background()); err != nil {
t.Fatal(err)
}
closeDone := make(chan struct{})
go func() {
manager.Close()
close(closeDone)
}()
select {
case <-closeDone:
t.Fatal("manager closed before terminal persistence finished")
case <-time.After(100 * time.Millisecond):
}
close(taskStore.updateRelease)
select {
case <-closeDone:
case <-time.After(time.Second):
t.Fatal("manager did not close after terminal persistence finished")
}
}
func TestManagerCloseContextTimesOutOnBlockedTerminalPersistence(t *testing.T) {
manager := NewSubagentManagerWithAdmission(
&recordingSubagentProvider{},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 1, MaxSpawnDepth: 1, MaxChildrenPerAgent: 1},
orchestration.NewChildRunAdmission(1, 1),
)
finish, ok := manager.beginLifecycleOperation()
if !ok {
t.Fatal("manager rejected lifecycle operation before close")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := manager.CloseContext(ctx)
if !errors.Is(err, ErrSubagentLifecycleDrainTimeout) {
t.Fatalf("CloseContext error = %v, want typed drain timeout", err)
}
finish()
if err := manager.CloseContext(context.Background()); err != nil {
t.Fatalf("CloseContext retry: %v", err)
}
}
func TestManagerCloseContextDropsPendingAnnounceBeforeLifecycleDrain(t *testing.T) {
manager := NewSubagentManagerWithAdmission(
&recordingSubagentProvider{},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 1, MaxSpawnDepth: 1, MaxChildrenPerAgent: 1},
orchestration.NewChildRunAdmission(1, 1),
)
drained := make(chan struct{}, 1)
manager.SetAnnounceQueue(NewAnnounceQueue(
20,
20,
func(string, []AnnounceQueueItem, AnnounceMetadata) {
drained <- struct{}{}
},
))
finish, ok := manager.beginLifecycleOperation()
if !ok {
t.Fatal("manager rejected lifecycle operation before close")
}
manager.announceQueue.Enqueue(
"session",
AnnounceQueueItem{SubagentID: "task"},
AnnounceMetadata{},
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
err := manager.CloseContext(ctx)
cancel()
if !errors.Is(err, ErrSubagentLifecycleDrainTimeout) {
t.Fatalf("CloseContext error = %v, want typed drain timeout", err)
}
select {
case <-drained:
t.Fatal("pending announce drained while lifecycle shutdown was blocked")
case <-time.After(50 * time.Millisecond):
}
finish()
if err := manager.CloseContext(context.Background()); err != nil {
t.Fatalf("CloseContext retry: %v", err)
}
}
func TestSpawnActivationFailureTerminalizesRowAndRemovesMemoryTask(t *testing.T) {
admission := orchestration.NewChildRunAdmission(1, 4)
manager := NewSubagentManagerWithAdmission(
&recordingSubagentProvider{},
nil,
"model",
nil,
NewRegistry,
SubagentConfig{MaxConcurrent: 2, MaxSpawnDepth: 3, MaxChildrenPerAgent: 2},
admission,
)
taskStore := newRecordingSubagentTaskStore()
taskStore.createStarted = make(chan struct{})
taskStore.createRelease = make(chan struct{})
manager.SetTaskStore(taskStore)
ctx := subagentTestContext("parent")
spawnDone := make(chan error, 1)
go func() {
_, err := manager.Spawn(ctx, "parent", 0, "task", "label", "", "test", "chat", "", nil)
spawnDone <- err
}()
select {
case <-taskStore.createStarted:
case <-time.After(time.Second):
t.Fatal("queued row was not persisted")
}
if err := admission.Close(context.Background()); err != nil {
t.Fatalf("close admission: %v", err)
}
close(taskStore.createRelease)
select {
case err := <-spawnDone:
if err == nil {
t.Fatal("activation unexpectedly succeeded after admission close")
}
case <-time.After(time.Second):
t.Fatal("spawn did not return after activation failure")
}
select {
case update := <-taskStore.updates:
if update.status != TaskStatusFailed || update.result == "" {
t.Fatalf("activation rollback update = %#v", update)
}
case <-time.After(time.Second):
t.Fatal("activation failure did not terminalize persisted row")
}
if got := manager.ListTasks(subagentScopeFromContext(ctx), ""); len(got) != 0 {
t.Fatalf("activation failure left in-memory task: %#v", got)
}
manager.Close()
}
+6 -6
View File
@@ -52,7 +52,7 @@ func (sm *SubagentManager) emitLLMSpanStart(ctx context.Context, start time.Time
span.InputPreview = truncate(string(b), 100000)
}
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
return spanID
}
@@ -97,7 +97,7 @@ func (sm *SubagentManager) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID,
}
updates["output_preview"] = truncate(resp.Content, previewLimit)
}
collector.EmitSpanUpdate(spanID, traceID, updates)
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
// ---------------------------------------------------------------------------
@@ -139,7 +139,7 @@ func (sm *SubagentManager) emitToolSpanStart(ctx context.Context, start time.Tim
if span.TenantID == uuid.Nil {
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
return spanID
}
@@ -169,7 +169,7 @@ func (sm *SubagentManager) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID
updates["status"] = store.SpanStatusError
updates["error"] = truncate(output, 200)
}
collector.EmitSpanUpdate(spanID, traceID, updates)
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
// ---------------------------------------------------------------------------
@@ -210,7 +210,7 @@ func (sm *SubagentManager) emitSubagentSpanStart(ctx context.Context, spanID uui
if span.TenantID == uuid.Nil {
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
collector.EmitSpan(tracing.RedactSpan(ctx, span))
}
// emitSubagentSpanEnd finalizes the running subagent root span.
@@ -239,5 +239,5 @@ func (sm *SubagentManager) emitSubagentSpanEnd(ctx context.Context, spanID uuid.
updates["status"] = store.SpanStatusError
updates["error"] = truncate(task.Result, 200)
}
collector.EmitSpanUpdate(spanID, traceID, updates)
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
}
+37 -34
View File
@@ -4,46 +4,49 @@ package tools
// These keys appear in bus.InboundMessage.Metadata for teammate and
// subagent messages routed through the gateway consumer.
const (
MetaOriginChannel = "origin_channel"
MetaOriginPeerKind = "origin_peer_kind"
MetaOriginChatID = "origin_chat_id"
MetaOriginUserID = "origin_user_id"
MetaOriginChannel = "origin_channel"
MetaOriginPeerKind = "origin_peer_kind"
MetaOriginChatID = "origin_chat_id"
MetaOriginUserID = "origin_user_id"
// MetaOriginSenderID carries the real acting sender through announce re-ingress
// so permission checks (e.g. CheckFileWriterPermission) attribute to the
// original user rather than a synthetic "subagent:<id>" / "notification:system" string.
MetaOriginSenderID = "origin_sender_id"
MetaOriginSenderID = "origin_sender_id"
// MetaOriginRole carries the caller's RBAC role through dispatch + re-ingress
// so permission checks can bypass per-user grants for authenticated admins
// (e.g. dashboard user dispatches a task that writes files in a group chat).
MetaOriginRole = "origin_role"
MetaOriginLocalKey = "origin_local_key"
MetaOriginSessionKey = "origin_session_key"
MetaOriginTraceID = "origin_trace_id"
MetaOriginRootSpanID = "origin_root_span_id"
MetaFromAgent = "from_agent"
MetaToAgent = "to_agent"
MetaToAgentDisplay = "to_agent_display"
MetaTeamTaskID = "team_task_id"
MetaTeamID = "team_id"
MetaTeamWorkspace = "team_workspace"
MetaLeaderAgentID = "leader_agent_id"
MetaParentAgent = "parent_agent"
MetaSubagentLabel = "subagent_label"
MetaSubagentStatus = "subagent_status"
MetaSubagentResult = "subagent_result"
MetaSubagentRuntime = "subagent_runtime_ms"
MetaSubagentIterations = "subagent_iterations"
MetaSubagentInputToks = "subagent_input_tokens"
MetaSubagentOutputToks = "subagent_output_tokens"
MetaCommand = "command"
MetaIsForum = "is_forum"
MetaMessageThreadID = "message_thread_id"
MetaDMThreadID = "dm_thread_id"
MetaChatTitle = "chat_title"
MetaUsername = "username"
MetaUserName = "user_name"
MetaTopicSystemPrompt = "topic_system_prompt"
MetaTopicSkills = "topic_skills"
MetaOriginRole = "origin_role"
MetaOriginLocalKey = "origin_local_key"
MetaOriginSessionKey = "origin_session_key"
MetaOriginTraceID = "origin_trace_id"
MetaOriginRootSpanID = "origin_root_span_id"
MetaFromAgent = "from_agent"
MetaToAgent = "to_agent"
MetaToAgentDisplay = "to_agent_display"
MetaTeamTaskID = "team_task_id"
MetaTeamID = "team_id"
MetaTeamWorkspace = "team_workspace"
MetaLeaderAgentID = "leader_agent_id"
MetaParentAgent = "parent_agent"
MetaSubagentRootAgentID = "subagent_root_agent_id"
MetaSubagentParentTask = "subagent_parent_task_id"
MetaSubagentDepth = "subagent_depth"
MetaSubagentLabel = "subagent_label"
MetaSubagentStatus = "subagent_status"
MetaSubagentResult = "subagent_result"
MetaSubagentRuntime = "subagent_runtime_ms"
MetaSubagentIterations = "subagent_iterations"
MetaSubagentInputToks = "subagent_input_tokens"
MetaSubagentOutputToks = "subagent_output_tokens"
MetaCommand = "command"
MetaIsForum = "is_forum"
MetaMessageThreadID = "message_thread_id"
MetaDMThreadID = "dm_thread_id"
MetaChatTitle = "chat_title"
MetaUsername = "username"
MetaUserName = "user_name"
MetaTopicSystemPrompt = "topic_system_prompt"
MetaTopicSkills = "topic_skills"
// MetaChannelSelfIdentity carries a channel-provided self-identity hint
// (e.g. "You are @viet_super_bot (ViệtBot) on this Telegram channel.")
// appended to the agent's system prompt so the LLM does not confuse its own