mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 22:15:00 +00:00
fix: propagate tenant_id through MCP bridge for memory indexing (#512)
Problem: Memory documents saved via write_file → MemoryInterceptor are stored in memory_documents (PutDocument succeeds via fail-open tenant fallback), but IndexDocument fails with "tenant_id required" because the MCP bridge chain never propagates tenant_id. This results in 0 memory_chunks, making memory_search return empty for all agents using claude_cli provider. Root cause: - BridgeContext struct lacks TenantID field - writeMCPConfigInternal never sets X-Tenant-ID header - bridgeContextMiddleware never extracts/injects tenant_id into context - PutDocument uses tenantIDForInsert() (fail-open, defaults to master) - IndexDocument uses scopeClause() → requireTenantID() (fail-closed) Fix: - Add OptTenantID constant and BridgeContext.TenantID field - Pass tenant_id from agent loop context → provider opts → MCP headers - Add X-Tenant-ID to bridge request headers (HMAC-signed) - Extract X-Tenant-ID in bridgeContextMiddleware → store.WithTenantID() - Backward-compatible HMAC verification with 3 fallback levels (with tenant, without tenant, without workspace+tenant) Affected: all agents using claude_cli provider type. Also fixes: "KG embeddings backfill failed: tenant_id required" Co-authored-by: admin <admin@admins-MacBook-Pro.local>
This commit is contained in:
1 parent
f4f050bcd4
commit
6551c2d1e2
5 files changed
+35
-13
No files matched your search
@@ -519,6 +519,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
providers.OptChatID: req.ChatID,
|
||||
providers.OptPeerKind: req.PeerKind,
|
||||
providers.OptWorkspace: tools.ToolWorkspaceFromCtx(ctx),
|
||||
providers.OptTenantID: store.TenantIDFromContext(ctx).String(),
|
||||
},
|
||||
}
|
||||
if l.thinkingLevel != "" && l.thinkingLevel != "off" {
|
||||
|
||||
@@ -225,8 +225,9 @@ 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")
|
||||
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, sig) {
|
||||
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig) {
|
||||
slog.Warn("security.mcp_bridge: invalid bridge context signature",
|
||||
"agent_id", agentIDStr, "user_id", userID)
|
||||
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
|
||||
@@ -241,6 +242,11 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle
|
||||
if userID != "" {
|
||||
ctx = store.WithUserID(ctx, userID)
|
||||
}
|
||||
if tenantIDStr != "" {
|
||||
if tid, err := uuid.Parse(tenantIDStr); err == nil {
|
||||
ctx = store.WithTenantID(ctx, tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inject channel routing context for tools like message, cron, etc.
|
||||
|
||||
@@ -31,6 +31,10 @@ const OptPeerKind = "peer_kind"
|
||||
// OptWorkspace passes the agent workspace path so MCP bridge tools can resolve file paths.
|
||||
const OptWorkspace = "workspace"
|
||||
|
||||
// OptTenantID passes the tenant UUID string for per-session MCP config.
|
||||
// Required for memory indexing and tenant-scoped queries via bridge tools.
|
||||
const OptTenantID = "tenant_id"
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -90,6 +90,7 @@ type BridgeContext struct {
|
||||
ChatID string
|
||||
PeerKind string
|
||||
Workspace string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
// WriteMCPConfig writes a per-session MCP config file with agent context headers.
|
||||
@@ -97,10 +98,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)
|
||||
return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind, bc.Workspace, bc.TenantID)
|
||||
}
|
||||
|
||||
func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace string) string {
|
||||
func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string {
|
||||
if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) {
|
||||
return ""
|
||||
}
|
||||
@@ -147,9 +148,12 @@ func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey,
|
||||
if workspace != "" && !strings.ContainsAny(workspace, "\r\n\x00") {
|
||||
headers["X-Workspace"] = workspace
|
||||
}
|
||||
if tenantID != "" && !strings.ContainsAny(tenantID, "\r\n\x00") {
|
||||
headers["X-Tenant-ID"] = tenantID
|
||||
}
|
||||
// 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)
|
||||
headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind, workspace, tenantID)
|
||||
}
|
||||
|
||||
bridgeEntry := map[string]any{
|
||||
@@ -252,22 +256,28 @@ func sanitizePathSegment(s string) string {
|
||||
}
|
||||
|
||||
// SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery.
|
||||
// Payload: agentID|userID|channel|chatID|peerKind|workspace
|
||||
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace string) string {
|
||||
// Payload: agentID|userID|channel|chatID|peerKind|workspace|tenantID
|
||||
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace))
|
||||
mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind + "|" + workspace + "|" + tenantID))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyBridgeContext checks the HMAC signature against the expected bridge context.
|
||||
// Falls back to old format (without workspace) for backward compatibility with
|
||||
// sessions whose MCP config was written before the workspace field was added.
|
||||
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, sig string) bool {
|
||||
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace)
|
||||
// Falls back to old formats for backward compatibility with sessions whose MCP config
|
||||
// was written before the workspace or tenantID fields were added.
|
||||
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID, sig string) bool {
|
||||
// Current format: all fields including tenantID
|
||||
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, tenantID)
|
||||
if hmac.Equal([]byte(expected), []byte(sig)) {
|
||||
return true
|
||||
}
|
||||
// Fallback: verify old format (without workspace) for backward compat.
|
||||
old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "")
|
||||
// Fallback: without tenantID (pre-tenantID sessions)
|
||||
noTenant := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, workspace, "")
|
||||
if hmac.Equal([]byte(noTenant), []byte(sig)) {
|
||||
return true
|
||||
}
|
||||
// Fallback: without workspace or tenantID (oldest sessions)
|
||||
old := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind, "", "")
|
||||
return hmac.Equal([]byte(old), []byte(sig))
|
||||
}
|
||||
@@ -166,6 +166,7 @@ func bridgeContextFromOpts(opts map[string]any) BridgeContext {
|
||||
ChatID: extractStringOpt(opts, OptChatID),
|
||||
PeerKind: extractStringOpt(opts, OptPeerKind),
|
||||
Workspace: extractStringOpt(opts, OptWorkspace),
|
||||
TenantID: extractStringOpt(opts, OptTenantID),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user