From ad893908a5baf75528232a94c7acbad7500e9fe5 Mon Sep 17 00:00:00 2001 From: henkedk Date: Sun, 12 Apr 2026 14:03:47 +0200 Subject: [PATCH] fix(telegram): propagate local_key for forum topic routing in team notifications (#800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(telegram): propagate local_key for forum topic routing in team notifications Team task status messages (dispatched, completed, progress) were always delivered to the General topic in Telegram forum groups because the notification pipeline had no access to the originating topic's local_key. Root cause: wireTeamProgressNotifySubscriber in gateway_events.go published OutboundMessage with no Metadata, so the Telegram adapter had no message_thread_id to route to the correct forum topic. Fix has two parts: 1. Team notify path (root cause): - Add LocalKey field to TeamTaskEventPayload (protocol) - Extract local_key from tool context in WithContextInfo() - Add LocalKey to NotifyRoutingMeta - Pass LocalKey through to OutboundMessage Metadata in both leader mode (InboundMessage) and direct mode (OutboundMessage) 2. MCP bridge context (supporting): - Propagate local_key and session_key through bridge HTTP headers - Add X-Local-Key and X-Session-Key to BridgeContext - Extract and inject into tool context in gateway middleware - Include in HMAC signature for integrity Closes #798 * fix(telegram): pass LocalKey from task metadata in all dispatch/fail broadcast sites The initial fix added LocalKey to the event payload and WithContextInfo(), but 4 broadcast call sites use individual With* options instead of WithContextInfo — so LocalKey was never populated for: - fallback_dispatch (team_tasks_create.go) - dispatch_unblocked (team_tool_dispatch.go) - post_turn dispatch (team_tool_validation.go) - blocker/fail (team_tasks_blocker.go) Add WithLocalKey() option function and extract TaskMetaLocalKey from task metadata at each site, matching the existing TaskMetaPeerKind pattern. * test(mcp): add HMAC verification tests for extra params (localKey, sessionKey) - Add tests for SignBridgeContext/VerifyBridgeContext with extra params - Test backward compat fallback for pre-localKey sessions - Test that param order matters in signature - Add clarifying comment for routing context injection security model --------- Co-authored-by: Jens Henke Co-authored-by: viettranx --- cmd/gateway_events.go | 16 ++++-- internal/agent/loop_pipeline_callbacks.go | 1 + internal/gateway/server.go | 29 +++++++++-- internal/mcp/bridge_server.go | 10 +++- internal/providers/claude_cli.go | 3 ++ internal/providers/claude_cli_mcp.go | 32 +++++++++--- internal/providers/claude_cli_mcp_test.go | 61 +++++++++++++++++++++++ internal/providers/claude_cli_session.go | 1 + internal/tools/team_event_helpers.go | 6 +++ internal/tools/team_notify_queue.go | 1 + internal/tools/team_tasks_blocker.go | 5 ++ internal/tools/team_tasks_create.go | 1 + internal/tools/team_tool_dispatch.go | 5 ++ internal/tools/team_tool_validation.go | 5 ++ pkg/protocol/team_events.go | 1 + 15 files changed, 161 insertions(+), 16 deletions(-) diff --git a/cmd/gateway_events.go b/cmd/gateway_events.go index 6569f5f8..932b4e07 100644 --- a/cmd/gateway_events.go +++ b/cmd/gateway_events.go @@ -95,13 +95,20 @@ func (d *gatewayDeps) wireTeamProgressNotifySubscriber() { UserID: meta.UserID, PeerKind: meta.PeerKind, Content: leaderContent, - Metadata: map[string]string{"run_kind": tools.RunKindNotification}, + Metadata: func() map[string]string { + m := map[string]string{"run_kind": tools.RunKindNotification} + if meta.LocalKey != "" { + m["local_key"] = meta.LocalKey + } + return m + }(), }) } else { d.msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: meta.Channel, - ChatID: meta.ChatID, - Content: content, + Channel: meta.Channel, + ChatID: meta.ChatID, + Content: content, + Metadata: buildAnnounceOutMeta(meta.LocalKey), }) } }) @@ -243,6 +250,7 @@ func (d *gatewayDeps) wireTeamProgressNotifySubscriber() { UserID: payload.UserID, LeadAgent: leadAgentKey, PeerKind: payload.PeerKind, + LocalKey: payload.LocalKey, }) }) slog.Info("team progress notification subscriber registered") diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index abc93157..10bbbb83 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -226,6 +226,7 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c chatReq.Options[providers.OptChannel] = req.Channel chatReq.Options[providers.OptChatID] = req.ChatID chatReq.Options[providers.OptPeerKind] = req.PeerKind + chatReq.Options[providers.OptLocalKey] = req.LocalKey chatReq.Options[providers.OptWorkspace] = tools.ToolWorkspaceFromCtx(ctx) if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { chatReq.Options[providers.OptTenantID] = tid.String() diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 065e7c4b..56c603ef 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -185,7 +185,7 @@ func (s *Server) BuildMux() *http.ServeMux { if s.cfg.Gateway.Token != "" { bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus) handler := tokenAuthMiddleware(s.cfg.Gateway.Token, - bridgeContextMiddleware(s.cfg.Gateway.Token, bridgeHandler)) + bridgeContextMiddleware(s.cfg.Gateway.Token, s.agentStore, bridgeHandler)) mux.Handle("/mcp/bridge", handler) } else { slog.Warn("security.mcp_bridge_disabled: no gateway token configured, MCP bridge is disabled") @@ -212,7 +212,7 @@ func (s *Server) BuildMux() *http.ServeMux { // access agent/user scope and resolve workspace-relative paths. // When a gateway token is configured, the context headers must be accompanied by // a valid X-Bridge-Sig HMAC to prevent forgery. -func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handler { +func bridgeContextMiddleware(gatewayToken string, agentStore store.AgentStore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() agentIDStr := r.Header.Get("X-Agent-ID") @@ -221,6 +221,8 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle chatID := r.Header.Get("X-Chat-ID") peerKind := r.Header.Get("X-Peer-Kind") workspace := r.Header.Get("X-Workspace") + localKey := r.Header.Get("X-Local-Key") + sessionKey := r.Header.Get("X-Session-Key") if agentIDStr != "" || userID != "" { // Reject context headers when no gateway token — prevents unauthenticated impersonation. @@ -234,7 +236,7 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle // Verify HMAC signature over all context fields. tenantIDStr := r.Header.Get("X-Tenant-ID") sig := r.Header.Get("X-Bridge-Sig") - ok, tenantVerified := providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig) + ok, tenantVerified := providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig, localKey, sessionKey) if !ok { slog.Warn("security.mcp_bridge: invalid bridge context signature", "agent_id", agentIDStr, "user_id", userID) @@ -245,6 +247,18 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle if agentIDStr != "" { if id, err := uuid.Parse(agentIDStr); err == nil { ctx = store.WithAgentID(ctx, id) + + // Inject per-agent shell deny group overrides so the exec tool + // respects the same policy as the normal agent loop. + if agentStore != nil { + ag, err := agentStore.GetByIDUnscoped(ctx, id) + if err == nil && ag != nil { + groups := ag.ParseShellDenyGroups() + if groups != nil { + ctx = store.WithShellDenyGroups(ctx, groups) + } + } + } } } if userID != "" { @@ -274,6 +288,15 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle if workspace != "" && (agentIDStr != "" || userID != "") { ctx = tools.WithToolWorkspace(ctx, workspace) } + // Routing context (localKey, sessionKey) is injected unconditionally like channel/chatID. + // These are used for message routing (forum topics), not security-sensitive operations. + // Without valid agent context, tool execution will fail anyway. + if localKey != "" { + ctx = tools.WithToolLocalKey(ctx, localKey) + } + if sessionKey != "" { + ctx = tools.WithToolSessionKey(ctx, sessionKey) + } next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index fb9a31f1..bb3646c4 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -98,7 +98,15 @@ func makeToolHandler(reg *tools.Registry, toolName string, msgBus *bus.MessageBu return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { args := req.GetArguments() - result := reg.Execute(ctx, toolName, args) + // Pass routing context (channel, chatID, peerKind, sessionKey) so native + // tools can access local_key, session_key etc. for forum topic routing. + result := reg.ExecuteWithContext(ctx, toolName, args, + tools.ToolChannelFromCtx(ctx), + tools.ToolChatIDFromCtx(ctx), + tools.ToolPeerKindFromCtx(ctx), + tools.ToolSessionKeyFromCtx(ctx), + nil, + ) if result.IsError { return mcpgo.NewToolResultError(result.ForLLM), nil diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index dc1cd164..e5e0dde7 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -35,6 +35,9 @@ const OptWorkspace = "workspace" // Required for memory indexing and tenant-scoped queries via bridge tools. const OptTenantID = "tenant_id" +// OptLocalKey passes the composite local key (e.g. "-100123:topic:42") for forum topic routing. +const OptLocalKey = "local_key" + // ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary. // It acts as a thin proxy: CLI manages session history, tool execution, and context. // GoClaw only forwards the latest user message and streams back the response. diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go index 80fa2517..ae31e41c 100644 --- a/internal/providers/claude_cli_mcp.go +++ b/internal/providers/claude_cli_mcp.go @@ -91,6 +91,7 @@ type BridgeContext struct { PeerKind string Workspace string TenantID string + LocalKey string } // WriteMCPConfig writes a per-session MCP config file with agent context headers. @@ -98,10 +99,10 @@ type BridgeContext struct { // outside the agent's workDir so tokens are not exposed. // Skips write if content is unchanged. Returns the file path. func (d *MCPConfigData) WriteMCPConfig(ctx context.Context, sessionKey string, bc BridgeContext) string { - return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID) + return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID, bc.LocalKey) } -func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { +func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID, localKey string) string { if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) { return "" } @@ -151,9 +152,15 @@ func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") { headers["X-Tenant-ID"] = tenantID } + if localKey != "" && !strings.ContainsAny(localKey, "\r\n\x00") { + headers["X-Local-Key"] = localKey + } + if sessionKey != "" && !strings.ContainsAny(sessionKey, "\r\n\x00") { + headers["X-Session-Key"] = sessionKey + } // HMAC signature over all context fields to prevent header forgery if d.GatewayToken != "" && (agentID != "" || userID != "") { - headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID) + headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID, localKey, sessionKey) } bridgeEntry := map[string]any{ @@ -257,9 +264,13 @@ func sanitizePathSegment(s string) string { // SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery. // Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID -func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string { +func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string, extra ...string) string { mac := hmac.New(sha256.New, []byte(key)) - mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID)) + payload := agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID + for _, e := range extra { + payload += "|" + e + } + mac.Write([]byte(payload)) return hex.EncodeToString(mac.Sum(nil)) } @@ -269,12 +280,17 @@ func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspac // Falls back to old formats for backward compatibility with sessions whose MCP config // was written before the workspace or tenantID fields were added. // Callers must NOT trust the tenantID header when tenantVerified is false. -func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string) (bool, bool) { - // Current format: all fields including tenantID - expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID) +func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string, extra ...string) (bool, bool) { + // Current format: all fields including localKey + expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, extra...) if hmac.Equal([]byte(expected), []byte(sig)) { return true, true } + // Fallback: without extra fields (pre-localKey sessions) + noExtra := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID) + if hmac.Equal([]byte(noExtra), []byte(sig)) { + return true, true + } // Fallback: without tenantID (pre-tenantID sessions) noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "") if hmac.Equal([]byte(noTenant), []byte(sig)) { diff --git a/internal/providers/claude_cli_mcp_test.go b/internal/providers/claude_cli_mcp_test.go index 527cd21d..7622557a 100644 --- a/internal/providers/claude_cli_mcp_test.go +++ b/internal/providers/claude_cli_mcp_test.go @@ -118,3 +118,64 @@ func TestVerifyBridgeContext_EmptyFields(t *testing.T) { t.Error("expected tenantVerified=true when all fields empty (level 1 matches)") } } + +// --- Extra params (localKey, sessionKey) tests --- + +func TestSignBridgeContext_WithExtraParams(t *testing.T) { + key := "test-secret" + // Without extra params + sig1 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1") + // With extra params + sig2 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", "-100123:topic:42", "session-abc") + + if sig1 == sig2 { + t.Error("signature with extra params should differ from signature without") + } +} + +func TestVerifyBridgeContext_WithExtraParams(t *testing.T) { + key := "gateway-token" + localKey := "-100123:topic:42" + sessionKey := "session-abc" + sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", localKey, sessionKey) + + ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, localKey, sessionKey) + if !ok { + t.Error("expected ok=true for valid signature with extra params") + } + if !tenantVerified { + t.Error("expected tenantVerified=true for full match") + } +} + +func TestVerifyBridgeContext_FallbackWithoutExtraParams(t *testing.T) { + key := "gateway-token" + // Pre-localKey session: signed without extra params + sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1") + + // New code passes localKey/sessionKey but signature was created without them + ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, "-100123:topic:42", "session-abc") + if !ok { + t.Error("expected ok=true for fallback (pre-localKey session)") + } + if !tenantVerified { + t.Error("expected tenantVerified=true — base fields match at fallback level") + } +} + +func TestVerifyBridgeContext_ExtraParamOrderMatters(t *testing.T) { + key := "gateway-token" + sig := SignBridgeContext(key, "agent1", "user1", "", "", "", "", "", "localKey", "sessionKey") + + // Verify with same order + ok, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "localKey", "sessionKey") + if !ok { + t.Error("expected ok=true for same order") + } + + // Verify with swapped order + ok2, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "sessionKey", "localKey") + if ok2 { + t.Error("expected ok=false for swapped extra param order") + } +} diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go index fdaaee0d..52c879f0 100644 --- a/internal/providers/claude_cli_session.go +++ b/internal/providers/claude_cli_session.go @@ -167,6 +167,7 @@ func bridgeContextFromOpts(opts map[string]any) BridgeContext { PeerKind: extractStringOpt(opts, OptPeerKind), Workspace: extractStringOpt(opts, OptWorkspace), TenantID: extractStringOpt(opts, OptTenantID), + LocalKey: extractStringOpt(opts, OptLocalKey), } } diff --git a/internal/tools/team_event_helpers.go b/internal/tools/team_event_helpers.go index a7a66fae..eff3494f 100644 --- a/internal/tools/team_event_helpers.go +++ b/internal/tools/team_event_helpers.go @@ -95,6 +95,11 @@ func WithPeerKind(pk string) TaskEventOption { return func(p *protocol.TeamTaskEventPayload) { p.PeerKind = pk } } +// WithLocalKey sets LocalKey on the payload for forum topic routing. +func WithLocalKey(lk string) TaskEventOption { + return func(p *protocol.TeamTaskEventPayload) { p.LocalKey = lk } +} + // WithCommentText sets CommentText on the payload. func WithCommentText(t string) TaskEventOption { return func(p *protocol.TeamTaskEventPayload) { p.CommentText = t } @@ -116,6 +121,7 @@ func WithContextInfo(ctx context.Context) TaskEventOption { p.Channel = ToolChannelFromCtx(ctx) p.ChatID = ToolChatIDFromCtx(ctx) p.PeerKind = ToolPeerKindFromCtx(ctx) + p.LocalKey = ToolLocalKeyFromCtx(ctx) } } diff --git a/internal/tools/team_notify_queue.go b/internal/tools/team_notify_queue.go index fc720639..8181060a 100644 --- a/internal/tools/team_notify_queue.go +++ b/internal/tools/team_notify_queue.go @@ -14,6 +14,7 @@ type NotifyRoutingMeta struct { UserID string LeadAgent string // agent key (only used in leader mode) PeerKind string // "group" or "direct" — routes to correct session (#266) + LocalKey string // composite key with topic suffix for forum routing } // TeamNotifyQueue batches team task notifications per chat with debounce, diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go index 6e486428..b2d36dbc 100644 --- a/internal/tools/team_tasks_blocker.go +++ b/internal/tools/team_tasks_blocker.go @@ -49,6 +49,10 @@ func (t *TeamTasksTool) handleBlockerComment( if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { blockerPeerKind = pk } + blockerLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + blockerLocalKey = lk + } t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, BuildTaskEventPayload( team.ID.String(), taskID.String(), store.TeamTaskStatusFailed, @@ -60,6 +64,7 @@ func (t *TeamTasksTool) handleBlockerComment( WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(blockerPeerKind), + WithLocalKey(blockerLocalKey), )) // Escalate to leader if enabled in team settings. diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go index f7b9965f..ceac0101 100644 --- a/internal/tools/team_tasks_create.go +++ b/internal/tools/team_tasks_create.go @@ -301,6 +301,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(ToolPeerKindFromCtx(ctx)), + WithLocalKey(ToolLocalKeyFromCtx(ctx)), )) t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID) } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index 51e6f0be..b3f28dc2 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -363,6 +363,10 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { taskPeerKind = pk } + taskLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + taskLocalKey = lk + } m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload( teamID.String(), task.ID.String(), store.TeamTaskStatusInProgress, @@ -372,6 +376,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(taskPeerKind), + WithLocalKey(taskLocalKey), )) // Append completed blocker results so the member agent has context. diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index 81f0dc57..7266f2a2 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -102,6 +102,10 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U if pk, ok := task.Metadata[TaskMetaPeerKind].(string); ok { taskPeerKind = pk } + taskLocalKey := "" + if lk, ok := task.Metadata[TaskMetaLocalKey].(string); ok { + taskLocalKey = lk + } m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload( teamID.String(), task.ID.String(), store.TeamTaskStatusInProgress, @@ -111,6 +115,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U WithChannel(task.Channel), WithChatID(task.ChatID), WithPeerKind(taskPeerKind), + WithLocalKey(taskLocalKey), )) // Restore leader's trace context from task metadata (ctx here is the // consumer goroutine context which has no trace after the turn ends). diff --git a/pkg/protocol/team_events.go b/pkg/protocol/team_events.go index 5af4fcf0..0b54d5f2 100644 --- a/pkg/protocol/team_events.go +++ b/pkg/protocol/team_events.go @@ -101,6 +101,7 @@ type TeamTaskEventPayload struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` PeerKind string `json:"peer_kind,omitempty"` // "group" or "direct" — for correct session routing (#266) + LocalKey string `json:"local_key,omitempty"` Timestamp string `json:"timestamp"` // Comment text preview (for team.task.commented events, truncated).