mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* 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>
182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package providers
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// --- SignBridgeContext tests ---
|
|
|
|
func TestSignBridgeContext_Deterministic(t *testing.T) {
|
|
key := "test-secret"
|
|
sig1 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/workspace", "tenant-abc")
|
|
sig2 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/workspace", "tenant-abc")
|
|
if sig1 != sig2 {
|
|
t.Errorf("expected deterministic output, got %q and %q", sig1, sig2)
|
|
}
|
|
if sig1 == "" {
|
|
t.Error("expected non-empty signature")
|
|
}
|
|
}
|
|
|
|
func TestSignBridgeContext_DifferentKey(t *testing.T) {
|
|
sig1 := SignBridgeContext("key-a", "agent1", "user1", "", "", "", "", "")
|
|
sig2 := SignBridgeContext("key-b", "agent1", "user1", "", "", "", "", "")
|
|
if sig1 == sig2 {
|
|
t.Error("different keys should produce different signatures")
|
|
}
|
|
}
|
|
|
|
func TestSignBridgeContext_FieldOrder(t *testing.T) {
|
|
key := "test-secret"
|
|
sig1 := SignBridgeContext(key, "a", "b", "c", "d", "e", "f", "g")
|
|
sig2 := SignBridgeContext(key, "b", "a", "c", "d", "e", "f", "g")
|
|
if sig1 == sig2 {
|
|
t.Error("swapping field values should produce different signatures")
|
|
}
|
|
}
|
|
|
|
// --- VerifyBridgeContext tests ---
|
|
|
|
func TestVerifyBridgeContext_Level1_AllFields(t *testing.T) {
|
|
key := "gateway-token"
|
|
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123")
|
|
|
|
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123", sig)
|
|
if !ok {
|
|
t.Error("expected ok=true for valid level 1 signature")
|
|
}
|
|
if !tenantVerified {
|
|
t.Error("expected tenantVerified=true for level 1 match")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBridgeContext_Level2_OldSessionWithWorkspace(t *testing.T) {
|
|
key := "gateway-token"
|
|
// Pre-tenantID session: signed with workspace but empty tenantID.
|
|
// Middleware now receives X-Tenant-ID header (e.g. new code adds it).
|
|
// Level 1 fails (tenantID mismatch), level 2 matches (ignores tenantID).
|
|
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "")
|
|
|
|
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "new-tenant-id", sig)
|
|
if !ok {
|
|
t.Error("expected ok=true for level 2 fallback")
|
|
}
|
|
if tenantVerified {
|
|
t.Error("expected tenantVerified=false — tenant was not in original signature")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBridgeContext_Level3_NoWorkspaceNoTenant(t *testing.T) {
|
|
key := "gateway-token"
|
|
// Signature from the oldest format (no workspace, no tenantID)
|
|
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "", "")
|
|
|
|
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123", sig)
|
|
if !ok {
|
|
t.Error("expected ok=true for level 3 fallback")
|
|
}
|
|
if tenantVerified {
|
|
t.Error("expected tenantVerified=false for level 3 fallback")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBridgeContext_InvalidSig(t *testing.T) {
|
|
ok, tenantVerified := VerifyBridgeContext("key", "agent1", "user1", "", "", "", "", "", "invalid-sig")
|
|
if ok {
|
|
t.Error("expected ok=false for invalid signature")
|
|
}
|
|
if tenantVerified {
|
|
t.Error("expected tenantVerified=false for invalid signature")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBridgeContext_TenantNotTrustedOnFallback(t *testing.T) {
|
|
key := "gateway-token"
|
|
// Old session signed WITHOUT tenantID
|
|
oldSig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "")
|
|
|
|
// Attacker replays old sig but adds a fake tenantID header
|
|
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "fake-tenant-id", oldSig)
|
|
if !ok {
|
|
t.Error("expected ok=true (sig valid via fallback)")
|
|
}
|
|
if tenantVerified {
|
|
t.Error("expected tenantVerified=false — tenant header not covered by HMAC, must not be trusted")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBridgeContext_EmptyFields(t *testing.T) {
|
|
key := "test-key"
|
|
sig := SignBridgeContext(key, "", "", "", "", "", "", "")
|
|
|
|
ok, tenantVerified := VerifyBridgeContext(key, "", "", "", "", "", "", "", sig)
|
|
if !ok {
|
|
t.Error("expected ok=true for empty fields with valid signature")
|
|
}
|
|
// Empty fields match at all levels; level 1 matches first → tenantVerified=true
|
|
if !tenantVerified {
|
|
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")
|
|
}
|
|
}
|