mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 14:15:46 +00:00
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:
1 parent
496b7ffce6
commit
bb7712a9ff
186 files changed
+18858
-1233
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
)
|
||||
|
||||
func isExistingRealDirectory(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
return err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0
|
||||
}
|
||||
|
||||
func validateDelegationArtifactWorkspace(delegationID, inputsPath, outputsPath string) bool {
|
||||
parsedID, err := uuid.Parse(delegationID)
|
||||
if err != nil || parsedID == uuid.Nil ||
|
||||
!isExistingRealDirectory(inputsPath) ||
|
||||
!isExistingRealDirectory(outputsPath) {
|
||||
return false
|
||||
}
|
||||
inputsReal, err := filepath.EvalSymlinks(filepath.Clean(inputsPath))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
outputsReal, err := filepath.EvalSymlinks(filepath.Clean(outputsPath))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
exchangeRoot := filepath.Dir(inputsReal)
|
||||
return filepath.Base(inputsReal) == "inputs" &&
|
||||
filepath.Base(outputsReal) == "outputs" &&
|
||||
filepath.Dir(outputsReal) == exchangeRoot &&
|
||||
filepath.Base(exchangeRoot) == parsedID.String() &&
|
||||
filepath.Base(filepath.Dir(exchangeRoot)) == "delegations" &&
|
||||
filepath.Base(filepath.Dir(filepath.Dir(exchangeRoot))) == "collaboration"
|
||||
}
|
||||
|
||||
func delegationArtifactTextRedactor(req *RunRequest) tracing.TextRedactor {
|
||||
if req == nil || req.RunKind != "delegate" ||
|
||||
req.DelegateInputsPath == "" || req.DelegateOutputsPath == "" {
|
||||
return nil
|
||||
}
|
||||
type replacement struct {
|
||||
value string
|
||||
alias string
|
||||
}
|
||||
var replacements []replacement
|
||||
seen := make(map[string]struct{})
|
||||
add := func(value, alias string) {
|
||||
for _, variant := range []string{
|
||||
value,
|
||||
filepath.ToSlash(value),
|
||||
strings.Trim(strconv.Quote(value), `"`),
|
||||
} {
|
||||
if variant == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[variant]; exists {
|
||||
continue
|
||||
}
|
||||
seen[variant] = struct{}{}
|
||||
replacements = append(replacements, replacement{value: variant, alias: alias})
|
||||
}
|
||||
}
|
||||
add(req.DelegateInputsPath, "inputs")
|
||||
add(req.DelegateOutputsPath, "outputs")
|
||||
if root := filepath.Dir(req.DelegateInputsPath); root == filepath.Dir(req.DelegateOutputsPath) {
|
||||
add(root, "delegation exchange")
|
||||
}
|
||||
sort.Slice(replacements, func(i, j int) bool {
|
||||
return len(replacements[i].value) > len(replacements[j].value)
|
||||
})
|
||||
pairs := make([]string, 0, len(replacements)*2)
|
||||
for _, replacement := range replacements {
|
||||
pairs = append(pairs, replacement.value, replacement.alias)
|
||||
}
|
||||
replacer := strings.NewReplacer(pairs...)
|
||||
return replacer.Replace
|
||||
}
|
||||
|
||||
func withDelegationArtifactTextRedactor(ctx context.Context, req *RunRequest) context.Context {
|
||||
return tracing.WithTextRedactor(ctx, delegationArtifactTextRedactor(req))
|
||||
}
|
||||
|
||||
func redactDelegationAgentEvent(req *RunRequest, event AgentEvent) AgentEvent {
|
||||
redactor := delegationArtifactTextRedactor(req)
|
||||
event.Payload = tracing.RedactValueWith(redactor, event.Payload)
|
||||
return event
|
||||
}
|
||||
|
||||
func redactDelegationMessage(req *RunRequest, message providers.Message) providers.Message {
|
||||
redactor := delegationArtifactTextRedactor(req)
|
||||
if redactor == nil {
|
||||
return message
|
||||
}
|
||||
message.Content = redactor(message.Content)
|
||||
message.Thinking = redactor(message.Thinking)
|
||||
if len(message.RawAssistantContent) > 0 {
|
||||
message.RawAssistantContent = json.RawMessage(redactor(string(message.RawAssistantContent)))
|
||||
}
|
||||
for i := range message.ToolCalls {
|
||||
if arguments, ok := tracing.RedactValueWith(redactor, message.ToolCalls[i].Arguments).(map[string]any); ok {
|
||||
message.ToolCalls[i].Arguments = arguments
|
||||
}
|
||||
message.ToolCalls[i].ParseError = redactor(message.ToolCalls[i].ParseError)
|
||||
}
|
||||
// Pre-publication paths are ephemeral and cannot be durable session media.
|
||||
message.MediaRefs = nil
|
||||
return message
|
||||
}
|
||||
|
||||
func redactDelegationRunResult(req *RunRequest, result *RunResult) *RunResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
redactor := delegationArtifactTextRedactor(req)
|
||||
if redactor == nil {
|
||||
return result
|
||||
}
|
||||
result.Content = redactor(result.Content)
|
||||
result.Thinking = redactor(result.Thinking)
|
||||
result.LastBlockReply = redactor(result.LastBlockReply)
|
||||
for i := range result.Deliverables {
|
||||
result.Deliverables[i] = redactor(result.Deliverables[i])
|
||||
}
|
||||
// The outer artifact publisher is the only egress path.
|
||||
result.Media = nil
|
||||
return result
|
||||
}
|
||||
@@ -27,6 +27,32 @@ type contextSetupResult struct {
|
||||
// values needed by the agent loop and tool execution. Also runs input guard and message
|
||||
// truncation. Returns error only if input guard blocks the message.
|
||||
func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetupResult, error) {
|
||||
isArtifactDelegation := req.RunKind == "delegate"
|
||||
|
||||
// A nested run must not inherit filesystem, Team, media, or delegation
|
||||
// authority from its caller. Install explicit empty values before resolving
|
||||
// this run's own scope.
|
||||
ctx = store.WithRunContext(ctx, nil)
|
||||
ctx = tools.WithToolWorkspace(ctx, "")
|
||||
ctx = tools.WithToolTeamWorkspace(ctx, "")
|
||||
ctx = tools.WithToolTeamRoot(ctx, "")
|
||||
ctx = tools.WithToolTeamID(ctx, "")
|
||||
ctx = tools.WithTeamTaskID(ctx, "")
|
||||
ctx = tools.WithLeaderAgentID(ctx, "")
|
||||
ctx = tools.WithTenantAllowedPaths(ctx, nil)
|
||||
ctx = tools.WithWorkspaceChannel(ctx, "")
|
||||
ctx = tools.WithWorkspaceChatID(ctx, "")
|
||||
ctx = tools.WithDelegationID(ctx, "")
|
||||
ctx = tools.WithDelegationArtifactInputs(ctx, "")
|
||||
ctx = tools.WithRunKind(ctx, "")
|
||||
ctx = tools.WithRunMediaPaths(ctx, nil)
|
||||
ctx = tools.WithRunMediaNames(ctx, nil)
|
||||
ctx = tools.WithMediaImages(ctx, nil)
|
||||
ctx = tools.WithMediaImageRefs(ctx, nil)
|
||||
ctx = tools.WithMediaDocRefs(ctx, nil)
|
||||
ctx = tools.WithMediaAudioRefs(ctx, nil)
|
||||
ctx = tools.WithMediaVideoRefs(ctx, nil)
|
||||
|
||||
// Inject agent UUID + key into context for tool routing
|
||||
if l.agentUUID != uuid.Nil {
|
||||
ctx = store.WithAgentID(ctx, l.agentUUID)
|
||||
@@ -87,7 +113,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
|
||||
}
|
||||
// Inject tenant-specific allowed paths for filesystem tools.
|
||||
if len(l.tenantAllowedPaths) > 0 {
|
||||
if !isArtifactDelegation && len(l.tenantAllowedPaths) > 0 {
|
||||
ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths)
|
||||
}
|
||||
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
|
||||
@@ -150,6 +176,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
if req.DelegationID != "" {
|
||||
ctx = tools.WithDelegationID(ctx, req.DelegationID)
|
||||
}
|
||||
if req.RunKind != "" {
|
||||
ctx = tools.WithRunKind(ctx, req.RunKind)
|
||||
}
|
||||
|
||||
// --- Per-user setup: file seeding + workspace resolution ---
|
||||
// Uses userSetups sync.Map to track both concerns atomically per user.
|
||||
@@ -163,7 +192,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
// Layer order: tenant → team → project (future) → user/chat
|
||||
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
|
||||
// Result is always a single folder set via WithToolWorkspace.
|
||||
if l.workspace != "" && req.UserID != "" {
|
||||
if !isArtifactDelegation && l.workspace != "" && req.UserID != "" {
|
||||
ws := setup.workspace
|
||||
if ws == "" {
|
||||
ws = l.workspace
|
||||
@@ -197,12 +226,21 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
effectiveWorkspace = l.workspace
|
||||
}
|
||||
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
|
||||
} else if l.workspace != "" {
|
||||
} else if !isArtifactDelegation && l.workspace != "" {
|
||||
ctx = tools.WithToolWorkspace(ctx, l.workspace)
|
||||
}
|
||||
|
||||
if isArtifactDelegation {
|
||||
if req.TeamWorkspace != "" ||
|
||||
!validateDelegationArtifactWorkspace(req.DelegationID, req.DelegateInputsPath, req.DelegateOutputsPath) {
|
||||
return contextSetupResult{}, fmt.Errorf("invalid delegation artifact workspace")
|
||||
}
|
||||
ctx = tools.WithDelegationArtifactInputs(ctx, req.DelegateInputsPath)
|
||||
ctx = tools.WithToolWorkspace(ctx, req.DelegateOutputsPath)
|
||||
}
|
||||
|
||||
// Team workspace: dispatched task overrides default workspace.
|
||||
if req.TeamWorkspace != "" {
|
||||
if !isArtifactDelegation && req.TeamWorkspace != "" {
|
||||
if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil {
|
||||
// See note above on loop_context user workspace fallback. A broken
|
||||
// req.TeamWorkspace would otherwise become cmd.Dir and surface as
|
||||
@@ -214,7 +252,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
|
||||
}
|
||||
}
|
||||
if req.TeamID != "" {
|
||||
if !isArtifactDelegation && req.TeamID != "" {
|
||||
ctx = tools.WithToolTeamID(ctx, req.TeamID)
|
||||
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
|
||||
// so the dispatched agent can still read peer-scoped files in the same team.
|
||||
@@ -228,7 +266,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
|
||||
}
|
||||
}
|
||||
if req.LeaderAgentID != "" {
|
||||
if !isArtifactDelegation && req.LeaderAgentID != "" {
|
||||
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
|
||||
}
|
||||
|
||||
@@ -237,14 +275,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
var resolvedTeamSettings json.RawMessage
|
||||
// Dispatched tasks already have TeamWorkspace set but still need team settings
|
||||
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
|
||||
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
|
||||
if !isArtifactDelegation && req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
|
||||
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
|
||||
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
|
||||
resolvedTeamSettings = team.Settings
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if !isArtifactDelegation && req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
|
||||
resolvedTeamSettings = team.Settings
|
||||
wsChat := req.ChatID
|
||||
@@ -285,7 +323,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
}
|
||||
|
||||
// V3 workspace: resolve once, set immutable context.
|
||||
{
|
||||
if isArtifactDelegation {
|
||||
ctx = workspace.WithContext(ctx, &workspace.WorkspaceContext{
|
||||
ActivePath: req.DelegateOutputsPath,
|
||||
Scope: workspace.ScopeDelegate,
|
||||
MemoryScope: "user",
|
||||
KGScope: "user",
|
||||
OwnerID: req.UserID,
|
||||
EnforcementLabel: workspace.DefaultEnforcementLabel(workspace.ScopeDelegate, false),
|
||||
})
|
||||
} else {
|
||||
var teamIDPtr *string
|
||||
if req.TeamID != "" {
|
||||
teamIDPtr = &req.TeamID
|
||||
@@ -386,6 +433,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
}
|
||||
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
|
||||
credUserID := store.ExplicitCredentialUserIDFromContext(ctx)
|
||||
tenantAllowedPaths := l.tenantAllowedPaths
|
||||
if isArtifactDelegation {
|
||||
tenantAllowedPaths = nil
|
||||
}
|
||||
rc := &store.RunContext{
|
||||
AgentID: l.agentUUID,
|
||||
AgentKey: l.id,
|
||||
@@ -419,9 +470,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
WorkspaceChatID: effectiveWorkspaceChatID,
|
||||
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
|
||||
TeamTaskID: req.TeamTaskID,
|
||||
DelegationID: req.DelegationID,
|
||||
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
|
||||
AgentToolKey: l.id,
|
||||
TenantAllowedPaths: l.tenantAllowedPaths,
|
||||
TenantAllowedPaths: tenantAllowedPaths,
|
||||
}
|
||||
ctx = store.WithRunContext(ctx, rc)
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/workspace"
|
||||
)
|
||||
|
||||
func newArtifactRunRequest(t *testing.T, root string) *RunRequest {
|
||||
t.Helper()
|
||||
delegationID := uuid.NewString()
|
||||
exchange := filepath.Join(root, "collaboration", "delegations", delegationID)
|
||||
inputs := filepath.Join(exchange, "inputs")
|
||||
outputs := filepath.Join(exchange, "outputs")
|
||||
if err := os.MkdirAll(inputs, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(outputs, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &RunRequest{
|
||||
SessionKey: "agent:worker:delegate:test",
|
||||
RunKind: "delegate",
|
||||
DelegationID: delegationID,
|
||||
DelegateInputsPath: inputs,
|
||||
DelegateOutputsPath: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func newArtifactTestLoop(root string) *Loop {
|
||||
return NewLoop(LoopConfig{
|
||||
ID: "worker",
|
||||
AgentUUID: uuid.New(),
|
||||
TenantID: store.MasterTenantID,
|
||||
Workspace: filepath.Join(root, "agents", "worker"),
|
||||
DataDir: root,
|
||||
Sessions: &nopSessionStore{},
|
||||
})
|
||||
}
|
||||
|
||||
func TestInjectContext_UsesOnlyDelegationArtifactWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
req := newArtifactRunRequest(t, root)
|
||||
parentCtx := tools.WithToolTeamWorkspace(context.Background(), filepath.Join(root, "teams", "stale"))
|
||||
parentCtx = tools.WithToolTeamID(parentCtx, uuid.NewString())
|
||||
parentCtx = tools.WithTenantAllowedPaths(parentCtx, []string{filepath.Join(root, "tenant-allowed")})
|
||||
parentCtx = tools.WithDelegationArtifactInputs(parentCtx, filepath.Join(root, "stale-inputs"))
|
||||
parentCtx = tools.WithRunMediaPaths(parentCtx, []string{"/stale/upload.txt"})
|
||||
|
||||
setup, err := newArtifactTestLoop(root).injectContext(parentCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("injectContext: %v", err)
|
||||
}
|
||||
if got := tools.ToolWorkspaceFromCtx(setup.ctx); got != req.DelegateOutputsPath {
|
||||
t.Fatalf("workspace = %q, want outputs %q", got, req.DelegateOutputsPath)
|
||||
}
|
||||
if got := tools.DelegationArtifactInputsFromCtx(setup.ctx); got != req.DelegateInputsPath {
|
||||
t.Fatalf("inputs = %q, want %q", got, req.DelegateInputsPath)
|
||||
}
|
||||
if got := tools.ToolTeamWorkspaceFromCtx(setup.ctx); got != "" {
|
||||
t.Fatalf("parent Team workspace survived: %q", got)
|
||||
}
|
||||
if got := tools.ToolTeamIDFromCtx(setup.ctx); got != "" {
|
||||
t.Fatalf("parent Team ID survived: %q", got)
|
||||
}
|
||||
if got := tools.TenantAllowedPathsFromCtx(setup.ctx); len(got) != 0 {
|
||||
t.Fatalf("parent tenant allowed paths survived: %v", got)
|
||||
}
|
||||
if got := tools.RunMediaPathsFromCtx(setup.ctx); len(got) != 0 {
|
||||
t.Fatalf("parent media paths survived: %v", got)
|
||||
}
|
||||
wc := workspace.FromContext(setup.ctx)
|
||||
if wc == nil || wc.Scope != workspace.ScopeDelegate || wc.ActivePath != req.DelegateOutputsPath {
|
||||
t.Fatalf("workspace context = %#v", wc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectContext_DelegationSetupFailsClosed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
req := newArtifactRunRequest(t, root)
|
||||
req.DelegateOutputsPath = filepath.Join(root, "missing")
|
||||
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
|
||||
t.Fatal("missing artifact workspace fell back to personal workspace")
|
||||
}
|
||||
|
||||
outside := filepath.Join(root, "outside")
|
||||
if err := os.MkdirAll(outside, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "output-link")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.DelegateOutputsPath = link
|
||||
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
|
||||
t.Fatal("symlink artifact workspace fell back to personal workspace")
|
||||
}
|
||||
|
||||
req = newArtifactRunRequest(t, root)
|
||||
req.DelegationID = uuid.NewString()
|
||||
if _, err := newArtifactTestLoop(root).injectContext(context.Background(), req); err == nil {
|
||||
t.Fatal("exchange path not bound to delegation ID was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectContext_DelegatePreservesAuthorizationScope(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
req := newArtifactRunRequest(t, root)
|
||||
req.UserID = "group:telegram:-100123"
|
||||
req.SenderID = "386246614"
|
||||
req.Role = "viewer"
|
||||
req.Channel = "delegate"
|
||||
req.ChannelType = "telegram"
|
||||
req.ChatID = "-100123"
|
||||
req.PeerKind = "group"
|
||||
req.WorkspaceChannel = "telegram-main"
|
||||
req.WorkspaceChatID = "-100123"
|
||||
|
||||
setup, err := newArtifactTestLoop(root).injectContext(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("injectContext: %v", err)
|
||||
}
|
||||
if got := store.UserIDFromContext(setup.ctx); got != req.UserID {
|
||||
t.Fatalf("UserID = %q, want %q", got, req.UserID)
|
||||
}
|
||||
if got := store.SenderIDFromContext(setup.ctx); got != req.SenderID {
|
||||
t.Fatalf("SenderID = %q, want %q", got, req.SenderID)
|
||||
}
|
||||
if got := store.RoleFromContext(setup.ctx); got != req.Role {
|
||||
t.Fatalf("Role = %q, want %q", got, req.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectContext_TeamDispatchKeepsSharedWorkspaceContract(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
teamWorkspace := filepath.Join(root, "teams", "team-a", "shared")
|
||||
if err := os.MkdirAll(teamWorkspace, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &RunRequest{
|
||||
SessionKey: "agent:worker:team:test",
|
||||
TeamWorkspace: teamWorkspace,
|
||||
TeamID: uuid.NewString(),
|
||||
TeamTaskID: uuid.NewString(),
|
||||
LeaderAgentID: uuid.NewString(),
|
||||
}
|
||||
|
||||
setup, err := newArtifactTestLoop(root).injectContext(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("injectContext: %v", err)
|
||||
}
|
||||
if got := tools.ToolWorkspaceFromCtx(setup.ctx); got != teamWorkspace {
|
||||
t.Fatalf("active workspace = %q, want Team workspace %q", got, teamWorkspace)
|
||||
}
|
||||
if got := tools.ToolTeamWorkspaceFromCtx(setup.ctx); got != teamWorkspace {
|
||||
t.Fatalf("Team workspace = %q, want %q", got, teamWorkspace)
|
||||
}
|
||||
if got := tools.ToolTeamIDFromCtx(setup.ctx); got != req.TeamID {
|
||||
t.Fatalf("Team ID = %q, want %q", got, req.TeamID)
|
||||
}
|
||||
if got := tools.TeamTaskIDFromCtx(setup.ctx); got != req.TeamTaskID {
|
||||
t.Fatalf("Team task ID = %q, want %q", got, req.TeamTaskID)
|
||||
}
|
||||
if got := tools.DelegationArtifactInputsFromCtx(setup.ctx); got != "" {
|
||||
t.Fatalf("Team dispatch gained artifact inputs: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationArtifactRedactionCoversEventsSessionsAndResults(t *testing.T) {
|
||||
req := newArtifactRunRequest(t, t.TempDir())
|
||||
hostResult := filepath.Join(req.DelegateOutputsPath, "report.txt")
|
||||
hostInput := filepath.Join(req.DelegateInputsPath, "source.txt")
|
||||
|
||||
event := redactDelegationAgentEvent(req, AgentEvent{
|
||||
Payload: map[string]any{
|
||||
"content": "created " + hostResult,
|
||||
"media": []any{hostResult},
|
||||
},
|
||||
})
|
||||
message := redactDelegationMessage(req, providers.Message{
|
||||
Content: "read " + hostInput,
|
||||
Thinking: "write " + hostResult,
|
||||
MediaRefs: []providers.MediaRef{{Path: hostResult}},
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
Arguments: map[string]any{"path": hostResult},
|
||||
}},
|
||||
RawAssistantContent: json.RawMessage(`{"path":` + mustJSONQuote(t, hostResult) + `}`),
|
||||
})
|
||||
result := redactDelegationRunResult(req, &RunResult{
|
||||
Content: "done at " + hostResult,
|
||||
Thinking: hostInput,
|
||||
Deliverables: []string{hostResult},
|
||||
Media: []MediaResult{{Path: hostResult}},
|
||||
})
|
||||
|
||||
encoded, err := json.Marshal([]any{event, message, result})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, hostRoot := range []string{
|
||||
filepath.Dir(req.DelegateInputsPath),
|
||||
req.DelegateInputsPath,
|
||||
req.DelegateOutputsPath,
|
||||
} {
|
||||
if strings.Contains(string(encoded), hostRoot) {
|
||||
t.Fatalf("redacted boundary leaked %q: %s", hostRoot, encoded)
|
||||
}
|
||||
}
|
||||
if len(message.MediaRefs) != 0 || len(result.Media) != 0 {
|
||||
t.Fatalf("ephemeral media survived: message=%#v result=%#v", message.MediaRefs, result.Media)
|
||||
}
|
||||
if !strings.Contains(string(encoded), "inputs/source.txt") ||
|
||||
!strings.Contains(string(encoded), "outputs/report.txt") {
|
||||
t.Fatalf("logical aliases missing: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONQuote(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// isUserFilePopulated checks if USER.md has been filled with actual user data
|
||||
@@ -48,7 +47,7 @@ func (l *Loop) finalizeRun(
|
||||
// BEFORE sanitize strips them. Covers cases where a tool returned its
|
||||
// artifact via the ForLLM MEDIA: prefix but the agent relayed it as plain
|
||||
// text (e.g. PDF from exec/weasyprint, TTS mp3 paths the LLM quotes back).
|
||||
if extracted := extractMediaFromContent(rs.finalContent, tools.ToolWorkspaceFromCtx(ctx)); len(extracted) > 0 {
|
||||
if extracted := extractMediaFromContent(rs.finalContent, l.mediaEgressRoots(ctx)); len(extracted) > 0 {
|
||||
rs.mediaResults = append(rs.mediaResults, extracted...)
|
||||
}
|
||||
|
||||
@@ -67,8 +66,12 @@ func (l *Loop) finalizeRun(
|
||||
rs.finalContent += "\n\n---\n_" + i18n.T(locale, i18n.MsgSkillNudgePostscript) + "_"
|
||||
}
|
||||
|
||||
// 7. Fallback for empty content
|
||||
if rs.finalContent == "" {
|
||||
// 7. Fallback only when there is no other deliverable output. Media-only
|
||||
// runs must remain media-only instead of gaining a visible "..." caption.
|
||||
hasDeliverableOutput := len(rs.mediaResults) > 0 ||
|
||||
len(req.ForwardMedia) > 0 ||
|
||||
req.ContentSuffix != ""
|
||||
if rs.finalContent == "" && !hasDeliverableOutput {
|
||||
if len(rs.asyncToolCalls) > 0 {
|
||||
rs.finalContent = "..."
|
||||
} else {
|
||||
@@ -200,6 +203,9 @@ func (l *Loop) finalizeRun(
|
||||
slog.Info("agent loop: NO_REPLY detected, suppressing delivery",
|
||||
"agent", l.id, "session", req.SessionKey)
|
||||
rs.finalContent = ""
|
||||
if req.ContentSuffix != "" {
|
||||
rs.finalContent = deduplicateMediaSuffix("", req.ContentSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Maybe summarize
|
||||
|
||||
@@ -48,6 +48,14 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
|
||||
)
|
||||
}
|
||||
if tools.IsDelegationArtifactRun(ctx) {
|
||||
promptWorkspace = "."
|
||||
const artifactGuidance = "Delegation workspace: write outputs using ordinary relative paths in the current workspace. Read staged inputs only through inputs/... . Files are returned to the caller only after runtime validation and publication."
|
||||
if extraSystemPrompt != "" {
|
||||
extraSystemPrompt += "\n\n"
|
||||
}
|
||||
extraSystemPrompt += artifactGuidance
|
||||
}
|
||||
|
||||
// Resolve context files once — also detect BOOTSTRAP.md presence.
|
||||
// lightContext: skip loading context files, only inject ExtraSystemPrompt (heartbeat checklist).
|
||||
|
||||
@@ -65,6 +65,9 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
|
||||
var mediaRefs []providers.MediaRef
|
||||
if len(req.Media) > 0 {
|
||||
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
|
||||
if req.RunKind == "delegate" {
|
||||
rehomeDelegatedMediaMessage(messages, req.Media, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
|
||||
}
|
||||
|
||||
// Register persisted text uploads in vault (async, non-blocking).
|
||||
if l.onTextUploaded != nil {
|
||||
@@ -109,46 +112,55 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
|
||||
ctx = l.loadHistoricalImagesForTool(ctx, mediaRefs, messages)
|
||||
}
|
||||
|
||||
// 2b. Collect document MediaRefs (historical + current) for read_document tool.
|
||||
// 2b. Collect image MediaRefs (historical + current) for exact media_id
|
||||
// resolution by read_image and create_image.
|
||||
if imageRefs := collectRefsByKind(messages, mediaRefs, "image"); len(imageRefs) > 0 {
|
||||
ctx = tools.WithMediaImageRefs(ctx, imageRefs)
|
||||
}
|
||||
|
||||
// 2c. Collect document MediaRefs (historical + current) for read_document tool.
|
||||
if docRefs := collectRefsByKind(messages, mediaRefs, "document"); len(docRefs) > 0 {
|
||||
ctx = tools.WithMediaDocRefs(ctx, docRefs)
|
||||
// Enrich the last user message with persisted file paths so skills can access
|
||||
// documents via exec (e.g. pypdf). Only for current-turn refs (just persisted).
|
||||
l.enrichDocumentPaths(messages, mediaRefs)
|
||||
// Enrich the last user message with exact IDs and logical workspace paths.
|
||||
// Only current-turn refs are paired with current-turn tags.
|
||||
l.enrichDocumentPaths(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
|
||||
}
|
||||
|
||||
// 2c. Collect audio MediaRefs (historical + current) for read_audio tool.
|
||||
// 2d. Collect audio MediaRefs (historical + current) for read_audio tool.
|
||||
if audioRefs := collectRefsByKind(messages, mediaRefs, "audio"); len(audioRefs) > 0 {
|
||||
ctx = tools.WithMediaAudioRefs(ctx, audioRefs)
|
||||
l.enrichAudioIDs(messages, mediaRefs)
|
||||
l.enrichAudioIDs(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
|
||||
}
|
||||
|
||||
// 2d. Collect video MediaRefs (historical + current) for read_video tool.
|
||||
// 2e. Collect video MediaRefs (historical + current) for read_video tool.
|
||||
if videoRefs := collectRefsByKind(messages, mediaRefs, "video"); len(videoRefs) > 0 {
|
||||
ctx = tools.WithMediaVideoRefs(ctx, videoRefs)
|
||||
l.enrichVideoIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2e. Enrich <media:image> tags with persisted media IDs so the LLM
|
||||
// 2f. Enrich <media:image> tags with persisted media IDs so the LLM
|
||||
// knows images were received and stored (consistent with audio/video enrichment).
|
||||
l.enrichImageIDs(messages, mediaRefs)
|
||||
l.enrichImageIDs(messages, mediaRefs, tools.ToolWorkspaceFromCtx(ctx))
|
||||
|
||||
// 2e-ii. In file-ref mode, enrich ALL user messages' image tags with file paths.
|
||||
// This enables read_image(path=...) for both current and historical images.
|
||||
if deferToReadImageTool {
|
||||
l.enrichImagePaths(messages)
|
||||
}
|
||||
// 2f-ii. Enrich ALL user messages' image tags with logical file paths.
|
||||
// This upgrades legacy absolute paths and keeps current and historical media
|
||||
// references safe regardless of whether images are also attached inline.
|
||||
l.enrichImagePaths(messages, tools.ToolWorkspaceFromCtx(ctx))
|
||||
|
||||
// 2f. Collect all media file paths for team workspace auto-collect.
|
||||
// 2g. Collect all media file paths for team workspace auto-collect.
|
||||
// When the leader calls team_tasks(create), these paths are copied to the
|
||||
// team workspace so members can access attached files.
|
||||
if len(mediaRefs) > 0 && l.mediaStore != nil {
|
||||
if len(mediaRefs) > 0 {
|
||||
var mediaPaths []string
|
||||
for _, ref := range mediaRefs {
|
||||
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
|
||||
if ref.Path != "" {
|
||||
mediaPaths = append(mediaPaths, ref.Path)
|
||||
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
} else if l.mediaStore != nil {
|
||||
p, err := l.mediaStore.LoadPath(ref.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mediaPaths = append(mediaPaths, p)
|
||||
}
|
||||
}
|
||||
@@ -166,3 +178,70 @@ func (l *Loop) enrichInputMedia(ctx context.Context, req *RunRequest, messages [
|
||||
|
||||
return ctx, messages, mediaRefs
|
||||
}
|
||||
|
||||
// rehomeDelegatedMediaMessage replaces caller-owned attachment paths with the
|
||||
// delegatee-owned persisted copies and ensures every imported attachment has a
|
||||
// media tag. The normal enrichment pass then adds the new IDs and local paths.
|
||||
func rehomeDelegatedMediaMessage(
|
||||
messages []providers.Message,
|
||||
input []bus.MediaFile,
|
||||
refs []providers.MediaRef,
|
||||
workspace string,
|
||||
) {
|
||||
if len(messages) == 0 || len(refs) == 0 {
|
||||
return
|
||||
}
|
||||
lastUser := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
lastUser = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastUser < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
content := messages[lastUser].Content
|
||||
for i := 0; i < len(input) && i < len(refs); i++ {
|
||||
if input[i].Path != "" && refs[i].Path != "" {
|
||||
replacement := logicalWorkspaceMediaPath(workspace, refs[i].Path)
|
||||
if replacement == "" {
|
||||
replacement = filepath.Base(refs[i].Path)
|
||||
}
|
||||
content = strings.ReplaceAll(content, input[i].Path, replacement)
|
||||
}
|
||||
}
|
||||
|
||||
tagCounts := map[string]int{
|
||||
"image": strings.Count(content, "<media:image"),
|
||||
"document": strings.Count(content, "<media:document"),
|
||||
"audio": strings.Count(content, "<media:audio") + strings.Count(content, "<media:voice"),
|
||||
"video": strings.Count(content, "<media:video"),
|
||||
}
|
||||
for _, ref := range refs {
|
||||
tag := ""
|
||||
switch ref.Kind {
|
||||
case "image":
|
||||
tag = "<media:image>"
|
||||
case "document":
|
||||
tag = "<media:document>"
|
||||
case "audio":
|
||||
tag = "<media:audio>"
|
||||
case "video":
|
||||
tag = "<media:video>"
|
||||
}
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if tagCounts[ref.Kind] > 0 {
|
||||
tagCounts[ref.Kind]--
|
||||
continue
|
||||
}
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
content += tag
|
||||
}
|
||||
messages[lastUser].Content = content
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// parseMediaResult extracts a MediaResult from a tool result string containing "MEDIA:" prefix.
|
||||
@@ -94,8 +97,7 @@ func confineToWorkspace(mediaPath, workspace string) (string, bool) {
|
||||
path = filepath.Join(wsRoot, path)
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
info, err := os.Lstat(cleaned)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
if err := tools.ValidateRegularFileForRead(cleaned); err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(cleaned)
|
||||
@@ -109,18 +111,31 @@ func confineToWorkspace(mediaPath, workspace string) (string, bool) {
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
// confineToAnyRoot accepts mediaPath if it is contained by ANY of the allowed
|
||||
// roots (each checked with the hardened confineToWorkspace). Used for the
|
||||
// result.Media egress: a tool's media legitimately lives in the agent
|
||||
// workspace, the team workspace, OR a tenant-allowed path — the same scopes the
|
||||
// producing tools (create_*, send_file, delegate) validate against. A path
|
||||
// outside every root (e.g. /etc/passwd from a prompt-injected path) is rejected,
|
||||
// so the egress guard holds without dropping legitimate cross-workspace media.
|
||||
// confineToAnyRoot accepts an absolute mediaPath if it is contained by any
|
||||
// allowed root. Relative paths are intentionally anchored to the first root,
|
||||
// which is always the active tool workspace; extra roots authorize explicit
|
||||
// paths but never change the meaning of an ambiguous relative path.
|
||||
func confineToAnyRoot(mediaPath string, roots []string) (string, bool) {
|
||||
for _, root := range roots {
|
||||
if len(roots) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if !filepath.IsAbs(mediaPath) {
|
||||
return confineToWorkspace(mediaPath, roots[0])
|
||||
}
|
||||
for i, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
// The active workspace (index 0) may itself be reached through an
|
||||
// operator-managed symlink. Extra read roots are authorization
|
||||
// boundaries and must be real directories, not partner-controlled
|
||||
// symlink aliases to arbitrary locations.
|
||||
if i > 0 {
|
||||
if info, err := os.Lstat(root); err == nil &&
|
||||
(info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if cleaned, ok := confineToWorkspace(mediaPath, root); ok {
|
||||
return cleaned, true
|
||||
}
|
||||
@@ -128,17 +143,43 @@ func confineToAnyRoot(mediaPath string, roots []string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// mediaEgressRoots returns every read-authorized root that may supply outbound
|
||||
// media. The active workspace stays first to preserve relative path semantics.
|
||||
func (l *Loop) mediaEgressRoots(ctx context.Context) []string {
|
||||
tenantAllowedPaths := tools.TenantAllowedPathsFromCtx(ctx)
|
||||
candidates := make([]string, 0, 3+len(tenantAllowedPaths))
|
||||
candidates = append(candidates,
|
||||
tools.ToolWorkspaceFromCtx(ctx),
|
||||
tools.ToolTeamWorkspaceFromCtx(ctx),
|
||||
tools.ToolTeamRootFromCtx(ctx),
|
||||
)
|
||||
candidates = append(candidates, tenantAllowedPaths...)
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
roots := make([]string, 0, len(candidates))
|
||||
for _, root := range candidates {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := filepath.Clean(root)
|
||||
if _, ok := seen[cleaned]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cleaned] = struct{}{}
|
||||
roots = append(roots, cleaned)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// extractMediaFromContent scans text for MEDIA:<path> tokens the LLM may echo
|
||||
// in its final response (e.g. when a tool returned the MEDIA: prefix as plain
|
||||
// text instead of setting Result.Media). Relative paths are resolved against
|
||||
// workspace. Called before sanitize strips the tokens so the attachments are
|
||||
// still delivered.
|
||||
// text instead of setting Result.Media). The first root is the active workspace
|
||||
// used for relative paths; later roots authorize explicit absolute paths.
|
||||
// Called before sanitize strips the tokens so the attachments are delivered.
|
||||
//
|
||||
// Security: only paths accepted by confineToWorkspace are emitted. An LLM cannot
|
||||
// inject attachments pointing at /etc/passwd, a sibling tenant's workspace, or a
|
||||
// hallucinated path — the extractor silently drops them.
|
||||
func extractMediaFromContent(content, workspace string) []MediaResult {
|
||||
if !strings.Contains(content, "MEDIA:") || workspace == "" {
|
||||
// Security: only paths accepted by confineToAnyRoot are emitted.
|
||||
func extractMediaFromContent(content string, roots []string) []MediaResult {
|
||||
if !strings.Contains(content, "MEDIA:") || len(roots) == 0 {
|
||||
return nil
|
||||
}
|
||||
matches := mediaPathPattern.FindAllString(content, -1)
|
||||
@@ -158,7 +199,7 @@ func extractMediaFromContent(content, workspace string) []MediaResult {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
cleaned, ok := confineToWorkspace(path, workspace)
|
||||
cleaned, ok := confineToAnyRoot(path, roots)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// writeTempFile drops a zero-byte file at workspace/relPath, creating dirs.
|
||||
@@ -31,6 +35,12 @@ func TestExtractMediaFromContent(t *testing.T) {
|
||||
audioA := writeTempFile(t, ws, "a.mp3")
|
||||
audioB := writeTempFile(t, ws, "b.mp3")
|
||||
chartPath := writeTempFile(t, ws, "charts/q4.png")
|
||||
partnerRaw := t.TempDir()
|
||||
partner, err := filepath.EvalSymlinks(partnerRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partnerPath := writeTempFile(t, partner, "partner-result.png")
|
||||
|
||||
// Outside-workspace file: should be rejected by containment check.
|
||||
outsideDir := t.TempDir()
|
||||
@@ -38,6 +48,14 @@ func TestExtractMediaFromContent(t *testing.T) {
|
||||
if err := os.WriteFile(outsidePath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hardlinkPath := filepath.Join(partner, "hardlink-result.png")
|
||||
if err := os.Link(outsidePath, hardlinkPath); err != nil {
|
||||
t.Skipf("hardlinks not supported: %v", err)
|
||||
}
|
||||
symlinkRoot := filepath.Join(t.TempDir(), "delegate")
|
||||
if err := os.Symlink(outsideDir, symlinkRoot); err != nil {
|
||||
t.Skipf("symlinks not supported: %v", err)
|
||||
}
|
||||
|
||||
// Symlink inside workspace pointing to outside: must be rejected by
|
||||
// EvalSymlinks-then-Rel containment. Covers the P0 ancestor-symlink
|
||||
@@ -59,7 +77,7 @@ func TestExtractMediaFromContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
workspace string
|
||||
roots []string
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
@@ -73,54 +91,75 @@ func TestExtractMediaFromContent(t *testing.T) {
|
||||
{
|
||||
name: "relative path resolved + exists",
|
||||
content: "MEDIA:deliver/report.pdf",
|
||||
workspace: ws,
|
||||
roots: []string{ws},
|
||||
wantPaths: []string{reportPath},
|
||||
},
|
||||
{
|
||||
name: "multiple tokens deduped",
|
||||
content: "First: MEDIA:a.mp3\nSecond: MEDIA:b.mp3\nAgain: MEDIA:a.mp3",
|
||||
workspace: ws,
|
||||
roots: []string{ws},
|
||||
wantPaths: []string{audioA, audioB},
|
||||
},
|
||||
{
|
||||
name: "markdown wrapped and punctuation stripped",
|
||||
content: `. See "MEDIA:deliver/report.pdf".`,
|
||||
workspace: ws,
|
||||
roots: []string{ws},
|
||||
wantPaths: []string{chartPath, reportPath},
|
||||
},
|
||||
{
|
||||
name: "hallucinated path dropped (file missing)",
|
||||
content: "MEDIA:not-real.pdf",
|
||||
workspace: ws,
|
||||
name: "hallucinated path dropped (file missing)",
|
||||
content: "MEDIA:not-real.pdf",
|
||||
roots: []string{ws},
|
||||
},
|
||||
{
|
||||
name: "path traversal escape blocked",
|
||||
content: "MEDIA:../leak.pdf",
|
||||
workspace: ws,
|
||||
name: "path traversal escape blocked",
|
||||
content: "MEDIA:../leak.pdf",
|
||||
roots: []string{ws},
|
||||
},
|
||||
{
|
||||
name: "absolute path outside workspace blocked",
|
||||
content: "MEDIA:" + outsidePath,
|
||||
workspace: ws,
|
||||
name: "absolute path outside workspace blocked",
|
||||
content: "MEDIA:" + outsidePath,
|
||||
roots: []string{ws},
|
||||
},
|
||||
{
|
||||
name: "absolute collaboration path allowed",
|
||||
content: "MEDIA:" + partnerPath,
|
||||
roots: []string{ws, partner},
|
||||
wantPaths: []string{partnerPath},
|
||||
},
|
||||
{
|
||||
name: "hardlink in collaboration path blocked",
|
||||
content: "MEDIA:" + hardlinkPath,
|
||||
roots: []string{ws, partner},
|
||||
},
|
||||
{
|
||||
name: "symlinked collaboration root blocked",
|
||||
content: "MEDIA:" + outsidePath,
|
||||
roots: []string{ws, symlinkRoot},
|
||||
},
|
||||
{
|
||||
name: "relative path does not search collaboration roots",
|
||||
content: "MEDIA:partner-result.png",
|
||||
roots: []string{ws, partner},
|
||||
},
|
||||
{
|
||||
name: "absolute path with no workspace dropped",
|
||||
content: "MEDIA:" + reportPath,
|
||||
},
|
||||
{
|
||||
name: "symlink leaf rejected by Lstat",
|
||||
content: "MEDIA:shortcut-to-leak.pdf",
|
||||
workspace: ws,
|
||||
name: "symlink leaf rejected by Lstat",
|
||||
content: "MEDIA:shortcut-to-leak.pdf",
|
||||
roots: []string{ws},
|
||||
},
|
||||
{
|
||||
name: "ancestor symlink escape blocked (P0)",
|
||||
content: "MEDIA:shared/victim.pdf",
|
||||
workspace: ws,
|
||||
name: "ancestor symlink escape blocked (P0)",
|
||||
content: "MEDIA:shared/victim.pdf",
|
||||
roots: []string{ws},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractMediaFromContent(tt.content, tt.workspace)
|
||||
got := extractMediaFromContent(tt.content, tt.roots)
|
||||
if len(got) != len(tt.wantPaths) {
|
||||
t.Fatalf("count = %d, want %d; got=%+v", len(got), len(tt.wantPaths), got)
|
||||
}
|
||||
@@ -133,6 +172,25 @@ func TestExtractMediaFromContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaEgressRoots(t *testing.T) {
|
||||
ctx := tools.WithToolWorkspace(context.Background(), "/workspace/parent")
|
||||
ctx = tools.WithToolTeamWorkspace(ctx, "/workspace/team/chat")
|
||||
ctx = tools.WithToolTeamRoot(ctx, "/workspace/team")
|
||||
ctx = tools.WithTenantAllowedPaths(ctx, []string{"/workspace/tenant-export"})
|
||||
loop := NewLoop(LoopConfig{})
|
||||
|
||||
got := loop.mediaEgressRoots(ctx)
|
||||
want := []string{
|
||||
"/workspace/parent",
|
||||
"/workspace/team/chat",
|
||||
"/workspace/team",
|
||||
"/workspace/tenant-export",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("mediaEgressRoots() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfineToWorkspace exercises the shared media path-containment boundary
|
||||
// directly. It is the single guard that both feeders of MediaResult.Path rely
|
||||
// on, so a regression here would reopen the outbound-exfiltration hole (H2).
|
||||
@@ -180,6 +238,7 @@ func TestConfineToWorkspace(t *testing.T) {
|
||||
{name: "absolute outside workspace rejected", path: outsidePath, workspace: ws, wantOK: false},
|
||||
{name: "traversal escape rejected", path: "../secret.txt", workspace: ws, wantOK: false},
|
||||
{name: "missing file rejected", path: "nope.pdf", workspace: ws, wantOK: false},
|
||||
{name: "directory rejected", path: "deliver", workspace: ws, wantOK: false},
|
||||
{name: "empty workspace rejected", path: insidePath, workspace: "", wantOK: false},
|
||||
{name: "empty path rejected", path: "", workspace: ws, wantOK: false},
|
||||
{name: "leaf symlink rejected", path: "shortcut.txt", workspace: ws, wantOK: false, symlink: true},
|
||||
|
||||
@@ -45,7 +45,7 @@ func (l *Loop) runViaPipeline(ctx context.Context, req RunRequest) (*RunResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertRunResult(pResult), nil
|
||||
return redactDelegationRunResult(&req, convertRunResult(pResult)), nil
|
||||
}
|
||||
|
||||
// buildPipelineDeps maps Loop fields + methods to PipelineDeps callbacks.
|
||||
@@ -94,7 +94,7 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
|
||||
ResolveContextWindow: l.resolveEffectiveContextWindow,
|
||||
EmitEvent: func(event any) {
|
||||
if ae, ok := event.(AgentEvent); ok {
|
||||
l.emit(ae)
|
||||
l.emit(redactDelegationAgentEvent(req, ae))
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCa
|
||||
event.ChatID = req.ChatID
|
||||
event.SessionKey = req.SessionKey
|
||||
event.TenantID = l.tenantID
|
||||
l.emit(event)
|
||||
l.emit(redactDelegationAgentEvent(req, event))
|
||||
}
|
||||
return pipelineCallbackSet{
|
||||
emitRun: emitRun,
|
||||
@@ -188,7 +188,7 @@ func (l *Loop) makeEnrichMedia(req *RunRequest) func(ctx context.Context, state
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
enrichedCtx, enrichedMsgs, _ := l.enrichInputMedia(ctx, req, msgs)
|
||||
enrichedCtx, enrichedMsgs, currentRefs := l.enrichInputMedia(ctx, req, msgs)
|
||||
// Propagate enriched context (media images/docs/audio/video refs for tools).
|
||||
state.Ctx = enrichedCtx
|
||||
// Update history with enriched messages (media tags, inline images).
|
||||
@@ -196,6 +196,23 @@ func (l *Loop) makeEnrichMedia(req *RunRequest) func(ctx context.Context, state
|
||||
if len(enrichedMsgs) > 1 {
|
||||
state.Messages.SetHistory(enrichedMsgs[1:])
|
||||
}
|
||||
// Preserve the enriched current input for the first session checkpoint.
|
||||
// Inline image bytes stay request-local; durable history stores only the
|
||||
// logical tags plus absolute MediaRefs used internally for exact lookup.
|
||||
if len(currentRefs) > 0 {
|
||||
for i := len(enrichedMsgs) - 1; i >= 0; i-- {
|
||||
if enrichedMsgs[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
req.enrichedInputMessage = providers.Message{
|
||||
Role: "user",
|
||||
Content: enrichedMsgs[i].Content,
|
||||
MediaRefs: append([]providers.MediaRef(nil), currentRefs...),
|
||||
}
|
||||
req.hasEnrichedInputMessage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -365,6 +382,12 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c
|
||||
chatReq.Options[providers.OptPeerKind] = req.PeerKind
|
||||
chatReq.Options[providers.OptLocalKey] = req.LocalKey
|
||||
chatReq.Options[providers.OptWorkspace] = tools.ToolWorkspaceFromCtx(ctx)
|
||||
if delegationID := tools.DelegationIDFromCtx(ctx); delegationID != "" {
|
||||
chatReq.Options[providers.OptDelegationID] = delegationID
|
||||
}
|
||||
if inputs := tools.DelegationArtifactInputsFromCtx(ctx); inputs != "" {
|
||||
chatReq.Options[providers.OptDelegationInputs] = inputs
|
||||
}
|
||||
// Pass the policy-filtered allowed tool set so the Claude CLI provider
|
||||
// can restrict its native built-in tools (Bash, Edit, Write, Read,
|
||||
// WebFetch, WebSearch) to what the agent's tool policy actually allows.
|
||||
@@ -706,13 +729,17 @@ func (l *Loop) makeFlushMessages(req *RunRequest) func(ctx context.Context, sess
|
||||
return func(ctx context.Context, sessionKey string, msgs []providers.Message) error {
|
||||
if !userMsgFlushed && !req.HideInput && req.Message != "" {
|
||||
userMsgFlushed = true
|
||||
l.sessions.AddMessage(ctx, sessionKey, providers.Message{
|
||||
inputMessage := providers.Message{
|
||||
Role: "user",
|
||||
Content: req.Message,
|
||||
})
|
||||
}
|
||||
if req.hasEnrichedInputMessage {
|
||||
inputMessage = req.enrichedInputMessage
|
||||
}
|
||||
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, inputMessage))
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
l.sessions.AddMessage(ctx, sessionKey, msg)
|
||||
l.sessions.AddMessage(ctx, sessionKey, redactDelegationMessage(req, msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,32 @@ func (p finalThinkingStreamProvider) ChatStream(context.Context, providers.ChatR
|
||||
func (p finalThinkingStreamProvider) DefaultModel() string { return "test-model" }
|
||||
func (p finalThinkingStreamProvider) Name() string { return "test-provider" }
|
||||
|
||||
type requestCaptureProvider struct {
|
||||
request providers.ChatRequest
|
||||
}
|
||||
|
||||
func (p *requestCaptureProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
|
||||
p.request = req
|
||||
return &providers.ChatResponse{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (p *requestCaptureProvider) ChatStream(_ context.Context, req providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
||||
p.request = req
|
||||
return &providers.ChatResponse{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (p *requestCaptureProvider) DefaultModel() string { return "test-model" }
|
||||
func (p *requestCaptureProvider) Name() string { return "test-provider" }
|
||||
|
||||
type recordingSessionStore struct {
|
||||
*nopSessionStore
|
||||
added []providers.Message
|
||||
}
|
||||
|
||||
func (s *recordingSessionStore) AddMessage(_ context.Context, _ string, msg providers.Message) {
|
||||
s.added = append(s.added, msg)
|
||||
}
|
||||
|
||||
func TestMakeCallLLM_StreamsFinalThinkingWhenNoThinkingChunkArrives(t *testing.T) {
|
||||
col := &eventCollector{}
|
||||
loop := &Loop{id: "test-agent", onEvent: col.onEvent}
|
||||
@@ -58,6 +88,73 @@ func TestMakeCallLLM_StreamsFinalThinkingWhenNoThinkingChunkArrives(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeCallLLMPropagatesDelegationArtifactBridgeOptions(t *testing.T) {
|
||||
provider := &requestCaptureProvider{}
|
||||
loop := &Loop{id: "target-agent", agentUUID: uuid.New()}
|
||||
req := &RunRequest{RunID: "run-1", SessionKey: "delegate:session"}
|
||||
state := &pipeline.RunState{Provider: provider, Model: "test-model"}
|
||||
ctx := tools.WithToolWorkspace(context.Background(), "/runtime/outputs")
|
||||
ctx = tools.WithDelegationID(ctx, "delegation-id")
|
||||
ctx = tools.WithDelegationArtifactInputs(ctx, "/runtime/inputs")
|
||||
|
||||
if _, err := loop.makeCallLLM(req, func(AgentEvent) {})(ctx, state, providers.ChatRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if provider.request.Options[providers.OptDelegationID] != "delegation-id" ||
|
||||
provider.request.Options[providers.OptDelegationInputs] != "/runtime/inputs" {
|
||||
t.Fatalf("delegation options = %#v", provider.request.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichedInputMediaPersistsForNextTurn(t *testing.T) {
|
||||
sourceDir := t.TempDir()
|
||||
sourcePath := filepath.Join(sourceDir, "photo.png")
|
||||
if err := os.WriteFile(sourcePath, minimalPNG, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
sessions := &recordingSessionStore{nopSessionStore: &nopSessionStore{}}
|
||||
loop := &Loop{sessions: sessions}
|
||||
req := &RunRequest{
|
||||
SessionKey: "session-media",
|
||||
Message: `<media:image url="attachment://photo.png">`,
|
||||
Media: []bus.MediaFile{{
|
||||
Path: sourcePath,
|
||||
MimeType: "image/png",
|
||||
Filename: "photo.png",
|
||||
}},
|
||||
}
|
||||
state := &pipeline.RunState{Messages: pipeline.NewMessageBuffer(providers.Message{Role: "system"})}
|
||||
state.Messages.SetHistory([]providers.Message{{Role: "user", Content: req.Message}})
|
||||
ctx := tools.WithToolWorkspace(context.Background(), workspace)
|
||||
|
||||
if err := loop.makeEnrichMedia(req)(ctx, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := loop.makeFlushMessages(req)(ctx, req.SessionKey, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(sessions.added) != 1 {
|
||||
t.Fatalf("persisted messages = %d, want 1", len(sessions.added))
|
||||
}
|
||||
persisted := sessions.added[0]
|
||||
if len(persisted.MediaRefs) != 1 {
|
||||
t.Fatalf("persisted MediaRefs = %#v, want one image ref", persisted.MediaRefs)
|
||||
}
|
||||
if strings.Contains(persisted.Content, workspace) {
|
||||
t.Fatalf("persisted content leaked workspace path: %q", persisted.Content)
|
||||
}
|
||||
if !strings.Contains(persisted.Content, `path=".uploads/`) {
|
||||
t.Fatalf("persisted content lacks logical image path: %q", persisted.Content)
|
||||
}
|
||||
|
||||
nextTurnRefs := collectRefsByKind([]providers.Message{persisted}, nil, "image")
|
||||
if len(nextTurnRefs) != 1 || nextTurnRefs[0].ID != persisted.MediaRefs[0].ID {
|
||||
t.Fatalf("next-turn refs = %#v, want persisted exact ID", nextTurnRefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCacheOptionsHelpers(t *testing.T) {
|
||||
tenantID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
agentID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
@@ -384,6 +384,6 @@ func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) {
|
||||
event.Channel = req.Channel
|
||||
event.ChatID = req.ChatID
|
||||
event.TenantID = l.tenantID
|
||||
l.emit(event)
|
||||
l.emit(redactDelegationAgentEvent(req, event))
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
@@ -72,6 +74,44 @@ func newTestLoopForToolCallbacks(onEvent func(AgentEvent)) *Loop {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessToolResultSuppressesUnpublishedArtifactMediaBeforeEvent(t *testing.T) {
|
||||
col := &eventCollector{}
|
||||
loop := newTestLoopForToolCallbacks(col.onEvent)
|
||||
req := &RunRequest{RunID: "run-artifact"}
|
||||
ctx := tools.WithDelegationID(context.Background(), "delegation-1")
|
||||
ctx = tools.WithDelegationArtifactInputs(ctx, "/runtime/delegations/delegation-1/inputs")
|
||||
result := &tools.Result{
|
||||
ForLLM: "created image\nMEDIA:/runtime/delegations/delegation-1/outputs/image.png",
|
||||
Media: []bus.MediaFile{{Path: "/runtime/delegations/delegation-1/outputs/image.png"}},
|
||||
}
|
||||
|
||||
toolMsg, _, _ := loop.processToolResult(
|
||||
ctx,
|
||||
&runState{},
|
||||
req,
|
||||
col.onEvent,
|
||||
providers.ToolCall{ID: "tc-artifact", Name: "create_image"},
|
||||
"create_image",
|
||||
result,
|
||||
false,
|
||||
)
|
||||
|
||||
if strings.Contains(toolMsg.Content, "MEDIA:") || len(result.Media) != 0 {
|
||||
t.Fatalf("unpublished media survived result policy: message=%q media=%#v", toolMsg.Content, result.Media)
|
||||
}
|
||||
events := col.filter(protocol.AgentEventToolResult)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("tool result events = %d, want 1", len(events))
|
||||
}
|
||||
payload, ok := events[0].Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T", events[0].Payload)
|
||||
}
|
||||
if value, _ := payload["result"].(string); strings.Contains(value, "MEDIA:") {
|
||||
t.Fatalf("event leaked unpublished media marker: %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMakeExecuteToolCall_EmitsToolCallEvent verifies the sequential wrapper
|
||||
// emits a tool.call event before running tool I/O.
|
||||
func TestMakeExecuteToolCall_EmitsToolCallEvent(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
l.activeRuns.Add(1)
|
||||
defer l.activeRuns.Add(-1)
|
||||
ctx = withDelegationArtifactTextRedactor(ctx, &req)
|
||||
|
||||
// Per-run emit wrapper: enriches every AgentEvent with delegation + routing context.
|
||||
emitRun := func(event AgentEvent) {
|
||||
@@ -32,7 +33,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
event.ChatID = req.ChatID
|
||||
event.SessionKey = req.SessionKey
|
||||
event.TenantID = store.TenantIDFromContext(ctx)
|
||||
l.emit(event)
|
||||
l.emit(redactDelegationAgentEvent(&req, event))
|
||||
}
|
||||
|
||||
emitRun(AgentEvent{
|
||||
@@ -75,7 +76,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
UserID: req.UserID,
|
||||
Channel: req.Channel,
|
||||
Name: traceName,
|
||||
InputPreview: truncateStr(req.Message, l.traceCollector.PreviewMaxLen()),
|
||||
InputPreview: tracing.RedactText(ctx, truncateStr(req.Message, l.traceCollector.PreviewMaxLen())),
|
||||
Status: store.TraceStatusRunning,
|
||||
StartTime: now,
|
||||
CreatedAt: now,
|
||||
@@ -140,7 +141,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
l.emitAgentSpanEnd(safeCtx, agentSpanID, runStart, nil, context.Canceled)
|
||||
}
|
||||
l.traceCollector.FinishTrace(safeCtx, traceID, store.TraceStatusError,
|
||||
"trace finalized by safety net (likely panic or goroutine leak)", "")
|
||||
tracing.RedactText(safeCtx, "trace finalized by safety net (likely panic or goroutine leak)"), "")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -195,7 +196,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
traceCtx = context.WithoutCancel(ctx)
|
||||
traceStatus = store.TraceStatusCancelled
|
||||
}
|
||||
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "")
|
||||
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, tracing.RedactText(traceCtx, err.Error()), "")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,7 +244,8 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
|
||||
traceFinalized = true
|
||||
if result != nil {
|
||||
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, l.traceCollector.PreviewMaxLen()))
|
||||
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "",
|
||||
tracing.RedactText(ctx, truncateStr(result.Content, l.traceCollector.PreviewMaxLen())))
|
||||
} else {
|
||||
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", "")
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ func (l *Loop) processToolResult(
|
||||
hadBootstrap bool,
|
||||
) (toolMsg providers.Message, warningMsgs []providers.Message, action toolResultAction) {
|
||||
|
||||
// Agent Link outputs are not valid outbound media until the runtime has
|
||||
// validated and atomically published the delegation manifest. Apply this
|
||||
// before logs, events, loop detection, or message construction so no
|
||||
// pre-publication MEDIA marker escapes through observability surfaces.
|
||||
tools.ApplyDelegationArtifactResultPolicy(ctx, result)
|
||||
|
||||
// Record for loop detection.
|
||||
argsHash := rs.loopDetector.record(registryName, tc.Arguments)
|
||||
rs.loopDetector.recordResult(argsHash, result.ForLLM)
|
||||
@@ -85,6 +91,7 @@ func (l *Loop) processToolResult(
|
||||
|
||||
// Collect MEDIA: paths from tool results.
|
||||
// Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates.
|
||||
mediaRoots := l.mediaEgressRoots(ctx)
|
||||
if len(result.Media) > 0 {
|
||||
// Egress containment: a tool that sets result.Media[].Path to a path
|
||||
// outside every allowed scope (e.g. /etc/passwd from a prompt-injected
|
||||
@@ -94,10 +101,6 @@ func (l *Loop) processToolResult(
|
||||
// to — agent workspace, team workspace, and tenant-allowed paths — so a
|
||||
// cross-workspace file (e.g. a teammate-produced file in the shared team
|
||||
// workspace, or a synchronous delegatee's output) is not wrongly dropped.
|
||||
mediaRoots := append([]string{
|
||||
tools.ToolWorkspaceFromCtx(ctx),
|
||||
tools.ToolTeamWorkspaceFromCtx(ctx),
|
||||
}, l.tenantAllowedPaths...)
|
||||
for i, mf := range result.Media {
|
||||
cleaned, ok := confineToAnyRoot(mf.Path, mediaRoots)
|
||||
if !ok {
|
||||
@@ -123,7 +126,7 @@ func (l *Loop) processToolResult(
|
||||
// Telegram sendDocument). A malicious or buggy tool emitting
|
||||
// MEDIA:/etc/passwd is dropped here — fixing every channel at the source
|
||||
// rather than per-channel. Mirrors extractMediaFromContent containment.
|
||||
if cleaned, ok := confineToWorkspace(mr.Path, tools.ToolWorkspaceFromCtx(ctx)); ok {
|
||||
if cleaned, ok := confineToAnyRoot(mr.Path, mediaRoots); ok {
|
||||
mr.Path = cleaned
|
||||
rs.mediaResults = append(rs.mediaResults, *mr)
|
||||
} else {
|
||||
|
||||
@@ -152,7 +152,7 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
|
||||
}
|
||||
}
|
||||
|
||||
collector.EmitSpan(span)
|
||||
collector.EmitSpan(tracing.RedactSpan(ctx, span))
|
||||
return spanID
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
|
||||
updates["metadata"] = spanMetadata
|
||||
}
|
||||
|
||||
collector.EmitSpanUpdate(spanID, traceID, updates)
|
||||
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,7 +289,7 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
|
||||
span.TenantID = store.MasterTenantID
|
||||
}
|
||||
|
||||
collector.EmitSpan(span)
|
||||
collector.EmitSpan(tracing.RedactSpan(ctx, span))
|
||||
return spanID
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
|
||||
}
|
||||
}
|
||||
|
||||
collector.EmitSpanUpdate(spanID, traceID, updates)
|
||||
collector.EmitSpanUpdate(spanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -401,7 +401,7 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
|
||||
span.TenantID = store.MasterTenantID
|
||||
}
|
||||
|
||||
collector.EmitSpan(span)
|
||||
collector.EmitSpan(tracing.RedactSpan(ctx, span))
|
||||
}
|
||||
|
||||
// emitAgentSpanEnd finalizes the running root agent span with results.
|
||||
@@ -433,7 +433,7 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star
|
||||
// with child spans that directly report model usage.
|
||||
}
|
||||
|
||||
collector.EmitSpanUpdate(agentSpanID, traceID, updates)
|
||||
collector.EmitSpanUpdate(agentSpanID, traceID, tracing.RedactSpanUpdates(ctx, updates))
|
||||
}
|
||||
|
||||
// previewLimitForVerbose returns the preview character limit based on verbose mode.
|
||||
|
||||
@@ -666,11 +666,13 @@ type RunRequest struct {
|
||||
OnTraceCreated func(traceID uuid.UUID)
|
||||
|
||||
// Delegation context (set when running as a delegate agent)
|
||||
DelegationID string // delegation ID for event correlation
|
||||
TeamID string // team ID (if delegation is team-scoped)
|
||||
TeamTaskID string // team task ID (if delegation has an associated task)
|
||||
ParentAgentID string // parent agent key that initiated the delegation
|
||||
LeaderAgentID string // leader agent UUID for member memory read fallback
|
||||
DelegationID string // delegation ID for event correlation
|
||||
DelegateInputsPath string // runtime-only read-only staged inputs root
|
||||
DelegateOutputsPath string // runtime-only writable exchange workspace
|
||||
TeamID string // team ID (if delegation is team-scoped)
|
||||
TeamTaskID string // team task ID (if delegation has an associated task)
|
||||
ParentAgentID string // parent agent key that initiated the delegation
|
||||
LeaderAgentID string // leader agent UUID for member memory read fallback
|
||||
|
||||
// Workspace scope propagation (set by delegation, read by workspace tools)
|
||||
WorkspaceChannel string
|
||||
@@ -678,6 +680,12 @@ type RunRequest struct {
|
||||
// TeamWorkspace overrides the member agent's workspace with the team's workspace
|
||||
// so file operations (read/write/image/audio) use the shared team directory.
|
||||
TeamWorkspace string
|
||||
|
||||
// enrichedInputMessage is populated by the media stage and consumed by the
|
||||
// first persistence checkpoint. It keeps current-turn MediaRefs and logical
|
||||
// tags durable without storing inline image bytes.
|
||||
enrichedInputMessage providers.Message
|
||||
hasEnrichedInputMessage bool
|
||||
}
|
||||
|
||||
// RunResult is the output of a completed agent run.
|
||||
|
||||
+162
-47
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -299,13 +300,23 @@ func copyMediaFile(src, dst string) error {
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// enrichDocumentPaths updates the last user message to include persisted file paths
|
||||
// in <media:document> tags. This allows skills (e.g. pdf skill via exec) to access
|
||||
// the file directly, matching how Claude Code skills work with file paths.
|
||||
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) {
|
||||
// enrichDocumentPaths updates document tags with exact media IDs and
|
||||
// workspace-relative paths. It upgrades historical tags and pairs current refs
|
||||
// with the last user message. Paths outside the active workspace are omitted.
|
||||
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef, workspace string) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade historical tags as they re-enter the prompt. Current-turn refs
|
||||
// are handled below because they are not yet attached to message history.
|
||||
for i := range messages {
|
||||
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
|
||||
continue
|
||||
}
|
||||
messages[i].Content = l.enrichDocumentTagContent(messages[i].Content, messages[i].MediaRefs, workspace)
|
||||
}
|
||||
|
||||
lastIdx := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
@@ -316,41 +327,62 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
|
||||
if lastIdx < 0 {
|
||||
return
|
||||
}
|
||||
messages[lastIdx].Content = l.enrichDocumentTagContent(messages[lastIdx].Content, refs, workspace)
|
||||
}
|
||||
|
||||
content := messages[lastIdx].Content
|
||||
func (l *Loop) enrichDocumentTagContent(content string, refs []providers.MediaRef, workspace string) string {
|
||||
for _, ref := range refs {
|
||||
if ref.Kind != "document" {
|
||||
continue
|
||||
}
|
||||
p := ref.Path
|
||||
if p == "" && l.mediaStore != nil {
|
||||
var err error
|
||||
p, err = l.mediaStore.LoadPath(ref.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
if loaded, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
p = loaded
|
||||
}
|
||||
}
|
||||
if p == "" {
|
||||
logical := logicalWorkspaceMediaPath(workspace, p)
|
||||
|
||||
updateTag := func(tag string) string {
|
||||
tag = setTagAttr(tag, "id", ref.ID)
|
||||
if logical == "" {
|
||||
return removeTagAttr(tag, "path")
|
||||
}
|
||||
return setTagAttr(tag, "path", logical)
|
||||
}
|
||||
|
||||
// Prefer a tag already carrying the exact media ID. This also upgrades
|
||||
// legacy absolute paths without relying on attribute order.
|
||||
var replaced bool
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:document", func(tag string) bool {
|
||||
return tagHasAttrValue(tag, "id", ref.ID)
|
||||
}, updateTag)
|
||||
if replaced {
|
||||
continue
|
||||
}
|
||||
pathAttr := fmt.Sprintf(" path=%q", p)
|
||||
|
||||
// Match first <media:document> without a path — covers bare, named, and file= variants.
|
||||
// Fallback: pair the next tag without an ID with this persisted ref.
|
||||
content, _ = replaceFirstMediaTag(content, "<media:document", func(tag string) bool {
|
||||
return !tagHasAttr(tag, "path")
|
||||
}, func(tag string) string {
|
||||
return appendTagAttrs(tag, pathAttr)
|
||||
})
|
||||
return !tagHasAttr(tag, "id")
|
||||
}, updateTag)
|
||||
}
|
||||
messages[lastIdx].Content = content
|
||||
return content
|
||||
}
|
||||
|
||||
// enrichAudioIDs updates the last user message to embed persisted media IDs
|
||||
// in <media:audio> and <media:voice> tags so the LLM can reference them.
|
||||
func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.MediaRef) {
|
||||
// enrichAudioIDs updates audio/voice tags with exact media IDs and logical
|
||||
// workspace paths. Historical tags are upgraded when they re-enter the prompt.
|
||||
func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.MediaRef, workspace string) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range messages {
|
||||
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
|
||||
continue
|
||||
}
|
||||
messages[i].Content = l.enrichAudioTagContent(messages[i].Content, messages[i].MediaRefs, workspace)
|
||||
}
|
||||
|
||||
lastIdx := -1
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
@@ -361,31 +393,55 @@ func (l *Loop) enrichAudioIDs(messages []providers.Message, refs []providers.Med
|
||||
if lastIdx < 0 {
|
||||
return
|
||||
}
|
||||
messages[lastIdx].Content = l.enrichAudioTagContent(messages[lastIdx].Content, refs, workspace)
|
||||
}
|
||||
|
||||
content := messages[lastIdx].Content
|
||||
func (l *Loop) enrichAudioTagContent(content string, refs []providers.MediaRef, workspace string) string {
|
||||
for _, ref := range refs {
|
||||
if ref.Kind != "audio" {
|
||||
continue
|
||||
}
|
||||
idAttr := fmt.Sprintf(" id=%q", ref.ID)
|
||||
p := ref.Path
|
||||
if p == "" && l.mediaStore != nil {
|
||||
if loaded, err := l.mediaStore.LoadPath(ref.ID); err == nil {
|
||||
p = loaded
|
||||
}
|
||||
}
|
||||
logical := logicalWorkspaceMediaPath(workspace, p)
|
||||
updateTag := func(tag string) string {
|
||||
tag = setTagAttr(tag, "id", ref.ID)
|
||||
if logical == "" {
|
||||
return removeTagAttr(tag, "path")
|
||||
}
|
||||
return setTagAttr(tag, "path", logical)
|
||||
}
|
||||
|
||||
// Upgrade a historical tag already carrying this exact ID first.
|
||||
var replaced bool
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:audio", func(tag string) bool {
|
||||
return !tagHasAttr(tag, "id")
|
||||
}, func(tag string) string {
|
||||
return appendTagAttrs(tag, idAttr)
|
||||
})
|
||||
return tagHasAttrValue(tag, "id", ref.ID)
|
||||
}, updateTag)
|
||||
if replaced {
|
||||
continue
|
||||
}
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
|
||||
return tagHasAttrValue(tag, "id", ref.ID)
|
||||
}, updateTag)
|
||||
if replaced {
|
||||
continue
|
||||
}
|
||||
|
||||
content, _ = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
|
||||
// Pair a current ref with the next unowned audio/voice tag.
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:audio", func(tag string) bool {
|
||||
return !tagHasAttr(tag, "id")
|
||||
}, func(tag string) string {
|
||||
return appendTagAttrs(tag, idAttr)
|
||||
})
|
||||
}, updateTag)
|
||||
if !replaced {
|
||||
content, _ = replaceFirstMediaTag(content, "<media:voice", func(tag string) bool {
|
||||
return !tagHasAttr(tag, "id")
|
||||
}, updateTag)
|
||||
}
|
||||
}
|
||||
messages[lastIdx].Content = content
|
||||
return content
|
||||
}
|
||||
|
||||
// enrichVideoIDs updates the last user message to embed persisted media IDs
|
||||
@@ -422,11 +478,11 @@ func (l *Loop) enrichVideoIDs(messages []providers.Message, refs []providers.Med
|
||||
}
|
||||
|
||||
// enrichImageIDs updates the last user message to embed persisted media IDs
|
||||
// and file paths in <media:image> tags so the LLM knows images were received
|
||||
// and stored. The path attribute allows tools called via MCP bridge (e.g.
|
||||
// and logical workspace paths in <media:image> tags so the LLM knows images
|
||||
// were received and stored. The path attribute allows tools called via MCP bridge (e.g.
|
||||
// claude-cli) to access images via read_image(path=...) even though the
|
||||
// bridge context does not carry WithMediaImages.
|
||||
func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.MediaRef) {
|
||||
func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.MediaRef, workspace string) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -448,8 +504,8 @@ func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.Med
|
||||
}
|
||||
idAttr := fmt.Sprintf(" id=%q", ref.ID)
|
||||
pathAttr := ""
|
||||
if ref.Path != "" {
|
||||
pathAttr = fmt.Sprintf(" path=%q", ref.Path)
|
||||
if logical := logicalWorkspaceMediaPath(workspace, ref.Path); logical != "" {
|
||||
pathAttr = fmt.Sprintf(" path=%q", logical)
|
||||
}
|
||||
|
||||
content, _ = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
|
||||
@@ -465,15 +521,12 @@ func (l *Loop) enrichImageIDs(messages []providers.Message, refs []providers.Med
|
||||
messages[lastIdx].Content = content
|
||||
}
|
||||
|
||||
// enrichImagePaths updates ALL user messages to include persisted file paths
|
||||
// enrichImagePaths updates ALL user messages to include logical workspace paths
|
||||
// in <media:image> tags. This enables the LLM to call read_image(path=...)
|
||||
// to analyze images without inline base64 (saving context tokens).
|
||||
// Unlike enrichImageIDs (last user message only), this enriches ALL messages
|
||||
// so historical images from prior turns are also accessible via file path.
|
||||
func (l *Loop) enrichImagePaths(messages []providers.Message) {
|
||||
if l.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
func (l *Loop) enrichImagePaths(messages []providers.Message, workspace string) {
|
||||
for i := range messages {
|
||||
if messages[i].Role != "user" || len(messages[i].MediaRefs) == 0 {
|
||||
continue
|
||||
@@ -485,7 +538,7 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
|
||||
continue
|
||||
}
|
||||
p := ref.Path
|
||||
if p == "" {
|
||||
if p == "" && l.mediaStore != nil {
|
||||
var err error
|
||||
p, err = l.mediaStore.LoadPath(ref.ID)
|
||||
if err != nil {
|
||||
@@ -495,14 +548,26 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
pathAttr := fmt.Sprintf(" path=%q", p)
|
||||
logical := logicalWorkspaceMediaPath(workspace, p)
|
||||
if logical == "" {
|
||||
var stripped bool
|
||||
content, stripped = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
|
||||
return tagHasAttrValue(tag, "id", ref.ID) && tagHasAttr(tag, "path")
|
||||
}, func(tag string) string {
|
||||
return removeTagAttr(tag, "path")
|
||||
})
|
||||
changed = changed || stripped
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer tags that already carry the matching media ID.
|
||||
// Prefer tags that already carry the matching media ID. Replacing an
|
||||
// existing path also upgrades legacy messages that persisted absolute
|
||||
// host paths before logical media paths became the prompt contract.
|
||||
var replaced bool
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
|
||||
return tagHasAttrValue(tag, "id", ref.ID) && !tagHasAttr(tag, "path")
|
||||
return tagHasAttrValue(tag, "id", ref.ID)
|
||||
}, func(tag string) string {
|
||||
return appendTagAttrs(tag, pathAttr)
|
||||
return setTagAttr(tag, "path", logical)
|
||||
})
|
||||
if replaced {
|
||||
changed = true
|
||||
@@ -513,7 +578,7 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
|
||||
content, replaced = replaceFirstMediaTag(content, "<media:image", func(tag string) bool {
|
||||
return !tagHasAttr(tag, "id")
|
||||
}, func(tag string) string {
|
||||
return appendTagAttrs(tag, fmt.Sprintf(` id=%q`, ref.ID), pathAttr)
|
||||
return appendTagAttrs(tag, fmt.Sprintf(` id=%q`, ref.ID), fmt.Sprintf(` path=%q`, logical))
|
||||
})
|
||||
if replaced {
|
||||
changed = true
|
||||
@@ -525,6 +590,26 @@ func (l *Loop) enrichImagePaths(messages []providers.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// logicalWorkspaceMediaPath converts a persisted host path into the stable path
|
||||
// contract exposed to models. Paths outside the active workspace are omitted.
|
||||
func logicalWorkspaceMediaPath(workspace, mediaPath string) string {
|
||||
if workspace == "" || mediaPath == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(mediaPath) {
|
||||
clean := filepath.Clean(mediaPath)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(clean)
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(mediaPath))
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// mediaKindFromMime returns the media kind ("image", "video", "audio", "document")
|
||||
// based on MIME type prefix.
|
||||
func mediaKindFromMime(mime string) string {
|
||||
@@ -580,6 +665,36 @@ func appendTagAttrs(tag string, attrs ...string) string {
|
||||
return strings.TrimSuffix(tag, ">") + strings.Join(attrs, "") + ">"
|
||||
}
|
||||
|
||||
func setTagAttr(tag, attr, value string) string {
|
||||
prefix := " " + attr + `="`
|
||||
start := strings.Index(tag, prefix)
|
||||
if start < 0 {
|
||||
return appendTagAttrs(tag, fmt.Sprintf(` %s=%q`, attr, value))
|
||||
}
|
||||
valueStart := start + len(prefix)
|
||||
valueEnd := strings.IndexByte(tag[valueStart:], '"')
|
||||
if valueEnd < 0 {
|
||||
return tag
|
||||
}
|
||||
valueEnd += valueStart
|
||||
quoted := strconv.Quote(value)
|
||||
return tag[:valueStart] + quoted[1:len(quoted)-1] + tag[valueEnd:]
|
||||
}
|
||||
|
||||
func removeTagAttr(tag, attr string) string {
|
||||
prefix := " " + attr + `="`
|
||||
start := strings.Index(tag, prefix)
|
||||
if start < 0 {
|
||||
return tag
|
||||
}
|
||||
valueStart := start + len(prefix)
|
||||
valueEnd := strings.IndexByte(tag[valueStart:], '"')
|
||||
if valueEnd < 0 {
|
||||
return tag
|
||||
}
|
||||
return tag[:start] + tag[valueStart+valueEnd+1:]
|
||||
}
|
||||
|
||||
// maxMediaReloadMessages is the default number of recent messages with image MediaRefs
|
||||
// to reload for LLM vision context.
|
||||
const maxMediaReloadMessages = 5
|
||||
|
||||
+216
-31
@@ -1,13 +1,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/media"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
// TestEnrichImageIDs_BareTag verifies enrichment of a bare <media:image> tag
|
||||
@@ -20,15 +23,62 @@ func TestEnrichImageIDs_BareTag(t *testing.T) {
|
||||
refs := []providers.MediaRef{{ID: "img-1", Kind: "image", Path: "/tmp/a.jpg"}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/tmp")
|
||||
|
||||
got := messages[0].Content
|
||||
want := `check <media:image id="img-1" path="/tmp/a.jpg">`
|
||||
want := `check <media:image id="img-1" path="a.jpg">`
|
||||
if got != want {
|
||||
t.Fatalf("bare tag enrichment:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichInputMedia_DelegateRehomesCallerAttachment(t *testing.T) {
|
||||
callerWorkspace := t.TempDir()
|
||||
delegateWorkspace := t.TempDir()
|
||||
source := filepath.Join(callerWorkspace, "reference.jpg")
|
||||
if err := os.WriteFile(source, []byte("reference"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := &RunRequest{
|
||||
SessionKey: "delegate:parent:cppai-media:test",
|
||||
RunKind: "delegate",
|
||||
Media: []bus.MediaFile{{
|
||||
Path: source,
|
||||
MimeType: "image/jpeg",
|
||||
Filename: "reference.jpg",
|
||||
}},
|
||||
}
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: "Use this exact reference: " + source,
|
||||
}}
|
||||
ctx := tools.WithToolWorkspace(context.Background(), delegateWorkspace)
|
||||
|
||||
loop := Loop{id: "cppai-media"}
|
||||
_, gotMessages, refs := loop.enrichInputMedia(ctx, req, messages)
|
||||
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("refs = %#v, want one delegate-owned attachment", refs)
|
||||
}
|
||||
if !strings.HasPrefix(refs[0].Path, filepath.Join(delegateWorkspace, ".uploads")+string(filepath.Separator)) {
|
||||
t.Fatalf("persisted path = %q, want under delegate workspace %q", refs[0].Path, delegateWorkspace)
|
||||
}
|
||||
if strings.Contains(gotMessages[0].Content, source) {
|
||||
t.Fatalf("delegate message still exposes caller path: %q", gotMessages[0].Content)
|
||||
}
|
||||
logicalPath := logicalWorkspaceMediaPath(delegateWorkspace, refs[0].Path)
|
||||
if logicalPath == "" || !strings.Contains(gotMessages[0].Content, logicalPath) {
|
||||
t.Fatalf("delegate message = %q, want logical copied path %q", gotMessages[0].Content, logicalPath)
|
||||
}
|
||||
if strings.Contains(gotMessages[0].Content, delegateWorkspace) {
|
||||
t.Fatalf("delegate message leaked delegate workspace: %q", gotMessages[0].Content)
|
||||
}
|
||||
if !strings.Contains(gotMessages[0].Content, `<media:image`) {
|
||||
t.Fatalf("delegate message = %q, want explicit image attachment tag", gotMessages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
@@ -41,7 +91,7 @@ func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
|
||||
}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/tmp")
|
||||
|
||||
got := messages[0].Content
|
||||
if !strings.Contains(got, `url="https://cdn.discordapp.com/attachments/1/2/photo.jpg"`) {
|
||||
@@ -50,7 +100,7 @@ func TestEnrichImageIDs_PreservesExistingTagAttributes(t *testing.T) {
|
||||
if !strings.Contains(got, `id="image-1"`) {
|
||||
t.Fatalf("expected id attribute to be added, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `path="/tmp/photo.jpg"`) {
|
||||
if !strings.Contains(got, `path="photo.jpg"`) {
|
||||
t.Fatalf("expected path attribute to be added, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +116,7 @@ func TestEnrichImageIDs_SkipsAlreadyEnriched(t *testing.T) {
|
||||
refs := []providers.MediaRef{{ID: "new-id", Kind: "image", Path: "/new/path.jpg"}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/new")
|
||||
|
||||
if messages[0].Content != original {
|
||||
t.Fatalf("already-enriched tag should not be modified:\n got %q\nwant %q", messages[0].Content, original)
|
||||
@@ -111,9 +161,9 @@ func testMediaStore(t *testing.T) *media.Store {
|
||||
return s
|
||||
}
|
||||
|
||||
// TestEnrichImagePaths_NoDoubleEnrich verifies that historical messages with
|
||||
// url+id+path are not re-enriched on subsequent turns.
|
||||
func TestEnrichImagePaths_NoDoubleEnrich(t *testing.T) {
|
||||
// TestEnrichImagePaths_UpgradesLegacyAbsolutePath verifies that historical
|
||||
// host paths are replaced by the current logical workspace contract.
|
||||
func TestEnrichImagePaths_UpgradesLegacyAbsolutePath(t *testing.T) {
|
||||
original := `<media:image url="https://cdn.example.com/photo.jpg" id="img-1" path="/workspace/.uploads/img-1.jpg">`
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
@@ -126,10 +176,69 @@ func TestEnrichImagePaths_NoDoubleEnrich(t *testing.T) {
|
||||
}}
|
||||
|
||||
loop := Loop{mediaStore: testMediaStore(t)}
|
||||
loop.enrichImagePaths(messages)
|
||||
loop.enrichImagePaths(messages, "/workspace")
|
||||
|
||||
if messages[0].Content != original {
|
||||
t.Fatalf("double-enrichment detected:\n got %q\nwant %q", messages[0].Content, original)
|
||||
want := `<media:image url="https://cdn.example.com/photo.jpg" id="img-1" path=".uploads/img-1.jpg">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("legacy path was not upgraded:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichImagePathsEscapesLogicalPathAttribute(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
mediaPath := filepath.Join(workspace, ".uploads", `photo"quoted.jpg`)
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: `<media:image id="img-1" path="/legacy/photo.jpg">`,
|
||||
MediaRefs: []providers.MediaRef{{
|
||||
ID: "img-1",
|
||||
Kind: "image",
|
||||
Path: mediaPath,
|
||||
}},
|
||||
}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImagePaths(messages, workspace)
|
||||
|
||||
want := `<media:image id="img-1" path=".uploads/photo\"quoted.jpg">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("escaped path = %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogicalWorkspaceMediaPathRejectsOutsideWorkspace(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "agent")
|
||||
inside := filepath.Join(workspace, ".uploads", "photo.jpg")
|
||||
outside := filepath.Join(filepath.Dir(workspace), "other-agent", ".uploads", "photo.jpg")
|
||||
|
||||
if got := logicalWorkspaceMediaPath(workspace, inside); got != ".uploads/photo.jpg" {
|
||||
t.Fatalf("inside path = %q, want %q", got, ".uploads/photo.jpg")
|
||||
}
|
||||
if got := logicalWorkspaceMediaPath(workspace, outside); got != "" {
|
||||
t.Fatalf("outside path = %q, want omitted", got)
|
||||
}
|
||||
if got := logicalWorkspaceMediaPath(workspace, "../escape.jpg"); got != "" {
|
||||
t.Fatalf("traversal path = %q, want omitted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichImagePathsStripsOutsideWorkspacePath(t *testing.T) {
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: `<media:image id="img-1" path="/workspace/other-agent/.uploads/photo.jpg">`,
|
||||
MediaRefs: []providers.MediaRef{{
|
||||
ID: "img-1",
|
||||
Kind: "image",
|
||||
Path: "/workspace/other-agent/.uploads/photo.jpg",
|
||||
}},
|
||||
}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImagePaths(messages, "/workspace/current-agent")
|
||||
|
||||
want := `<media:image id="img-1">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("outside workspace path was not stripped: got %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,10 +257,10 @@ func TestEnrichImagePaths_AttributeOrderIndependence(t *testing.T) {
|
||||
}}
|
||||
|
||||
loop := Loop{mediaStore: testMediaStore(t)}
|
||||
loop.enrichImagePaths(messages)
|
||||
loop.enrichImagePaths(messages, "/workspace")
|
||||
|
||||
got := messages[0].Content
|
||||
if !strings.Contains(got, `path="/workspace/.uploads/img-1.jpg"`) {
|
||||
if !strings.Contains(got, `path=".uploads/img-1.jpg"`) {
|
||||
t.Fatalf("expected path to be added regardless of attribute order, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `url="https://cdn.example.com/photo.jpg"`) {
|
||||
@@ -173,9 +282,9 @@ func TestEnrichImageIDs_MultipleRefs(t *testing.T) {
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/tmp")
|
||||
|
||||
want := `first <media:image id="img-a" path="/tmp/a.jpg">` + "\n" + `second <media:image id="img-b" path="/tmp/b.jpg">`
|
||||
want := `first <media:image id="img-a" path="a.jpg">` + "\n" + `second <media:image id="img-b" path="b.jpg">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
@@ -223,53 +332,129 @@ func TestEnrichImagePaths_MultipleRefsKeepTagAlignment(t *testing.T) {
|
||||
|
||||
var loop Loop
|
||||
loop.mediaStore = mediaStore
|
||||
loop.enrichImagePaths(messages)
|
||||
loop.enrichImagePaths(messages, "/persisted")
|
||||
|
||||
want := `first <media:image id="` + idA + `" path="` + pathA + `">` +
|
||||
"\n" + `second <media:image id="` + idB + `" path="` + pathB + `">`
|
||||
want := `first <media:image id="` + idA + `" path="a.jpg">` +
|
||||
"\n" + `second <media:image id="` + idB + `" path="b.jpg">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("enrichImagePaths() content = %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichDocumentPaths_MultipleRefs(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: "first <media:document>\nsecond <media:document>",
|
||||
}}
|
||||
refs := []providers.MediaRef{
|
||||
{ID: "doc-a", Kind: "document", Path: "/tmp/a.pdf"},
|
||||
{ID: "doc-b", Kind: "document", Path: "/tmp/b.pdf"},
|
||||
{ID: "doc-a", Kind: "document", Path: filepath.Join(workspace, ".uploads", "a.pdf")},
|
||||
{ID: "doc-b", Kind: "document", Path: filepath.Join(workspace, ".uploads", "b.pdf")},
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichDocumentPaths(messages, refs)
|
||||
loop.enrichDocumentPaths(messages, refs, workspace)
|
||||
|
||||
want := `first <media:document path="/tmp/a.pdf">` + "\n" + `second <media:document path="/tmp/b.pdf">`
|
||||
want := `first <media:document id="doc-a" path=".uploads/a.pdf">` + "\n" +
|
||||
`second <media:document id="doc-b" path=".uploads/b.pdf">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichDocumentPathsStripsOutsideWorkspacePath(t *testing.T) {
|
||||
workspace := filepath.Join(t.TempDir(), "current-agent")
|
||||
outside := filepath.Join(filepath.Dir(workspace), "other-agent", ".uploads", "secret.pdf")
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: `<media:document name="secret.pdf" path="/legacy/secret.pdf">`,
|
||||
}}
|
||||
refs := []providers.MediaRef{{
|
||||
ID: "doc-1",
|
||||
Kind: "document",
|
||||
Path: outside,
|
||||
}}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichDocumentPaths(messages, refs, workspace)
|
||||
|
||||
want := `<media:document name="secret.pdf" id="doc-1">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("outside workspace path was not stripped: got %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichDocumentPathsUpgradesHistoricalAbsolutePath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
docPath := filepath.Join(workspace, ".uploads", "history.pdf")
|
||||
messages := []providers.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: `<media:document id="doc-old" path="/legacy/history.pdf">`,
|
||||
MediaRefs: []providers.MediaRef{{
|
||||
ID: "doc-old",
|
||||
Kind: "document",
|
||||
Path: docPath,
|
||||
}},
|
||||
},
|
||||
{Role: "user", Content: "follow up"},
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichDocumentPaths(messages, nil, workspace)
|
||||
|
||||
want := `<media:document id="doc-old" path=".uploads/history.pdf">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("historical path was not upgraded: got %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAudioIDs_MultipleRefs(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
Content: "first <media:audio>\nsecond <media:audio>",
|
||||
}}
|
||||
refs := []providers.MediaRef{
|
||||
{ID: "aud-a", Kind: "audio"},
|
||||
{ID: "aud-b", Kind: "audio"},
|
||||
{ID: "aud-a", Kind: "audio", Path: filepath.Join(workspace, ".uploads", "a.mp3")},
|
||||
{ID: "aud-b", Kind: "audio", Path: filepath.Join(workspace, ".uploads", "b.mp3")},
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichAudioIDs(messages, refs)
|
||||
loop.enrichAudioIDs(messages, refs, workspace)
|
||||
|
||||
want := `first <media:audio id="aud-a">` + "\n" + `second <media:audio id="aud-b">`
|
||||
want := `first <media:audio id="aud-a" path=".uploads/a.mp3">` + "\n" +
|
||||
`second <media:audio id="aud-b" path=".uploads/b.mp3">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("multi-ref alignment:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAudioIDsUpgradesHistoricalLogicalPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
audioPath := filepath.Join(workspace, ".uploads", "history.mp3")
|
||||
messages := []providers.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: `<media:voice id="aud-old" path="/legacy/history.mp3">`,
|
||||
MediaRefs: []providers.MediaRef{{
|
||||
ID: "aud-old",
|
||||
Kind: "audio",
|
||||
Path: audioPath,
|
||||
}},
|
||||
},
|
||||
{Role: "user", Content: "follow up"},
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichAudioIDs(messages, nil, workspace)
|
||||
|
||||
want := `<media:voice id="aud-old" path=".uploads/history.mp3">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("historical audio path was not upgraded: got %q, want %q", messages[0].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichVideoIDs_MultipleRefs(t *testing.T) {
|
||||
messages := []providers.Message{{
|
||||
Role: "user",
|
||||
@@ -301,9 +486,9 @@ func TestEnrichImageIDs_MoreRefsThanTags(t *testing.T) {
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/tmp")
|
||||
|
||||
want := `only one <media:image id="img-a" path="/tmp/a.jpg">`
|
||||
want := `only one <media:image id="img-a" path="a.jpg">`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("more refs than tags:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
@@ -320,9 +505,9 @@ func TestEnrichImageIDs_MoreTagsThanRefs(t *testing.T) {
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichImageIDs(messages, refs)
|
||||
loop.enrichImageIDs(messages, refs, "/tmp")
|
||||
|
||||
want := `first <media:image id="img-a" path="/tmp/a.jpg">` + "\n" + `second <media:image>`
|
||||
want := `first <media:image id="img-a" path="a.jpg">` + "\n" + `second <media:image>`
|
||||
if messages[0].Content != want {
|
||||
t.Fatalf("more tags than refs:\n got %q\nwant %q", messages[0].Content, want)
|
||||
}
|
||||
@@ -341,7 +526,7 @@ func TestEnrichAudioIDs_MixedAudioAndVoice(t *testing.T) {
|
||||
}
|
||||
|
||||
var loop Loop
|
||||
loop.enrichAudioIDs(messages, refs)
|
||||
loop.enrichAudioIDs(messages, refs, "")
|
||||
|
||||
want := `hear this <media:audio id="aud-1">` + "\n" + `and this <media:voice id="aud-2">`
|
||||
if messages[0].Content != want {
|
||||
|
||||
@@ -111,4 +111,10 @@ func TestBridgePromptBuilder_OrchestrationTargetContent(t *testing.T) {
|
||||
if !strings.Contains(output, "Does work") {
|
||||
t.Error("output should contain description")
|
||||
}
|
||||
if !strings.Contains(output, "Return generated files or media artifacts to the caller") {
|
||||
t.Error("output should tell delegates to return media artifacts through the caller")
|
||||
}
|
||||
if !strings.Contains(output, "Do not ask the delegatee to send directly to your current chat") {
|
||||
t.Error("output should prevent direct delivery attempts from the delegate channel")
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,6 @@ type WorkspaceSectionData struct {
|
||||
ActivePath string
|
||||
Scope string
|
||||
Enforced bool
|
||||
ReadOnlyPaths []string
|
||||
SharedPath *string
|
||||
ContextFiles []string
|
||||
EnforcementMsg string
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ var coreToolSummaries = map[string]string{
|
||||
"session_status": "Show session status (model, tokens, compaction count)",
|
||||
"sessions_history": "Fetch message history for a session",
|
||||
"sessions_send": "Send a message into another session",
|
||||
"read_image": "Analyze images — call with path from <media:image> tags, or a direct HTTP/HTTPS URL via the 'url' parameter",
|
||||
"read_image": "Analyze images — use the exact logical path from <media:image> tags; media_id and direct HTTP/HTTPS URLs are also supported",
|
||||
"read_audio": "Analyze audio — call with media_id from <media:audio> tags",
|
||||
"read_video": "Analyze video — call with media_id from <media:video> tags, or a direct HTTP/HTTPS URL via the 'url' parameter",
|
||||
"create_video": "Generate videos from text descriptions using AI",
|
||||
@@ -673,7 +673,7 @@ func buildToolingSection(toolNames []string, hasSandbox bool, shellDenyGroups ma
|
||||
lines = append(lines,
|
||||
"",
|
||||
"### Media Files",
|
||||
`When users send media (<media:image path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the path/media_id. For archives (.zip, .tar.gz, etc.), use exec with the document path to inspect/extract the archive.`,
|
||||
`When users send media (<media:image id="..." path="...">, <media:video id="...">, <media:audio id="...">, <media:document path="...">), use the corresponding read_* tool with the exact path/media_id from the tag. Never reconstruct or guess an absolute workspace path. For archives (.zip, .tar.gz, etc.), use exec with the document path to inspect/extract the archive.`,
|
||||
"You have full vision/audio/video capabilities. NEVER say you cannot see images or files.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -771,6 +771,8 @@ func buildOrchestrationSection(data OrchestrationSectionData) []string {
|
||||
lines = append(lines,
|
||||
"",
|
||||
"Use `delegate` with the agent_key of the target agent. Do NOT invent agent keys.",
|
||||
"Return generated files or media artifacts to the caller through the delegate result.",
|
||||
"Do not ask the delegatee to send directly to your current chat; delegated runs use an internal `delegate` channel and the caller handles final delivery.",
|
||||
"",
|
||||
)
|
||||
return lines
|
||||
|
||||
Reference in new issue
Block a user