diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 72dd996d..eee6e24c 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -346,6 +346,24 @@ func processNormalMessage( schedCtx = tools.WithRunKind(schedCtx, rk) } + // Resolve effective sender: prefer MetaOriginSenderID when the on-wire + // SenderID is an internal/synthetic one (e.g. "notification:progress", + // "ticker:system", "system:escalation", "session_send_tool"). This lets + // system-initiated turns that DO have a real user behind them (because + // they propagated the origin via metadata) attribute actions to that user + // — e.g. for CheckFileWriterPermission in group chats (#915). Synthetic + // senders without propagation keep their on-wire value and hit F1's + // deny-in-group rule (safe default). + effectiveSenderID := msg.SenderID + if bus.IsInternalSender(effectiveSenderID) { + // Defense-in-depth: if a propagation bug ever writes a synthetic + // value into MetaOriginSenderID, do NOT honour it. We want only real + // user senders to override the on-wire synthetic. + if realSender := msg.Metadata[tools.MetaOriginSenderID]; realSender != "" && !bus.IsInternalSender(realSender) { + effectiveSenderID = realSender + } + } + // Schedule through main lane (per-session concurrency controlled by maxConcurrent) outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{ SessionKey: sessionKey, @@ -359,7 +377,7 @@ func processNormalMessage( PeerKind: peerKind, LocalKey: msg.Metadata["local_key"], UserID: userID, - SenderID: msg.SenderID, + SenderID: effectiveSenderID, SenderName: resolveSenderName(msg), RunID: runID, Stream: enableStream, diff --git a/internal/gateway/methods/teams_tasks_mutations.go b/internal/gateway/methods/teams_tasks_mutations.go index c9e032c3..f4698d38 100644 --- a/internal/gateway/methods/teams_tasks_mutations.go +++ b/internal/gateway/methods/teams_tasks_mutations.go @@ -398,6 +398,18 @@ func (m *TeamsMethods) dispatchTaskToAgent(ctx context.Context, task *store.Team meta["origin_local_key"] = lk } } + // Preserve acting sender through dashboard dispatch: prefer the live WS + // caller's sender (rarely set) then fall back to the stored origin sender + // from the task at creation time. Without this, group-scope tasks + // dispatched from the dashboard would hit the empty-sender DENY rule in + // CheckFileWriterPermission (#915 Flow F). + if dispatchSender := store.SenderIDFromContext(ctx); dispatchSender != "" { + meta["origin_sender_id"] = dispatchSender + } else if task.Metadata != nil { + if taskSender, _ := task.Metadata["origin_sender_id"].(string); taskSender != "" { + meta["origin_sender_id"] = taskSender + } + } m.msgBus.PublishInbound(bus.InboundMessage{ Channel: "system", diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go index 10b27baa..fc77f0ee 100644 --- a/internal/tools/team_tasks_create.go +++ b/internal/tools/team_tasks_create.go @@ -204,6 +204,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil { taskMeta[TaskMetaOriginRootSpan] = rootSpanID.String() } + // Persist the real acting sender so deferred/dashboard dispatches can + // restore permission attribution when the teammate runs (#915 Flow F). + if sender := store.SenderIDFromContext(ctx); sender != "" { + taskMeta["origin_sender_id"] = sender + } task := &store.TeamTaskData{ TeamID: team.ID,