mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-18 20:18:05 +00:00
Dashboard users (and other tenant-authenticated admin/operator/owner
actors) dispatching team tasks to agents in Telegram/Discord groups
previously hit the empty-sender DENY rule when the assigned agent
tried to write files — because dashboard users have tenant identities
(e.g. "viettx") that don't match the numeric file-writer grants stored
per channel.
Two-part fix:
1. CheckFileWriterPermission / CheckCronPermission add an RBAC bypass
at the store layer: if ctx carries RoleFromContext in {admin,
operator, owner}, skip the per-user grant check. Tenant RBAC already
pre-authenticated these callers at the gateway edge; requiring a
redundant per-channel grant blocks legitimate admin work.
2. Role propagation through the full dispatch chain:
ctx (RoleFromContext)
→ RunRequest.Role
→ SubagentTask.OriginRole / AnnounceMetadata.OriginRole
/ DelegateRequest.Role
→ InboundMessage.Metadata[MetaOriginRole]
→ subagentAnnounceRouting.Role / teammateRole
→ RunRequest.Role (re-ingress)
→ store.WithRole(ctx, ...) in loop_context
Covers subagent announce, delegate announce, teammate dispatch
(WS dashboard, tool), session_send, and processNormalMessage's
synthetic-sender override path. team_tasks_create persists
origin_role for deferred dispatches.
Viewer and empty roles fall through to the existing sender-based path
(no bypass).
Regression tests:
- A.10 AdminRoleBypass: admin/operator/owner in ctx allowed even with
empty sender (normally DENY).
- A.11 ViewerRoleDoesNotBypass: viewer and empty roles still hit DENY.
Builds + integration + unit tests green.
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// DelegateResult carries the delegatee's response content and any media produced.
|
|
type DelegateResult struct {
|
|
Content string
|
|
Media []bus.MediaFile
|
|
}
|
|
|
|
// DelegateRunFunc dispatches a delegation to a target agent.
|
|
// Injected by the gateway to avoid circular dependency with agent package.
|
|
// Returns the delegatee's response content + media, or error.
|
|
type DelegateRunFunc func(ctx context.Context, req DelegateRequest) (DelegateResult, error)
|
|
|
|
// DelegateRequest describes a delegation dispatch.
|
|
type DelegateRequest struct {
|
|
FromAgentID uuid.UUID
|
|
FromAgentKey string
|
|
ToAgentKey string
|
|
Task string
|
|
DelegationID string
|
|
UserID string
|
|
SenderID string // real acting sender preserved through delegate announce re-ingress (#915)
|
|
Role string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
|
|
TenantID string
|
|
Channel string
|
|
ChatID string
|
|
PeerKind string
|
|
SessionKey string
|
|
}
|
|
|
|
// DelegateTool implements the `delegate` tool for inter-agent task delegation.
|
|
// Uses existing agent_links infrastructure for permission checks.
|
|
type DelegateTool struct {
|
|
links store.AgentLinkStore
|
|
agents store.AgentCRUDStore
|
|
eventBus eventbus.DomainEventBus
|
|
runFn DelegateRunFunc
|
|
msgBus *bus.MessageBus // for async announce back to parent
|
|
hookDispatcher hooks.Dispatcher // optional; nil-safe
|
|
}
|
|
|
|
// SetMsgBus sets the message bus for async result delivery to parent agent.
|
|
func (t *DelegateTool) SetMsgBus(mb *bus.MessageBus) { t.msgBus = mb }
|
|
|
|
// SetHookDispatcher sets the hook dispatcher for SubagentStart/Stop events.
|
|
func (t *DelegateTool) SetHookDispatcher(d hooks.Dispatcher) { t.hookDispatcher = d }
|
|
|
|
// NewDelegateTool creates a delegate tool.
|
|
func NewDelegateTool(links store.AgentLinkStore, agents store.AgentCRUDStore, eb eventbus.DomainEventBus, runFn DelegateRunFunc) *DelegateTool {
|
|
return &DelegateTool{links: links, agents: agents, eventBus: eb, runFn: runFn}
|
|
}
|
|
|
|
func (t *DelegateTool) Name() string { return "delegate" }
|
|
|
|
func (t *DelegateTool) Description() string {
|
|
return "Delegate a task to a linked agent. The target agent must be connected via an agent link."
|
|
}
|
|
|
|
func (t *DelegateTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"agent_key": map[string]any{
|
|
"type": "string",
|
|
"description": "The agent_key of the target agent to delegate to",
|
|
},
|
|
"task": map[string]any{
|
|
"type": "string",
|
|
"description": "Description of the task to delegate",
|
|
},
|
|
"mode": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"async", "sync"},
|
|
"description": "async: fire-and-forget (default), sync: wait for completion",
|
|
},
|
|
"timeout": map[string]any{
|
|
"type": "integer",
|
|
"description": "Timeout in seconds for sync mode (default: 300)",
|
|
},
|
|
},
|
|
"required": []string{"agent_key", "task"},
|
|
}
|
|
}
|
|
|
|
func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
agentKey, _ := args["agent_key"].(string)
|
|
task, _ := args["task"].(string)
|
|
mode, _ := args["mode"].(string)
|
|
if mode == "" {
|
|
mode = "async"
|
|
}
|
|
timeoutSec := 300
|
|
if ts, ok := args["timeout"].(float64); ok && int(ts) > 0 {
|
|
timeoutSec = int(ts)
|
|
}
|
|
if timeoutSec > 600 {
|
|
timeoutSec = 600 // hard cap to prevent resource exhaustion
|
|
}
|
|
|
|
if agentKey == "" || task == "" {
|
|
return ErrorResult("agent_key and task are required")
|
|
}
|
|
|
|
// Resolve calling agent from context
|
|
fromAgentID := store.AgentIDFromContext(ctx)
|
|
if fromAgentID == uuid.Nil {
|
|
return ErrorResult("delegate requires agent context")
|
|
}
|
|
|
|
// Resolve target agent
|
|
target, err := t.agents.GetByKey(ctx, agentKey)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("target agent %q not found", agentKey))
|
|
}
|
|
|
|
// Permission check via agent_links
|
|
allowed, err := t.links.CanDelegate(ctx, fromAgentID, target.ID)
|
|
if err != nil {
|
|
slog.Warn("delegate.permission_check_error", "from", fromAgentID, "to", target.ID, "error", err)
|
|
return ErrorResult("failed to check delegation permission")
|
|
}
|
|
if !allowed {
|
|
return ErrorResult(fmt.Sprintf("no delegation link from current agent to %q", agentKey))
|
|
}
|
|
|
|
delegationID := uuid.New().String()
|
|
// Audit-trail identity = actor (real sender). Groups audit actions to the
|
|
// individual user rather than the group principal (#915).
|
|
actorID := store.ActorIDFromContext(ctx)
|
|
|
|
req := DelegateRequest{
|
|
FromAgentID: fromAgentID,
|
|
FromAgentKey: store.AgentKeyFromContext(ctx),
|
|
ToAgentKey: agentKey,
|
|
Task: task,
|
|
DelegationID: delegationID,
|
|
UserID: actorID,
|
|
SenderID: store.SenderIDFromContext(ctx),
|
|
Role: store.RoleFromContext(ctx),
|
|
TenantID: store.TenantIDFromContext(ctx).String(),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
PeerKind: ToolPeerKindFromCtx(ctx),
|
|
SessionKey: ToolSessionKeyFromCtx(ctx),
|
|
}
|
|
|
|
// Emit delegate.sent event
|
|
t.emitEvent(ctx, eventbus.EventDelegateSent, eventbus.DelegateSentPayload{
|
|
DelegationID: delegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: agentKey,
|
|
Task: task,
|
|
Mode: mode,
|
|
})
|
|
|
|
// Fire SubagentStart hook (blocking). Nil-safe: skip if no dispatcher.
|
|
if t.hookDispatcher != nil {
|
|
evt := hooks.Event{
|
|
EventID: uuid.NewString(),
|
|
SessionID: req.SessionKey,
|
|
TenantID: parseUUIDOrNil(req.TenantID),
|
|
AgentID: req.FromAgentID,
|
|
HookEvent: hooks.EventSubagentStart,
|
|
Depth: hooks.DepthFrom(ctx),
|
|
}
|
|
r, err := t.hookDispatcher.Fire(ctx, evt)
|
|
if err != nil {
|
|
t.emitEvent(ctx, eventbus.EventDelegateFailed, eventbus.DelegateFailedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Error: fmt.Sprintf("subagent_start hook error: %v", err),
|
|
})
|
|
return ErrorResult(fmt.Sprintf("subagent_start hook error: %v", err))
|
|
}
|
|
// Updated* from FireResult intentionally unused — delegate has no
|
|
// mutation need in Wave 1.
|
|
if r.Decision == hooks.DecisionBlock {
|
|
t.emitEvent(ctx, eventbus.EventDelegateFailed, eventbus.DelegateFailedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Error: "blocked by subagent_start hook",
|
|
})
|
|
return ErrorResult(fmt.Sprintf("delegation to %q blocked by hook policy", req.ToAgentKey))
|
|
}
|
|
// Increment depth so nested delegate calls honor MaxLoopDepth.
|
|
ctx = hooks.IncDepth(ctx)
|
|
}
|
|
|
|
if mode == "sync" {
|
|
return t.executeSyncMode(ctx, req, timeoutSec)
|
|
}
|
|
return t.executeAsyncMode(ctx, req)
|
|
}
|
|
|
|
// executeSyncMode blocks until the delegatee completes or timeout.
|
|
func (t *DelegateTool) executeSyncMode(ctx context.Context, req DelegateRequest, timeoutSec int) *Result {
|
|
syncCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
|
|
dr, err := t.runFn(syncCtx, req)
|
|
if err != nil {
|
|
t.emitEvent(ctx, eventbus.EventDelegateFailed, eventbus.DelegateFailedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Error: err.Error(),
|
|
})
|
|
return ErrorResult(fmt.Sprintf("delegation to %q failed: %v", req.ToAgentKey, err))
|
|
}
|
|
|
|
t.emitEvent(ctx, eventbus.EventDelegateCompleted, eventbus.DelegateCompletedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Content: truncate(dr.Content, 500),
|
|
MediaCount: len(dr.Media),
|
|
})
|
|
|
|
resultJSON, _ := json.Marshal(map[string]any{
|
|
"delegation_id": req.DelegationID,
|
|
"agent": req.ToAgentKey,
|
|
"status": "completed",
|
|
"content": dr.Content,
|
|
})
|
|
r := NewResult(string(resultJSON))
|
|
r.Media = dr.Media
|
|
return r
|
|
}
|
|
|
|
// executeAsyncMode spawns a goroutine and returns immediately.
|
|
func (t *DelegateTool) executeAsyncMode(ctx context.Context, req DelegateRequest) *Result {
|
|
// Detach from parent cancel but add a deadline to prevent goroutine leaks.
|
|
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Minute)
|
|
|
|
go func() {
|
|
defer cancel()
|
|
dr, err := t.runFn(bgCtx, req)
|
|
if err != nil {
|
|
t.emitEvent(bgCtx, eventbus.EventDelegateFailed, eventbus.DelegateFailedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Error: err.Error(),
|
|
})
|
|
slog.Warn("delegate.async.failed", "to", req.ToAgentKey, "error", err)
|
|
t.announceToParent(req, fmt.Sprintf("[Delegation to %s failed: %v]", req.ToAgentKey, err), nil)
|
|
return
|
|
}
|
|
t.emitEvent(bgCtx, eventbus.EventDelegateCompleted, eventbus.DelegateCompletedPayload{
|
|
DelegationID: req.DelegationID,
|
|
FromAgent: req.FromAgentKey,
|
|
ToAgent: req.ToAgentKey,
|
|
Content: truncate(dr.Content, 500),
|
|
MediaCount: len(dr.Media),
|
|
})
|
|
t.announceToParent(req, fmt.Sprintf("[Delegation result from %s]\n\n%s", req.ToAgentKey, dr.Content), dr.Media)
|
|
}()
|
|
|
|
result, _ := json.Marshal(map[string]any{
|
|
"delegation_id": req.DelegationID,
|
|
"agent": req.ToAgentKey,
|
|
"status": "delegated",
|
|
"message": fmt.Sprintf("Task delegated to %s. You will be notified when complete.", req.ToAgentKey),
|
|
})
|
|
return NewResult(string(result))
|
|
}
|
|
|
|
// announceToParent delivers the delegate result back to the parent agent's
|
|
// conversation via msgBus, following the same pattern as subagent announce.
|
|
func (t *DelegateTool) announceToParent(req DelegateRequest, content string, media []bus.MediaFile) {
|
|
if t.msgBus == nil || req.ChatID == "" {
|
|
return
|
|
}
|
|
tenantUUID, _ := uuid.Parse(req.TenantID)
|
|
meta := map[string]string{
|
|
"origin_channel": req.Channel,
|
|
"origin_peer_kind": req.PeerKind,
|
|
"origin_session_key": req.SessionKey,
|
|
"delegation_id": req.DelegationID,
|
|
"delegate_from": req.FromAgentKey,
|
|
"delegate_to": req.ToAgentKey,
|
|
MetaParentAgent: req.FromAgentKey,
|
|
}
|
|
if req.SenderID != "" {
|
|
meta[MetaOriginSenderID] = req.SenderID
|
|
}
|
|
if req.Role != "" {
|
|
meta[MetaOriginRole] = req.Role
|
|
}
|
|
if req.UserID != "" {
|
|
meta[MetaOriginUserID] = req.UserID
|
|
}
|
|
t.msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: fmt.Sprintf("subagent:delegate:%s", req.DelegationID),
|
|
ChatID: req.ChatID,
|
|
Content: content,
|
|
Media: media,
|
|
UserID: req.UserID,
|
|
TenantID: tenantUUID,
|
|
Metadata: meta,
|
|
})
|
|
}
|
|
|
|
// parseUUIDOrNil parses s as a UUID; returns uuid.Nil on failure.
|
|
func parseUUIDOrNil(s string) uuid.UUID {
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return uuid.Nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (t *DelegateTool) emitEvent(ctx context.Context, eventType eventbus.EventType, payload any) {
|
|
if t.eventBus == nil {
|
|
return
|
|
}
|
|
t.eventBus.Publish(eventbus.DomainEvent{
|
|
ID: uuid.New().String(),
|
|
Type: eventType,
|
|
TenantID: store.TenantIDFromContext(ctx).String(),
|
|
AgentID: store.AgentIDFromContext(ctx).String(),
|
|
UserID: store.ActorIDFromContext(ctx), // audit actor, not scope (#915)
|
|
Timestamp: time.Now().UTC(),
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|