fix(telegram): propagate local_key for forum topic routing in team notifications (#800)

* 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 <jens@henke.dk>
Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-12 19:03:47 +07:00
1 parent 2c2e2cb536
commit ad893908a5
15 files changed
+161 -16

No files matched your search

+12 -4
View File
@@ -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")
@@ -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()
+26 -3
View File
@@ -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))
})
+9 -1
View File
@@ -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
+3
View File
@@ -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.
+24 -8
View File
@@ -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)) {
+61
View File
@@ -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")
}
}
+1
View File
@@ -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),
}
}
+6
View File
@@ -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)
}
}
+1
View File
@@ -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,
+5
View File
@@ -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.
+1
View File
@@ -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)
}
+5
View File
@@ -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.
+5
View File
@@ -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).
+1
View File
@@ -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).