mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 07:12:50 +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.
233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
|
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// makeDelegateAnnounceCallback returns the batch callback used by tools.NewAnnounceQueue.
|
|
// Extracted from runGateway to keep the main function concise.
|
|
func makeDelegateAnnounceCallback(
|
|
subagentMgr *tools.SubagentManager,
|
|
msgBus *bus.MessageBus,
|
|
) func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
|
|
return func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
|
|
roster := subagentMgr.RosterForParent(meta.ParentAgent)
|
|
content := tools.FormatBatchedAnnounce(items, roster)
|
|
senderID := fmt.Sprintf("subagent:batch-%d", len(items))
|
|
label := items[0].Label
|
|
if len(items) > 1 {
|
|
label = fmt.Sprintf("%d tasks", len(items))
|
|
}
|
|
batchMeta := map[string]string{
|
|
tools.MetaOriginChannel: meta.OriginChannel,
|
|
tools.MetaOriginPeerKind: meta.OriginPeerKind,
|
|
tools.MetaParentAgent: meta.ParentAgent,
|
|
tools.MetaSubagentLabel: label,
|
|
tools.MetaOriginTraceID: meta.OriginTraceID,
|
|
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
|
|
}
|
|
if meta.OriginLocalKey != "" {
|
|
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
|
|
}
|
|
if meta.OriginSessionKey != "" {
|
|
batchMeta[tools.MetaOriginSessionKey] = meta.OriginSessionKey
|
|
}
|
|
if meta.OriginSenderID != "" {
|
|
batchMeta[tools.MetaOriginSenderID] = meta.OriginSenderID
|
|
}
|
|
if meta.OriginRole != "" {
|
|
batchMeta[tools.MetaOriginRole] = meta.OriginRole
|
|
}
|
|
if meta.OriginUserID != "" {
|
|
batchMeta[tools.MetaOriginUserID] = meta.OriginUserID
|
|
}
|
|
// Collect media from all items in the batch.
|
|
var batchMedia []bus.MediaFile
|
|
for _, item := range items {
|
|
batchMedia = append(batchMedia, item.Media...)
|
|
}
|
|
// Notify clients that leader is processing team results
|
|
// (bridges UI gap between last task.completed and announce run.started).
|
|
bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{
|
|
"agentId": meta.ParentAgent,
|
|
"tasks": len(items),
|
|
})
|
|
|
|
msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: senderID,
|
|
ChatID: meta.OriginChatID,
|
|
Content: content,
|
|
UserID: meta.OriginUserID,
|
|
TenantID: meta.OriginTenantID,
|
|
Metadata: batchMeta,
|
|
Media: batchMedia,
|
|
})
|
|
}
|
|
}
|
|
|
|
// subagentAnnounceEntry holds one subagent completion result waiting to be announced.
|
|
type subagentAnnounceEntry struct {
|
|
Label string
|
|
Status string // "completed", "failed", "cancelled"
|
|
Content string
|
|
Media []bus.MediaFile
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
Runtime time.Duration
|
|
Iterations int
|
|
}
|
|
|
|
// subagentAnnounceRouting holds shared routing info captured by the first enqueue.
|
|
type subagentAnnounceRouting struct {
|
|
QueueKey string // tenant-scoped key for sync.Map (tenantID:sessionKey)
|
|
SessionKey string // original session key (no tenant prefix) for RunRequest
|
|
TenantID uuid.UUID // preserved for tenant-scoped scheduling
|
|
OrigChannel string
|
|
OrigChannelType string
|
|
OrigChatID string
|
|
OrigPeerKind string
|
|
OrigLocalKey string
|
|
UserID string
|
|
SenderID string // real acting sender (preserves permission attribution through re-ingress, #915)
|
|
Role string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
|
|
ParentAgent string
|
|
ParentTraceID uuid.UUID
|
|
ParentRootSpanID uuid.UUID
|
|
OutMeta map[string]string
|
|
}
|
|
|
|
// subagentAnnounceQueue uses BatchQueue for producer-consumer synchronization.
|
|
var subagentAnnounceQueue orch.BatchQueue[subagentAnnounceEntry]
|
|
|
|
// enqueueSubagentAnnounce adds a result to the queue. Returns isProcessor.
|
|
func enqueueSubagentAnnounce(key string, entry subagentAnnounceEntry) bool {
|
|
return subagentAnnounceQueue.Enqueue(key, entry)
|
|
}
|
|
|
|
// processSubagentAnnounceLoop drains entries, builds merged announce, schedules to parent.
|
|
func processSubagentAnnounceLoop(
|
|
ctx context.Context,
|
|
r subagentAnnounceRouting,
|
|
roster tools.SubagentRoster,
|
|
subagentMgr *tools.SubagentManager,
|
|
sched *scheduler.Scheduler,
|
|
msgBus *bus.MessageBus,
|
|
cfg *config.Config,
|
|
) {
|
|
// Ensure tenant scope is always set for the scheduler.
|
|
if r.TenantID != uuid.Nil {
|
|
ctx = store.WithTenantID(ctx, r.TenantID)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
subagentAnnounceQueue.TryFinish(r.QueueKey)
|
|
return
|
|
default:
|
|
}
|
|
|
|
entries := subagentAnnounceQueue.Drain(r.QueueKey)
|
|
if len(entries) == 0 {
|
|
if subagentAnnounceQueue.TryFinish(r.QueueKey) {
|
|
return
|
|
}
|
|
// Brief sleep to avoid tight spin when entries arrive between drain and tryFinish.
|
|
time.Sleep(50 * time.Millisecond)
|
|
continue
|
|
}
|
|
|
|
// Refresh roster each iteration for up-to-date task statuses.
|
|
roster = subagentMgr.RosterForParent(r.ParentAgent)
|
|
content := buildMergedSubagentAnnounce(entries, roster)
|
|
|
|
// Collect media from all entries.
|
|
var fwdMedia []bus.MediaFile
|
|
for _, e := range entries {
|
|
fwdMedia = append(fwdMedia, e.Media...)
|
|
}
|
|
contentSuffix := ""
|
|
if r.OrigChannel == "ws" && len(fwdMedia) > 0 {
|
|
contentSuffix = mediaToMarkdownFromPaths(fwdMedia, cfg)
|
|
fwdMedia = nil
|
|
}
|
|
|
|
req := agent.RunRequest{
|
|
SessionKey: r.SessionKey,
|
|
Message: content,
|
|
ForwardMedia: fwdMedia,
|
|
ContentSuffix: contentSuffix,
|
|
Channel: r.OrigChannel,
|
|
ChannelType: r.OrigChannelType,
|
|
ChatID: r.OrigChatID,
|
|
PeerKind: r.OrigPeerKind,
|
|
LocalKey: r.OrigLocalKey,
|
|
UserID: r.UserID,
|
|
SenderID: r.SenderID, // preserves real acting sender for permission checks (#915)
|
|
Role: r.Role, // preserves RBAC role for admin bypass in group writes (#915)
|
|
RunID: fmt.Sprintf("subagent-announce-%s-%d", r.ParentAgent, len(entries)),
|
|
RunKind: "announce",
|
|
HideInput: true,
|
|
Stream: false,
|
|
ParentTraceID: r.ParentTraceID,
|
|
ParentRootSpanID: r.ParentRootSpanID,
|
|
}
|
|
|
|
outCh := sched.Schedule(ctx, scheduler.LaneSubagent, req)
|
|
outcome := <-outCh
|
|
|
|
if outcome.Err != nil {
|
|
if !errors.Is(outcome.Err, context.Canceled) {
|
|
slog.Error("subagent announce: lead run failed", "error", outcome.Err, "batch_size", len(entries))
|
|
errContent := formatAgentError(outcome.Err)
|
|
if isExternalChannel(r.OrigChannelType) {
|
|
slog.Info("subagent announce: suppressed error for external channel",
|
|
"channel", r.OrigChannel, "type", r.OrigChannelType)
|
|
errContent = ""
|
|
}
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: r.OrigChannel,
|
|
ChatID: r.OrigChatID,
|
|
Content: errContent,
|
|
Metadata: r.OutMeta,
|
|
})
|
|
}
|
|
} else {
|
|
isSilent := outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content)
|
|
if !(isSilent && len(outcome.Result.Media) == 0) {
|
|
out := outcome.Result.Content
|
|
if isSilent {
|
|
out = ""
|
|
}
|
|
outMsg := bus.OutboundMessage{
|
|
Channel: r.OrigChannel,
|
|
ChatID: r.OrigChatID,
|
|
Content: out,
|
|
Metadata: r.OutMeta,
|
|
}
|
|
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
|
msgBus.PublishOutbound(outMsg)
|
|
}
|
|
}
|
|
|
|
slog.Info("subagent announce: batch processed",
|
|
"batch_size", len(entries), "session", r.SessionKey)
|
|
}
|
|
}
|