diff --git a/README.md b/README.md
index 3e877496..1d3081e5 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,8 @@
-
+
-GoClaw
-
-Enterprise AI Agent Platform
+Multi-Tenant AI Agent Platform
Multi-agent AI gateway built in Go. 20+ LLM providers. 7 channels. Multi-tenant PostgreSQL.
diff --git a/_statics/goclaw-icon.svg b/_statics/goclaw-icon.svg
new file mode 100644
index 00000000..797941f5
--- /dev/null
+++ b/_statics/goclaw-icon.svg
@@ -0,0 +1,74 @@
+
diff --git a/_statics/goclaw-logo.svg b/_statics/goclaw-logo.svg
new file mode 100644
index 00000000..a12bce9b
--- /dev/null
+++ b/_statics/goclaw-logo.svg
@@ -0,0 +1,104 @@
+
diff --git a/cmd/gateway.go b/cmd/gateway.go
index 78a5b766..754d9592 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strings"
"syscall"
"github.com/google/uuid"
@@ -45,6 +46,21 @@ func runGateway() {
if verbose {
logLevel = slog.LevelDebug
}
+ // Env override (docker/K8s friendly, default: info): GOCLAW_LOG_LEVEL=debug|info|warn|error
+ if lvl := os.Getenv("GOCLAW_LOG_LEVEL"); lvl != "" {
+ switch strings.ToLower(lvl) {
+ case "debug":
+ logLevel = slog.LevelDebug
+ case "info":
+ logLevel = slog.LevelInfo
+ case "warn":
+ logLevel = slog.LevelWarn
+ case "error":
+ logLevel = slog.LevelError
+ default:
+ fmt.Fprintf(os.Stderr, "warning: unknown GOCLAW_LOG_LEVEL=%q, using info\n", lvl)
+ }
+ }
textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
@@ -64,7 +80,7 @@ func runGateway() {
msgBus := bus.New()
// Create provider registry
- providerRegistry := providers.NewRegistry()
+ providerRegistry := providers.NewRegistry(store.TenantIDFromContext)
registerProviders(providerRegistry, cfg)
// Resolve workspace (must be absolute for system prompt + file tool path resolution)
@@ -145,12 +161,9 @@ func runGateway() {
}
// Notify clients that leader is processing team results
// (bridges UI gap between last task.completed and announce run.started).
- msgBus.Broadcast(bus.Event{
- Name: protocol.EventTeamLeaderProcessing,
- Payload: map[string]any{
- "agentId": meta.ParentAgent,
- "tasks": len(items),
- },
+ bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{
+ "agentId": meta.ParentAgent,
+ "tasks": len(items),
})
msgBus.PublishInbound(bus.InboundMessage{
@@ -159,6 +172,7 @@ func runGateway() {
ChatID: meta.OriginChatID,
Content: content,
UserID: meta.OriginUserID,
+ TenantID: meta.OriginTenantID,
Metadata: batchMeta,
Media: batchMedia,
})
@@ -170,7 +184,7 @@ func runGateway() {
slog.Info("subagent system enabled", "tools", []string{"spawn"})
}
- skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus)
+ skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus)
_ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity
// DateTime tool (precise time for cron scheduling, memory timestamps, etc.)
@@ -282,19 +296,10 @@ func runGateway() {
server.SetAgentStore(pgStores.Agents)
}
- // Dynamic custom tools: load global tools from DB before resolver
- var dynamicLoader *tools.DynamicToolLoader
- if pgStores.CustomTools != nil {
- dynamicLoader = tools.NewDynamicToolLoader(pgStores.CustomTools, workspace)
- if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil {
- slog.Warn("failed to load global custom tools", "error", err)
- }
- }
-
var mcpPool *mcpbridge.Pool
var mediaStore *media.Store
var postTurn tools.PostTurnProcessor
- contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader, redisClient)
+ contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient)
if mcpPool != nil {
defer mcpPool.Stop()
}
@@ -303,7 +308,7 @@ func runGateway() {
if mcpMgr != nil {
mcpToolLister = mcpMgr
}
- agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
+ agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
if providersH != nil {
providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType)
}
@@ -323,10 +328,13 @@ func runGateway() {
}
server.SetWakeHandler(wakeH)
if mcpH != nil {
+ if mcpPool != nil {
+ mcpH.SetPoolEvictor(mcpPool)
+ }
server.SetMCPHandler(mcpH)
}
- if customToolsH != nil {
- server.SetCustomToolsHandler(customToolsH)
+ if mcpUserCredsH != nil {
+ server.SetMCPUserCredentialsHandler(mcpUserCredsH)
}
if channelInstancesH != nil {
server.SetChannelInstancesHandler(channelInstancesH)
@@ -334,14 +342,11 @@ func runGateway() {
if providersH != nil {
server.SetProvidersHandler(providersH)
}
- if delegationsH != nil {
- server.SetDelegationsHandler(delegationsH)
- }
if teamEventsH != nil {
server.SetTeamEventsHandler(teamEventsH)
}
if pgStores != nil && pgStores.Teams != nil {
- server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, dataDir))
+ server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, workspace))
}
if builtinToolsH != nil {
server.SetBuiltinToolsHandler(builtinToolsH)
@@ -399,7 +404,7 @@ func runGateway() {
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
// Supports media from any agent workspace (each agent has its own workspace from DB).
- server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace))
+ server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace, dataDir))
// Storage file management — browse/delete files under the resolved workspace directory.
// Uses GOCLAW_WORKSPACE (or default ~/.goclaw/workspace) so it works correctly
@@ -472,7 +477,7 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory)
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
- if err := instanceLoader.LoadAll(context.Background()); err != nil {
+ if err := instanceLoader.LoadAll(store.WithCrossTenant(context.Background())); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}
}
@@ -507,7 +512,8 @@ func runGateway() {
})
go func() {
for payload := range auditCh {
- if err := pgStores.Activity.Log(context.Background(), &store.ActivityLog{
+ auditCtx := store.WithTenantID(context.Background(), payload.TenantID)
+ if err := pgStores.Activity.Log(auditCtx, &store.ActivityLog{
ActorType: payload.ActorType,
ActorID: payload.ActorID,
Action: payload.Action,
@@ -612,7 +618,7 @@ func runGateway() {
if err != nil {
return
}
- team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID)
+ team, err := notifyTeamStore.GetTeam(store.WithCrossTenant(context.Background()), teamUUID)
if err != nil || team == nil {
return
}
@@ -654,7 +660,7 @@ func runGateway() {
// Resolve lead agent key (needed for leader mode routing + completed-by-leader skip).
var leadAgentKey string
if notifyAgentStore != nil {
- if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
+ if la, err := notifyAgentStore.GetByID(store.WithCrossTenant(context.Background()), team.LeadAgentID); err == nil {
leadAgentKey = la.AgentKey
}
}
@@ -793,10 +799,11 @@ func runGateway() {
// Uses calibrated token estimation (actual prompt tokens from last LLM call)
// and the agent's real context window (cached on session by the Loop).
sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) {
- history := pgStores.Sessions.GetHistory(sessionKey)
- lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(sessionKey)
+ bctx := context.Background()
+ history := pgStores.Sessions.GetHistory(bctx, sessionKey)
+ lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(bctx, sessionKey)
tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC)
- cw := pgStores.Sessions.GetContextWindow(sessionKey)
+ cw := pgStores.Sessions.GetContextWindow(bctx, sessionKey)
if cw <= 0 {
cw = config.DefaultContextWindow
}
@@ -870,6 +877,23 @@ func runGateway() {
methods.NewAPIKeysMethods(pgStores.APIKeys).Register(server.Router())
}
+ // Tenant management RPC + HTTP
+ if pgStores.Tenants != nil {
+ methods.NewTenantsMethods(pgStores.Tenants, msgBus, workspace).Register(server.Router())
+ server.SetTenantsHandler(httpapi.NewTenantsHandler(pgStores.Tenants, cfg.Gateway.Token, msgBus, workspace))
+ server.Router().SetTenantStore(pgStores.Tenants)
+ // Permission cache for tenant membership checks (tenant role, agent access, etc.)
+ permCache := cache.NewPermissionCache()
+ msgBus.Subscribe("permission-cache", func(e bus.Event) {
+ if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok {
+ permCache.HandleInvalidation(p)
+ }
+ })
+ server.Router().SetPermissionCache(permCache)
+ httpapi.InitTenantStore(pgStores.Tenants, msgBus)
+ httpapi.InitOwnerIDs(cfg.Gateway.OwnerIDs)
+ }
+
// Reload quota config on config changes via pub/sub.
if quotaChecker != nil {
msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go
index 05654f72..37dce7d7 100644
--- a/cmd/gateway_agents.go
+++ b/cmd/gateway_agents.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "context"
"log/slog"
"strings"
@@ -86,7 +87,7 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem
// Fallback: resolve from provider registry (DB-stored providers like "openai-embedding").
// Any OpenAI-compatible provider in the registry can serve embeddings.
if providerReg != nil {
- if regProv, err := providerReg.Get(name); err == nil {
+ if regProv, err := providerReg.Get(context.Background(), name); err == nil {
if op, ok := regProv.(*providers.OpenAIProvider); ok {
embBase := op.APIBase()
if apiBase != "" {
@@ -102,15 +103,15 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem
}
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager) *tools.SubagentManager {
- names := providerReg.List()
+ names := providerReg.List(context.Background())
if len(names) == 0 {
return nil
}
agentCfg := cfg.ResolveAgent("default")
- provider, err := providerReg.Get(agentCfg.Provider)
+ provider, err := providerReg.Get(context.Background(), agentCfg.Provider)
if err != nil {
- provider, _ = providerReg.Get(names[0])
+ provider, _ = providerReg.Get(context.Background(), names[0])
}
if provider == nil {
return nil
diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go
index fa98b727..71f6f209 100644
--- a/cmd/gateway_builtin_tools.go
+++ b/cmd/gateway_builtin_tools.go
@@ -44,33 +44,26 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Requires: []string{"knowledge_graph"},
},
- // media — seed uses chain format: {"providers":[...]}
- {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`),
+ // media — user must configure provider chain via UI before use
+ {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"vision_provider"},
},
- {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"document_provider"},
},
- {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: false,
Requires: []string{"image_gen_provider"},
},
- {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"audio_provider"},
},
- {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"video_provider"},
},
- {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"veo-3.0-generate-preview","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: false,
Requires: []string{"video_gen_provider"},
},
- {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: true,
- Settings: json.RawMessage(`{"providers":[{"provider":"minimax","model":"music-2.5+","enabled":true,"timeout":120,"max_retries":2}]}`),
+ {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: false,
Requires: []string{"audio_gen_provider"},
},
{Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to natural-sounding speech audio", Category: "media", Enabled: true,
diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go
index 2c0edffd..fc055513 100644
--- a/cmd/gateway_channels_setup.go
+++ b/cmd/gateway_channels_setup.go
@@ -143,7 +143,7 @@ func wireChannelEventSubscribers(
if !ok || payload.Kind != bus.CacheKindChannelInstances {
return
}
- go instanceLoader.Reload(context.Background())
+ go instanceLoader.Reload(store.WithCrossTenant(context.Background()))
})
}
@@ -197,7 +197,7 @@ func wireChannelEventSubscribers(
if err != nil {
return
}
- all, err := ciStore.ListAll(context.Background())
+ all, err := ciStore.ListAll(store.WithCrossTenant(context.Background()))
if err != nil {
slog.Warn("cascade disable: failed to list channel instances", "error", err)
return
@@ -205,7 +205,7 @@ func wireChannelEventSubscribers(
disabled := 0
for _, inst := range all {
if inst.AgentID == agentID && inst.Enabled {
- if err := ciStore.Update(context.Background(), inst.ID, map[string]any{"enabled": false}); err != nil {
+ if err := ciStore.Update(store.WithCrossTenant(context.Background()), inst.ID, map[string]any{"enabled": false}); err != nil {
slog.Warn("cascade disable: failed to disable channel instance", "name", inst.Name, "error", err)
} else {
disabled++
diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go
index 53804167..0de0526a 100644
--- a/cmd/gateway_consumer.go
+++ b/cmd/gateway_consumer.go
@@ -120,6 +120,7 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore
if agentStore == nil {
return
}
+ // Caller (processNormalMessage) already injected tenant_id into ctx.
// agentKey may be a slug ("default") or a UUID string (from WS clients).
var ag *store.AgentData
var err error
diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go
index 9b7a2506..310e668a 100644
--- a/cmd/gateway_consumer_handlers.go
+++ b/cmd/gateway_consumer_handlers.go
@@ -39,6 +39,13 @@ func handleSubagentAnnounce(
return false
}
+ // Inject tenant scope — same as processNormalMessage.
+ if msg.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, msg.TenantID)
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ }
+
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
@@ -190,6 +197,13 @@ func handleTeammateMessage(
return false
}
+ // Inject tenant scope — same as processNormalMessage.
+ if msg.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, msg.TenantID)
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ }
+
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
@@ -346,24 +360,21 @@ func handleTeammateMessage(
if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil {
slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err)
} else {
- msgBus.Broadcast(bus.Event{
- Name: protocol.EventTeamTaskFailed,
- Payload: protocol.TeamTaskEventPayload{
- TeamID: teamID.String(),
- TaskID: teamTaskID.String(),
- TaskNumber: taskNumber,
- Subject: taskSubject,
- Status: store.TeamTaskStatusFailed,
- Reason: outcome.Err.Error(),
- Channel: taskChannel,
- ChatID: taskChatID,
- Timestamp: now,
- ActorType: "agent",
- ActorID: toAgent,
- },
+ bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
+ TeamID: teamID.String(),
+ TaskID: teamTaskID.String(),
+ TaskNumber: taskNumber,
+ Subject: taskSubject,
+ Status: store.TeamTaskStatusFailed,
+ Reason: outcome.Err.Error(),
+ Channel: taskChannel,
+ ChatID: taskChatID,
+ Timestamp: now,
+ ActorType: "agent",
+ ActorID: toAgent,
})
}
- } else {
+ } else if outcome.Result != nil {
result := outcome.Result.Content
if len(outcome.Result.Deliverables) > 0 {
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
@@ -374,21 +385,18 @@ func handleTeammateMessage(
if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil {
slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err)
} else {
- msgBus.Broadcast(bus.Event{
- Name: protocol.EventTeamTaskCompleted,
- Payload: protocol.TeamTaskEventPayload{
- TeamID: teamID.String(),
- TaskID: teamTaskID.String(),
- TaskNumber: taskNumber,
- Subject: taskSubject,
- Status: store.TeamTaskStatusCompleted,
- OwnerAgentKey: toAgent,
- Channel: taskChannel,
+ bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
+ TeamID: teamID.String(),
+ TaskID: teamTaskID.String(),
+ TaskNumber: taskNumber,
+ Subject: taskSubject,
+ Status: store.TeamTaskStatusCompleted,
+ OwnerAgentKey: toAgent,
+ Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
- },
})
}
}
@@ -413,6 +421,9 @@ func handleTeammateMessage(
errMsg = errMsg[:500] + "..."
}
announceContent = fmt.Sprintf("[FAILED] %s", errMsg)
+ } else if outcome.Result == nil {
+ slog.Warn("teammate message: nil result without error", "from", senderID)
+ return
} else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
return
@@ -560,8 +571,9 @@ func handleResetCommand(
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
- sessStore.Reset(sessionKey)
- sessStore.Save(sessionKey)
+ ctx := store.WithTenantID(context.Background(), msg.TenantID)
+ sessStore.Reset(ctx, sessionKey)
+ sessStore.Save(ctx, sessionKey)
providers.ResetCLISession("", sessionKey)
slog.Info("inbound: /reset command", "session", sessionKey)
diff --git a/cmd/gateway_consumer_helpers.go b/cmd/gateway_consumer_helpers.go
index 904cc674..420650a9 100644
--- a/cmd/gateway_consumer_helpers.go
+++ b/cmd/gateway_consumer_helpers.go
@@ -116,11 +116,6 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string {
return ""
}
- tokenQuery := ""
- if cfg.Gateway.Token != "" {
- tokenQuery = "?token=" + cfg.Gateway.Token
- }
-
var parts []string
for _, mr := range media {
cleanPath := filepath.Clean(mr.Path)
@@ -129,7 +124,9 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string {
if urlPath == "" {
continue
}
- fileURL := "/v1/files/" + urlPath + tokenQuery
+ // Store clean path only — no auth tokens in persisted session messages.
+ // Frontend adds auth (Bearer header or ?ft= signed token) at render time.
+ fileURL := "/v1/files/" + urlPath
if strings.HasPrefix(mr.ContentType, "image/") {
parts = append(parts, fmt.Sprintf("", fileURL))
} else {
diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go
index 9035caa0..4a380628 100644
--- a/cmd/gateway_consumer_normal.go
+++ b/cmd/gateway_consumer_normal.go
@@ -38,13 +38,21 @@ func processNormalMessage(
postTurn tools.PostTurnProcessor,
msgBus *bus.MessageBus,
) {
+ // Inject tenant from channel instance into context so all store operations
+ // (agent lookup, session creation, etc.) are tenant-scoped.
+ if msg.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, msg.TenantID)
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ }
+
// Determine target agent via bindings or explicit AgentID
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
- agentLoop, err := agents.Get(agentID)
+ agentLoop, err := agents.Get(ctx, agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
return
@@ -96,7 +104,7 @@ func processNormalMessage(
// Persist friendly names from channel metadata into session + user profile.
sessionMeta := extractSessionMetadata(msg, peerKind)
if len(sessionMeta) > 0 {
- sessStore.SetSessionMetadata(sessionKey, sessionMeta)
+ sessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta)
if agentStore != nil {
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
@@ -298,6 +306,11 @@ func processNormalMessage(
}
}
+ // Inject tenant context from channel instance so all store queries are tenant-scoped.
+ if msg.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, msg.TenantID)
+ }
+
// Inject post-turn dispatch tracker so team task creates are deferred.
ptd := tools.NewPendingTeamDispatch()
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
diff --git a/cmd/gateway_consumer_process.go b/cmd/gateway_consumer_process.go
index 9f75bc17..08be6a06 100644
--- a/cmd/gateway_consumer_process.go
+++ b/cmd/gateway_consumer_process.go
@@ -30,7 +30,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru
}
}
- loop, err := agents.Get(agentID)
+ loop, err := agents.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("agent %s not found: %w", agentID, err)
}
diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go
index 2075f1f6..1abb565b 100644
--- a/cmd/gateway_cron.go
+++ b/cmd/gateway_cron.go
@@ -60,8 +60,11 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
)
}
+ // Build context with tenant scope so agent loop events are scoped correctly.
+ cronCtx := store.WithTenantID(context.Background(), job.TenantID)
+
// Schedule through cron lane — scheduler handles agent resolution and concurrency
- outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{
+ outCh := sched.Schedule(cronCtx, scheduler.LaneCron, agent.RunRequest{
SessionKey: sessionKey,
Message: job.Payload.Message,
Channel: channel,
diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go
index 66f9ce24..483df227 100644
--- a/cmd/gateway_http_handlers.go
+++ b/cmd/gateway_http_handlers.go
@@ -9,16 +9,14 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
-// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages).
-func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler) {
+// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages).
+func wireHTTP(stores *store.Stores, token, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
var mcpH *httpapi.MCPHandler
- var customToolsH *httpapi.CustomToolsHandler
var channelInstancesH *httpapi.ChannelInstancesHandler
var providersH *httpapi.ProvidersHandler
- var delegationsH *httpapi.DelegationsHandler
var builtinToolsH *httpapi.BuiltinToolsHandler
var pendingMessagesH *httpapi.PendingMessagesHandler
var secureCLIH *httpapi.SecureCLIHandler
@@ -35,7 +33,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok {
dirs := pgSkills.Dirs()
if len(dirs) > 0 {
- skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], token, msgBus)
+ skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, token, msgBus)
}
}
}
@@ -47,13 +45,13 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
if stores != nil && stores.MCP != nil {
mcpH = httpapi.NewMCPHandler(stores.MCP, token, msgBus, mcpToolLister)
}
-
- if stores != nil && stores.CustomTools != nil {
- customToolsH = httpapi.NewCustomToolsHandler(stores.CustomTools, token, msgBus, toolsReg)
+ var mcpUserCredsH *httpapi.MCPUserCredentialsHandler
+ if stores != nil && stores.MCP != nil {
+ mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, token)
}
if stores != nil && stores.ChannelInstances != nil {
- channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus)
+ channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, token, msgBus)
}
if stores != nil && stores.Providers != nil {
@@ -67,12 +65,11 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
var teamEventsH *httpapi.TeamEventsHandler
if stores != nil && stores.Teams != nil {
- delegationsH = httpapi.NewDelegationsHandler(stores.Teams, token)
teamEventsH = httpapi.NewTeamEventsHandler(stores.Teams, token)
}
if stores != nil && stores.BuiltinTools != nil {
- builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, token, msgBus)
+ builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, token, msgBus)
}
if stores != nil && stores.PendingMessages != nil {
@@ -83,5 +80,5 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, token, msgBus)
}
- return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH
+ return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH
}
diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go
index 84508a14..5c6db2f7 100644
--- a/cmd/gateway_managed.go
+++ b/cmd/gateway_managed.go
@@ -8,9 +8,12 @@ import (
"github.com/google/uuid"
+ "strings"
+
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
@@ -45,7 +48,6 @@ func wireExtras(
injectionAction string,
appCfg *config.Config,
sandboxMgr sandbox.Manager,
- dynamicLoader *tools.DynamicToolLoader,
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
// 1. Build cache instances (in-memory or Redis depending on build tags)
@@ -115,7 +117,7 @@ func wireExtras(
// 5. Shared MCP connection pool (eliminates duplicate connections across agents)
var mcpPool *mcpbridge.Pool
if stores.MCP != nil {
- mcpPool = mcpbridge.NewPool()
+ mcpPool = mcpbridge.NewPool(mcpbridge.DefaultPoolConfig())
}
// 6. Set up agent resolver: lazy-creates Loops from DB
@@ -145,7 +147,6 @@ func wireExtras(
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
- DynamicLoader: dynamicLoader,
AgentLinkStore: stores.AgentLinks,
TeamStore: stores.Teams,
DataDir: workspace,
@@ -158,10 +159,22 @@ func wireExtras(
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
MemoryStore: stores.Memory,
+ TenantStore: stores.Tenants,
+ BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs,
+ SkillTenantCfgs: stores.SkillTenantCfgs,
+ Workspace: workspace,
OnEvent: func(event agent.AgentEvent) {
+ // Sign /v1/files/ and /v1/media/ URLs in content before delivery.
+ // Sessions store clean paths; signing happens only at delivery time.
+ if m, ok := event.Payload.(map[string]string); ok {
+ if c, has := m["content"]; has && strings.Contains(c, "/v1/") {
+ m["content"] = httpapi.SignFileURLs(c, httpapi.FileSigningKey())
+ }
+ }
msgBus.Broadcast(bus.Event{
- Name: protocol.EventAgent,
- Payload: event,
+ Name: protocol.EventAgent,
+ Payload: event,
+ TenantID: event.TenantID,
})
},
})
@@ -381,22 +394,6 @@ func wireExtras(
})
}
- // Custom tools cache: reload global tools on create/update/delete
- if dynamicLoader != nil {
- msgBus.Subscribe(bus.TopicCacheCustomTools, func(event bus.Event) {
- if event.Name != protocol.EventCacheInvalidate {
- return
- }
- payload, ok := event.Payload.(bus.CacheInvalidatePayload)
- if !ok || payload.Kind != bus.CacheKindCustomTools {
- return
- }
- dynamicLoader.ReloadGlobal(context.Background(), toolsReg)
- // Invalidate all agent caches so they re-resolve with updated tools
- agentRouter.InvalidateAll()
- })
- }
-
// Builtin tools cache: re-apply disables on settings/enabled changes
if stores.BuiltinTools != nil {
msgBus.Subscribe(bus.TopicCacheBuiltinTools, func(event bus.Event) {
@@ -530,7 +527,7 @@ func buildKGExtractFunc(kgStore store.KnowledgeGraphStore, bts store.BuiltinTool
return
}
- p, err := providerReg.Get(settings.ExtractionProvider)
+ p, err := providerReg.Get(ctx, settings.ExtractionProvider)
if err != nil {
slog.Warn("kg extract: provider not found", "provider", settings.ExtractionProvider, "error", err)
return
diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go
index 9749a114..d4544961 100644
--- a/cmd/gateway_methods.go
+++ b/cmd/gateway_methods.go
@@ -19,14 +19,14 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
chatMethods := methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus)
chatMethods.Register(router)
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
- methods.NewSessionsMethods(sessStore, msgBus).Register(router)
+ methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router)
// Phase 2: Skills (uses SkillStore interface — PG or File)
methods.NewSkillsMethods(skillStore).Register(router)
// Phase 2: Cron (store created externally, shared with gateway)
- methods.NewCronMethods(cronStore, msgBus).Register(router)
+ methods.NewCronMethods(cronStore, msgBus, cfg).Register(router)
// Phase 2: Heartbeat
heartbeatMethods := methods.NewHeartbeatMethods(heartbeatStore, msgBus)
@@ -52,11 +52,6 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
// Phase 3: Live log tailing
methods.NewLogsMethods(logTee).Register(router)
- // Phase 4: Delegation history
- if teamStore != nil {
- methods.NewDelegationsMethods(teamStore).Register(router)
- }
-
slog.Info("registered all RPC methods",
"phase1", []string{"chat", "agents", "sessions", "config"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"},
diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go
index b505c68a..6b4ed746 100644
--- a/cmd/gateway_providers.go
+++ b/cmd/gateway_providers.go
@@ -233,7 +233,7 @@ func jsonToStringMap(data json.RawMessage) map[string]string {
// mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config.
// cfg provides fallback api_base values from config/env when DB providers have none set.
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) {
- ctx := context.Background()
+ ctx := store.WithCrossTenant(context.Background())
dbProviders, err := provStore.ListProviders(ctx)
if err != nil {
slog.Warn("failed to load providers from DB", "error", err)
@@ -265,7 +265,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore)
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
- registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
+ registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
slog.Info("registered provider from DB", "name", p.Name)
continue
}
@@ -280,7 +280,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
if host == "" {
host = "http://localhost:11434"
}
- registry.Register(providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3"))
+ registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3"))
slog.Info("registered provider from DB", "name", p.Name)
continue
}
@@ -297,37 +297,37 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}
switch p.ProviderType {
case store.ProviderChatGPTOAuth:
- ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name)
- registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, ""))
+ ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID)
+ registry.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, p.APIBase, ""))
case store.ProviderAnthropicNative:
- registry.Register(providers.NewAnthropicProvider(p.APIKey,
+ registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
providers.WithAnthropicBaseURL(p.APIBase)))
case store.ProviderDashScope:
- registry.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
+ registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
case store.ProviderBailian:
base := p.APIBase
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
- registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
+ registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
case store.ProviderZai:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
}
- registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
+ registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderZaiCoding:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
}
- registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
+ registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderOllamaCloud:
base := p.APIBase
if base == "" {
base = "https://ollama.com/v1"
}
- registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
+ registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
case store.ProviderSuno:
// Suno is a media-only provider (music gen). Register as OpenAI-compat
// so credentialProvider interface works for API key/base extraction.
@@ -337,14 +337,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, "")
prov.WithProviderType(p.ProviderType)
- registry.Register(prov)
+ registry.RegisterForTenant(p.TenantID, prov)
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
prov.WithProviderType(p.ProviderType)
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
- registry.Register(prov)
+ registry.RegisterForTenant(p.TenantID, prov)
}
slog.Info("registered provider from DB", "name", p.Name)
}
@@ -416,7 +416,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) {
if workDir == "" {
workDir = defaultACPWorkDir()
}
- registry.Register(providers.NewACPProvider(
+ registry.RegisterForTenant(p.TenantID, providers.NewACPProvider(
binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(),
providers.WithACPModel(p.Name),
))
diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go
index e2543f47..f32d290e 100644
--- a/cmd/gateway_setup.go
+++ b/cmd/gateway_setup.go
@@ -201,6 +201,15 @@ func setupToolRegistry(
if et, ok := execTool.(*tools.ExecTool); ok {
et.DenyPaths(dataDir, ".goclaw/")
et.AllowPathExemptions(".goclaw/skills-store/")
+ // Harden: block access to internal workspace files via shell commands.
+ // Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces.
+ et.DenyPaths(
+ filepath.Join(workspace, "memory.db"),
+ filepath.Join(workspace, "memory.db-wal"),
+ filepath.Join(workspace, "memory.db-shm"),
+ filepath.Join(workspace, "config.json"),
+ filepath.Join(workspace, "delegate"),
+ )
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
}
@@ -214,11 +223,17 @@ func setupToolRegistry(
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
- "memory/", ".media/", "delegate/",
+ "memory/", ".media/", ".uploads/", "delegate/",
+ }
+ // read_file: allow .media/ access (uploaded documents accessed via AllowPaths
+ // for backward compat; new uploads go to per-user .uploads/ within workspace).
+ readFileDenyPaths := []string{
+ "config.json", "memory.db", "memory.db-wal", "memory.db-shm",
+ "memory/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
if t, ok := rf.(*tools.ReadFileTool); ok {
- t.DenyPaths(internalDenyPaths...)
+ t.DenyPaths(readFileDenyPaths...)
}
}
if wf, ok := toolsReg.Get("write_file"); ok {
@@ -338,7 +353,7 @@ func setupMemoryEmbeddings(
if pgStores.Memory != nil {
memCfg := cfg.Agents.Defaults.Memory
if pgStores.Agents != nil {
- if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil {
+ if defaultAgent, agErr := pgStores.Agents.GetByKey(store.WithCrossTenant(context.Background()), "default"); agErr == nil {
if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil {
memCfg = agentMemCfg
slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey)
@@ -452,7 +467,9 @@ func setupSkillsSystem(
toolsReg *tools.Registry,
providerRegistry *providers.Registry,
msgBus *bus.MessageBus,
-) (*skills.Loader, *tools.SkillSearchTool, string) {
+) (*skills.Loader, *tools.SkillSearchTool, string, string) {
+ var bundledSkillsDir string // resolved later; returned for HTTP handler fallback
+
// Skills loader + search tool
// Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/.
globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR")
@@ -469,7 +486,7 @@ func setupSkillsSystem(
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
toolsReg.Register(skillSearchTool)
toolsReg.Register(tools.NewUseSkillTool())
- slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills()))
+ slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(store.WithCrossTenant(context.Background()))))
// Wire skills-store directory into filesystem loader so agents
// can discover uploaded skills in their system prompt and BM25 search index.
@@ -480,7 +497,7 @@ func setupSkillsSystem(
slog.Info("skills-store directory wired into loader", "dir", storeDirs[0])
// Seed system/bundled skills into DB
- bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
+ bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
if bundledSkillsDir == "" {
// Check common locations: Docker default, then local dev
for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} {
@@ -518,7 +535,7 @@ func setupSkillsSystem(
if len(storeDirs) > 0 {
toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader))
slog.Info("publish_skill tool registered")
- toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], skillsLoader))
+ toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], dataDir, skillsLoader))
slog.Info("skill_manage tool registered")
}
}
@@ -549,6 +566,6 @@ func setupSkillsSystem(
}
}
- return skillsLoader, skillSearchTool, globalSkillsDir
+ return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir
}
diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go
index 542d4691..49dc1459 100644
--- a/cmd/skills_cmd.go
+++ b/cmd/skills_cmd.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "context"
"encoding/json"
"fmt"
"os"
@@ -30,7 +31,7 @@ func skillsListCmd() *cobra.Command {
Short: "List all available skills",
Run: func(cmd *cobra.Command, args []string) {
loader := loadSkillsLoader()
- allSkills := loader.ListSkills()
+ allSkills := loader.ListSkills(context.Background())
if jsonOutput {
data, _ := json.MarshalIndent(allSkills, "", " ")
@@ -66,7 +67,7 @@ func skillsShowCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
loader := loadSkillsLoader()
- info, ok := loader.GetSkill(args[0])
+ info, ok := loader.GetSkill(context.Background(), args[0])
if !ok {
fmt.Fprintf(os.Stderr, "Skill not found: %s\n", args[0])
os.Exit(1)
@@ -77,7 +78,7 @@ func skillsShowCmd() *cobra.Command {
fmt.Printf("Location: %s\n", info.Path)
fmt.Println()
- content, ok := loader.LoadSkill(args[0])
+ content, ok := loader.LoadSkill(context.Background(), args[0])
if ok {
fmt.Println("--- Content ---")
fmt.Println(content)
diff --git a/docs/18-http-api.md b/docs/18-http-api.md
index ee4d95bc..4cae6e79 100644
--- a/docs/18-http-api.md
+++ b/docs/18-http-api.md
@@ -32,6 +32,7 @@ API keys are hashed with SHA-256 before lookup — the raw key is never stored.
| `Authorization` | Bearer token for authentication |
| `X-GoClaw-User-Id` | External user ID for multi-tenant context |
| `X-GoClaw-Agent-Id` | Agent identifier for scoped operations |
+| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |
| `Accept-Language` | Locale (`en`, `vi`, `zh`) for i18n error messages |
| `Content-Type` | `application/json` for request bodies |
diff --git a/docs/23-multi-tenant-architecture.md b/docs/23-multi-tenant-architecture.md
new file mode 100644
index 00000000..ac801a84
--- /dev/null
+++ b/docs/23-multi-tenant-architecture.md
@@ -0,0 +1,387 @@
+# Multi-Tenant Integration Guide
+
+GoClaw is an **AI agent gateway** — it handles agents, chat, sessions, tools, MCP servers, and memory. It supports two deployment modes:
+
+1. **Personal / Single-tenant** — Use GoClaw directly as your AI backend. Built-in dashboard included.
+2. **SaaS / Multi-tenant** — Integrate GoClaw behind your application. API keys bridge the two systems.
+
+---
+
+## Deployment Modes
+
+### Mode 1: Personal Use (Single-Tenant)
+
+Use GoClaw as a standalone AI backend with its built-in web dashboard. No separate frontend or backend needed.
+
+```mermaid
+graph LR
+ U[You] -->|browser| GC[GoClaw Dashboard
+ Gateway]
+ GC --> AG[Agents / Chat / Tools]
+ AG --> DB[(PostgreSQL)]
+ AG -->|LLM calls| LLM[Anthropic / OpenAI / Gemini / ...]
+```
+
+**How it works:**
+- Log in with the gateway token via the built-in web dashboard
+- Create agents, configure LLM providers, chat — all from the dashboard
+- Connect chat channels (Telegram, Discord, etc.) for messaging
+- All data lives under the default "master" tenant — no tenant config needed
+
+**Setup:**
+
+```bash
+# 1. Build and onboard
+go build -o goclaw . && ./goclaw onboard
+
+# 2. Start the gateway
+source .env.local && ./goclaw
+
+# 3. Open dashboard at http://localhost:3777
+# Log in with your gateway token + user ID "system"
+```
+
+**When to use:** Personal AI assistant, small team, self-hosted AI tools, development/testing.
+
+**Scaling up:** When you need multiple isolated environments (clients, departments, projects), create additional tenants. Multi-tenant features activate automatically — no migration needed.
+
+---
+
+### Mode 2: SaaS Integration (Multi-Tenant)
+
+Integrate GoClaw as the AI engine behind your SaaS application. Your app handles auth, billing, and UI. GoClaw handles AI. Each tenant is fully isolated — agents, sessions, memory, teams, providers, and files.
+
+```mermaid
+graph TB
+ subgraph "Tenant A"
+ FEa[Frontend A]
+ BEa[Backend A]
+ TGa[Telegram Bot A]
+ end
+
+ subgraph "Tenant B"
+ FEb[Frontend B]
+ BEb[Backend B]
+ DCb[Discord Bot B]
+ end
+
+ subgraph "GoClaw Gateway"
+ subgraph "Entry Points"
+ HTTP[HTTP API]
+ WS[WebSocket]
+ CH[Channel Manager]
+ end
+
+ TI{Tenant Isolation
Layer}
+
+ subgraph "Tenant-Scoped Engine"
+ AG[Agent Loop]
+ TOOLS[Tools / Skills / MCP]
+ MEM[Memory / KG]
+ end
+
+ DB[(PostgreSQL
WHERE tenant_id = $N)]
+ end
+
+ FEa -->|authenticated| BEa
+ BEa -->|API Key A + user_id| HTTP
+ TGa -->|webhook| CH
+
+ FEb -->|authenticated| BEb
+ BEb -->|API Key B + user_id| HTTP
+ DCb -->|webhook| CH
+
+ HTTP --> TI
+ WS --> TI
+ CH --> TI
+
+ TI -->|ctx with tenant_id| AG
+ AG --> TOOLS
+ AG --> MEM
+ AG --> DB
+ TOOLS --> DB
+ MEM --> DB
+```
+
+**How it works:**
+- Each tenant's **backend connects via a tenant-bound API key** — GoClaw auto-scopes all data
+- **Chat channels** (Telegram, Discord, etc.) connect directly — tenant resolved from channel instance config
+- The **Tenant Isolation Layer** resolves tenant_id from credentials and injects it into Go context
+- Every SQL query enforces `WHERE tenant_id = $N` — fail-closed, no cross-tenant leakage
+
+**When to use:** SaaS products with AI features, multi-client platforms, white-label AI solutions.
+
+### Connection Types Summary
+
+All connections go through the **Tenant Isolation Layer** before reaching the agent engine:
+
+| Connection | Auth Method | Tenant Resolution | Isolation |
+|------------|-------------|-------------------|-----------|
+| **HTTP API** | `Bearer` token (API key or gateway token) | Auto from API key's `tenant_id` | Per-request |
+| **WebSocket** | Token on `connect` (API key or gateway token) | Auto from API key's `tenant_id` | Per-session |
+| **Chat Channels** | None (direct webhook/WS) | Baked into channel instance DB config | Per-instance |
+| **Dashboard** | Gateway token or browser pairing | User's tenant membership | Per-session |
+
+**Tenant Isolation Layer** — resolves credentials → injects `tenant_id` into Go `context.Context` → all downstream SQL queries enforce `WHERE tenant_id = $N`. Fail-closed: missing tenant = error, never unfiltered data.
+
+---
+
+## Tenant Setup (Multi-Tenant Only)
+
+```mermaid
+sequenceDiagram
+ participant Admin as System Admin
+ participant GC as GoClaw API
+
+ Admin->>GC: tenants.create {name: "Acme Corp", slug: "acme"}
+ GC-->>Admin: {id: "tenant-uuid", slug: "acme"}
+
+ Admin->>GC: tenants.users.add {tenant_id, user_id: "user-123", role: "admin"}
+
+ Admin->>GC: api_keys.create {tenant_id, scopes: ["operator.read", "operator.write"]}
+ GC-->>Admin: {key: "goclaw_sk_abc123..."}
+
+ Note over Admin: Store API key in your backend's config/secrets
+```
+
+Each tenant gets isolated: **agents, sessions, teams, memory, LLM providers, MCP servers, skills**. A tenant-bound API key automatically scopes every request — no extra headers needed.
+
+---
+
+## Tenant Resolution
+
+GoClaw determines the tenant from the credentials used to connect:
+
+| Credential | Tenant Resolution | Use Case |
+|------------|-------------------|----------|
+| **Gateway token** + owner user ID | All tenants (cross-tenant) | System administration |
+| **Gateway token** + non-owner user ID | User's tenant membership | Dashboard users |
+| **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration |
+| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug) | Cross-tenant admin tools |
+| **Browser pairing** | Paired tenant | Dashboard operators |
+| **No credentials** | Master tenant | Dev/single-user mode |
+
+**Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`.
+
+**Recommended for SaaS**: Use **tenant-bound API keys**. The tenant is resolved automatically from the key — your backend doesn't need to send any tenant header.
+
+---
+
+## HTTP API
+
+All HTTP endpoints accept standard headers:
+
+| Header | Required | Description |
+|--------|:---:|-------------|
+| `Authorization` | Yes | `Bearer ` |
+| `X-GoClaw-User-Id` | Yes | Your app's user ID (max 255 chars). Scopes sessions and per-user data |
+| `X-GoClaw-Tenant-Id` | No | Tenant UUID or slug. Only needed for system-level keys |
+| `X-GoClaw-Agent-Id` | No | Target agent ID (alternative to `model` field) |
+| `Accept-Language` | No | Locale for error messages: `en`, `vi`, `zh` |
+
+### Chat (OpenAI-Compatible)
+
+```bash
+curl -X POST https://goclaw.example.com/v1/chat/completions \
+ -H "Authorization: Bearer goclaw_sk_abc123..." \
+ -H "X-GoClaw-User-Id: user-456" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "agent:my-agent",
+ "messages": [{"role": "user", "content": "Hello"}]
+ }'
+```
+
+The `model` field uses `agent:` format. The API key is bound to tenant "Acme Corp" — the response only includes data from that tenant.
+
+### List Resources
+
+```bash
+# List agents
+curl https://goclaw.example.com/v1/agents \
+ -H "Authorization: Bearer goclaw_sk_abc123..." \
+ -H "X-GoClaw-User-Id: user-456"
+
+# List sessions
+curl https://goclaw.example.com/v1/sessions \
+ -H "Authorization: Bearer goclaw_sk_abc123..." \
+ -H "X-GoClaw-User-Id: user-456"
+```
+
+### System Admin (Cross-Tenant)
+
+```bash
+# List agents for a specific tenant (requires gateway token + owner user ID)
+curl https://goclaw.example.com/v1/agents \
+ -H "Authorization: Bearer $GATEWAY_TOKEN" \
+ -H "X-GoClaw-Tenant-Id: acme" \
+ -H "X-GoClaw-User-Id: system"
+```
+
+---
+
+## WebSocket Integration
+
+For real-time features (streaming chat, live events), connect via WebSocket:
+
+```mermaid
+sequenceDiagram
+ participant FE as Your Frontend
+ participant BE as Your Backend
+ participant GC as GoClaw Gateway
+
+ FE->>BE: User sends message (authenticated)
+ BE->>GC: WS connect {token: "goclaw_sk_abc...", user_id: "user-456"}
+ GC-->>BE: {role: "operator", tenant_id, tenant_name, tenant_slug}
+
+ BE->>GC: chat.send {agent_key: "my-agent", message: "Hello"}
+ GC-->>BE: event: agent {type: "chunk", content: "Hi..."}
+ BE-->>FE: Stream response to user
+ GC-->>BE: event: agent {type: "run.completed"}
+```
+
+After `connect`, **all methods are auto-scoped** to the API key's tenant. Events are server-side filtered — your backend only receives events belonging to its tenant.
+
+**Protocol**: Frame types `req` (client→server), `res` (server→client), `event` (async push). Protocol version 3.
+
+---
+
+## Chat Channels
+
+Chat channels (Telegram, Discord, Zalo, Slack, WhatsApp, Feishu) connect **directly** to GoClaw — no API key needed. Tenant isolation is baked into the channel instance at registration time.
+
+```mermaid
+sequenceDiagram
+ participant TG as Telegram
+ participant CH as Channel Manager
+ participant TI as Tenant Isolation
+ participant AG as Agent Loop
+ participant DB as PostgreSQL
+
+ Note over CH: Channel instance loaded from DB
with tenant_id baked in
+
+ TG->>CH: Incoming message (webhook)
+ CH->>TI: Resolve tenant from instance config
+ TI->>AG: ctx with tenant_id + user_id
+ AG->>DB: Query with WHERE tenant_id = $N
+ AG-->>CH: Agent response
+ CH-->>TG: Reply to user
+```
+
+Each channel instance stores its `tenant_id` in the `channel_instances` table. When a message arrives, the Channel Manager looks up the instance config and injects the tenant context — no headers or tokens required.
+
+| Channel | Protocol | Tenant Source |
+|---------|----------|---------------|
+| Telegram | Webhook | Instance config |
+| Discord | WebSocket | Instance config |
+| Zalo | Webhook | Instance config |
+| Slack | Events API | Instance config |
+| WhatsApp | Webhook | Instance config |
+| Feishu/Lark | Webhook | Instance config |
+
+---
+
+## API Key Scopes
+
+API keys use scopes to control access level:
+
+| Scope | Role | Permissions |
+|-------|------|-------------|
+| `operator.admin` | admin | Full access — agents, config, API keys, tenants |
+| `operator.read` | viewer | Read-only — list agents, sessions, configs |
+| `operator.write` | operator | Read + write — chat, create sessions, manage agents |
+| `operator.approvals` | operator | Approve/reject execution requests |
+| `operator.provision` | operator | Create tenants + manage tenant users |
+| `operator.pairing` | operator | Manage device pairing |
+
+A key with `["operator.read", "operator.write"]` gets `operator` role. A key with `["operator.admin"]` gets `admin` role.
+
+---
+
+## Per-Tenant Overrides
+
+Tenants can customize their environment without affecting other tenants:
+
+| Feature | Scope | How |
+|---------|-------|-----|
+| **LLM Providers** | Per-tenant provider configs | Each tenant registers own API keys + models |
+| **Builtin Tools** | Enable/disable per tenant | `builtin_tool_tenant_configs` table |
+| **Skills** | Enable/disable per tenant | `skill_tenant_configs` table |
+| **MCP Servers** | Per-tenant + per-user credentials | Server-level shared, user-level overrides |
+| **MCP Require User Credentials** | Per-server setting | `settings.require_user_credentials` — forces per-user API keys |
+
+MCP servers support two credential tiers:
+- **Server-level** (shared): configured in the MCP server form, used by all users
+- **User-level** (overrides): configured via "My Credentials", per-user API keys merged at runtime (user wins on key collision)
+
+When `require_user_credentials` is enabled, users without personal credentials cannot use that MCP server.
+
+---
+
+## Security
+
+| Concern | How GoClaw Handles It |
+|---------|-----------------------|
+| API key exposure | Keys stay in your backend — never sent to browser |
+| Cross-tenant data access | All SQL queries include `WHERE tenant_id = $N` (fail-closed) |
+| Event leakage | Server-side 3-mode filter: unscoped admin, scoped admin, regular user |
+| Missing tenant context | Fail-closed: returns error, never unfiltered data |
+| API key storage | Keys hashed with SHA-256 at rest; only prefix shown in UI |
+| Tenant impersonation | Tenant resolved from API key binding, not client headers |
+| Privilege escalation | Role derived from key scopes, not client claims |
+| Gateway token abuse | Only configured owner IDs get cross-tenant; others are tenant-scoped |
+| System config access | Config page restricted to cross-tenant owners only |
+| Logout isolation | Tenant scope cleared from localStorage on logout |
+| Tenant access revocation | Proactive WS event + `TENANT_ACCESS_REVOKED` error forces immediate UI logout |
+| File URL security | HMAC-signed file tokens (`?ft=`) — no gateway token in URLs |
+
+---
+
+## Tenant Data Model
+
+```mermaid
+erDiagram
+ TENANTS ||--o{ TENANT_USERS : "members"
+ TENANTS ||--o{ API_KEYS : "keys"
+ TENANTS ||--o{ AGENTS : "owns"
+ TENANTS ||--o{ SESSIONS : "owns"
+ TENANTS ||--o{ TEAMS : "owns"
+ TENANTS ||--o{ LLM_PROVIDERS : "configures"
+ TENANTS ||--o{ MCP_SERVERS : "registers"
+ TENANTS ||--o{ SKILLS : "manages"
+
+ TENANTS {
+ uuid id PK
+ string name
+ string slug UK
+ string status
+ jsonb settings
+ }
+
+ TENANT_USERS {
+ uuid tenant_id FK
+ string user_id
+ string role
+ }
+
+ API_KEYS {
+ uuid id PK
+ uuid tenant_id FK "NULL = system key"
+ string owner_id
+ text[] scopes
+ boolean revoked
+ }
+```
+
+40+ tables carry `tenant_id` with NOT NULL constraint. Exception: `api_keys.tenant_id` is nullable — NULL means system-level cross-tenant key.
+
+**Master tenant** (UUID `0193a5b0-7000-7000-8000-000000000001`): All legacy/default data. Single-tenant deployments use this exclusively.
+
+---
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `GOCLAW_OWNER_IDS` | `system` | Comma-separated user IDs with cross-tenant access |
+| `GOCLAW_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
+| `GOCLAW_CONFIG` | `config.json5` | Path to gateway config file |
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
index f05b0e1d..111e5d7a 100644
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@ -43,6 +43,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if l.agentUUID != uuid.Nil {
ctx = store.WithAgentID(ctx, l.agentUUID)
}
+ // Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.)
+ if l.tenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, l.tenantID)
+ }
// Inject user ID into context for per-user scoping (memory, context files, etc.)
if req.UserID != "" {
ctx = store.WithUserID(ctx, req.UserID)
@@ -182,7 +186,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if tools.IsSharedWorkspace(team.Settings) {
wsChat = ""
}
- if wsDir, err := tools.WorkspaceDir(l.dataDir, team.ID, wsChat); err == nil {
+ tenantBase := config.TenantWorkspace(l.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx))
+ if wsDir, err := tools.WorkspaceDir(tenantBase, team.ID, wsChat); err == nil {
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
if team.LeadAgentID == l.agentUUID {
ctx = tools.WithToolWorkspace(ctx, wsDir)
@@ -196,7 +201,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Persist agent UUID + user ID on the session (for querying/tracing)
if l.agentUUID != uuid.Nil || req.UserID != "" {
- l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID)
+ l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID)
}
// Security: scan user message for injection patterns.
@@ -247,19 +252,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// 0. Cache agent's context window on the session (first run only).
// Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K.
- if l.sessions.GetContextWindow(req.SessionKey) <= 0 {
- l.sessions.SetContextWindow(req.SessionKey, l.contextWindow)
+ if l.sessions.GetContextWindow(ctx, req.SessionKey) <= 0 {
+ l.sessions.SetContextWindow(ctx, req.SessionKey, l.contextWindow)
}
// 0b. Load adaptive tool timing from session metadata.
- toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(req.SessionKey))
+ toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(ctx, req.SessionKey))
// Resolve slow_tool notification config from already-loaded team settings (no extra DB query).
slowToolEnabled := tools.ParseTeamNotifyConfig(resolvedTeamSettings).SlowTool
// 1. Build messages from session history
- history := l.sessions.GetHistory(req.SessionKey)
- summary := l.sessions.GetSummary(req.SessionKey)
+ history := l.sessions.GetHistory(ctx, req.SessionKey)
+ summary := l.sessions.GetSummary(ctx, req.SessionKey)
// buildMessages resolves context files once and also detects BOOTSTRAP.md presence
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
@@ -279,14 +284,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// 2. Process media: sanitize images, persist to media store.
var mediaRefs []providers.MediaRef
if len(req.Media) > 0 {
- mediaRefs = l.persistMedia(req.SessionKey, req.Media)
- // Load current-turn images from persisted refs.
+ mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
+ // Load current-turn images from persisted refs (Path is always set for new uploads).
var imageFiles []bus.MediaFile
for _, ref := range mediaRefs {
- if ref.Kind == "image" {
- if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
- imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType})
- }
+ if ref.Kind == "image" && ref.Path != "" {
+ imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
}
}
if images := loadImages(imageFiles); len(images) > 0 {
@@ -384,7 +387,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if len(mediaRefs) > 0 && l.mediaStore != nil {
var mediaPaths []string
for _, ref := range mediaRefs {
- if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
+ // Prefer workspace-local path (.uploads/) over canonical .media/ path.
+ if ref.Path != "" {
+ mediaPaths = append(mediaPaths, ref.Path)
+ } else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
mediaPaths = append(mediaPaths, p)
}
}
@@ -636,6 +642,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
toolDefs = l.tools.ProviderDefs()
}
+ // Per-tenant tool exclusions: remove tools disabled for this agent's tenant.
+ if len(l.disabledTools) > 0 {
+ filtered := toolDefs[:0]
+ for _, td := range toolDefs {
+ if !l.disabledTools[td.Function.Name] {
+ filtered = append(filtered, td)
+ } else {
+ delete(allowedTools, td.Function.Name)
+ }
+ }
+ toolDefs = filtered
+ }
+
// Bootstrap mode: restrict API tool definitions to write_file only (open agents).
// Predefined agents keep all tools — BOOTSTRAP.md guides behavior.
if hadBootstrap && l.agentType != store.AgentTypePredefined {
@@ -874,7 +893,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if sanitized != "" && !IsSilentReply(sanitized) {
blockReplies++
lastBlockReply = sanitized
- l.emit(AgentEvent{
+ emitRun(AgentEvent{
Type: protocol.AgentEventBlockReply,
AgentID: l.id,
RunID: req.RunID,
@@ -1032,6 +1051,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
} else if mr := parseMediaResult(result.ForLLM); mr != nil {
mediaResults = append(mediaResults, *mr)
}
+ // Auto-attach workspace media to task (covers create_image/audio/video).
+ if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" {
+ for _, mf := range result.Media {
+ tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path)
+ }
+ }
if result.Deliverable != "" {
deliverables = append(deliverables, result.Deliverable)
}
@@ -1229,6 +1254,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
} else if mr := parseMediaResult(r.result.ForLLM); mr != nil {
mediaResults = append(mediaResults, *mr)
}
+ // Auto-attach workspace media to task (covers create_image/audio/video).
+ if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" {
+ for _, mf := range r.result.Media {
+ tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path)
+ }
+ }
if r.result.Deliverable != "" {
deliverables = append(deliverables, r.result.Deliverable)
}
@@ -1403,27 +1434,27 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Flush all buffered messages to session atomically.
// This ensures concurrent runs never see each other's in-progress messages.
for _, msg := range pendingMsgs {
- l.sessions.AddMessage(req.SessionKey, msg)
+ l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
- l.sessions.SetSessionMetadata(req.SessionKey, map[string]string{"tool_timing": serialized})
+ l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
- l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel)
- l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens))
+ l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
+ l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
// Next time EstimateTokensWithCalibration() is called, it uses this as a base
// instead of the chars/3 heuristic (more accurate for multilingual content).
if totalUsage.PromptTokens > 0 {
msgCount := len(history) + len(pendingMsgs)
- l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount)
+ l.sessions.SetLastPromptTokens(ctx, req.SessionKey, totalUsage.PromptTokens, msgCount)
}
- l.sessions.Save(req.SessionKey)
+ l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md
// as a safety net in case the LLM didn't clear it itself.
diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go
index 7e67d883..bbbdd3c2 100644
--- a/internal/agent/loop_history.go
+++ b/internal/agent/loop_history.go
@@ -179,7 +179,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
OwnerIDs: l.ownerIDs,
Mode: mode,
ToolNames: toolNames,
- SkillsSummary: l.resolveSkillsSummary(skillFilter),
+ SkillsSummary: l.resolveSkillsSummary(ctx, skillFilter),
HasMemory: l.hasMemory,
HasSpawn: l.tools != nil && hasSpawn,
HasTeam: hasTeamTools,
@@ -231,8 +231,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
slog.Info("sanitizeHistory: cleaned session history",
"session", sessionKey, "dropped", droppedCount)
cleanedHistory, _ := sanitizeHistory(history)
- l.sessions.SetHistory(sessionKey, cleanedHistory)
- l.sessions.Save(sessionKey)
+ l.sessions.SetHistory(ctx, sessionKey, cleanedHistory)
+ l.sessions.Save(ctx, sessionKey)
}
// Current user message
@@ -305,7 +305,7 @@ const (
// Returns (summary XML, useInline) — useInline=true means skills are inlined and
// the system prompt should use TS-style "scan " instructions
// instead of "use skill_search".
-func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
+func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) string {
if l.skillsLoader == nil {
return ""
}
@@ -316,7 +316,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
allowList = skillFilter
}
- filtered := l.skillsLoader.FilterSkills(allowList)
+ filtered := l.skillsLoader.FilterSkills(ctx, allowList)
if len(filtered) == 0 {
return ""
}
@@ -330,7 +330,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
if len(filtered) <= skillInlineMaxCount && estimatedTokens <= skillInlineMaxTokens {
// Inline mode: build full XML summary
- return l.skillsLoader.BuildSummary(allowList)
+ return l.skillsLoader.BuildSummary(ctx, allowList)
}
// Search mode: no XML in prompt, agent uses skill_search tool
@@ -471,10 +471,10 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) {
}
func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
- history := l.sessions.GetHistory(sessionKey)
+ history := l.sessions.GetHistory(ctx, sessionKey)
// Use calibrated token estimation when available.
- lastPT, lastMC := l.sessions.GetLastPromptTokens(sessionKey)
+ lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey)
tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC)
// Resolve compaction thresholds from config with sensible defaults.
@@ -505,7 +505,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
// Memory flush runs synchronously INSIDE the guard
// (so concurrent runs don't both trigger flush for the same compaction cycle).
flushSettings := ResolveMemoryFlushSettings(l.compactionCfg)
- if l.shouldRunMemoryFlush(sessionKey, tokenEstimate, flushSettings) {
+ if l.shouldRunMemoryFlush(ctx, sessionKey, tokenEstimate, flushSettings) {
l.runMemoryFlush(ctx, sessionKey, flushSettings)
}
@@ -521,15 +521,15 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
// Re-check: history may have been truncated by a concurrent summarize
// that finished between our threshold check and acquiring the lock.
- history := l.sessions.GetHistory(sessionKey)
+ sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second)
+ defer cancel()
+
+ history := l.sessions.GetHistory(sctx, sessionKey)
if len(history) <= keepLast {
return
}
- sctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
- defer cancel()
-
- summary := l.sessions.GetSummary(sessionKey)
+ summary := l.sessions.GetSummary(sctx, sessionKey)
toSummarize := history[:len(history)-keepLast]
var sb strings.Builder
@@ -579,10 +579,10 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
return
}
- l.sessions.SetSummary(sessionKey, SanitizeAssistantContent(resp.Content))
- l.sessions.TruncateHistory(sessionKey, keepLast)
- l.sessions.IncrementCompaction(sessionKey)
- l.sessions.Save(sessionKey)
+ l.sessions.SetSummary(sctx, sessionKey, SanitizeAssistantContent(resp.Content))
+ l.sessions.TruncateHistory(sctx, sessionKey, keepLast)
+ l.sessions.IncrementCompaction(sctx, sessionKey)
+ l.sessions.Save(sctx, sessionKey)
}()
}
diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go
index 8b329335..051774f2 100644
--- a/internal/agent/loop_run.go
+++ b/internal/agent/loop_run.go
@@ -29,6 +29,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
event.UserID = req.UserID
event.Channel = req.Channel
event.ChatID = req.ChatID
+ event.TenantID = store.TenantIDFromContext(ctx)
l.emit(event)
}
@@ -160,7 +161,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
traceCtx := ctx
traceStatus := store.TraceStatusError
if ctx.Err() != nil {
- traceCtx = context.Background()
+ traceCtx = context.WithoutCancel(ctx)
traceStatus = store.TraceStatusCancelled
}
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "")
diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go
index f7805a5b..caabdbf3 100644
--- a/internal/agent/loop_tracing.go
+++ b/internal/agent/loop_tracing.go
@@ -65,6 +65,10 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
// Verbose mode: include input messages (same stripping as emitLLMSpan).
if collector.Verbose() && len(messages) > 0 {
@@ -195,6 +199,10 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
collector.EmitSpan(span)
return spanID
@@ -302,6 +310,10 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
collector.EmitSpan(span)
}
diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go
index 00c509bf..9d8840a6 100644
--- a/internal/agent/loop_types.go
+++ b/internal/agent/loop_types.go
@@ -40,6 +40,7 @@ type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID st
type Loop struct {
id string
agentUUID uuid.UUID // set for context propagation
+ tenantID uuid.UUID // agent's owning tenant
agentType string // "open" or "predefined"
provider providers.Provider
model string
@@ -108,6 +109,9 @@ type Loop struct {
// Global builtin tool settings (from builtin_tools table)
builtinToolSettings tools.BuiltinToolSettings
+ // Per-tenant disabled tools (tool name → true means excluded from LLM)
+ disabledTools map[string]bool
+
// Thinking level for extended thinking support
thinkingLevel string
@@ -160,6 +164,9 @@ type AgentEvent struct {
UserID string `json:"userId,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chatId,omitempty"`
+
+ // TenantID scopes this event to a specific tenant for filtering (not serialized).
+ TenantID uuid.UUID `json:"-"`
}
// LoopConfig configures a new Loop.
@@ -209,9 +216,10 @@ type LoopConfig struct {
// Shell deny group overrides (nil = all defaults)
ShellDenyGroups map[string]bool
- // Agent UUID for context propagation to tools
+ // Agent UUID + tenant for context propagation to tools
AgentUUID uuid.UUID
- AgentType string // "open" or "predefined"
+ TenantID uuid.UUID // agent's owning tenant — injected into execution context
+ AgentType string // "open" or "predefined"
// Per-user file seeding + dynamic context loading
EnsureUserFiles EnsureUserFilesFunc
@@ -229,6 +237,9 @@ type LoopConfig struct {
// Global builtin tool settings (from builtin_tools table)
BuiltinToolSettings tools.BuiltinToolSettings
+ // Per-tenant disabled tools (tool name → true means excluded)
+ DisabledTools map[string]bool
+
// Thinking level: "off", "low", "medium", "high" (from agent other_config)
ThinkingLevel string
@@ -298,6 +309,7 @@ func NewLoop(cfg LoopConfig) *Loop {
return &Loop{
id: cfg.ID,
agentUUID: cfg.AgentUUID,
+ tenantID: cfg.TenantID,
agentType: cfg.AgentType,
provider: cfg.Provider,
model: cfg.Model,
@@ -337,6 +349,7 @@ func NewLoop(cfg LoopConfig) *Loop {
injectionAction: action,
maxMessageChars: cfg.MaxMessageChars,
builtinToolSettings: cfg.BuiltinToolSettings,
+ disabledTools: cfg.DisabledTools,
thinkingLevel: cfg.ThinkingLevel,
selfEvolve: cfg.SelfEvolve,
skillEvolve: cfg.SkillEvolve,
diff --git a/internal/agent/media.go b/internal/agent/media.go
index 6223d66e..3895a531 100644
--- a/internal/agent/media.go
+++ b/internal/agent/media.go
@@ -3,12 +3,16 @@ package agent
import (
"encoding/base64"
"fmt"
+ "io"
"log/slog"
"os"
"path/filepath"
"strings"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -50,19 +54,20 @@ func loadImages(files []bus.MediaFile) []providers.ImageContent {
return images
}
-// persistMedia sanitizes images, saves all media files to persistent storage,
-// and returns lightweight MediaRefs. Non-image files are saved as-is.
-// If mediaStore is nil, falls back to legacy behavior (no persistence, delete temp files).
-func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []providers.MediaRef {
- if l.mediaStore == nil {
- // Fallback: no persistent storage configured.
- // slog.Warn("media: no media store configured, temp files will be deleted", "agent", l.id)
- // Keep workspace files — don't delete originals.
- // defer func() {
- // for _, f := range files {
- // _ = os.Remove(f.Path)
- // }
- // }()
+// persistMedia sanitizes images, saves all media files to the per-user workspace
+// .uploads/ directory, and returns lightweight MediaRefs with persisted paths.
+// All media types (images, documents, audio, video) are stored within the user's
+// workspace for filesystem-level tenant isolation.
+// workspace is the per-user workspace path from ToolWorkspaceFromCtx(ctx).
+func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile, workspace string) []providers.MediaRef {
+ if workspace == "" {
+ slog.Warn("media: no workspace, cannot persist media")
+ return nil
+ }
+
+ uploadsDir := filepath.Join(workspace, ".uploads")
+ if err := os.MkdirAll(uploadsDir, 0755); err != nil {
+ slog.Warn("media: failed to create .uploads dir", "dir", uploadsDir, "error", err)
return nil
}
@@ -70,49 +75,80 @@ func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []provider
for _, f := range files {
mime := f.MimeType
if mime == "" {
- mime = mimeFromExt(filepath.Ext(f.Path)) // fallback for legacy callers
+ mime = mimeFromExt(filepath.Ext(f.Path))
}
kind := mediaKindFromMime(mime)
// Sanitize images before persistent storage.
srcPath := f.Path
+ var sanitizedTemp string // track temp file for cleanup
if kind == "image" {
sanitized, err := SanitizeImage(f.Path)
if err != nil {
slog.Warn("media: sanitize image failed, using original", "path", f.Path, "error", err)
} else {
srcPath = sanitized
+ sanitizedTemp = sanitized
mime = "image/jpeg" // sanitized output is always JPEG
- // Keep workspace files — don't delete originals after sanitization.
- // if sanitized != f.Path {
- // _ = os.Remove(f.Path)
- // }
}
}
- id, _, err := l.mediaStore.SaveFile(sessionKey, srcPath, mime)
- if err != nil {
+ id := uuid.New().String()
+ ext := media.ExtFromMime(mime)
+ if ext == "" {
+ ext = filepath.Ext(srcPath) // fallback to source extension
+ }
+ dstPath := filepath.Join(uploadsDir, id+ext)
+
+ if err := copyMediaFile(srcPath, dstPath); err != nil {
slog.Warn("media: failed to persist file", "path", f.Path, "error", err)
- // Keep workspace files — don't delete on persist failure.
- // _ = os.Remove(srcPath)
+ if sanitizedTemp != "" {
+ os.Remove(sanitizedTemp)
+ }
continue
}
+ if sanitizedTemp != "" {
+ os.Remove(sanitizedTemp) // cleanup sanitized temp file
+ }
refs = append(refs, providers.MediaRef{
ID: id,
MimeType: mime,
Kind: kind,
+ Path: dstPath,
})
- slog.Debug("media: persisted file", "id", id, "kind", kind, "mime", mime, "agent", l.id)
+ slog.Debug("media: persisted file", "id", id, "kind", kind, "path", dstPath, "agent", l.id)
}
return refs
}
+// copyMediaFile copies src to dst using buffered I/O.
+// Removes partial dst file on failure.
+func copyMediaFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ os.Remove(dst) // cleanup partial file
+ return err
+ }
+ return out.Close()
+}
+
// enrichDocumentPaths updates the last user message to include persisted file paths
// in tags. This allows skills (e.g. pdf skill via exec) to access
// the file directly, matching how Claude Code skills work with file paths.
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) {
- if l.mediaStore == nil || len(messages) == 0 {
+ if len(messages) == 0 {
return
}
// Find last user message
@@ -132,8 +168,16 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
if ref.Kind != "document" {
continue
}
- p, err := l.mediaStore.LoadPath(ref.ID)
- if err != nil {
+ // Use persisted workspace path; fall back to legacy .media/ lookup.
+ p := ref.Path
+ if p == "" && l.mediaStore != nil {
+ var err error
+ p, err = l.mediaStore.LoadPath(ref.ID)
+ if err != nil {
+ continue
+ }
+ }
+ if p == "" {
continue
}
// Replace or with version that includes path.
@@ -307,7 +351,7 @@ const maxMediaReloadMessages = 5
// reloadMediaForMessages populates Images on historical messages that have image MediaRefs.
// Only reloads the last maxMessages messages with image refs (newest first) to limit context usage.
func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) {
- if l.mediaStore == nil || maxMessages <= 0 {
+ if maxMessages <= 0 {
return
}
@@ -324,9 +368,16 @@ func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int)
continue
}
hasImageRef = true
- p, err := l.mediaStore.LoadPath(ref.ID)
- if err != nil {
- slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err)
+ p := ref.Path
+ if p == "" && l.mediaStore != nil {
+ var err error
+ p, err = l.mediaStore.LoadPath(ref.ID)
+ if err != nil {
+ slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err)
+ continue
+ }
+ }
+ if p == "" {
continue
}
imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType})
diff --git a/internal/agent/media_sanitize.go b/internal/agent/media_sanitize.go
index a06ead50..813f80df 100644
--- a/internal/agent/media_sanitize.go
+++ b/internal/agent/media_sanitize.go
@@ -7,7 +7,6 @@ import (
"image/jpeg"
"image/png"
"os"
- "path/filepath"
"github.com/disintegration/imaging"
)
@@ -51,10 +50,17 @@ func SanitizeImage(inputPath string) (string, error) {
return "", fmt.Errorf("encode jpeg (q=%d): %w", quality, err)
}
if buf.Len() <= imageSanitizeMaxBytes {
- outPath := filepath.Join(os.TempDir(), fmt.Sprintf("goclaw_sanitized_%d.jpg", os.Getpid()))
- if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil {
+ f, err := os.CreateTemp("", "goclaw_sanitized_*.jpg")
+ if err != nil {
+ return "", fmt.Errorf("create temp file: %w", err)
+ }
+ outPath := f.Name()
+ if _, err := f.Write(buf.Bytes()); err != nil {
+ f.Close()
+ os.Remove(outPath)
return "", fmt.Errorf("write sanitized image: %w", err)
}
+ f.Close()
return outPath, nil
}
}
diff --git a/internal/agent/media_tool_routing.go b/internal/agent/media_tool_routing.go
index e2231837..61369346 100644
--- a/internal/agent/media_tool_routing.go
+++ b/internal/agent/media_tool_routing.go
@@ -69,8 +69,11 @@ func (l *Loop) loadHistoricalImagesForTool(ctx context.Context, currentRefs []pr
continue
}
hasImage = true
- p, err := l.mediaStore.LoadPath(ref.ID)
- if err != nil {
+ p := ref.Path
+ if p == "" && l.mediaStore != nil {
+ p, _ = l.mediaStore.LoadPath(ref.ID)
+ }
+ if p == "" {
continue
}
histPaths = append(histPaths, bus.MediaFile{Path: p, MimeType: ref.MimeType})
diff --git a/internal/agent/memoryflush.go b/internal/agent/memoryflush.go
index 1abed466..223cda57 100644
--- a/internal/agent/memoryflush.go
+++ b/internal/agent/memoryflush.go
@@ -67,7 +67,7 @@ func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlus
// shouldRunMemoryFlush checks whether a memory flush should run before compaction.
// Flush always runs when compaction triggers (called inside maybeSummarize),
// gated only by enabled/memory checks and a dedup guard per compaction cycle.
-func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
+func (l *Loop) shouldRunMemoryFlush(ctx context.Context, sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
if settings == nil || !settings.Enabled || !l.hasMemory {
return false
}
@@ -77,8 +77,8 @@ func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings
}
// Deduplication: skip if already flushed in this compaction cycle.
- compactionCount := l.sessions.GetCompactionCount(sessionKey)
- lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(sessionKey)
+ compactionCount := l.sessions.GetCompactionCount(ctx, sessionKey)
+ lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(ctx, sessionKey)
if lastFlushAt >= 0 && lastFlushAt == compactionCount {
return false
}
@@ -95,8 +95,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
defer cancel()
// Build messages: system prompt + history summary + flush prompt
- history := l.sessions.GetHistory(sessionKey)
- summary := l.sessions.GetSummary(sessionKey)
+ history := l.sessions.GetHistory(ctx, sessionKey)
+ summary := l.sessions.GetSummary(ctx, sessionKey)
var messages []providers.Message
@@ -203,8 +203,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
}
// Mark flush as done
- l.sessions.SetMemoryFlushDone(sessionKey)
- l.sessions.Save(sessionKey)
+ l.sessions.SetMemoryFlushDone(ctx, sessionKey)
+ l.sessions.Save(ctx, sessionKey)
slog.Info("memory flush: completed", "session", sessionKey)
}
diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go
index 137fde47..a6e0a03f 100644
--- a/internal/agent/resolver.go
+++ b/internal/agent/resolver.go
@@ -50,9 +50,6 @@ type ResolverDeps struct {
SandboxContainerDir string
SandboxWorkspaceAccess string
- // Dynamic custom tools
- DynamicLoader *tools.DynamicToolLoader
-
// Inter-agent delegation
AgentLinkStore store.AgentLinkStore
@@ -89,13 +86,22 @@ type ResolverDeps struct {
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
+
+ // Tenant store for workspace path resolution
+ TenantStore store.TenantStore
+
+ // Per-tenant tool/skill config overrides
+ BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore
+ SkillTenantCfgs store.SkillTenantConfigStore
+
+ // Global workspace root (GOCLAW_WORKSPACE)
+ Workspace string
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
// Agents are defined in Postgres, not config.json.
func NewManagedResolver(deps ResolverDeps) ResolverFunc {
- return func(agentKey string) (Agent, error) {
- ctx := context.Background()
+ return func(ctx context.Context, agentKey string) (Agent, error) {
// Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID)
var ag *store.AgentData
@@ -113,15 +119,15 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
return nil, fmt.Errorf("agent %s is inactive", agentKey)
}
- // Resolve provider
- provider, err := deps.ProviderReg.Get(ag.Provider)
+ // Resolve provider (tenant-aware: tries tenant-specific first, falls back to master)
+ provider, err := deps.ProviderReg.GetForTenant(ag.TenantID, ag.Provider)
if err != nil {
- // Fallback to any available provider
- names := deps.ProviderReg.List()
+ // Fallback to any available provider for this tenant
+ names := deps.ProviderReg.ListForTenant(ag.TenantID)
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured for agent %s", agentKey)
}
- provider, _ = deps.ProviderReg.Get(names[0])
+ provider, _ = deps.ProviderReg.GetForTenant(ag.TenantID, names[0])
slog.Warn("agent provider not found, using fallback",
"agent", agentKey, "wanted", ag.Provider, "using", names[0])
if tl := ag.ParseThinkingLevel(); tl != "" && tl != "off" {
@@ -210,27 +216,33 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
sandboxCfgOverride = &resolved
}
- // Expand ~ in workspace path and ensure directory exists
+ // Resolve tenant slug once for workspace + dataDir scoping.
+ var tenantSlug string
+ if ag.TenantID != store.MasterTenantID && ag.TenantID != uuid.Nil {
+ tenantSlug = resolveTenantSlug(deps.TenantStore, ag.TenantID)
+ }
+
+ // Expand ~ in workspace path and ensure directory exists.
+ // For non-master tenants, prefix workspace with tenant slug directory.
workspace := ag.Workspace
if workspace != "" {
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
+ }
+ if tenantSlug != "" {
+ if deps.Workspace != "" {
+ workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug)
+ }
+ }
+ if workspace != "" {
if err := os.MkdirAll(workspace, 0755); err != nil {
slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err)
}
}
- // Per-agent custom tools (clone registry if agent has custom tools)
toolsReg := deps.Tools
- if deps.DynamicLoader != nil {
- if agentReg, err := deps.DynamicLoader.LoadForAgent(ctx, deps.Tools, ag.ID); err != nil {
- slog.Warn("failed to load custom tools", "agent", agentKey, "error", err)
- } else if agentReg != nil {
- toolsReg = agentReg
- }
- }
// Per-agent MCP servers: connect to granted MCP servers and register their tools.
// Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers.
@@ -292,6 +304,18 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
}
+ // Load per-tenant tool exclusions (disabled tools for this agent's tenant)
+ var disabledTools map[string]bool
+ if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil {
+ if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 {
+ disabledTools = make(map[string]bool, len(disabled))
+ for _, name := range disabled {
+ disabledTools[name] = true
+ }
+ slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled))
+ }
+ }
+
// Filter skills by visibility + agent grants.
// Only public skills and explicitly granted internal skills appear in the system prompt.
var skillAllowList []string
@@ -308,10 +332,17 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
}
+ // Resolve tenant-scoped DataDir for team workspace resolution.
+ dataDir := deps.DataDir
+ if tenantSlug != "" {
+ dataDir = config.TenantDataDir(deps.DataDir, ag.TenantID, tenantSlug)
+ }
+
restrictVal := true // always restrict agents to their workspace
loop := NewLoop(LoopConfig{
ID: ag.AgentKey,
AgentUUID: ag.ID,
+ TenantID: ag.TenantID,
AgentType: ag.AgentType,
Provider: provider,
Model: ag.Model,
@@ -319,7 +350,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
MaxTokens: ag.ParseMaxTokens(),
MaxIterations: maxIter,
Workspace: workspace,
- DataDir: deps.DataDir,
+ DataDir: dataDir,
RestrictToWs: &restrictVal,
SubagentsCfg: ag.ParseSubagentsConfig(),
MemoryCfg: ag.ParseMemoryConfig(),
@@ -346,6 +377,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
+ DisabledTools: disabledTools,
ThinkingLevel: ag.ParseThinkingLevel(),
SelfEvolve: ag.ParseSelfEvolve(),
SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(),
@@ -385,6 +417,19 @@ func (r *Router) InvalidateAll() {
slog.Debug("invalidated all agent caches")
}
+// resolveTenantSlug looks up the tenant slug for workspace path resolution.
+// Returns the tenant ID string as fallback if lookup fails.
+func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string {
+ if ts == nil {
+ return tenantID.String()
+ }
+ tenant, err := ts.GetTenant(store.WithCrossTenant(context.Background()), tenantID)
+ if err != nil || tenant == nil {
+ return tenantID.String()
+ }
+ return tenant.Slug
+}
+
func derefInt(p *int) int {
if p == nil {
return 0
diff --git a/internal/agent/router.go b/internal/agent/router.go
index f3e251b7..486624e2 100644
--- a/internal/agent/router.go
+++ b/internal/agent/router.go
@@ -5,11 +5,15 @@ import (
"fmt"
"sync"
"time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// ResolverFunc is called when an agent isn't found in the cache.
-// Used to lazy-create agents from DB.
-type ResolverFunc func(agentKey string) (Agent, error)
+// Used to lazy-create agents from DB. Context carries tenant scope.
+type ResolverFunc func(ctx context.Context, agentKey string) (Agent, error)
const defaultRouterTTL = 10 * time.Minute
@@ -64,9 +68,12 @@ func (r *Router) Register(ag Agent) {
// Get returns an agent by ID. Lazy-creates from DB via resolver if needed.
// Cached entries expire after TTL as a safety net for multi-instance deployments.
-func (r *Router) Get(agentID string) (Agent, error) {
+// Cache key includes tenant so the same agent_key in different tenants resolves independently.
+func (r *Router) Get(ctx context.Context, agentID string) (Agent, error) {
+ cacheKey := agentCacheKey(ctx, agentID)
+
r.mu.RLock()
- entry, ok := r.agents[agentID]
+ entry, ok := r.agents[cacheKey]
resolver := r.resolver
r.mu.RUnlock()
@@ -77,23 +84,23 @@ func (r *Router) Get(agentID string) (Agent, error) {
// TTL expired → remove stale entry so resolver re-creates
if ok {
r.mu.Lock()
- delete(r.agents, agentID)
+ delete(r.agents, cacheKey)
r.mu.Unlock()
}
// Try resolver (create from DB)
if resolver != nil {
- ag, err := resolver(agentID)
+ ag, err := resolver(ctx, agentID)
if err != nil {
return nil, err
}
r.mu.Lock()
// Double-check: another goroutine might have created it
- if existing, ok := r.agents[agentID]; ok {
+ if existing, ok := r.agents[cacheKey]; ok {
r.mu.Unlock()
return existing.agent, nil
}
- r.agents[agentID] = &agentEntry{agent: ag, cachedAt: time.Now()}
+ r.agents[cacheKey] = &agentEntry{agent: ag, cachedAt: time.Now()}
r.mu.Unlock()
return ag, nil
}
@@ -101,6 +108,15 @@ func (r *Router) Get(agentID string) (Agent, error) {
return nil, fmt.Errorf("agent not found: %s", agentID)
}
+// agentCacheKey builds a tenant-scoped cache key for the agent router.
+func agentCacheKey(ctx context.Context, agentID string) string {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return agentID
+ }
+ return tid.String() + ":" + agentID
+}
+
// Remove removes an agent from the router.
func (r *Router) Remove(agentID string) {
r.mu.Lock()
diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go
index 61f32378..f260e766 100644
--- a/internal/bootstrap/seed_store_test.go
+++ b/internal/bootstrap/seed_store_test.go
@@ -63,6 +63,12 @@ func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData,
func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
return nil, nil
}
+func (s *seedStubStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
+ return nil, nil
+}
+func (s *seedStubStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
+ return nil, nil
+}
func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil }
diff --git a/internal/bus/broadcast_helpers.go b/internal/bus/broadcast_helpers.go
new file mode 100644
index 00000000..5ca181d7
--- /dev/null
+++ b/internal/bus/broadcast_helpers.go
@@ -0,0 +1,10 @@
+package bus
+
+import "github.com/google/uuid"
+
+// BroadcastForTenant broadcasts an event with explicit tenant scoping.
+// Use this for events that carry tenant-specific data to ensure proper
+// isolation in the WS event filter (event_filter.go).
+func BroadcastForTenant(pub EventPublisher, name string, tenantID uuid.UUID, payload any) {
+ pub.Broadcast(Event{Name: name, TenantID: tenantID, Payload: payload})
+}
diff --git a/internal/bus/types.go b/internal/bus/types.go
index 6cf66205..095df11c 100644
--- a/internal/bus/types.go
+++ b/internal/bus/types.go
@@ -3,6 +3,8 @@ package bus
import (
"context"
"encoding/json"
+
+ "github.com/google/uuid"
)
// MediaFile represents an inbound media file with its MIME type.
@@ -21,6 +23,7 @@ type InboundMessage struct {
Media []MediaFile `json:"media,omitempty"`
SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key
PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key)
+ TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance
AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing)
UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap)
HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config)
@@ -46,8 +49,9 @@ type MediaAttachment struct {
// Event represents a server-side event to broadcast to WebSocket clients.
type Event struct {
- Name string `json:"name"` // event name (e.g. "agent", "chat", "health")
- Payload any `json:"payload,omitempty"`
+ Name string `json:"name"` // event name (e.g. "agent", "chat", "health")
+ Payload any `json:"payload,omitempty"`
+ TenantID uuid.UUID `json:"-"` // tenant scope for event filtering (not serialized to clients)
}
// Cache invalidation kind constants.
@@ -56,7 +60,6 @@ const (
CacheKindBootstrap = "bootstrap"
CacheKindSkills = "skills"
CacheKindCron = "cron"
- CacheKindCustomTools = "custom_tools"
CacheKindChannelInstances = "channel_instances"
CacheKindBuiltinTools = "builtin_tools"
CacheKindTeam = "team"
@@ -67,6 +70,10 @@ const (
CacheKindAPIKeys = "api_keys"
CacheKindHeartbeat = "heartbeat"
CacheKindConfigPerms = "config_perms"
+ CacheKindTenantUsers = "tenant_users"
+ CacheKindAgentAccess = "agent_access"
+ CacheKindTeamAccess = "team_access"
+ CacheKindTenants = "tenants"
)
// Topic constants for msgBus.Subscribe() / Broadcast().
@@ -75,7 +82,6 @@ const (
TopicCacheAgent = "cache:agent"
TopicCacheSkills = "cache:skills"
TopicCacheCron = "cache:cron"
- TopicCacheCustomTools = "cache:custom_tools"
TopicCacheBuiltinTools = "cache:builtin_tools"
TopicCacheTeam = "cache:team"
TopicCacheUserWorkspace = "cache:user_workspace"
@@ -122,6 +128,7 @@ type AuditEventPayload struct {
EntityID string `json:"entity_id"`
IPAddress string `json:"ip_address,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
+ TenantID uuid.UUID `json:"tenant_id,omitempty"` // for async subscriber tenant scoping
}
// CacheInvalidatePayload signals cache layers to evict stale entries.
diff --git a/internal/cache/permission_cache.go b/internal/cache/permission_cache.go
new file mode 100644
index 00000000..744387a8
--- /dev/null
+++ b/internal/cache/permission_cache.go
@@ -0,0 +1,121 @@
+package cache
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+)
+
+// agentAccessEntry caches agent access check results.
+type agentAccessEntry struct {
+ Allowed bool
+ Role string
+}
+
+// PermissionCache provides short-TTL caching for hot permission lookups.
+// Uses InMemoryCache[V] caches with pubsub invalidation.
+type PermissionCache struct {
+ tenantResolve *InMemoryCache[uuid.UUID]
+ tenantRole *InMemoryCache[string]
+ agentAccess *InMemoryCache[agentAccessEntry]
+ teamAccess *InMemoryCache[bool]
+}
+
+// NewPermissionCache creates a new permission cache.
+func NewPermissionCache() *PermissionCache {
+ return &PermissionCache{
+ tenantResolve: NewInMemoryCache[uuid.UUID](),
+ tenantRole: NewInMemoryCache[string](),
+ agentAccess: NewInMemoryCache[agentAccessEntry](),
+ teamAccess: NewInMemoryCache[bool](),
+ }
+}
+
+const (
+ tenantResolveTTL = 60 * time.Second
+ tenantRoleTTL = 30 * time.Second
+ agentAccessTTL = 30 * time.Second
+ teamAccessTTL = 30 * time.Second
+)
+
+// --- Tenant Resolution ---
+
+func (pc *PermissionCache) GetTenantResolve(ctx context.Context, userID string) (uuid.UUID, bool) {
+ return pc.tenantResolve.Get(ctx, userID)
+}
+
+func (pc *PermissionCache) SetTenantResolve(ctx context.Context, userID string, tenantID uuid.UUID) {
+ pc.tenantResolve.Set(ctx, userID, tenantID, tenantResolveTTL)
+}
+
+// --- Tenant Role ---
+
+func (pc *PermissionCache) GetTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, bool) {
+ return pc.tenantRole.Get(ctx, tenantID.String()+":"+userID)
+}
+
+func (pc *PermissionCache) SetTenantRole(ctx context.Context, tenantID uuid.UUID, userID, role string) {
+ pc.tenantRole.Set(ctx, tenantID.String()+":"+userID, role, tenantRoleTTL)
+}
+
+// --- Agent Access ---
+
+func (pc *PermissionCache) GetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, bool) {
+ entry, ok := pc.agentAccess.Get(ctx, agentID.String()+":"+userID)
+ if !ok {
+ return false, "", false
+ }
+ return entry.Allowed, entry.Role, true
+}
+
+func (pc *PermissionCache) SetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string, allowed bool, role string) {
+ pc.agentAccess.Set(ctx, agentID.String()+":"+userID, agentAccessEntry{Allowed: allowed, Role: role}, agentAccessTTL)
+}
+
+// --- Team Access ---
+
+func (pc *PermissionCache) GetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, bool) {
+ return pc.teamAccess.Get(ctx, teamID.String()+":"+userID)
+}
+
+func (pc *PermissionCache) SetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string, allowed bool) {
+ pc.teamAccess.Set(ctx, teamID.String()+":"+userID, allowed, teamAccessTTL)
+}
+
+// --- Invalidation ---
+
+// HandleInvalidation processes a cache invalidation event from the bus.
+func (pc *PermissionCache) HandleInvalidation(p bus.CacheInvalidatePayload) {
+ slog.Debug("perm_cache.invalidated", "kind", string(p.Kind), "key", p.Key)
+ ctx := context.Background()
+ switch p.Kind {
+ case bus.CacheKindTenantUsers:
+ // Key is userID — invalidate resolve + all tenant roles for this user.
+ // Can't efficiently delete all tenantRole entries for a user by prefix,
+ // so clear all tenant roles (short TTL makes this acceptable).
+ if p.Key != "" {
+ pc.tenantResolve.Delete(ctx, p.Key)
+ pc.tenantRole.Clear(ctx)
+ } else {
+ pc.tenantResolve.Clear(ctx)
+ pc.tenantRole.Clear(ctx)
+ }
+ case bus.CacheKindAgentAccess:
+ // Key is agentID — delete all access entries for this agent.
+ if p.Key != "" {
+ pc.agentAccess.DeleteByPrefix(ctx, p.Key+":")
+ } else {
+ pc.agentAccess.Clear(ctx)
+ }
+ case bus.CacheKindTeamAccess:
+ // Key is teamID — delete all access entries for this team.
+ if p.Key != "" {
+ pc.teamAccess.DeleteByPrefix(ctx, p.Key+":")
+ } else {
+ pc.teamAccess.Clear(ctx)
+ }
+ }
+}
diff --git a/internal/channels/channel.go b/internal/channels/channel.go
index 5d4d26e8..c4bf4570 100644
--- a/internal/channels/channel.go
+++ b/internal/channels/channel.go
@@ -14,6 +14,8 @@ import (
"net/http"
"strings"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -156,6 +158,7 @@ type BaseChannel struct {
running bool
allowList []string
agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute)
+ tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback)
contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages
}
@@ -191,6 +194,12 @@ func (c *BaseChannel) AgentID() string { return c.agentID }
// SetAgentID sets the explicit agent ID for routing (used by InstanceLoader for DB instances).
func (c *BaseChannel) SetAgentID(id string) { c.agentID = id }
+// TenantID returns the tenant UUID for this channel (zero = master tenant fallback).
+func (c *BaseChannel) TenantID() uuid.UUID { return c.tenantID }
+
+// SetTenantID sets the tenant scope (used by InstanceLoader for DB instances).
+func (c *BaseChannel) SetTenantID(id uuid.UUID) { c.tenantID = id }
+
// SetContactCollector sets the contact collector for auto-collecting contacts from messages.
func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contactCollector = cc }
@@ -325,6 +334,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
PeerKind: peerKind,
UserID: userID,
Metadata: metadata,
+ TenantID: c.tenantID,
AgentID: c.agentID,
}
diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go
index 80f96a62..89f90dbd 100644
--- a/internal/channels/discord/discord.go
+++ b/internal/channels/discord/discord.go
@@ -70,7 +70,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair
config: cfg,
requireMention: requireMention,
pairingService: pairingSvc,
- groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore),
+ groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore, base.TenantID()),
historyLimit: historyLimit,
agentStore: agentStore,
configPermStore: configPermStore,
diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go
index db665c5d..77c74d72 100644
--- a/internal/channels/discord/handler.go
+++ b/internal/channels/discord/handler.go
@@ -13,10 +13,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleMessage processes incoming Discord messages.
func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
// Ignore bot's own messages
if m.Author == nil || m.Author.ID == c.botUserID {
return
@@ -40,11 +43,11 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
if isDM {
- if !c.checkDMPolicy(senderID, channelID) {
+ if !c.checkDMPolicy(ctx, senderID, channelID) {
return
}
} else {
- if !c.checkGroupPolicy(senderID, channelID) {
+ if !c.checkGroupPolicy(ctx, senderID, channelID) {
slog.Debug("discord group message rejected by policy",
"user_id", senderID,
"username", senderName,
@@ -192,7 +195,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group")
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group")
}
slog.Debug("discord group message recorded (no mention)",
@@ -284,7 +287,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind)
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind)
}
// Publish directly to bus (to preserve MediaFile MIME types)
@@ -307,7 +310,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
// checkGroupPolicy evaluates the group policy for a sender, with pairing support.
-func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
+func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -327,7 +330,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", channelID)
if c.pairingService != nil {
- paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -338,7 +341,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
return true
}
}
- c.sendPairingReply(groupSenderID, channelID)
+ c.sendPairingReply(ctx, groupSenderID, channelID)
return false
default: // "open"
return true
@@ -346,7 +349,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow.
-func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -367,7 +370,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(senderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -382,13 +385,13 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
return true
}
- c.sendPairingReply(senderID, channelID)
+ c.sendPairingReply(ctx, senderID, channelID)
return false
}
}
// sendPairingReply sends a pairing code to the user via DM.
-func (c *Channel) sendPairingReply(senderID, channelID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) {
if c.pairingService == nil {
return
}
@@ -400,7 +403,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil)
if err != nil {
slog.Debug("discord pairing request failed", "sender_id", senderID, "error", err)
return
diff --git a/internal/channels/events.go b/internal/channels/events.go
index af552f7e..b261423f 100644
--- a/internal/channels/events.go
+++ b/internal/channels/events.go
@@ -6,7 +6,10 @@ import (
"log/slog"
"strings"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -28,6 +31,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
}
ctx := context.Background()
+ if ta, ok := ch.(interface{ TenantID() uuid.UUID }); ok {
+ ctx = store.WithTenantID(ctx, ta.TenantID())
+ }
// Forward to StreamingChannel
if sc, ok := ch.(StreamingChannel); ok {
diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go
index 9d01195d..e03829b0 100644
--- a/internal/channels/feishu/bot.go
+++ b/internal/channels/feishu/bot.go
@@ -62,7 +62,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
// 4. Group policy
if mc.ChatType == "group" {
- if !c.checkGroupPolicy(mc.SenderID, mc.ChatID) {
+ if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) {
slog.Debug("feishu group message rejected by policy", "sender_id", mc.SenderID, "chat_id", mc.ChatID)
return
}
@@ -99,7 +99,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
// 6. DM policy (pairing flow)
if mc.ChatType == "p2p" {
- if !c.checkDMPolicy(mc.SenderID, mc.ChatID) {
+ if !c.checkDMPolicy(ctx, mc.SenderID, mc.ChatID) {
return
}
}
diff --git a/internal/channels/feishu/bot_policy.go b/internal/channels/feishu/bot_policy.go
index f91724da..470c3a19 100644
--- a/internal/channels/feishu/bot_policy.go
+++ b/internal/channels/feishu/bot_policy.go
@@ -46,7 +46,7 @@ func (c *Channel) fetchSenderName(ctx context.Context, openID string) string {
// --- Policy checks ---
-func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
+func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.cfg.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -85,7 +85,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
- paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -96,14 +96,14 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
return true
}
}
- c.sendPairingReply(groupSenderID, chatID)
+ c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
return true
}
}
-func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.cfg.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -124,7 +124,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(senderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -139,12 +139,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
- c.sendPairingReply(senderID, chatID)
+ c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
-func (c *Channel) sendPairingReply(senderID, chatID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -156,7 +156,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err)
return
diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go
index da87c40e..7db0c75d 100644
--- a/internal/channels/feishu/feishu.go
+++ b/internal/channels/feishu/feishu.go
@@ -86,7 +86,7 @@ func New(cfg config.FeishuConfig, msgBus *bus.MessageBus, pairingSvc store.Pairi
client: client,
pairingService: pairingSvc,
groupAllowList: cfg.GroupAllowFrom,
- groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore),
+ groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore, base.TenantID()),
historyLimit: historyLimit,
stopCh: make(chan struct{}),
}, nil
@@ -255,7 +255,8 @@ func (c *Channel) WebhookHandler() (string, http.Handler) {
}
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
- c.handleMessageEvent(context.Background(), event)
+ ctx := store.WithTenantID(context.Background(), c.TenantID())
+ c.handleMessageEvent(ctx, event)
})
return path, http.HandlerFunc(handler)
@@ -275,7 +276,8 @@ func (c *Channel) startWebhook(ctx context.Context) error {
slog.Info("feishu: starting Webhook server", "port", port, "path", path)
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
- c.handleMessageEvent(context.Background(), event)
+ ctx := store.WithTenantID(context.Background(), c.TenantID())
+ c.handleMessageEvent(ctx, event)
})
mux := http.NewServeMux()
diff --git a/internal/channels/history.go b/internal/channels/history.go
index a5470e30..fb077ac6 100644
--- a/internal/channels/history.go
+++ b/internal/channels/history.go
@@ -16,6 +16,7 @@ import (
"sync"
"time"
+ "github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -56,6 +57,9 @@ type PendingHistory struct {
stopCh chan struct{}
stopped chan struct{}
+ // Tenant isolation for DB operations.
+ tenantID uuid.UUID
+
// Compaction (optional — nil means no auto-compaction)
compactionCfg *CompactionConfig
@@ -70,11 +74,12 @@ func NewPendingHistory() *PendingHistory {
// NewPersistentHistory creates a persistent history tracker with batched DB flush.
// Call StartFlusher() after creation and StopFlusher() on shutdown.
-func NewPersistentHistory(channelName string, s store.PendingMessageStore) *PendingHistory {
+func NewPersistentHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory {
return &PendingHistory{
entries: make(map[string][]HistoryEntry),
channelName: channelName,
store: s,
+ tenantID: tenantID,
flushSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
@@ -103,13 +108,22 @@ func (ph *PendingHistory) LoadFromDB(ctx context.Context) {
}
// MakeHistory creates a PendingHistory — persistent if store is non-nil, RAM-only otherwise.
-func MakeHistory(channelName string, s store.PendingMessageStore) *PendingHistory {
+func MakeHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory {
if s != nil {
- return NewPersistentHistory(channelName, s)
+ return NewPersistentHistory(channelName, s, tenantID)
}
return NewPendingHistory()
}
+// tenantCtx returns a context with the tenant ID set for DB operations.
+func (ph *PendingHistory) tenantCtx() context.Context {
+ ctx := context.Background()
+ if ph.tenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, ph.tenantID)
+ }
+ return ctx
+}
+
// Record adds a message to the pending history for a group.
// If limit ≤ 0, recording is disabled.
func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit int) {
@@ -155,7 +169,7 @@ func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit in
// populates RAM cache, and returns converted entries.
// Called when RAM has no entries but DB store is available.
func (ph *PendingHistory) loadFromDB(historyKey string) []HistoryEntry {
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
msgs, err := ph.store.ListByKey(ctx, ph.channelName, historyKey)
@@ -270,7 +284,7 @@ func (ph *PendingHistory) Clear(historyKey string) {
// Remove pending flushes for this key
ph.removeFromFlushBuf(historyKey)
// Delete from DB
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
if err := ph.store.DeleteByKey(ctx, ph.channelName, historyKey); err != nil {
slog.Warn("pending_history.clear_db_failed", "channel", ph.channelName, "key", historyKey, "error", err)
diff --git a/internal/channels/history_compaction.go b/internal/channels/history_compaction.go
index 62349794..5306d499 100644
--- a/internal/channels/history_compaction.go
+++ b/internal/channels/history_compaction.go
@@ -37,7 +37,7 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg
// RAM count may be stale after restart (LoadFromDB doesn't warm full history).
// If RAM says below threshold, ask DB for the real count.
if currentCount <= threshold {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
dbCount, err := ph.store.CountByKey(ctx, ph.channelName, historyKey)
if err != nil || dbCount <= threshold {
@@ -137,7 +137,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig
// Force-flush buffer to ensure DB is consistent
ph.flushNow()
- ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ ctx, cancel := context.WithTimeout(ph.tenantCtx(), 180*time.Second)
defer cancel()
// Check threshold from DB (may have been cleared since trigger)
diff --git a/internal/channels/history_flush.go b/internal/channels/history_flush.go
index bd5501a9..b19a5cf5 100644
--- a/internal/channels/history_flush.go
+++ b/internal/channels/history_flush.go
@@ -93,7 +93,7 @@ func (ph *PendingHistory) flushLoop() {
// writeBatch writes a batch of messages to the DB store.
func (ph *PendingHistory) writeBatch(batch []store.PendingMessage) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ ctx, cancel := context.WithTimeout(ph.tenantCtx(), 15*time.Second)
defer cancel()
if err := ph.store.AppendBatch(ctx, batch); err != nil {
diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go
index 79f38109..07a00d2c 100644
--- a/internal/channels/instance_loader.go
+++ b/internal/channels/instance_loader.go
@@ -9,6 +9,8 @@ import (
"sync"
"time"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -23,7 +25,7 @@ type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error)
// InstanceLoader loads channel instances from the database and registers them with the Manager.
-// Follows the DynamicToolLoader pattern: LoadAll at startup, Reload on cache invalidation.
+// Follows a load-all-at-startup pattern with cache invalidation for reload.
type InstanceLoader struct {
store store.ChannelInstanceStore
agentStore store.AgentStore
@@ -234,6 +236,10 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
if base, ok := ch.(interface{ SetType(string) }); ok {
base.SetType(inst.ChannelType)
}
+ // Propagate tenant_id from DB instance to channel for tenant-scoped message handling.
+ if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok {
+ base.SetTenantID(inst.TenantID)
+ }
// Wire pending message auto-compaction.
// Priority: config provider/model > agent's provider/model > fallback.
@@ -242,8 +248,9 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
var model string
// Try config-level provider/model first.
+ tctx := store.WithTenantID(ctx, inst.TenantID)
if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" {
- if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil {
+ if cp, err := l.providerReg.Get(tctx, l.pendingCompactCfg.Provider); err == nil {
p = cp
model = l.pendingCompactCfg.Model
if model == "" {
@@ -253,7 +260,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
}
// Fallback: agent's provider/model.
if p == nil && ag != nil && ag.Provider != "" {
- if ap, err := l.providerReg.Get(ag.Provider); err == nil {
+ if ap, err := l.providerReg.Get(tctx, ag.Provider); err == nil {
p = ap
model = ag.Model
if model == "" {
diff --git a/internal/channels/quota.go b/internal/channels/quota.go
index 8a9bcef6..6c9a9684 100644
--- a/internal/channels/quota.go
+++ b/internal/channels/quota.go
@@ -3,11 +3,14 @@ package channels
import (
"context"
"database/sql"
+ "fmt"
"log/slog"
"sync"
"time"
+ "github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// QuotaResult is returned by QuotaChecker.Check.
@@ -222,20 +225,23 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult {
dayAgo := now.Add(-24 * time.Hour)
weekAgo := now.Add(-7 * 24 * time.Hour)
- // Query per-user counts for the past week
- rows, err := qc.db.QueryContext(ctx, `
+ // Query per-user counts for the past week (tenant-scoped)
+ tenantFilter, tenantArgs, nextIdx := tenantWhereClause(ctx, 4)
+ args := append([]any{hourAgo, dayAgo, weekAgo}, tenantArgs...)
+ rows, err := qc.db.QueryContext(ctx, fmt.Sprintf(`
SELECT
user_id,
COUNT(*) FILTER (WHERE created_at >= $1) AS hour_count,
COUNT(*) FILTER (WHERE created_at >= $2) AS day_count,
COUNT(*) AS week_count
FROM traces
- WHERE parent_trace_id IS NULL AND created_at >= $3
+ WHERE parent_trace_id IS NULL AND created_at >= $3%s
GROUP BY user_id
ORDER BY week_count DESC
- LIMIT 50`,
- hourAgo, dayAgo, weekAgo,
+ LIMIT 50`, tenantFilter),
+ args...,
)
+ _ = nextIdx
if err != nil {
slog.Warn("quota.usage: failed to query user counts", "error", err)
QueryTodaySummary(ctx, qc.db, &result)
@@ -272,7 +278,9 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
now := time.Now().UTC()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
- err := db.QueryRowContext(ctx, `
+ tenantFilter, tenantArgs, _ := tenantWhereClause(ctx, 2)
+ args := append([]any{startOfDay}, tenantArgs...)
+ err := db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT
COUNT(*),
COALESCE(SUM(total_input_tokens), 0),
@@ -280,14 +288,28 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
COALESCE(SUM(total_cost), 0),
COUNT(DISTINCT user_id)
FROM traces
- WHERE parent_trace_id IS NULL AND created_at >= $1`,
- startOfDay,
+ WHERE parent_trace_id IS NULL AND created_at >= $1%s`, tenantFilter),
+ args...,
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday)
if err != nil {
slog.Warn("quota.usage: failed to query today summary", "error", err)
}
}
+// tenantWhereClause returns a SQL fragment " AND tenant_id = $N" with the tenant UUID arg,
+// or empty string if the caller has cross-tenant access. startIdx is the next $N placeholder.
+func tenantWhereClause(ctx context.Context, startIdx int) (string, []any, int) {
+ if store.IsCrossTenant(ctx) {
+ return "", nil, startIdx
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ // Fail-closed: no tenant = filter to impossible value
+ return " AND tenant_id = '00000000-0000-0000-0000-000000000000'", nil, startIdx
+ }
+ return fmt.Sprintf(" AND tenant_id = $%d", startIdx), []any{tid}, startIdx + 1
+}
+
// cleanupLoop periodically evicts stale cache entries.
func (qc *QuotaChecker) cleanupLoop() {
ticker := time.NewTicker(2 * time.Minute)
diff --git a/internal/channels/slack/channel.go b/internal/channels/slack/channel.go
index 8e965847..4c6954f3 100644
--- a/internal/channels/slack/channel.go
+++ b/internal/channels/slack/channel.go
@@ -122,7 +122,7 @@ func New(cfg config.SlackConfig, msgBus *bus.MessageBus, pairingSvc store.Pairin
config: cfg,
requireMention: requireMention,
pairingService: pairingSvc,
- groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore),
+ groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore, base.TenantID()),
historyLimit: historyLimit,
debounceDelay: debounceDelay,
threadTTL: threadTTL,
diff --git a/internal/channels/slack/handlers.go b/internal/channels/slack/handlers.go
index 92cbc5a6..fa165abb 100644
--- a/internal/channels/slack/handlers.go
+++ b/internal/channels/slack/handlers.go
@@ -12,6 +12,7 @@ import (
"github.com/slack-go/slack/socketmode"
"github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
func (c *Channel) handleEventsAPI(evt socketmode.Event) {
@@ -32,6 +33,8 @@ func (c *Channel) handleEventsAPI(evt socketmode.Event) {
}
func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
// For message_changed: extract user/text from the nested Message field.
// Only process if the edit introduces a new @bot mention.
if ev.SubType == "message_changed" {
@@ -89,11 +92,11 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
// Policy check
if isDM {
- if !c.checkDMPolicy(senderID, channelID) {
+ if !c.checkDMPolicy(ctx, senderID, channelID) {
return
}
} else {
- if !c.checkGroupPolicy(senderID, channelID) {
+ if !c.checkGroupPolicy(ctx, senderID, channelID) {
return
}
}
@@ -196,7 +199,7 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, displayName, "", "group")
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group")
}
slog.Debug("slack group message recorded (no mention)",
diff --git a/internal/channels/slack/handlers_mention.go b/internal/channels/slack/handlers_mention.go
index 2a0ba6f7..62b6ecd3 100644
--- a/internal/channels/slack/handlers_mention.go
+++ b/internal/channels/slack/handlers_mention.go
@@ -1,6 +1,7 @@
package slack
import (
+ "context"
"fmt"
"log/slog"
"strings"
@@ -10,9 +11,12 @@ import (
"github.com/slack-go/slack/slackevents"
"github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
if ev.User == c.botUserID || ev.User == "" {
return
}
@@ -35,7 +39,7 @@ func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) {
displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_")
compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName)
- if !c.checkGroupPolicy(senderID, channelID) {
+ if !c.checkGroupPolicy(ctx, senderID, channelID) {
return
}
@@ -115,7 +119,7 @@ func (c *Channel) stripBotMention(text string) string {
// --- Policy checks ---
-func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -130,7 +134,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
return c.HasAllowList() && c.IsAllowed(senderID)
default: // "pairing"
if c.pairingService != nil {
- paired, err := c.pairingService.IsPaired(senderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -143,12 +147,12 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
if c.HasAllowList() && c.IsAllowed(senderID) {
return true
}
- c.sendPairingReply(senderID, channelID)
+ c.sendPairingReply(ctx, senderID, channelID)
return false
}
}
-func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
+func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -172,7 +176,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", channelID)
if c.pairingService != nil {
- paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -183,14 +187,14 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
return true
}
}
- c.sendPairingReply(groupSenderID, channelID)
+ c.sendPairingReply(ctx, groupSenderID, channelID)
return false
default: // "open"
return true
}
}
-func (c *Channel) sendPairingReply(senderID, channelID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) {
if c.pairingService == nil {
return
}
@@ -201,7 +205,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil)
if err != nil {
slog.Warn("slack: failed to request pairing code", "error", err)
return
diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go
index 307095c0..da639a61 100644
--- a/internal/channels/slack/utils.go
+++ b/internal/channels/slack/utils.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel)
@@ -32,7 +33,8 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind)
+ ctx := store.WithTenantID(context.Background(), c.TenantID())
+ cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind)
}
c.Bus().PublishInbound(bus.InboundMessage{
diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go
index ea6b61ad..4c328f90 100644
--- a/internal/channels/telegram/channel.go
+++ b/internal/channels/telegram/channel.go
@@ -122,7 +122,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
agentStore: agentStore,
configPermStore: configPermStore,
teamStore: teamStore,
- groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
+ groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore, base.TenantID()),
historyLimit: historyLimit,
requireMention: requireMention,
}, nil
diff --git a/internal/channels/telegram/commands_pairing.go b/internal/channels/telegram/commands_pairing.go
index 9ff8446a..9a003495 100644
--- a/internal/channels/telegram/commands_pairing.go
+++ b/internal/channels/telegram/commands_pairing.go
@@ -36,7 +36,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, chatID int64, userID, us
}
meta := map[string]string{"username": username}
- code, err := c.pairingService.RequestPairing(userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta)
+ code, err := c.pairingService.RequestPairing(ctx, userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta)
if err != nil {
slog.Debug("pairing request failed", "user_id", userID, "error", err)
return
@@ -70,7 +70,7 @@ func (c *Channel) sendGroupPairingReply(ctx context.Context, chatID int64, chatI
if chatTitle != "" {
meta = map[string]string{"chat_title": chatTitle}
}
- code, err := c.pairingService.RequestPairing(groupSenderID, c.Name(), localKey, "default", meta)
+ code, err := c.pairingService.RequestPairing(ctx, groupSenderID, c.Name(), localKey, "default", meta)
if err != nil {
slog.Debug("group pairing request failed", "chat_id", chatIDStr, "error", err)
return
diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go
index 1d9f06d7..4e796635 100644
--- a/internal/channels/telegram/handlers.go
+++ b/internal/channels/telegram/handlers.go
@@ -148,8 +148,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
default: // "pairing" or unknown → secure default
paired := false
if c.pairingService != nil {
- p1, err1 := c.pairingService.IsPaired(userID, c.Name())
- p2, err2 := c.pairingService.IsPaired(senderID, c.Name())
+ p1, err1 := c.pairingService.IsPaired(ctx, userID, c.Name())
+ p2, err2 := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err1 != nil || err2 != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"user_id", userID, "channel", c.Name(), "err1", err1, "err2", err2)
@@ -256,7 +256,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
if topicCfg.groupPolicy == "pairing" && c.pairingService != nil {
if _, cached := c.approvedGroups.Load(chatIDStr); !cached {
groupSenderID := fmt.Sprintf("group:%d", chatID)
- paired, pairErr := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, pairErr := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if pairErr != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", pairErr)
@@ -295,7 +295,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
if isGroup && topicCfg.groupPolicy == "pairing" && c.pairingService != nil {
if _, cached := c.approvedGroups.Load(chatIDStr); !cached {
groupSenderID := fmt.Sprintf("group:%d", chatID)
- paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go
index 66e10e10..582bb5d2 100644
--- a/internal/channels/whatsapp/whatsapp.go
+++ b/internal/channels/whatsapp/whatsapp.go
@@ -204,6 +204,8 @@ func (c *Channel) listenLoop() {
// handleIncomingMessage processes a message received from the bridge.
// Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]}
func (c *Channel) handleIncomingMessage(msg map[string]any) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
senderID, ok := msg["from"].(string)
if !ok || senderID == "" {
return
@@ -222,11 +224,11 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) {
// DM/Group policy check
if peerKind == "direct" {
- if !c.checkDMPolicy(senderID, chatID) {
+ if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
} else {
- if !c.checkGroupPolicy(senderID, chatID) {
+ if !c.checkGroupPolicy(ctx, senderID, chatID) {
slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID)
return
}
@@ -269,14 +271,14 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) {
// Collect contact for processed messages.
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind)
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind)
}
c.HandleMessage(senderID, chatID, content, media, metadata, peerKind)
}
// checkGroupPolicy evaluates the group policy for a sender, with pairing support.
-func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
+func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -296,7 +298,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
- paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -307,7 +309,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
return true
}
}
- c.sendPairingReply(groupSenderID, chatID)
+ c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
return true
@@ -315,7 +317,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow.
-func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -336,7 +338,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(senderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -351,13 +353,13 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
- c.sendPairingReply(senderID, chatID)
+ c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
// sendPairingReply sends a pairing code to the user via the WS bridge.
-func (c *Channel) sendPairingReply(senderID, chatID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -369,7 +371,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err)
return
diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go
index 98d39d9b..07dd74ed 100644
--- a/internal/channels/zalo/personal/channel.go
+++ b/internal/channels/zalo/personal/channel.go
@@ -64,7 +64,7 @@ func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store
BaseChannel: base,
config: cfg,
pairingService: pairingSvc,
- groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore),
+ groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore, base.TenantID()),
historyLimit: historyLimit,
requireMention: requireMention,
stopCh: make(chan struct{}),
diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go
index 5ecb1614..4cefe8ac 100644
--- a/internal/channels/zalo/personal/handlers.go
+++ b/internal/channels/zalo/personal/handlers.go
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -32,6 +33,8 @@ func (c *Channel) handleMessage(msg protocol.Message) {
}
func (c *Channel) handleDM(msg protocol.UserMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
@@ -40,7 +43,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
return
}
- if !c.checkDMPolicy(senderID, threadID) {
+ if !c.checkDMPolicy(ctx, senderID, threadID) {
return
}
@@ -61,7 +64,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
// Collect contact for DM messages.
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "direct")
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct")
}
metadata := map[string]string{
@@ -72,6 +75,8 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
}
func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
@@ -81,7 +86,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
}
// Step 1: enforce access policy (allowlist/pairing). Hard reject — don't record history.
- if !c.checkGroupPolicy(senderID, threadID) {
+ if !c.checkGroupPolicy(ctx, senderID, threadID) {
return
}
@@ -105,7 +110,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
}
slog.Debug("zalo_personal group message recorded (no mention)",
@@ -138,7 +143,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
// Collect contact for group-mentioned messages.
if cc := c.ContactCollector(); cc != nil {
- cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
+ cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
}
metadata := map[string]string{
diff --git a/internal/channels/zalo/personal/policy.go b/internal/channels/zalo/personal/policy.go
index 6dcf31db..04d4c5fb 100644
--- a/internal/channels/zalo/personal/policy.go
+++ b/internal/channels/zalo/personal/policy.go
@@ -13,7 +13,7 @@ import (
const pairingDebounce = 60 * time.Second
// checkDMPolicy enforces DM policy for incoming messages.
-func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "allowlist"
@@ -37,7 +37,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(senderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -52,12 +52,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
- c.sendPairingReply(senderID, chatID)
+ c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
-func (c *Channel) sendPairingReply(senderID, chatID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
sess := c.session()
if c.pairingService == nil || sess == nil {
return
@@ -70,7 +70,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("zalo_personal pairing request failed", "sender_id", senderID, "error", err)
return
@@ -98,7 +98,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
// checkGroupPolicy enforces group access policy (allowlist/pairing).
// Returns false if the group is blocked by policy; does NOT check @mention gating.
-func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
+func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, groupID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "allowlist"
@@ -124,7 +124,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
groupSenderID := fmt.Sprintf("group:%s", groupID)
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(groupSenderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -135,7 +135,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
if paired {
c.approvedGroups.Store(groupID, true)
} else {
- c.sendPairingReply(groupSenderID, groupID)
+ c.sendPairingReply(ctx, groupSenderID, groupID)
return false
}
}
diff --git a/internal/channels/zalo/personal/zalomethods/qr.go b/internal/channels/zalo/personal/zalomethods/qr.go
index 27a66f54..9fe5266c 100644
--- a/internal/channels/zalo/personal/zalomethods/qr.go
+++ b/internal/channels/zalo/personal/zalomethods/qr.go
@@ -110,7 +110,7 @@ func (m *QRMethods) runQRFlow(ctx context.Context, client *gateway.Client, insta
return
}
- if err := m.instanceStore.Update(context.Background(), instanceID, map[string]any{
+ if err := m.instanceStore.Update(ctx, instanceID, map[string]any{
"credentials": string(credsJSON),
}); err != nil {
slog.Error("Zalo Personal QR: save credentials failed", "instance", instanceIDStr, "error", err)
diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go
index 88279a4b..2357f117 100644
--- a/internal/channels/zalo/zalo.go
+++ b/internal/channels/zalo/zalo.go
@@ -186,6 +186,8 @@ func (c *Channel) processUpdate(update zaloUpdate) {
}
func (c *Channel) handleTextMessage(msg *zaloMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID)
@@ -197,7 +199,7 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) {
}
// DM policy enforcement (Zalo is DM-only)
- if !c.checkDMPolicy(senderID, chatID) {
+ if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
@@ -221,6 +223,8 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) {
}
func (c *Channel) handleImageMessage(msg *zaloMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID)
@@ -231,7 +235,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) {
chatID = senderID
}
- if !c.checkDMPolicy(senderID, chatID) {
+ if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
@@ -278,7 +282,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) {
// --- DM Policy ---
-func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
switch c.dmPolicy {
case "disabled":
slog.Debug("zalo message rejected: DMs disabled", "sender_id", senderID)
@@ -298,7 +302,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
// Check if already paired or in allowlist
paired := false
if c.pairingService != nil {
- p, err := c.pairingService.IsPaired(senderID, c.Name())
+ p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -314,12 +318,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
}
// Send pairing reply (debounced)
- c.sendPairingReply(senderID, chatID)
+ c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
-func (c *Channel) sendPairingReply(senderID, chatID string) {
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -331,7 +335,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
- code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
+ code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err)
return
diff --git a/internal/config/tenant_paths.go b/internal/config/tenant_paths.go
new file mode 100644
index 00000000..495887ac
--- /dev/null
+++ b/internal/config/tenant_paths.go
@@ -0,0 +1,58 @@
+package config
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+// masterTenantID mirrors store.MasterTenantID.
+// Duplicated here to avoid an import cycle (store imports config).
+var masterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
+
+// TenantDataDir returns the data directory root for a tenant.
+// Master tenant returns dataDir unchanged (backward compat).
+// Other tenants return dataDir/tenants/{slug}/.
+func TenantDataDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
+ if tenantID == masterTenantID {
+ return dataDir
+ }
+ result := filepath.Join(dataDir, "tenants", tenantSlug)
+ // Defense-in-depth: prevent path traversal via malicious slug.
+ tenantsBase := filepath.Join(dataDir, "tenants") + string(filepath.Separator)
+ if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) {
+ return filepath.Join(dataDir, "tenants", tenantID.String())
+ }
+ return result
+}
+
+// TenantWorkspace returns the workspace root for a tenant.
+// Master tenant returns workspace unchanged (backward compat).
+// Other tenants return workspace/tenants/{slug}/.
+func TenantWorkspace(workspace string, tenantID uuid.UUID, tenantSlug string) string {
+ if tenantID == masterTenantID {
+ return workspace
+ }
+ result := filepath.Join(workspace, "tenants", tenantSlug)
+ tenantsBase := filepath.Join(workspace, "tenants") + string(filepath.Separator)
+ if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) {
+ return filepath.Join(workspace, "tenants", tenantID.String())
+ }
+ return result
+}
+
+// TenantTeamDir returns the team workspace directory for a tenant.
+func TenantTeamDir(dataDir string, tenantID uuid.UUID, tenantSlug string, teamID uuid.UUID) string {
+ return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "teams", teamID.String())
+}
+
+// TenantSkillsStoreDir returns the managed skills directory for a tenant.
+func TenantSkillsStoreDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
+ return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "skills-store")
+}
+
+// TenantMediaDir returns the media storage directory for a tenant.
+func TenantMediaDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
+ return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "media")
+}
diff --git a/internal/gateway/client.go b/internal/gateway/client.go
index b76d006d..837e338f 100644
--- a/internal/gateway/client.go
+++ b/internal/gateway/client.go
@@ -34,6 +34,14 @@ type Client struct {
pairingPending bool // true while waiting for admin approval
pairedSenderID string // senderID used for browser pairing auth (for revocation lookup)
pairedChannel string // channel used for pairing auth (e.g., "browser")
+
+ // Team access cache for event filtering (lazily populated).
+ teamIDs map[string]bool
+
+ tenantID uuid.UUID // resolved tenant (uuid.Nil = cross-tenant)
+ crossTenant bool // true for owner/system admin
+ tenantName string // resolved tenant display name (set during connect)
+ tenantSlug string // resolved tenant URL slug (set during connect)
}
func NewClient(conn *websocket.Conn, server *Server, remoteIP string) *Client {
@@ -192,6 +200,43 @@ func (c *Client) ConnectedAt() time.Time { return c.connectedAt }
// RemoteAddr returns the peer IP:port.
func (c *Client) RemoteAddr() string { return c.remoteAddr }
+// TenantID returns the resolved tenant UUID (uuid.Nil means cross-tenant).
+func (c *Client) TenantID() uuid.UUID { return c.tenantID }
+
+// IsCrossTenant returns true if the client has cross-tenant (owner/system admin) access.
+func (c *Client) IsCrossTenant() bool { return c.crossTenant }
+
+// HasScope reports whether the client has the given scope.
+func (c *Client) HasScope(scope permissions.Scope) bool {
+ for _, s := range c.scopes {
+ if s == scope {
+ return true
+ }
+ }
+ return false
+}
+
+// hasTeamAccess checks if the client has access to a team (for event filtering).
+// Returns true for admin role. For others, checks the lazily-populated teamIDs cache.
+// TODO: populate teamIDs from team_user_grants on connect or first team event.
+func (c *Client) hasTeamAccess(teamID string) bool {
+ if permissions.HasMinRole(c.role, permissions.RoleAdmin) {
+ return true
+ }
+ if c.teamIDs == nil {
+ return false
+ }
+ return c.teamIDs[teamID]
+}
+
+// SetTeamAccess sets the team access cache for this client.
+func (c *Client) SetTeamAccess(teamIDs []string) {
+ c.teamIDs = make(map[string]bool, len(teamIDs))
+ for _, id := range teamIDs {
+ c.teamIDs[id] = true
+ }
+}
+
// Close shuts down the client connection.
func (c *Client) Close() {
close(c.send)
diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go
new file mode 100644
index 00000000..53d6c634
--- /dev/null
+++ b/internal/gateway/event_filter.go
@@ -0,0 +1,208 @@
+package gateway
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/agent"
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// clientCanReceiveEvent checks whether a WS client should receive a given bus event.
+// Admin clients receive all events. Non-admin clients are filtered by user/team scope.
+func clientCanReceiveEvent(c *Client, event bus.Event) bool {
+ // Internal events are never forwarded.
+ if strings.HasPrefix(event.Name, "cache.") || event.Name == protocol.EventAuditLog {
+ return false
+ }
+
+ // System-wide events go to everyone.
+ if isSystemEvent(event.Name) {
+ return true
+ }
+
+ // Tenant isolation: fail-closed, 3-mode filtering.
+ //
+ // Mode 1: Unscoped admin (crossTenant=true, tenantID=Nil) → see everything
+ // Mode 2: Scoped admin (crossTenant=true, tenantID=X) → tenant X events + unscoped system events
+ // Mode 3: Regular user (crossTenant=false, tenantID=X) → ONLY tenant X events (fail-closed)
+ if !c.crossTenant && c.tenantID == uuid.Nil {
+ return false // fail-closed: no tenant assigned to non-admin client
+ } else if c.crossTenant && c.tenantID == uuid.Nil {
+ // Mode 1: unscoped cross-tenant admin → no tenant filtering
+ } else if c.tenantID != uuid.Nil {
+ // Event has explicit tenant → must match client's tenant
+ if event.TenantID != uuid.Nil && event.TenantID != c.tenantID {
+ return false
+ }
+ // Event has no tenant → only cross-tenant admin (scoped) can see unscoped events
+ if event.TenantID == uuid.Nil && !c.crossTenant {
+ return false // fail-closed: regular users blocked from unscoped events
+ }
+ }
+
+ // Admin sees everything (when not tenant-scoped, handled above).
+ if permissions.HasMinRole(c.role, permissions.RoleAdmin) {
+ return true
+ }
+
+ // Agent / chat events: filter by UserID.
+ if event.Name == protocol.EventAgent || event.Name == protocol.EventChat {
+ if uid := extractEventUserID(event); uid != "" {
+ return uid == c.userID
+ }
+ return true // no routing context → broadcast (legacy)
+ }
+
+ // Session events: filter by UserID in payload.
+ if event.Name == protocol.EventSessionUpdated {
+ if uid := extractMapField(event.Payload, "userId"); uid != "" {
+ return uid == c.userID
+ }
+ return true
+ }
+
+ // Cron events: filter by UserID in payload.
+ if event.Name == protocol.EventCron {
+ if ce, ok := event.Payload.(store.CronEvent); ok && ce.UserID != "" {
+ return ce.UserID == c.userID
+ }
+ if uid := extractMapField(event.Payload, "userId"); uid != "" {
+ return uid == c.userID
+ }
+ return true
+ }
+
+ // Trace events: filter by UserID.
+ if event.Name == protocol.EventTraceUpdated {
+ if uid := extractMapField(event.Payload, "userId"); uid != "" {
+ return uid == c.userID
+ }
+ return true
+ }
+
+ // Team events: filter by TeamID.
+ if strings.HasPrefix(event.Name, "team.") || strings.HasPrefix(event.Name, "delegation.") {
+ if tid := extractTeamID(event); tid != "" {
+ return c.hasTeamAccess(tid)
+ }
+ return true // no team context → broadcast
+ }
+
+ // Tenant access revocation: deliver to the affected user only.
+ if event.Name == protocol.EventTenantAccessRevoked {
+ if uid := extractMapField(event.Payload, "user_id"); uid != "" {
+ return uid == c.userID
+ }
+ return false
+ }
+
+ // Admin-only events: pairing, node, agent links.
+ if isAdminOnlyEvent(event.Name) {
+ return false // non-admin clients don't receive these
+ }
+
+ // Exec approval events: scoped to the requesting user.
+ if strings.HasPrefix(event.Name, "exec.approval.") {
+ if uid := extractMapField(event.Payload, "userId"); uid != "" {
+ return uid == c.userID
+ }
+ return true
+ }
+
+ // Zalo personal QR events: admin-only (channel management).
+ if strings.HasPrefix(event.Name, "zalo.personal.") {
+ return false
+ }
+
+ // Skill dep events → broadcast (non-sensitive, skill names only).
+ if strings.HasPrefix(event.Name, "skill.") {
+ return true
+ }
+
+ // Default: deny unknown events to non-admin (fail-closed).
+ return false
+}
+
+// isSystemEvent returns true for events that should be broadcast to all clients.
+func isSystemEvent(name string) bool {
+ switch name {
+ case protocol.EventHealth, protocol.EventPresence, protocol.EventVoicewakeChanged,
+ protocol.EventTick, protocol.EventShutdown, protocol.EventConnectChallenge,
+ protocol.EventTalkMode, protocol.EventHeartbeat:
+ return true
+ }
+ return false
+}
+
+// isAdminOnlyEvent returns true for events that only admin clients should receive.
+func isAdminOnlyEvent(name string) bool {
+ switch name {
+ case protocol.EventNodePairRequested, protocol.EventNodePairResolved,
+ protocol.EventDevicePairReq, protocol.EventDevicePairRes,
+ protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted,
+ protocol.EventWorkspaceFileChanged:
+ return true
+ }
+ return false
+}
+
+// extractEventUserID extracts UserID from agent.AgentEvent payload.
+func extractEventUserID(event bus.Event) string {
+ switch ae := event.Payload.(type) {
+ case agent.AgentEvent:
+ return ae.UserID
+ case *agent.AgentEvent:
+ return ae.UserID
+ }
+ return extractMapField(event.Payload, "userId")
+}
+
+// extractTeamID extracts TeamID from team event payloads.
+func extractTeamID(event bus.Event) string {
+ switch te := event.Payload.(type) {
+ case protocol.TeamTaskEventPayload:
+ return te.TeamID
+ case *protocol.TeamTaskEventPayload:
+ return te.TeamID
+ }
+ // Fallback: check map payloads.
+ if tid := extractMapField(event.Payload, "team_id"); tid != "" {
+ return tid
+ }
+ return extractMapField(event.Payload, "teamId")
+}
+
+// extractMapField extracts a string field from a map[string]any or map[string]string payload.
+// Falls back to JSON unmarshaling for struct payloads.
+func extractMapField(payload any, key string) string {
+ switch m := payload.(type) {
+ case map[string]any:
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ case map[string]string:
+ return m[key]
+ default:
+ // Try JSON round-trip for struct payloads (lazy, used rarely).
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return ""
+ }
+ var parsed map[string]any
+ if json.Unmarshal(data, &parsed) == nil {
+ if v, ok := parsed[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ }
+ }
+ return ""
+}
diff --git a/internal/gateway/methods/access.go b/internal/gateway/methods/access.go
new file mode 100644
index 00000000..566f1817
--- /dev/null
+++ b/internal/gateway/methods/access.go
@@ -0,0 +1,18 @@
+package methods
+
+import (
+ "slices"
+
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+)
+
+// canSeeAll checks if user has full data visibility (admin role OR owner user).
+func canSeeAll(role permissions.Role, ownerIDs []string, userID string) bool {
+ if permissions.HasMinRole(role, permissions.RoleAdmin) {
+ return true
+ }
+ if userID != "" && slices.Contains(ownerIDs, userID) {
+ return true
+ }
+ return false
+}
diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go
index b040e33b..290bf5fc 100644
--- a/internal/gateway/methods/agent_links.go
+++ b/internal/gateway/methods/agent_links.go
@@ -59,7 +59,7 @@ func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Clie
return
}
- agentID, err := resolveAgentUUID(m.agentStore, params.AgentID)
+ agentID, err := resolveAgentUUID(ctx, m.agentStore, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -133,12 +133,12 @@ func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Cl
return
}
- sourceAgent, err := resolveAgentInfo(m.agentStore, params.SourceAgent)
+ sourceAgent, err := resolveAgentInfo(ctx, m.agentStore, params.SourceAgent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source agent: "+err.Error()))
return
}
- targetAgent, err := resolveAgentInfo(m.agentStore, params.TargetAgent)
+ targetAgent, err := resolveAgentInfo(ctx, m.agentStore, params.TargetAgent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "target agent: "+err.Error()))
return
@@ -408,15 +408,15 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source
// --- helpers ---
-func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
+func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
- ag, err := agentStore.GetByID(context.Background(), id)
+ ag, err := agentStore.GetByID(ctx, id)
if err != nil {
return uuid.Nil, err
}
return ag.ID, nil
}
- ag, err := agentStore.GetByKey(context.Background(), keyOrID)
+ ag, err := agentStore.GetByKey(ctx, keyOrID)
if err != nil {
return uuid.Nil, err
}
@@ -424,9 +424,9 @@ func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, e
}
// resolveAgentInfo returns full agent data for validation and cache invalidation.
-func resolveAgentInfo(agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) {
+func resolveAgentInfo(ctx context.Context, agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
- return agentStore.GetByID(context.Background(), id)
+ return agentStore.GetByID(ctx, id)
}
- return agentStore.GetByKey(context.Background(), keyOrID)
+ return agentStore.GetByKey(ctx, keyOrID)
}
diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go
index dca94bac..5424a6c7 100644
--- a/internal/gateway/methods/agents.go
+++ b/internal/gateway/methods/agents.go
@@ -4,13 +4,13 @@ import (
"context"
"encoding/json"
"log/slog"
- "slices"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -34,10 +34,7 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa
// isOwnerUser checks if the given user ID is in the configured owner IDs.
func (m *AgentsMethods) isOwnerUser(userID string) bool {
- if userID == "" {
- return false
- }
- return slices.Contains(m.cfg.Gateway.OwnerIDs, userID)
+ return canSeeAll(permissions.RoleViewer, m.cfg.Gateway.OwnerIDs, userID)
}
func (m *AgentsMethods) Register(router *gateway.MethodRouter) {
@@ -57,7 +54,7 @@ type agentParams struct {
AgentID string `json:"agentId"`
}
-func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleAgent(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, ¶ms)
@@ -66,7 +63,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r
params.AgentID = "default"
}
- loop, err := m.agents.Get(params.AgentID)
+ loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -78,7 +75,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r
}))
}
-func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleAgentWait(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, ¶ms)
@@ -87,7 +84,7 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien
params.AgentID = "default"
}
- loop, err := m.agents.Get(params.AgentID)
+ loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go
index dd19758f..67815c1d 100644
--- a/internal/gateway/methods/agents_create.go
+++ b/internal/gateway/methods/agents_create.go
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
@@ -28,6 +30,7 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
Avatar string `json:"avatar"`
AgentType string `json:"agent_type"` // "open" (default) or "predefined"
OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system"
+ TenantID string `json:"tenant_id"` // required for cross-tenant callers; ignored otherwise
// Per-agent config overrides
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"`
@@ -67,7 +70,6 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: create agent in store ---
- ctx := context.Background()
// Check if agent already exists in DB
if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil {
@@ -82,10 +84,28 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
ownerID = params.OwnerIDs[0]
}
+ // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant.
+ var tenantID uuid.UUID
+ if client.IsCrossTenant() {
+ if params.TenantID == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "tenant_id")))
+ return
+ }
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+ tenantID = tid
+ } else {
+ tenantID = client.TenantID()
+ }
+
agentData := &store.AgentData{
AgentKey: agentID,
DisplayName: params.Name,
OwnerID: ownerID,
+ TenantID: tenantID,
AgentType: agentType,
Provider: m.cfg.Agents.Defaults.Provider,
Model: m.cfg.Agents.Defaults.Model,
diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go
index 1928d4d5..6e3768aa 100644
--- a/internal/gateway/methods/agents_create_owner_test.go
+++ b/internal/gateway/methods/agents_create_owner_test.go
@@ -31,6 +31,12 @@ func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.Agent
func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
return nil, nil
}
+func (s *createCaptureStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
+ return nil, nil
+}
+func (s *createCaptureStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
+ return nil, nil
+}
func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go
index 039ed9ef..cd3f6f1b 100644
--- a/internal/gateway/methods/agents_delete.go
+++ b/internal/gateway/methods/agents_delete.go
@@ -40,7 +40,6 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: delete from store ---
- ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go
index c6058209..d0dd2ac5 100644
--- a/internal/gateway/methods/agents_files.go
+++ b/internal/gateway/methods/agents_files.go
@@ -38,7 +38,7 @@ func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Cli
if m.agentStore != nil {
// --- DB-backed: list from store ---
- ctx := context.Background()
+
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
@@ -137,7 +137,7 @@ func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Clie
if m.agentStore != nil {
// --- DB-backed: read from store ---
- ctx := context.Background()
+
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
@@ -236,7 +236,7 @@ func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Clie
if m.agentStore != nil {
// --- DB-backed: write to store ---
- ctx := context.Background()
+
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
diff --git a/internal/gateway/methods/agents_identity.go b/internal/gateway/methods/agents_identity.go
index 8bd361e4..403e6899 100644
--- a/internal/gateway/methods/agents_identity.go
+++ b/internal/gateway/methods/agents_identity.go
@@ -15,7 +15,7 @@ import (
// --- agent.identity.get ---
// Matching TS src/gateway/server-methods/agent.ts:601-643
-func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleIdentityGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
AgentID string `json:"agentId"`
SessionKey string `json:"sessionKey"`
@@ -42,7 +42,6 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli
if m.agentStore != nil {
// --- DB-backed: read identity from store ---
- ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err == nil {
result["name"] = ag.DisplayName
diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go
index 2172ba91..9dee9639 100644
--- a/internal/gateway/methods/agents_update.go
+++ b/internal/gateway/methods/agents_update.go
@@ -46,7 +46,6 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: update agent in store ---
- ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
diff --git a/internal/gateway/methods/api_keys.go b/internal/gateway/methods/api_keys.go
index cb816788..1ca6ec93 100644
--- a/internal/gateway/methods/api_keys.go
+++ b/internal/gateway/methods/api_keys.go
@@ -35,7 +35,12 @@ func (m *APIKeysMethods) Register(router *gateway.MethodRouter) {
func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
- keys, err := m.apiKeys.List(ctx)
+ // Non-admin callers only see their own keys.
+ ownerID := ""
+ if client.Role() != permissions.RoleAdmin {
+ ownerID = client.UserID()
+ }
+ keys, err := m.apiKeys.List(ctx, ownerID)
if err != nil {
slog.Error("api_keys.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys")))
@@ -54,6 +59,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
+ OwnerID string `json:"owner_id"` // optional; non-admin callers always get their own user_id
+ TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
@@ -79,6 +86,12 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
}
}
+ // Non-admin callers always bind the key to their own user_id.
+ ownerID := params.OwnerID
+ if client.Role() != permissions.RoleAdmin {
+ ownerID = client.UserID()
+ }
+
raw, hash, prefix, err := crypto.GenerateAPIKey()
if err != nil {
slog.Error("api_keys.generate failed", "error", err)
@@ -86,6 +99,34 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
return
}
+ // Resolve tenant_id based on caller type.
+ var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB)
+ if client.IsCrossTenant() {
+ if params.TenantID != "" {
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+ tenantID = tid
+ }
+ // else: uuid.Nil stays → system-level key
+ } else if client.HasScope(permissions.ScopeProvision) {
+ // Provision-scoped callers may create tenant-bound keys only (not system-level).
+ if params.TenantID != "" {
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+ tenantID = tid
+ } else {
+ tenantID = client.TenantID()
+ }
+ } else {
+ tenantID = client.TenantID()
+ }
+
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
@@ -93,6 +134,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
Prefix: prefix,
KeyHash: hash,
Scopes: params.Scopes,
+ OwnerID: ownerID,
+ TenantID: tenantID,
CreatedBy: store.UserIDFromContext(ctx),
CreatedAt: now,
UpdatedAt: now,
@@ -139,7 +182,13 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien
return
}
- if err := m.apiKeys.Revoke(ctx, id); err != nil {
+ // Non-admin callers can only revoke their own keys.
+ ownerID := ""
+ if client.Role() != permissions.RoleAdmin {
+ ownerID = client.UserID()
+ }
+
+ if err := m.apiKeys.Revoke(ctx, id, ownerID); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", params.ID)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID)))
return
diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go
index d670c6a3..bc981df4 100644
--- a/internal/gateway/methods/chat.go
+++ b/internal/gateway/methods/chat.go
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
@@ -22,11 +23,11 @@ import (
// ChatMethods handles chat.send, chat.history, chat.abort, chat.inject.
type ChatMethods struct {
- agents *agent.Router
- sessions store.SessionStore
- rateLimiter *gateway.RateLimiter
- eventBus bus.EventPublisher
- postTurn tools.PostTurnProcessor
+ agents *agent.Router
+ sessions store.SessionStore
+ rateLimiter *gateway.RateLimiter
+ eventBus bus.EventPublisher
+ postTurn tools.PostTurnProcessor
}
func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods {
@@ -114,7 +115,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
}
}
- loop, err := m.agents.Get(params.AgentID)
+ loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -222,24 +223,25 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
}
// Auto-generate conversation title on first message (label empty = never titled).
- if label := m.sessions.GetLabel(sessionKey); label == "" {
+ if label := m.sessions.GetLabel(ctx, sessionKey); label == "" {
agentProvider := loop.Provider()
agentModel := loop.Model()
userMsg := params.Message
+ // Use runCtxBase (WithoutCancel + tenant-aware) so title save uses correct tenant.
+ titleCtx := runCtxBase
go func() {
- title := agent.GenerateTitle(context.Background(), agentProvider, agentModel, userMsg)
+ title := agent.GenerateTitle(titleCtx, agentProvider, agentModel, userMsg)
if title == "" {
return
}
- m.sessions.SetLabel(sessionKey, title)
- if err := m.sessions.Save(sessionKey); err != nil {
+ m.sessions.SetLabel(titleCtx, sessionKey, title)
+ if err := m.sessions.Save(titleCtx, sessionKey); err != nil {
slog.Warn("failed to save session title", "sessionKey", sessionKey, "error", err)
return
}
- m.eventBus.Broadcast(bus.Event{
- Name: protocol.EventSessionUpdated,
- Payload: map[string]string{"sessionKey": sessionKey, "label": title},
- })
+ bus.BroadcastForTenant(m.eventBus, protocol.EventSessionUpdated,
+ client.TenantID(),
+ map[string]string{"sessionKey": sessionKey, "label": title, "userId": userID})
}()
}
@@ -277,7 +279,12 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client,
sessionKey = sessions.BuildWSSessionKey(params.AgentID, uuid.NewString())
}
- history := m.sessions.GetHistory(sessionKey)
+ history := m.sessions.GetHistory(ctx, sessionKey)
+
+ // Sign file URLs before delivery — sessions store clean paths.
+ for i := range history {
+ history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey())
+ }
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"messages": history,
@@ -320,7 +327,7 @@ func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client,
// Create an assistant message with gateway-injected metadata
messageID := uuid.NewString()
- m.sessions.AddMessage(params.SessionKey, providers.Message{
+ m.sessions.AddMessage(ctx, params.SessionKey, providers.Message{
Role: "assistant",
Content: text,
})
diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go
index d8dcf46a..1a2dbf02 100644
--- a/internal/gateway/methods/config.go
+++ b/internal/gateway/methods/config.go
@@ -29,10 +29,25 @@ func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.Con
}
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
- router.Register(protocol.MethodConfigGet, m.handleGet)
- router.Register(protocol.MethodConfigApply, m.handleApply)
- router.Register(protocol.MethodConfigPatch, m.handlePatch)
- router.Register(protocol.MethodConfigSchema, m.handleSchema)
+ router.Register(protocol.MethodConfigGet, m.requireCrossTenant(m.handleGet))
+ router.Register(protocol.MethodConfigApply, m.requireCrossTenant(m.handleApply))
+ router.Register(protocol.MethodConfigPatch, m.requireCrossTenant(m.handlePatch))
+ router.Register(protocol.MethodConfigSchema, m.requireCrossTenant(m.handleSchema))
+}
+
+// requireCrossTenant wraps a handler to only allow cross-tenant (owner/system) users.
+func (m *ConfigMethods) requireCrossTenant(next gateway.MethodHandler) gateway.MethodHandler {
+ return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ if !client.IsCrossTenant() {
+ locale := store.LocaleFromContext(ctx)
+ client.SendResponse(protocol.NewErrorResponse(
+ req.ID, protocol.ErrUnauthorized,
+ i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
+ ))
+ return
+ }
+ next(ctx, client, req)
+ }
}
func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go
index 92d6b7ac..4969375e 100644
--- a/internal/gateway/methods/cron.go
+++ b/internal/gateway/methods/cron.go
@@ -7,6 +7,7 @@ import (
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -19,10 +20,11 @@ var cronSlugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
type CronMethods struct {
service store.CronStore
eventBus bus.EventPublisher
+ cfg *config.Config
}
-func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher) *CronMethods {
- return &CronMethods{service: service, eventBus: eventBus}
+func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher, cfg *config.Config) *CronMethods {
+ return &CronMethods{service: service, eventBus: eventBus, cfg: cfg}
}
func (m *CronMethods) Register(router *gateway.MethodRouter) {
@@ -36,7 +38,7 @@ func (m *CronMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodCronRuns, m.handleRuns)
}
-func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
IncludeDisabled bool `json:"includeDisabled"`
}
@@ -44,7 +46,11 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req
json.Unmarshal(req.Params, ¶ms)
}
- jobs := m.service.ListJobs(params.IncludeDisabled, "", "")
+ userID := ""
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ userID = client.UserID()
+ }
+ jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"jobs": jobs,
@@ -80,7 +86,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client,
return
}
- job, err := m.service.AddJob(params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID())
+ job, err := m.service.AddJob(ctx, params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID())
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -106,7 +112,15 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client,
return
}
- if err := m.service.RemoveJob(params.JobID); err != nil {
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ job, ok := m.service.GetJob(ctx, params.JobID)
+ if !ok || job.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
+ return
+ }
+ }
+
+ if err := m.service.RemoveJob(ctx, params.JobID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -132,7 +146,15 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client,
return
}
- if err := m.service.EnableJob(params.JobID, params.Enabled); err != nil {
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ job, ok := m.service.GetJob(ctx, params.JobID)
+ if !ok || job.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
+ return
+ }
+ }
+
+ if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -168,7 +190,15 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client,
return
}
- job, err := m.service.UpdateJob(jobID, params.Patch)
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ existing, ok := m.service.GetJob(ctx, jobID)
+ if !ok || existing.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
+ return
+ }
+ }
+
+ job, err := m.service.UpdateJob(ctx, jobID, params.Patch)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -203,12 +233,19 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req
force := params.Mode == "force"
// Validate job exists before responding
- _, ok := m.service.GetJob(jobID)
+ job, ok := m.service.GetJob(ctx, jobID)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound)))
return
}
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ if job.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
+ return
+ }
+ }
+
// Respond immediately — job execution happens in background
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
@@ -216,14 +253,17 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req
}))
emitAudit(m.eventBus, client, "cron.run", "cron", jobID)
+ // Preserve tenant scope for async execution.
+ tenantID := store.TenantIDFromContext(ctx)
go func() {
- if _, _, err := m.service.RunJob(jobID, force); err != nil {
+ bgCtx := store.WithTenantID(context.Background(), tenantID)
+ if _, _, err := m.service.RunJob(bgCtx, jobID, force); err != nil {
slog.Warn("cron.run background error", "jobId", jobID, "error", err)
}
}()
}
-func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleRuns(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
JobID string `json:"jobId"`
ID string `json:"id"`
@@ -239,7 +279,7 @@ func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req
jobID = params.ID
}
- entries, total := m.service.GetRunLog(jobID, params.Limit, params.Offset)
+ entries, total := m.service.GetRunLog(ctx, jobID, params.Limit, params.Offset)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"entries": entries,
"total": total,
diff --git a/internal/gateway/methods/delegations.go b/internal/gateway/methods/delegations.go
deleted file mode 100644
index 32110098..00000000
--- a/internal/gateway/methods/delegations.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package methods
-
-import (
- "context"
- "encoding/json"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/gateway"
- "github.com/nextlevelbuilder/goclaw/internal/i18n"
- "github.com/nextlevelbuilder/goclaw/internal/store"
- "github.com/nextlevelbuilder/goclaw/pkg/protocol"
-)
-
-// DelegationsMethods handles delegations.* RPC methods.
-type DelegationsMethods struct {
- teamStore store.TeamStore
-}
-
-func NewDelegationsMethods(teamStore store.TeamStore) *DelegationsMethods {
- return &DelegationsMethods{teamStore: teamStore}
-}
-
-func (m *DelegationsMethods) Register(router *gateway.MethodRouter) {
- router.Register(protocol.MethodDelegationsList, m.handleList)
- router.Register(protocol.MethodDelegationsGet, m.handleGet)
-}
-
-func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
- locale := store.LocaleFromContext(ctx)
- if m.teamStore == nil {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
- return
- }
-
- var params struct {
- SourceAgentID string `json:"source_agent_id"`
- TargetAgentID string `json:"target_agent_id"`
- TeamID string `json:"team_id"`
- UserID string `json:"user_id"`
- Status string `json:"status"`
- Limit int `json:"limit"`
- Offset int `json:"offset"`
- }
- if req.Params != nil {
- _ = json.Unmarshal(req.Params, ¶ms)
- }
-
- opts := store.DelegationHistoryListOpts{
- UserID: params.UserID,
- Status: params.Status,
- Limit: params.Limit,
- Offset: params.Offset,
- }
-
- if params.SourceAgentID != "" {
- if id, err := uuid.Parse(params.SourceAgentID); err == nil {
- opts.SourceAgentID = &id
- }
- }
- if params.TargetAgentID != "" {
- if id, err := uuid.Parse(params.TargetAgentID); err == nil {
- opts.TargetAgentID = &id
- }
- }
- if params.TeamID != "" {
- if id, err := uuid.Parse(params.TeamID); err == nil {
- opts.TeamID = &id
- }
- }
-
- records, total, err := m.teamStore.ListDelegationHistory(ctx, opts)
- if err != nil {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
- return
- }
-
- // Truncate results for WS transport
- const maxResultRunes = 500
- for i := range records {
- if records[i].Result != nil {
- r := []rune(*records[i].Result)
- if len(r) > maxResultRunes {
- s := string(r[:maxResultRunes]) + "..."
- records[i].Result = &s
- }
- }
- }
-
- client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
- "records": records,
- "total": total,
- }))
-}
-
-func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
- locale := store.LocaleFromContext(ctx)
- if m.teamStore == nil {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
- return
- }
-
- var params struct {
- ID string `json:"id"`
- }
- if req.Params != nil {
- _ = json.Unmarshal(req.Params, ¶ms)
- }
- if params.ID == "" {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
- return
- }
-
- id, err := uuid.Parse(params.ID)
- if err != nil {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id")))
- return
- }
-
- record, err := m.teamStore.GetDelegationHistory(ctx, id)
- if err != nil {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
- return
- }
-
- // Truncate result for WS transport
- const maxResultRunes = 8000
- if record.Result != nil {
- r := []rune(*record.Result)
- if len(r) > maxResultRunes {
- s := string(r[:maxResultRunes]) + "..."
- record.Result = &s
- }
- }
-
- client.SendResponse(protocol.NewOKResponse(req.ID, record))
-}
diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go
index 69641730..d0611828 100644
--- a/internal/gateway/methods/pairing.go
+++ b/internal/gateway/methods/pairing.go
@@ -70,7 +70,7 @@ func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Clie
params.AccountID = "default"
}
- code, err := m.service.RequestPairing(params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
+ code, err := m.service.RequestPairing(ctx, params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -99,7 +99,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
params.ApprovedBy = "operator"
}
- paired, err := m.service.ApprovePairing(params.Code, params.ApprovedBy)
+ paired, err := m.service.ApprovePairing(ctx, params.Code, params.ApprovedBy)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -135,7 +135,7 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client,
return
}
- if err := m.service.DenyPairing(params.Code); err != nil {
+ if err := m.service.DenyPairing(ctx, params.Code); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -150,9 +150,9 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client,
}))
}
-func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
- pending := m.service.ListPending()
- paired := m.service.ListPaired()
+func (m *PairingMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ pending := m.service.ListPending(ctx)
+ paired := m.service.ListPaired(ctx)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"pending": pending,
@@ -175,7 +175,7 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien
return
}
- if err := m.service.RevokePairing(params.SenderID, params.Channel); err != nil {
+ if err := m.service.RevokePairing(ctx, params.SenderID, params.Channel); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -222,7 +222,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client
return
}
- paired, pairErr := m.service.IsPaired(params.SenderID, "browser")
+ paired, pairErr := m.service.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr)
}
@@ -234,7 +234,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client
}
// Check if the pairing request still exists (not expired)
- pending := m.service.ListPending()
+ pending := m.service.ListPending(ctx)
for _, p := range pending {
if p.SenderID == params.SenderID && p.Channel == "browser" {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
diff --git a/internal/gateway/methods/sessions.go b/internal/gateway/methods/sessions.go
index 11f6290b..02124e43 100644
--- a/internal/gateway/methods/sessions.go
+++ b/internal/gateway/methods/sessions.go
@@ -5,7 +5,9 @@ import (
"encoding/json"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
+ httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -15,10 +17,11 @@ import (
type SessionsMethods struct {
sessions store.SessionStore
eventBus bus.EventPublisher
+ cfg *config.Config
}
-func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher) *SessionsMethods {
- return &SessionsMethods{sessions: sess, eventBus: eventBus}
+func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher, cfg *config.Config) *SessionsMethods {
+ return &SessionsMethods{sessions: sess, eventBus: eventBus, cfg: cfg}
}
func (m *SessionsMethods) Register(router *gateway.MethodRouter) {
@@ -36,7 +39,7 @@ type sessionsListParams struct {
Offset int `json:"offset"`
}
-func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SessionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params sessionsListParams
if req.Params != nil {
json.Unmarshal(req.Params, ¶ms)
@@ -47,18 +50,19 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client,
}
opts := store.SessionListOpts{
- AgentID: params.AgentID,
- Channel: params.Channel,
- Limit: params.Limit,
- Offset: params.Offset,
+ AgentID: params.AgentID,
+ Channel: params.Channel,
+ Limit: params.Limit,
+ Offset: params.Offset,
+ TenantID: store.TenantIDFromContext(ctx),
}
- // Only filter by UserID when a channel filter is specified (e.g. chat sidebar sends channel="ws").
- // Sessions admin page omits channel → sees all sessions unfiltered.
- if params.Channel != "" {
+ // Role-based filtering: admins/owners see all sessions; regular users see only their own.
+ // Tenant scope is always applied above — admin sees all sessions within the tenant.
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
opts.UserID = client.UserID()
}
- result := m.sessions.ListPagedRich(opts)
+ result := m.sessions.ListPagedRich(ctx, opts)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"sessions": result.Sessions,
"total": result.Total,
@@ -79,8 +83,26 @@ func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Cli
return
}
- history := m.sessions.GetHistory(params.Key)
- summary := m.sessions.GetSummary(params.Key)
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ sess := m.sessions.Get(ctx, params.Key)
+ if sess == nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
+ return
+ }
+ if sess.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
+ return
+ }
+ }
+
+ history := m.sessions.GetHistory(ctx, params.Key)
+ summary := m.sessions.GetSummary(ctx, params.Key)
+
+ // Sign file URLs before delivery — sessions store clean paths.
+ for i := range history {
+ history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey())
+ }
+ summary = httpapi.SignFileURLs(summary, httpapi.FileSigningKey())
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"key": params.Key,
@@ -109,23 +131,35 @@ func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Clien
return
}
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ sess := m.sessions.Get(ctx, params.Key)
+ if sess == nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
+ return
+ }
+ if sess.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
+ return
+ }
+ }
+
// Apply label patch
if params.Label != nil {
- m.sessions.SetLabel(params.Key, *params.Label)
+ m.sessions.SetLabel(ctx, params.Key, *params.Label)
}
// Apply model patch
if params.Model != nil {
- m.sessions.UpdateMetadata(params.Key, *params.Model, "", "")
+ m.sessions.UpdateMetadata(ctx, params.Key, *params.Model, "", "")
}
// Apply metadata patch
if len(params.Metadata) > 0 {
- m.sessions.SetSessionMetadata(params.Key, params.Metadata)
+ m.sessions.SetSessionMetadata(ctx, params.Key, params.Metadata)
}
// Save changes to DB
- m.sessions.Save(params.Key)
+ m.sessions.Save(ctx, params.Key)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
@@ -142,7 +176,19 @@ func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Clie
return
}
- if err := m.sessions.Delete(params.Key); err != nil {
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ sess := m.sessions.Get(ctx, params.Key)
+ if sess == nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
+ return
+ }
+ if sess.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
+ return
+ }
+ }
+
+ if err := m.sessions.Delete(ctx, params.Key); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
@@ -161,7 +207,19 @@ func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Clien
return
}
- m.sessions.Reset(params.Key)
+ if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
+ sess := m.sessions.Get(ctx, params.Key)
+ if sess == nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
+ return
+ }
+ if sess.UserID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
+ return
+ }
+ }
+
+ m.sessions.Reset(ctx, params.Key)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go
index a96b1062..5ff87570 100644
--- a/internal/gateway/methods/skills.go
+++ b/internal/gateway/methods/skills.go
@@ -33,8 +33,8 @@ func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodSkillsUpdate, m.handleUpdate)
}
-func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
- allSkills := m.store.ListSkills()
+func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ allSkills := m.store.ListSkills(ctx)
result := make([]map[string]any, 0, len(allSkills))
for _, s := range allSkills {
@@ -86,13 +86,13 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
return
}
- info, ok := m.store.GetSkill(params.Name)
+ info, ok := m.store.GetSkill(ctx, params.Name)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
- content, _ := m.store.LoadSkill(params.Name)
+ content, _ := m.store.LoadSkill(ctx, params.Name)
resp := map[string]any{
"name": info.Name,
@@ -116,7 +116,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
type skillUpdater interface {
- UpdateSkill(id uuid.UUID, updates map[string]any) error
+ UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error
}
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
@@ -153,7 +153,7 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
} else {
// Look up by name — use GetSkill which returns path info, but we need DB ID
// For PGSkillStore, the name is the slug
- info, exists := m.store.GetSkill(params.Name)
+ info, exists := m.store.GetSkill(ctx, params.Name)
if !exists {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
@@ -172,17 +172,21 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
return
}
- // Ownership check: only skill owner or admin can update
+ // Ownership check: only skill owner or admin can update.
+ // Fail-closed: if store doesn't implement skillOwnerGetter, deny non-admin callers.
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
- if ownerGetter, ok := m.store.(skillOwnerGetter); ok {
- if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
- return
- }
+ ownerGetter, ok := m.store.(skillOwnerGetter)
+ if !ok {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
+ return
+ }
+ if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
+ return
}
}
- if err := updater.UpdateSkill(skillID, params.Updates); err != nil {
+ if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go
index 53759aa5..a00c865b 100644
--- a/internal/gateway/methods/teams.go
+++ b/internal/gateway/methods/teams.go
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -22,7 +23,7 @@ type TeamsMethods struct {
agentRouter *agent.Router // for cache invalidation
msgBus *bus.MessageBus // for pub/sub cache invalidation
eventBus bus.EventPublisher
- dataDir string // workspace data directory for resolving file paths
+ dataDir string // workspace data directory for resolving file paths
}
func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router, msgBus *bus.MessageBus, eventBus bus.EventPublisher, dataDir string) *TeamsMethods {
@@ -73,7 +74,14 @@ func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, r
return
}
- teams, err := m.teamStore.ListTeams(ctx)
+ var teams []store.TeamData
+ var err error
+ if permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
+ teams, err = m.teamStore.ListTeams(ctx)
+ } else {
+ callerID := store.UserIDFromContext(ctx)
+ teams, err = m.teamStore.ListUserTeams(ctx, callerID)
+ }
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
@@ -118,7 +126,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
}
// Resolve lead agent
- leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead)
+ leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error()))
return
@@ -134,7 +142,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
// Resolve member agents
var memberAgents []*store.AgentData
for _, memberKey := range params.Members {
- ag, err := resolveAgentInfo(m.agentStore, memberKey)
+ ag, err := resolveAgentInfo(ctx, m.agentStore, memberKey)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error()))
return
diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go
index 1feed25d..58bcf229 100644
--- a/internal/gateway/methods/teams_crud.go
+++ b/internal/gateway/methods/teams_crud.go
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -50,6 +51,15 @@ func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, re
return
}
+ // Non-admin callers must be a member of the team to view it.
+ if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(ctx)
+ if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID)))
+ return
+ }
+ }
+
members, err := m.teamStore.ListMembers(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -92,6 +102,15 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client,
return
}
+ // Non-admin callers must have team access to delete.
+ if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(ctx)
+ if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID)))
+ return
+ }
+ }
+
// Fetch team and members before deleting for event + cache invalidation
team, _ := m.teamStore.GetTeam(ctx, teamID)
members, _ := m.teamStore.ListMembers(ctx, teamID)
diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go
index b58e320d..9dfe2116 100644
--- a/internal/gateway/methods/teams_members.go
+++ b/internal/gateway/methods/teams_members.go
@@ -53,7 +53,7 @@ func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Clie
}
// Resolve agent
- ag, err := resolveAgentInfo(m.agentStore, params.Agent)
+ ag, err := resolveAgentInfo(ctx, m.agentStore, params.Agent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error()))
return
diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go
index 41c5956d..cfaa9078 100644
--- a/internal/gateway/methods/teams_tasks.go
+++ b/internal/gateway/methods/teams_tasks.go
@@ -12,6 +12,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
+ httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -104,6 +105,13 @@ func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client
events, _ := m.teamStore.ListTaskEvents(ctx, taskID)
attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID)
+ // Sign download URLs at delivery time (same pattern as chat file URLs).
+ for i := range attachments {
+ dlPath := fmt.Sprintf("/v1/teams/%s/attachments/%s/download", teamID, attachments[i].ID)
+ ft := httpapi.SignFileToken(dlPath, httpapi.FileSigningKey(), httpapi.FileTokenTTL)
+ attachments[i].DownloadURL = dlPath + "?ft=" + ft
+ }
+
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"task": task,
"comments": comments,
diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go
index f9f389c4..52eb3804 100644
--- a/internal/gateway/methods/teams_workspace.go
+++ b/internal/gateway/methods/teams_workspace.go
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
+ "log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,14 +26,54 @@ func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) {
router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete)
}
-// teamWorkspaceDir returns the base directory for a team's workspace files.
-// Pattern: {dataDir}/teams/{teamID}/
-// If chatID is provided, scopes to {dataDir}/teams/{teamID}/{chatID}/
-func teamWorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) string {
- if chatID != "" {
- return filepath.Join(dataDir, "teams", teamID.String(), chatID)
+// resolveWorkspacePath resolves fileName within scopeDir and validates that the
+// canonical path (after symlink resolution) stays inside the scope directory.
+// Returns the resolved disk path or an error if the path escapes the boundary.
+func resolveWorkspacePath(scopeDir, fileName string) (string, error) {
+ diskPath := filepath.Clean(filepath.Join(scopeDir, fileName))
+
+ // Resolve scope dir to canonical form.
+ scopeReal, err := filepath.EvalSymlinks(filepath.Clean(scopeDir))
+ if err != nil {
+ scopeReal = filepath.Clean(scopeDir)
}
- return filepath.Join(dataDir, "teams", teamID.String())
+
+ // Resolve target path — follow symlinks to detect escapes.
+ diskReal, err := filepath.EvalSymlinks(diskPath)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ slog.Warn("security.workspace_path_resolve_failed", "path", fileName, "error", err)
+ return "", fmt.Errorf("invalid file_name")
+ }
+ // File doesn't exist yet — validate parent directory.
+ parentReal, parentErr := filepath.EvalSymlinks(filepath.Dir(diskPath))
+ if parentErr != nil {
+ return "", fmt.Errorf("invalid file_name")
+ }
+ diskReal = filepath.Join(parentReal, filepath.Base(diskPath))
+ }
+
+ // Boundary check: canonical path must be inside canonical scope.
+ if diskReal != scopeReal && !strings.HasPrefix(diskReal, scopeReal+string(filepath.Separator)) {
+ slog.Warn("security.workspace_path_escape", "path", fileName, "resolved", diskReal, "scope", scopeReal)
+ return "", fmt.Errorf("invalid file_name")
+ }
+
+ return diskPath, nil
+}
+
+// teamWorkspaceDir returns the base directory for a team's workspace files.
+// Scoped to tenant via config.TenantTeamDir: {dataDir}/tenants/{slug}/teams/{teamID}/
+// Master tenant: {dataDir}/teams/{teamID}/ (backward compat).
+// If chatID is provided, further scopes to .../{chatID}/
+func teamWorkspaceDir(ctx context.Context, dataDir string, teamID uuid.UUID, chatID string) string {
+ tid := store.TenantIDFromContext(ctx)
+ slug := store.TenantSlugFromContext(ctx)
+ base := config.TenantTeamDir(dataDir, tid, slug, teamID)
+ if chatID != "" {
+ return filepath.Join(base, chatID)
+ }
+ return base
}
// workspaceFileEntry is the response shape for workspace file listing.
@@ -76,7 +118,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
shared = tools.IsSharedWorkspace(team.Settings)
}
- baseDir := teamWorkspaceDir(m.dataDir, teamID, "")
+ baseDir := teamWorkspaceDir(ctx, m.dataDir, teamID, "")
var files []workspaceFileEntry
if shared || params.ChatID != "" {
@@ -84,7 +126,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
scopeDir := baseDir
scopeChatID := ""
if !shared && params.ChatID != "" {
- scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
+ scopeDir = teamWorkspaceDir(ctx, m.dataDir, teamID, params.ChatID)
scopeChatID = params.ChatID
}
files = walkDir(scopeDir, "", scopeChatID)
@@ -207,11 +249,10 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.
return
}
- scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
- diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
- // Ensure resolved path stays within the workspace scope directory.
- if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
+ scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID)
+ diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName)
+ if pathErr != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error()))
return
}
data, err := os.ReadFile(diskPath)
@@ -283,11 +324,10 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa
return
}
- scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
- diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
- // Ensure resolved path stays within the workspace scope directory.
- if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
- client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
+ scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID)
+ diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName)
+ if pathErr != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error()))
return
}
if err := os.Remove(diskPath); err != nil {
diff --git a/internal/gateway/methods/teams_workspace_test.go b/internal/gateway/methods/teams_workspace_test.go
new file mode 100644
index 00000000..510e9d96
--- /dev/null
+++ b/internal/gateway/methods/teams_workspace_test.go
@@ -0,0 +1,124 @@
+package methods
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestResolveWorkspacePath_NormalFile(t *testing.T) {
+ scope := t.TempDir()
+ if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("data"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ diskPath, err := resolveWorkspacePath(scope, "file.txt")
+ if err != nil {
+ t.Fatalf("expected success, got: %v", err)
+ }
+ if filepath.Base(diskPath) != "file.txt" {
+ t.Fatalf("expected file.txt, got: %s", diskPath)
+ }
+}
+
+func TestResolveWorkspacePath_NestedFile(t *testing.T) {
+ scope := t.TempDir()
+ sub := filepath.Join(scope, "sub")
+ if err := os.MkdirAll(sub, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("data"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ diskPath, err := resolveWorkspacePath(scope, "sub/nested.txt")
+ if err != nil {
+ t.Fatalf("expected success, got: %v", err)
+ }
+ if filepath.Base(diskPath) != "nested.txt" {
+ t.Fatalf("expected nested.txt, got: %s", diskPath)
+ }
+}
+
+func TestResolveWorkspacePath_TraversalBlocked(t *testing.T) {
+ scope := t.TempDir()
+ // Even though teams_workspace.go checks for ".." in the RPC handler,
+ // resolveWorkspacePath should also catch it via boundary check.
+ _, err := resolveWorkspacePath(scope, "../../../etc/passwd")
+ if err == nil {
+ t.Fatal("expected error for path traversal, got nil")
+ }
+}
+
+func TestResolveWorkspacePath_SymlinkEscapeBlocked(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks require special privileges on Windows")
+ }
+ scope := t.TempDir()
+ outside := t.TempDir()
+ secret := filepath.Join(outside, "secret.txt")
+ if err := os.WriteFile(secret, []byte("secret"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Plant a symlink inside workspace pointing outside
+ link := filepath.Join(scope, "evil_link")
+ if err := os.Symlink(secret, link); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := resolveWorkspacePath(scope, "evil_link")
+ if err == nil {
+ t.Fatal("expected error for symlink escaping workspace, got nil")
+ }
+}
+
+func TestResolveWorkspacePath_DirSymlinkEscapeBlocked(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks require special privileges on Windows")
+ }
+ scope := t.TempDir()
+ outside := t.TempDir()
+ if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Plant a directory symlink inside workspace pointing outside
+ link := filepath.Join(scope, "evil_dir")
+ if err := os.Symlink(outside, link); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := resolveWorkspacePath(scope, "evil_dir/secret.txt")
+ if err == nil {
+ t.Fatal("expected error for dir symlink escape, got nil")
+ }
+}
+
+func TestResolveWorkspacePath_SymlinkInsideAllowed(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks require special privileges on Windows")
+ }
+ scope := t.TempDir()
+ target := filepath.Join(scope, "real.txt")
+ if err := os.WriteFile(target, []byte("data"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(scope, "good_link")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := resolveWorkspacePath(scope, "good_link")
+ if err != nil {
+ t.Fatalf("expected success for symlink within workspace, got: %v", err)
+ }
+}
+
+func TestResolveWorkspacePath_NonExistentFile(t *testing.T) {
+ scope := t.TempDir()
+ // Non-existent file in workspace should succeed (for new file creation)
+ _, err := resolveWorkspacePath(scope, "new_file.txt")
+ if err != nil {
+ t.Fatalf("expected success for non-existent file, got: %v", err)
+ }
+}
diff --git a/internal/gateway/methods/tenants.go b/internal/gateway/methods/tenants.go
new file mode 100644
index 00000000..6108315d
--- /dev/null
+++ b/internal/gateway/methods/tenants.go
@@ -0,0 +1,404 @@
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/gateway"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+var slugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
+
+// TenantsMethods handles tenant management RPC methods.
+type TenantsMethods struct {
+ tenantStore store.TenantStore
+ msgBus *bus.MessageBus
+ workspace string // base workspace directory for tenant dirs
+}
+
+// NewTenantsMethods creates a new TenantsMethods handler.
+func NewTenantsMethods(tenantStore store.TenantStore, msgBus *bus.MessageBus, workspace string) *TenantsMethods {
+ return &TenantsMethods{tenantStore: tenantStore, msgBus: msgBus, workspace: workspace}
+}
+
+// Register registers tenant management RPC methods.
+func (m *TenantsMethods) Register(router *gateway.MethodRouter) {
+ router.Register("tenants.list", m.handleList)
+ router.Register("tenants.get", m.handleGet)
+ router.Register("tenants.create", m.handleCreate)
+ router.Register("tenants.update", m.handleUpdate)
+ router.Register("tenants.users.list", m.handleUsersList)
+ router.Register("tenants.users.add", m.handleUsersAdd)
+ router.Register("tenants.users.remove", m.handleUsersRemove)
+ router.Register("tenants.mine", m.handleMine)
+}
+
+func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")))
+ return
+ }
+
+ tenants, err := m.tenantStore.ListTenants(ctx)
+ if err != nil {
+ slog.Error("tenants.list failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
+ return
+ }
+ if tenants == nil {
+ tenants = []store.TenantData{}
+ }
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": tenants}))
+}
+
+func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")))
+ return
+ }
+
+ var params struct {
+ ID string `json:"id"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ id, err := uuid.Parse(params.ID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
+ return
+ }
+
+ tenant, err := m.tenantStore.GetTenant(ctx, id)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "tenant", params.ID)))
+ return
+ }
+ client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
+}
+
+func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")))
+ return
+ }
+
+ var params struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Settings any `json:"settings"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ if params.Name == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
+ return
+ }
+ if params.Slug == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "slug")))
+ return
+ }
+ if !slugRe.MatchString(params.Slug) {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "slug")))
+ return
+ }
+
+ tenant := &store.TenantData{
+ ID: store.GenNewID(),
+ Name: params.Name,
+ Slug: params.Slug,
+ Status: store.TenantStatusActive,
+ }
+
+ if err := m.tenantStore.CreateTenant(ctx, tenant); err != nil {
+ slog.Error("tenants.create failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())))
+ return
+ }
+
+ // Create workspace directory for the tenant.
+ if m.workspace != "" {
+ tenantDir := filepath.Join(m.workspace, "tenants", tenant.Slug)
+ if err := os.MkdirAll(tenantDir, 0755); err != nil {
+ slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err)
+ }
+ }
+
+ m.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String())
+ m.emitCacheInvalidate(bus.CacheKindTenants, "")
+ client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
+}
+
+func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")))
+ return
+ }
+
+ var params struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Settings map[string]any `json:"settings"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ id, err := uuid.Parse(params.ID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
+ return
+ }
+
+ updates := make(map[string]any)
+ if params.Name != "" {
+ updates["name"] = params.Name
+ }
+ if params.Status != "" {
+ updates["status"] = params.Status
+ }
+ if params.Settings != nil {
+ updates["settings"] = params.Settings
+ }
+
+ if len(updates) == 0 {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
+ return
+ }
+
+ if err := m.tenantStore.UpdateTenant(ctx, id, updates); err != nil {
+ slog.Error("tenants.update failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())))
+ return
+ }
+
+ m.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String())
+ m.emitCacheInvalidate(bus.CacheKindTenants, "")
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
+}
+
+func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")))
+ return
+ }
+
+ var params struct {
+ TenantID string `json:"tenant_id"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+
+ users, err := m.tenantStore.ListUsers(ctx, tid)
+ if err != nil {
+ slog.Error("tenants.users.list failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenant users")))
+ return
+ }
+ if users == nil {
+ users = []store.TenantUserData{}
+ }
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"users": users}))
+}
+
+func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")))
+ return
+ }
+
+ var params struct {
+ TenantID string `json:"tenant_id"`
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ if params.UserID == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
+ return
+ }
+ if params.Role == "" {
+ params.Role = store.TenantRoleMember
+ }
+ validRoles := map[string]bool{
+ store.TenantRoleOwner: true, store.TenantRoleAdmin: true,
+ store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true,
+ }
+ if !validRoles[params.Role] {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRole)))
+ return
+ }
+
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+
+ if err := m.tenantStore.AddUser(ctx, tid, params.UserID, params.Role); err != nil {
+ slog.Error("tenants.users.add failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())))
+ return
+ }
+
+ m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
+}
+
+func (m *TenantsMethods) handleUsersRemove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ if !client.IsCrossTenant() {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")))
+ return
+ }
+
+ var params struct {
+ TenantID string `json:"tenant_id"`
+ UserID string `json:"user_id"`
+ }
+ if req.Params != nil {
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
+ return
+ }
+ }
+
+ if params.UserID == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
+ return
+ }
+
+ tid, err := uuid.Parse(params.TenantID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
+ return
+ }
+
+ if err := m.tenantStore.RemoveUser(ctx, tid, params.UserID); err != nil {
+ slog.Error("tenants.users.remove failed", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())))
+ return
+ }
+
+ m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
+
+ // Notify affected user's WS sessions to force logout
+ m.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventTenantAccessRevoked,
+ Payload: map[string]string{"user_id": params.UserID, "tenant_id": tid.String()},
+ })
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
+}
+
+// handleMine returns the current user's tenant memberships.
+// Unlike other tenant methods, this does NOT require cross-tenant access.
+// Cross-tenant admins receive all tenants instead.
+func (m *TenantsMethods) handleMine(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+
+ type tenantEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ }
+
+ // Cross-tenant admin: return all tenants with "owner" role
+ if client.IsCrossTenant() {
+ tenants, err := m.tenantStore.ListTenants(ctx)
+ if err != nil {
+ slog.Error("tenants.mine failed (cross-tenant)", "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
+ return
+ }
+ entries := make([]tenantEntry, len(tenants))
+ for i, t := range tenants {
+ entries[i] = tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: "owner", Status: t.Status}
+ }
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
+ return
+ }
+
+ // Regular user: return their tenant memberships enriched with name/slug
+ userID := client.UserID()
+ if userID == "" {
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": []tenantEntry{}}))
+ return
+ }
+
+ memberships, err := m.tenantStore.ListUserTenants(ctx, userID)
+ if err != nil {
+ slog.Error("tenants.mine failed", "error", err, "user_id", userID)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
+ return
+ }
+
+ entries := make([]tenantEntry, 0, len(memberships))
+ for _, mem := range memberships {
+ t, err := m.tenantStore.GetTenant(ctx, mem.TenantID)
+ if err != nil || t == nil || t.Status != store.TenantStatusActive {
+ continue
+ }
+ entries = append(entries, tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: mem.Role, Status: t.Status})
+ }
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
+}
+
+func (m *TenantsMethods) emitCacheInvalidate(kind, key string) {
+ if m.msgBus == nil {
+ return
+ }
+ m.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventCacheInvalidate,
+ Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
+ })
+}
diff --git a/internal/gateway/methods/usage.go b/internal/gateway/methods/usage.go
index c2f705e2..e779a79c 100644
--- a/internal/gateway/methods/usage.go
+++ b/internal/gateway/methods/usage.go
@@ -37,7 +37,7 @@ func (m *UsageMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodUsageSummary, m.handleSummary)
}
-func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *UsageMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
AgentID string `json:"agentId"`
Limit int `json:"limit"`
@@ -50,12 +50,12 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req
params.Limit = 20
}
- sessions := m.sessions.List(params.AgentID)
+ sessions := m.sessions.List(ctx, params.AgentID)
records := make([]UsageRecord, 0, len(sessions))
for _, s := range sessions {
// Get full session data for token info
- data := m.sessions.GetOrCreate(s.Key)
+ data := m.sessions.GetOrCreate(ctx, s.Key)
if data.InputTokens == 0 && data.OutputTokens == 0 {
continue
}
@@ -95,8 +95,8 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req
}))
}
-func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
- sessions := m.sessions.List("") // all agents
+func (m *UsageMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ sessions := m.sessions.List(ctx, "") // all agents
type agentSummary struct {
InputTokens int64 `json:"inputTokens"`
@@ -109,7 +109,7 @@ func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client,
var totalRecords int
for _, s := range sessions {
- data := m.sessions.GetOrCreate(s.Key)
+ data := m.sessions.GetOrCreate(ctx, s.Key)
if data.InputTokens == 0 && data.OutputTokens == 0 {
continue
}
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index 334b1d44..c0c06b28 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -7,6 +7,8 @@ import (
"log/slog"
"time"
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/cache"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
@@ -19,8 +21,10 @@ type MethodHandler func(ctx context.Context, client *Client, req *protocol.Reque
// MethodRouter maps method names to handlers.
type MethodRouter struct {
- handlers map[string]MethodHandler
- server *Server
+ handlers map[string]MethodHandler
+ server *Server
+ tenantStore store.TenantStore // optional, for enriching connect response
+ permCache *cache.PermissionCache // optional, for caching tenant membership checks
}
func NewMethodRouter(server *Server) *MethodRouter {
@@ -32,6 +36,12 @@ func NewMethodRouter(server *Server) *MethodRouter {
return r
}
+// SetTenantStore sets the tenant store for enriching connect responses with tenant name/slug.
+func (r *MethodRouter) SetTenantStore(ts store.TenantStore) { r.tenantStore = ts }
+
+// SetPermissionCache sets the permission cache for tenant membership checks.
+func (r *MethodRouter) SetPermissionCache(pc *cache.PermissionCache) { r.permCache = pc }
+
// Register adds a method handler.
func (r *MethodRouter) Register(method string, handler MethodHandler) {
r.handlers[method] = handler
@@ -67,8 +77,13 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
}
}
- // Inject locale into context for i18n support
+ // Inject locale + tenant into context.
+ // All connect paths now guarantee client.tenantID is set (cross-tenant defaults to MasterTenantID),
+ // so WithCrossTenant is no longer needed here.
ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
+ if client.TenantID() != uuid.Nil {
+ ctx = store.WithTenantID(ctx, client.TenantID())
+ }
slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
handler(ctx, client, req)
@@ -87,10 +102,13 @@ func (r *MethodRouter) registerDefaults() {
func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *protocol.RequestFrame) {
// Parse connect params
var params struct {
- Token string `json:"token"`
- UserID string `json:"user_id"`
- SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
- Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
+ Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
+ TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant
+ TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug)
+ TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat)
}
if req.Params != nil {
json.Unmarshal(req.Params, ¶ms)
@@ -106,7 +124,39 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleAdmin
client.authenticated = true
client.userID = params.UserID
- r.sendConnectResponse(client, req.ID)
+
+ // Only owner IDs get cross-tenant (god-mode) access;
+ // other users with the gateway token still get admin role
+ // but are scoped to their tenant memberships.
+ isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs)
+ client.crossTenant = isOwner
+
+ if isOwner {
+ // Cross-tenant admin can narrow scope to a specific tenant
+ tenantScope := params.TenantID
+ if tenantScope == "" {
+ tenantScope = params.TenantScope // backward compat
+ }
+ r.applyTenantScope(ctx, client, tenantScope)
+ // Always ensure tenant is set — prevents unscoped operations
+ // that use MasterTenantID fallback inconsistently with scoped sessions.
+ if client.tenantID == uuid.Nil {
+ client.tenantID = store.MasterTenantID
+ }
+ } else {
+ // Non-owner with gateway token: resolve tenant via hint or membership
+ hint := params.TenantID
+ if hint == "" {
+ hint = params.TenantScope
+ }
+ tid, errCode := r.resolveTenantHint(ctx, hint, params.UserID)
+ if errCode != "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
+ return
+ }
+ client.tenantID = tid
+ }
+ r.sendConnectResponse(ctx, client, req.ID)
return
}
@@ -120,8 +170,44 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = role
client.scopes = scopes
client.authenticated = true
- client.userID = params.UserID
- r.sendConnectResponse(client, req.ID)
+ // If the key has a bound owner, force user_id to owner_id.
+ if keyData.OwnerID != "" {
+ if params.UserID != "" && params.UserID != keyData.OwnerID {
+ slog.Warn("security.ws_api_key_owner_override",
+ "param_user_id", params.UserID,
+ "owner_id", keyData.OwnerID,
+ )
+ }
+ client.userID = keyData.OwnerID
+ } else {
+ client.userID = params.UserID
+ }
+ if keyData.TenantID == uuid.Nil {
+ client.crossTenant = true
+ // Cross-tenant API key can narrow scope to a specific tenant
+ apiKeyScope := params.TenantID
+ if apiKeyScope == "" {
+ apiKeyScope = params.TenantScope // backward compat
+ }
+ r.applyTenantScope(ctx, client, apiKeyScope)
+ if client.tenantID == uuid.Nil {
+ client.tenantID = store.MasterTenantID
+ }
+ slog.Debug("security.ws_connect_resolved",
+ "client", client.id,
+ "role", string(client.role),
+ "cross_tenant", client.crossTenant,
+ "tenant_id", client.tenantID.String(),
+ )
+ } else {
+ client.tenantID = keyData.TenantID
+ slog.Debug("security.ws_connect_resolved",
+ "client", client.id,
+ "role", string(client.role),
+ "tenant_id", client.tenantID.String(),
+ )
+ }
+ r.sendConnectResponse(ctx, client, req.ID)
return
}
}
@@ -131,7 +217,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleOperator
client.authenticated = true
client.userID = params.UserID
- r.sendConnectResponse(client, req.ID)
+ client.tenantID = store.MasterTenantID
+ r.sendConnectResponse(ctx, client, req.ID)
return
}
@@ -140,7 +227,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
// Path 3a: Reconnecting with a previously-paired sender_id
if ps != nil && params.SenderID != "" {
- paired, pairErr := ps.IsPaired(params.SenderID, "browser")
+ paired, pairErr := ps.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed",
"sender_id", params.SenderID, "error", pairErr)
@@ -156,15 +243,21 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.userID = params.UserID
client.pairedSenderID = params.SenderID
client.pairedChannel = "browser"
- slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id)
- r.sendConnectResponse(client, req.ID)
+ tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
+ if errCode != "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
+ return
+ }
+ client.tenantID = tid
+ slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id, "tenant_id", client.tenantID)
+ r.sendConnectResponse(ctx, client, req.ID)
return
}
}
// Path 3b: No token, no valid pairing → initiate browser pairing (if service available)
if ps != nil && params.Token == "" {
- code, err := ps.RequestPairing(client.id, "browser", "", "default", nil)
+ code, err := ps.RequestPairing(ctx, client.id, "browser", "", "default", nil)
if err != nil {
slog.Warn("browser pairing request failed", "error", err, "client", client.id)
// Fall through to viewer role
@@ -190,19 +283,134 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleViewer
client.authenticated = true
client.userID = params.UserID
- r.sendConnectResponse(client, req.ID)
+ tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
+ if errCode != "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
+ return
+ }
+ client.tenantID = tid
+ r.sendConnectResponse(ctx, client, req.ID)
}
-func (r *MethodRouter) sendConnectResponse(client *Client, reqID string) {
- client.SendResponse(protocol.NewOKResponse(reqID, map[string]any{
- "protocol": protocol.ProtocolVersion,
- "role": string(client.role),
- "user_id": client.userID,
+func (r *MethodRouter) sendConnectResponse(ctx context.Context, client *Client, reqID string) {
+ resp := map[string]any{
+ "protocol": protocol.ProtocolVersion,
+ "role": string(client.role),
+ "user_id": client.userID,
+ "tenant_id": client.tenantID.String(),
+ "cross_tenant": client.crossTenant,
"server": map[string]any{
"name": "goclaw",
"version": "0.2.0",
},
- }))
+ }
+
+ // Enrich with tenant name/slug if tenant store available and tenant is set
+ if r.tenantStore != nil && client.tenantID != uuid.Nil {
+ if t, err := r.tenantStore.GetTenant(ctx, client.tenantID); err == nil && t != nil {
+ resp["tenant_name"] = t.Name
+ resp["tenant_slug"] = t.Slug
+ client.tenantName = t.Name
+ client.tenantSlug = t.Slug
+ }
+ }
+
+ client.SendResponse(protocol.NewOKResponse(reqID, resp))
+}
+
+// isOwnerID checks if the given user ID is in the configured owner list.
+// If no owner IDs configured, only "system" is treated as owner (fail-closed).
+func isOwnerID(userID string, ownerIDs []string) bool {
+ if userID == "" {
+ return false
+ }
+ if len(ownerIDs) == 0 {
+ return userID == "system"
+ }
+ for _, id := range ownerIDs {
+ if id == userID {
+ return true
+ }
+ }
+ return false
+}
+
+// resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation.
+// Non-admin users must be a member of the tenant.
+// Returns (uuid.Nil, ErrTenantAccessRevoked) when the user is not a member of the requested tenant.
+// Returns (MasterTenantID, "") when no hint is provided.
+func (r *MethodRouter) resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, string) {
+ if hint == "" || r.tenantStore == nil {
+ return store.MasterTenantID, ""
+ }
+ t, err := r.tenantStore.GetTenantBySlug(ctx, hint)
+ if err != nil || t == nil {
+ slog.Debug("tenant_hint not resolved, falling back to master", "hint", hint)
+ return store.MasterTenantID, ""
+ }
+
+ // Validate membership: user must belong to the requested tenant.
+ // Deny tenant access for anonymous users (no userID) — fail-closed.
+ if userID == "" {
+ slog.Warn("security.tenant_hint_denied_anonymous", "hint", hint, "tenant_id", t.ID)
+ return uuid.Nil, protocol.ErrTenantAccessRevoked
+ }
+ role, err := r.getUserTenantRole(ctx, t.ID, userID)
+ if err != nil || role == "" {
+ slog.Warn("security.tenant_access_revoked",
+ "hint", hint, "user", userID, "tenant_id", t.ID, "error", err)
+ return uuid.Nil, protocol.ErrTenantAccessRevoked
+ }
+ return t.ID, ""
+}
+
+// getUserTenantRole returns the user's role in a tenant, using permission cache if available.
+func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
+ // Check cache first
+ if r.permCache != nil {
+ if role, ok := r.permCache.GetTenantRole(ctx, tenantID, userID); ok {
+ slog.Debug("perm_cache.tenant_role.hit", "tenant", tenantID, "user", userID, "role", role)
+ return role, nil
+ }
+ slog.Debug("perm_cache.tenant_role.miss", "tenant", tenantID, "user", userID)
+ }
+
+ // Fallback to DB
+ role, err := r.tenantStore.GetUserRole(ctx, tenantID, userID)
+ if err != nil {
+ return "", err
+ }
+
+ // Cache the result (including empty role = not a member)
+ if r.permCache != nil {
+ r.permCache.SetTenantRole(ctx, tenantID, userID, role)
+ }
+ return role, nil
+}
+
+// applyTenantScope narrows a cross-tenant client's data scope to a specific tenant.
+// Client stays crossTenant=true (retains admin privileges) but tenantID is set
+// so the router injects WithTenantID instead of WithCrossTenant for data filtering.
+// Accepts both UUID and slug values.
+func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, tenantVal string) {
+ if tenantVal == "" || r.tenantStore == nil {
+ return
+ }
+ // Try UUID first, then slug
+ var t *store.TenantData
+ var err error
+ if tid, parseErr := uuid.Parse(tenantVal); parseErr == nil {
+ t, err = r.tenantStore.GetTenant(ctx, tid)
+ } else {
+ t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal)
+ }
+ if err != nil || t == nil {
+ slog.Debug("tenant scope not resolved, keeping unscoped", "value", tenantVal)
+ return
+ }
+ client.tenantID = t.ID
+ // Keep crossTenant=true so client retains admin role + tenant admin access
+ slog.Info("tenant scope applied", "client", client.id, "tenant", t.Slug, "tenant_id", t.ID)
}
func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) {
@@ -266,7 +474,7 @@ func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *pr
sessionCount := 0
if r.server.sessions != nil {
- sessionCount = len(r.server.sessions.List(""))
+ sessionCount = len(r.server.sessions.List(ctx, ""))
}
// Agents are lazily resolved — router only has loaded agents.
diff --git a/internal/gateway/server.go b/internal/gateway/server.go
index fa05c7a7..9eeedce2 100644
--- a/internal/gateway/server.go
+++ b/internal/gateway/server.go
@@ -43,15 +43,14 @@ type Server struct {
tracesHandler *httpapi.TracesHandler // LLM trace listing API
wakeHandler *httpapi.WakeHandler // external wake/trigger API
mcpHandler *httpapi.MCPHandler // MCP server management API
- customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API
channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API
providersHandler *httpapi.ProvidersHandler // provider CRUD API
- delegationsHandler *httpapi.DelegationsHandler // delegation history API
teamEventsHandler *httpapi.TeamEventsHandler // team event history API
teamAttachmentsHandler *httpapi.TeamAttachmentsHandler // team attachment download API
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API
+ mcpUserCredsHandler *httpapi.MCPUserCredentialsHandler // MCP per-user credentials API
packagesHandler *httpapi.PackagesHandler // runtime package management API
memoryHandler *httpapi.MemoryHandler // memory management API
kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API
@@ -64,6 +63,7 @@ type Server struct {
usageHandler *httpapi.UsageHandler // usage analytics API
apiKeysHandler *httpapi.APIKeysHandler // API key management
apiKeyStore store.APIKeyStore // for API key auth lookup
+ tenantsHandler *httpapi.TenantsHandler // tenant management API
docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI
agentStore store.AgentStore // for context injection in tools_invoke
msgBus *bus.MessageBus // for MCP bridge media delivery
@@ -207,10 +207,9 @@ func (s *Server) BuildMux() *http.ServeMux {
if s.mcpHandler != nil {
s.mcpHandler.RegisterRoutes(mux)
}
-
- // Custom tool CRUD API
- if s.customToolsHandler != nil {
- s.customToolsHandler.RegisterRoutes(mux)
+ // MCP per-user credentials API
+ if s.mcpUserCredsHandler != nil {
+ s.mcpUserCredsHandler.RegisterRoutes(mux)
}
// Secure CLI credential CRUD API
@@ -228,10 +227,6 @@ func (s *Server) BuildMux() *http.ServeMux {
s.providersHandler.RegisterRoutes(mux)
}
- // Delegation history API
- if s.delegationsHandler != nil {
- s.delegationsHandler.RegisterRoutes(mux)
- }
// Team event history API
if s.teamEventsHandler != nil {
@@ -287,6 +282,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.apiKeysHandler.RegisterRoutes(mux)
}
+ // Tenant management API
+ if s.tenantsHandler != nil {
+ s.tenantsHandler.RegisterRoutes(mux)
+ }
+
if s.activityHandler != nil {
s.activityHandler.RegisterRoutes(mux)
}
@@ -485,10 +485,8 @@ func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler =
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h }
// SetMCPHandler sets the MCP server management handler.
-func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
-
-// SetCustomToolsHandler sets the custom tool CRUD handler.
-func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h }
+func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
+func (s *Server) SetMCPUserCredentialsHandler(h *httpapi.MCPUserCredentialsHandler) { s.mcpUserCredsHandler = h }
// SetChannelInstancesHandler sets the channel instance CRUD handler.
func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) {
@@ -498,9 +496,6 @@ func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler)
// SetProvidersHandler sets the provider CRUD handler.
func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h }
-// SetDelegationsHandler sets the delegation history handler.
-func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h }
-
// SetTeamEventsHandler sets the team event history handler.
func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) { s.teamEventsHandler = h }
@@ -531,6 +526,9 @@ func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h }
// SetAPIKeysHandler sets the API key management handler.
func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h }
+// SetTenantsHandler sets the tenant management handler.
+func (s *Server) SetTenantsHandler(h *httpapi.TenantsHandler) { s.tenantsHandler = h }
+
// SetAPIKeyStore sets the API key store for token-based auth lookup.
func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st }
@@ -623,12 +621,11 @@ func (s *Server) registerClient(c *Client) {
defer s.mu.Unlock()
s.clients[c.id] = c
- // Subscribe to bus events for this client (skip internal cache events)
+ // Subscribe to bus events with per-user/team filtering.
s.eventPub.Subscribe(c.id, func(event bus.Event) {
- if strings.HasPrefix(event.Name, "cache.") {
- return // internal event, don't forward to WS clients
+ if clientCanReceiveEvent(c, event) {
+ c.SendEvent(*protocol.NewEvent(event.Name, event.Payload))
}
- c.SendEvent(*protocol.NewEvent(event.Name, event.Payload))
})
slog.Info("client connected", "id", c.id)
diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go
index f79c65bd..19a7742b 100644
--- a/internal/heartbeat/ticker.go
+++ b/internal/heartbeat/ticker.go
@@ -152,10 +152,23 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) {
start := time.Now()
agentIDStr := hb.AgentID.String()
- // Resolve agent key for events.
+ // Resolve agent to get tenant scope + display key.
+ // System-level lookup (cross-tenant) since ticker is a global scheduler.
+ sysCtx := store.WithCrossTenant(context.Background())
agentKey := agentIDStr
- if ag, err := t.agents.GetByID(ctx, hb.AgentID); err == nil {
- agentKey = ag.AgentKey
+ ag, agErr := t.agents.GetByID(sysCtx, hb.AgentID)
+ if agErr != nil {
+ slog.Warn("heartbeat.agent_not_found", "agent_id", agentIDStr, "error", agErr)
+ return
+ }
+ agentKey = ag.AgentKey
+
+ // Inject agent's tenant into context so all store operations
+ // (context files, sessions, etc.) are tenant-scoped.
+ if ag.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, ag.TenantID)
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
// [1] Active hours filter.
@@ -336,7 +349,7 @@ func (t *Ticker) finishRun(ctx context.Context, hb store.AgentHeartbeat, session
// Cleanup isolated session — data is already in heartbeat_run_logs.
if hb.IsolatedSession && t.sessions != nil && sessionKey != "" {
- if err := t.sessions.Delete(sessionKey); err != nil {
+ if err := t.sessions.Delete(ctx, sessionKey); err != nil {
slog.Debug("heartbeat.session_cleanup_failed", "session_key", sessionKey, "error", err)
}
}
diff --git a/internal/http/activity.go b/internal/http/activity.go
index 7c46ba21..1853669f 100644
--- a/internal/http/activity.go
+++ b/internal/http/activity.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -39,6 +40,13 @@ func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("actor_id"); v != "" {
opts.ActorID = v
}
+
+ // Non-admin callers may only see their own activity logs.
+ auth := resolveAuth(r, h.token)
+ if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(r.Context())
+ opts.ActorID = callerID
+ }
if v := r.URL.Query().Get("action"); v != "" {
opts.Action = v
}
diff --git a/internal/http/agents.go b/internal/http/agents.go
index 549997d3..3c767972 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -120,6 +120,17 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
req.OwnerID = userID
+
+ // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant.
+ if store.IsCrossTenant(r.Context()) {
+ if req.TenantID == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tenant_id")})
+ return
+ }
+ } else {
+ req.TenantID = store.TenantIDFromContext(r.Context())
+ }
+
if req.AgentType == "" {
req.AgentType = store.AgentTypeOpen
}
@@ -167,7 +178,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
// Start LLM summoning in background if applicable
if req.Status == store.AgentStatusSummoning {
- go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
+ go h.summoner.SummonAgent(req.ID, req.TenantID, req.Provider, req.Model, description)
}
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
@@ -256,14 +267,13 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
// Cascade: if status changed, broadcast so channel instances and cron jobs react.
if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status {
if h.msgBus != nil {
- h.msgBus.Broadcast(bus.Event{
- Name: bus.EventAgentStatusChanged,
- Payload: bus.AgentStatusChangedPayload{
+ bus.BroadcastForTenant(h.msgBus, bus.EventAgentStatusChanged,
+ store.TenantIDFromContext(r.Context()),
+ bus.AgentStatusChangedPayload{
AgentID: id.String(),
OldStatus: ag.Status,
NewStatus: newStatus,
- },
- })
+ })
}
}
diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go
index f9cd8755..9cdbaeaf 100644
--- a/internal/http/agents_sharing.go
+++ b/internal/http/agents_sharing.go
@@ -168,7 +168,7 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
return
}
- go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt)
+ go h.summoner.RegenerateAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, req.Prompt)
emitAudit(h.msgBus, r, "agent.regenerated", "agent", id.String())
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning})
@@ -214,7 +214,7 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
return
}
- go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description)
+ go h.summoner.SummonAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, description)
emitAudit(h.msgBus, r, "agent.resummoned", "agent", id.String())
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning})
diff --git a/internal/http/api_key_cache.go b/internal/http/api_key_cache.go
index 372f9872..4ca76552 100644
--- a/internal/http/api_key_cache.go
+++ b/internal/http/api_key_cache.go
@@ -2,6 +2,7 @@ package http
import (
"context"
+ "log/slog"
"sync"
"time"
@@ -49,9 +50,15 @@ func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, boo
// getOrFetch returns a cached entry or fetches from the store on cache miss.
func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) {
+ hashPrefix := hash
+ if len(hashPrefix) > 8 {
+ hashPrefix = hashPrefix[:8]
+ }
if key, role, ok := c.get(hash); ok {
+ slog.Debug("api_key_cache.hit", "hash_prefix", hashPrefix)
return key, role
}
+ slog.Debug("api_key_cache.miss", "hash_prefix", hashPrefix)
// Cache miss — fetch from DB
keyData, err := c.store.GetByHash(ctx, hash)
diff --git a/internal/http/api_key_cache_test.go b/internal/http/api_key_cache_test.go
index 7d3d643b..749a0fd2 100644
--- a/internal/http/api_key_cache_test.go
+++ b/internal/http/api_key_cache_test.go
@@ -41,10 +41,10 @@ func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error {
return nil
}
-func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
-func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil }
-func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
-func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
+func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
+func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { return nil, nil }
+func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil }
+func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil }
func (m *mockAPIKeyStore) getCalls() int {
m.mu.Lock()
diff --git a/internal/http/api_keys.go b/internal/http/api_keys.go
index 3f5c38e4..dbbed9ff 100644
--- a/internal/http/api_keys.go
+++ b/internal/http/api_keys.go
@@ -42,7 +42,8 @@ func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
- keys, err := h.apiKeys.List(r.Context())
+ // HTTP API key list is admin-only (adminAuth middleware), so no owner filter needed.
+ keys, err := h.apiKeys.List(r.Context(), "")
if err != nil {
slog.Error("api_keys.list failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")})
@@ -61,6 +62,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
+ TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
@@ -95,6 +97,22 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
return
}
+ // Resolve tenant_id based on caller type.
+ var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB)
+ if store.IsCrossTenant(r.Context()) {
+ if input.TenantID != "" {
+ tid, err := uuid.Parse(input.TenantID)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant_id")})
+ return
+ }
+ tenantID = tid
+ }
+ // else: uuid.Nil stays → system-level key
+ } else {
+ tenantID = store.TenantIDFromContext(r.Context())
+ }
+
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
@@ -102,6 +120,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
Prefix: prefix,
KeyHash: hash,
Scopes: input.Scopes,
+ TenantID: tenantID,
CreatedBy: extractUserID(r),
CreatedAt: now,
UpdatedAt: now,
@@ -141,7 +160,8 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.apiKeys.Revoke(r.Context(), id); err != nil {
+ // HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed.
+ if err := h.apiKeys.Revoke(r.Context(), id, ""); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", idStr)
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)})
return
diff --git a/internal/http/audit.go b/internal/http/audit.go
index 9de66e6e..19e4d848 100644
--- a/internal/http/audit.go
+++ b/internal/http/audit.go
@@ -29,6 +29,7 @@ func emitAudit(msgBus *bus.MessageBus, r *http.Request, action, entityType, enti
EntityType: entityType,
EntityID: entityID,
IPAddress: r.RemoteAddr,
+ TenantID: store.TenantIDFromContext(r.Context()),
},
})
}
diff --git a/internal/http/auth.go b/internal/http/auth.go
index 652fc62c..d8a27f20 100644
--- a/internal/http/auth.go
+++ b/internal/http/auth.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
@@ -77,6 +78,8 @@ func extractAgentID(r *http.Request, model string) string {
var pkgAPIKeyCache *apiKeyCache
var pkgPairingStore store.PairingStore
+var pkgTenantCache *tenantCache
+var pkgOwnerIDs []string
// InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation.
// Must be called once during server startup before handling requests.
@@ -97,6 +100,42 @@ func InitPairingAuth(ps store.PairingStore) {
pkgPairingStore = ps
}
+// InitOwnerIDs sets the configured owner user IDs for HTTP auth.
+// Only owners get cross-tenant access with gateway token; others are tenant-scoped.
+func InitOwnerIDs(ids []string) {
+ pkgOwnerIDs = ids
+}
+
+// isHTTPOwnerID checks if the user ID is a configured owner.
+// If no owner IDs configured, only "system" is treated as owner (fail-closed).
+func isHTTPOwnerID(userID string, ownerIDs []string) bool {
+ if userID == "" {
+ return false
+ }
+ if len(ownerIDs) == 0 {
+ return userID == "system"
+ }
+ for _, id := range ownerIDs {
+ if id == userID {
+ return true
+ }
+ }
+ return false
+}
+
+// InitTenantStore sets the tenant cache for HTTP auth with TTL and pubsub invalidation.
+// Allows gateway token requests to scope to a specific tenant via X-GoClaw-Tenant-Id header.
+func InitTenantStore(ts store.TenantStore, mb *bus.MessageBus) {
+ pkgTenantCache = newTenantCache(ts, 5*time.Minute)
+ if mb != nil {
+ mb.Subscribe("http-tenant-cache", func(e bus.Event) {
+ if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok && p.Kind == bus.CacheKindTenants {
+ pkgTenantCache.invalidateAll()
+ }
+ })
+ }
+}
+
// ResolveAPIKey checks if the bearer token is a valid API key using the shared cache.
// Returns the key data and derived role, or nil if not found/expired/revoked.
func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) {
@@ -111,6 +150,9 @@ func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permis
type authResult struct {
Role permissions.Role
Authenticated bool
+ KeyData *store.APIKeyData // non-nil when authenticated via API key
+ TenantID uuid.UUID // resolved tenant; uuid.Nil for cross-tenant
+ CrossTenant bool // true for owner/system admin
}
// resolveAuth determines the caller's role from the request.
@@ -123,18 +165,54 @@ func resolveAuth(r *http.Request, gatewayToken string) authResult {
// Useful for handlers that also accept tokens from query params.
func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult {
// Gateway token → admin
+ // Only owner IDs get cross-tenant access; others are tenant-scoped.
+ // If no owner IDs configured, all gateway token users get cross-tenant (backward compat).
if gatewayToken != "" && tokenMatch(bearer, gatewayToken) {
- return authResult{Role: permissions.RoleAdmin, Authenticated: true}
+ userID := extractUserID(r)
+ isOwner := isHTTPOwnerID(userID, pkgOwnerIDs)
+ res := authResult{Role: permissions.RoleAdmin, Authenticated: true, CrossTenant: isOwner}
+ tenantVal := r.Header.Get("X-GoClaw-Tenant-Id")
+ if isOwner && tenantVal != "" && pkgTenantCache != nil {
+ // Cross-tenant admin can narrow scope via header
+ if tid, err := uuid.Parse(tenantVal); err == nil {
+ if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil {
+ res.TenantID = t.ID
+ }
+ } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil {
+ res.TenantID = t.ID
+ }
+ } else if !isOwner && pkgTenantCache != nil {
+ // Non-owner with gateway token: resolve tenant from header or fallback to master
+ if tenantVal != "" {
+ if tid, err := uuid.Parse(tenantVal); err == nil {
+ if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil {
+ res.TenantID = t.ID
+ }
+ } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil {
+ res.TenantID = t.ID
+ }
+ }
+ if res.TenantID == uuid.Nil {
+ res.TenantID = store.MasterTenantID
+ }
+ }
+ return res
}
// API key → role from scopes
- if _, role := ResolveAPIKey(r.Context(), bearer); role != "" {
- return authResult{Role: role, Authenticated: true}
+ if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" {
+ res := authResult{Role: role, Authenticated: true, KeyData: keyData}
+ if keyData.TenantID == uuid.Nil {
+ res.CrossTenant = true
+ } else {
+ res.TenantID = keyData.TenantID
+ }
+ return res
}
// Browser pairing → operator (via X-GoClaw-Sender-Id header)
if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil {
- paired, err := pkgPairingStore.IsPaired(senderID, "browser")
+ paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser")
if err == nil && paired {
- return authResult{Role: permissions.RoleOperator, Authenticated: true}
+ return authResult{Role: permissions.RoleOperator, Authenticated: true, TenantID: store.MasterTenantID}
}
if err != nil {
slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err)
@@ -144,7 +222,7 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult
}
// No auth configured → admin (no token = dev/single-user mode, full access)
if gatewayToken == "" {
- return authResult{Role: permissions.RoleAdmin, Authenticated: true}
+ return authResult{Role: permissions.RoleAdmin, Authenticated: true, TenantID: store.MasterTenantID}
}
return authResult{}
}
@@ -187,16 +265,60 @@ func requireAuth(token string, minRole permissions.Role, next http.HandlerFunc)
}
ctx := store.WithLocale(r.Context(), locale)
- if userID := extractUserID(r); userID != "" {
+ userID := extractUserID(r)
+ // If the API key has a bound owner, force user_id to owner regardless of header.
+ if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
+ if userID != "" && userID != auth.KeyData.OwnerID {
+ slog.Warn("security.api_key_owner_override",
+ "header_user_id", userID,
+ "owner_id", auth.KeyData.OwnerID,
+ )
+ }
+ userID = auth.KeyData.OwnerID
+ }
+ if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
+ if auth.CrossTenant && auth.TenantID != uuid.Nil {
+ // Cross-tenant admin with tenant scope: filter data by chosen tenant
+ ctx = store.WithTenantID(ctx, auth.TenantID)
+ slog.Debug("security.http_auth_resolved",
+ "path", r.URL.Path,
+ "role", string(auth.Role),
+ "tenant_scope", auth.TenantID.String(),
+ )
+ } else if auth.CrossTenant {
+ // Auto-scope to MasterTenantID so all operations use a concrete tenant.
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ slog.Debug("security.http_auth_resolved",
+ "path", r.URL.Path,
+ "role", string(auth.Role),
+ "cross_tenant", true,
+ "auto_scope", store.MasterTenantID.String(),
+ )
+ } else if auth.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, auth.TenantID)
+ slog.Debug("security.http_auth_resolved",
+ "path", r.URL.Path,
+ "role", string(auth.Role),
+ "tenant_id", auth.TenantID.String(),
+ )
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ slog.Debug("security.http_auth_resolved",
+ "path", r.URL.Path,
+ "role", string(auth.Role),
+ "tenant_id", store.MasterTenantID.String(),
+ )
+ }
next(w, r.WithContext(ctx))
}
}
// requireAuthBearer is like requireAuth but accepts a pre-extracted bearer token.
// Used by handlers that accept tokens from query params (files, media).
-func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) bool {
+// Returns the authenticated request with user context applied (owner_id enforcement included).
+func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) (*http.Request, bool) {
locale := extractLocale(r)
auth := resolveAuthBearer(r, token, bearer)
@@ -204,7 +326,7 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": i18n.T(locale, i18n.MsgUnauthorized),
})
- return false
+ return r, false
}
required := minRole
@@ -216,9 +338,33 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
})
- return false
+ return r, false
}
- return true
+
+ // Apply user context + owner_id enforcement (same as requireAuth).
+ ctx := store.WithLocale(r.Context(), locale)
+ userID := extractUserID(r)
+ if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
+ if userID != "" && userID != auth.KeyData.OwnerID {
+ slog.Warn("security.api_key_owner_override",
+ "header_user_id", userID,
+ "owner_id", auth.KeyData.OwnerID,
+ "path", r.URL.Path,
+ )
+ }
+ userID = auth.KeyData.OwnerID
+ }
+ if userID != "" {
+ ctx = store.WithUserID(ctx, userID)
+ }
+ if auth.CrossTenant {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ } else if auth.TenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, auth.TenantID)
+ } else {
+ ctx = store.WithTenantID(ctx, store.MasterTenantID)
+ }
+ return r.WithContext(ctx), true
}
// extractLocale parses the Accept-Language header and returns a supported locale.
diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go
index 43e06ed9..f560bbd3 100644
--- a/internal/http/builtin_tools.go
+++ b/internal/http/builtin_tools.go
@@ -5,6 +5,8 @@ import (
"log/slog"
"net/http"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -14,14 +16,15 @@ import (
// BuiltinToolsHandler handles built-in tool management endpoints.
// Built-in tools are seeded at startup; only enabled and settings are editable.
type BuiltinToolsHandler struct {
- store store.BuiltinToolStore
- token string
- msgBus *bus.MessageBus
+ store store.BuiltinToolStore
+ tenantCfgStore store.BuiltinToolTenantConfigStore
+ token string
+ msgBus *bus.MessageBus
}
// NewBuiltinToolsHandler creates a handler for built-in tool management endpoints.
-func NewBuiltinToolsHandler(s store.BuiltinToolStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler {
- return &BuiltinToolsHandler{store: s, token: token, msgBus: msgBus}
+func NewBuiltinToolsHandler(s store.BuiltinToolStore, tenantCfgs store.BuiltinToolTenantConfigStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler {
+ return &BuiltinToolsHandler{store: s, tenantCfgStore: tenantCfgs, token: token, msgBus: msgBus}
}
// RegisterRoutes registers all built-in tool routes on the given mux.
@@ -29,6 +32,8 @@ func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/tools/builtin", h.auth(h.handleList))
mux.HandleFunc("GET /v1/tools/builtin/{name}", h.auth(h.handleGet))
mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.auth(h.handleUpdate))
+ mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleSetTenantConfig))
+ mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleDeleteTenantConfig))
}
func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -53,6 +58,28 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
return
}
+
+ // Merge per-tenant overrides into response when tenant-scoped
+ tid := store.TenantIDFromContext(r.Context())
+ if tid != uuid.Nil && h.tenantCfgStore != nil {
+ overrides, err := h.tenantCfgStore.ListAll(r.Context(), tid)
+ if err == nil && len(overrides) > 0 {
+ type toolWithTenant struct {
+ store.BuiltinToolDef
+ TenantEnabled *bool `json:"tenant_enabled"`
+ }
+ enriched := make([]toolWithTenant, len(result))
+ for i, t := range result {
+ enriched[i] = toolWithTenant{BuiltinToolDef: t}
+ if enabled, ok := overrides[t.Name]; ok {
+ enriched[i].TenantEnabled = &enabled
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tools": enriched})
+ return
+ }
+ }
+
writeJSON(w, http.StatusOK, map[string]any{"tools": result})
}
@@ -111,3 +138,54 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques
h.emitCacheInvalidate(name)
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
+
+// handleSetTenantConfig sets a per-tenant override for a builtin tool.
+func (h *BuiltinToolsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) {
+ if h.tenantCfgStore == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
+ return
+ }
+ name := r.PathValue("name")
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
+ return
+ }
+
+ var body struct {
+ Enabled bool `json:"enabled"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+ return
+ }
+
+ if err := h.tenantCfgStore.Set(r.Context(), tid, name, body.Enabled); err != nil {
+ slog.Warn("set tenant tool config failed", "tool", name, "tenant", tid, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
+}
+
+// handleDeleteTenantConfig removes a per-tenant override (reverts to default).
+func (h *BuiltinToolsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) {
+ if h.tenantCfgStore == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
+ return
+ }
+ name := r.PathValue("name")
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
+ return
+ }
+
+ if err := h.tenantCfgStore.Delete(r.Context(), tid, name); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index b670d292..cd55660e 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -21,13 +21,14 @@ type ChannelInstancesHandler struct {
agentStore store.AgentStore
configPermStore store.ConfigPermissionStore
contactStore store.ContactStore
+ tenantStore store.TenantStore
token string
msgBus *bus.MessageBus
}
// NewChannelInstancesHandler creates a handler for channel instance management endpoints.
-func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
- return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, token: token, msgBus: msgBus}
+func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, tenantStore store.TenantStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
+ return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, tenantStore: tenantStore, token: token, msgBus: msgBus}
}
// RegisterRoutes registers all channel instance routes on the given mux.
@@ -42,6 +43,12 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
if h.contactStore != nil {
mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts))
mux.HandleFunc("GET /v1/contacts/resolve", h.auth(h.handleResolveContacts))
+ mux.HandleFunc("POST /v1/contacts/merge", h.auth(h.handleMergeContacts))
+ mux.HandleFunc("POST /v1/contacts/unmerge", h.auth(h.handleUnmergeContacts))
+ mux.HandleFunc("GET /v1/contacts/merged/{tenantUserId}", h.auth(h.handleListMergedContacts))
+ }
+ if h.tenantStore != nil {
+ mux.HandleFunc("GET /v1/tenant-users", h.auth(h.handleListTenantUsers))
}
// Group file writers (nested under channel instances)
diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go
index 5a523a17..85dda076 100644
--- a/internal/http/chat_completions.go
+++ b/internal/http/chat_completions.go
@@ -136,7 +136,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
return
}
- loop, err := h.agents.Get(agentID)
+ loop, err := h.agents.Get(r.Context(), agentID)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNotFound, "agent", agentID)), http.StatusNotFound)
return
@@ -205,7 +205,7 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http.
Model: model,
Choices: []chatChoice{{
Index: 0,
- Message: &chatMessage{Role: "assistant", Content: result.Content},
+ Message: &chatMessage{Role: "assistant", Content: SignFileURLs(result.Content, FileSigningKey())},
FinishReason: "stop",
}},
}
@@ -257,7 +257,7 @@ func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Req
writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: "Error: " + err.Error()}, "stop")
} else {
// Send content chunk
- writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: result.Content}, "stop")
+ writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: SignFileURLs(result.Content, FileSigningKey())}, "stop")
}
// Send [DONE]
diff --git a/internal/http/contact_merge_handlers.go b/internal/http/contact_merge_handlers.go
new file mode 100644
index 00000000..1e960b4b
--- /dev/null
+++ b/internal/http/contact_merge_handlers.go
@@ -0,0 +1,198 @@
+package http
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// handleMergeContacts links selected contacts to a tenant_user identity.
+// POST /v1/contacts/merge
+func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *http.Request) {
+ locale := store.LocaleFromContext(r.Context())
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)})
+ return
+ }
+
+ var body struct {
+ ContactIDs []uuid.UUID `json:"contact_ids"`
+ TenantUserID *uuid.UUID `json:"tenant_user_id"`
+ CreateUser *struct {
+ UserID string `json:"user_id"`
+ DisplayName string `json:"display_name"`
+ } `json:"create_user"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
+ return
+ }
+
+ if len(body.ContactIDs) == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)})
+ return
+ }
+ if len(body.ContactIDs) > 500 {
+ body.ContactIDs = body.ContactIDs[:500]
+ }
+ hasTU := body.TenantUserID != nil
+ hasCU := body.CreateUser != nil
+ if hasTU == hasCU { // must have exactly one
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMergeTargetRequired)})
+ return
+ }
+
+ var targetID uuid.UUID
+
+ if hasTU {
+ // Link to existing tenant_user — verify same tenant.
+ tu, err := h.tenantStore.GetTenantUser(r.Context(), *body.TenantUserID)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgTenantUserNotFound)})
+ return
+ }
+ if tu.TenantID != tid {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgTenantMismatch)})
+ return
+ }
+ targetID = tu.ID
+ } else {
+ // Create new tenant_user.
+ userID := body.CreateUser.UserID
+ displayName := body.CreateUser.DisplayName
+ if userID == "" {
+ // Fallback: derive from first contact's username.
+ userID = h.deriveUserIDFromContacts(r.Context(), body.ContactIDs)
+ }
+ if userID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
+ return
+ }
+ tu, err := h.tenantStore.CreateTenantUserReturning(r.Context(), tid, userID, displayName, store.TenantRoleMember)
+ if err != nil {
+ slog.Error("contacts.merge.create_user", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())})
+ return
+ }
+ targetID = tu.ID
+ }
+
+ if err := h.contactStore.MergeContacts(r.Context(), body.ContactIDs, targetID); err != nil {
+ slog.Error("contacts.merge", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())})
+ return
+ }
+
+ emitAudit(h.msgBus, r, "contacts.merged", "tenant_user", targetID.String())
+ writeJSON(w, http.StatusOK, map[string]any{
+ "merged_id": targetID,
+ "merged_count": len(body.ContactIDs),
+ })
+}
+
+// handleUnmergeContacts removes merged_id from selected contacts.
+// POST /v1/contacts/unmerge
+func (h *ChannelInstancesHandler) handleUnmergeContacts(w http.ResponseWriter, r *http.Request) {
+ locale := store.LocaleFromContext(r.Context())
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)})
+ return
+ }
+
+ var body struct {
+ ContactIDs []uuid.UUID `json:"contact_ids"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
+ return
+ }
+ if len(body.ContactIDs) == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)})
+ return
+ }
+ if len(body.ContactIDs) > 500 {
+ body.ContactIDs = body.ContactIDs[:500]
+ }
+
+ if err := h.contactStore.UnmergeContacts(r.Context(), body.ContactIDs); err != nil {
+ slog.Error("contacts.unmerge", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())})
+ return
+ }
+
+ emitAudit(h.msgBus, r, "contacts.unmerged", "contacts", "")
+ writeJSON(w, http.StatusOK, map[string]any{"unmerged_count": len(body.ContactIDs)})
+}
+
+// handleListMergedContacts returns contacts linked to a tenant_user.
+// GET /v1/contacts/merged/{tenantUserId}
+func (h *ChannelInstancesHandler) handleListMergedContacts(w http.ResponseWriter, r *http.Request) {
+ locale := store.LocaleFromContext(r.Context())
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)})
+ return
+ }
+
+ mergedID, err := uuid.Parse(r.PathValue("tenantUserId"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenantUserId")})
+ return
+ }
+
+ contacts, err := h.contactStore.GetContactsByMergedID(r.Context(), mergedID)
+ if err != nil {
+ slog.Error("contacts.merged.list", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")})
+ return
+ }
+ if contacts == nil {
+ contacts = []store.ChannelContact{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"contacts": contacts})
+}
+
+// handleListTenantUsers returns users for the current tenant (for merge dialog dropdown).
+// GET /v1/tenant-users
+func (h *ChannelInstancesHandler) handleListTenantUsers(w http.ResponseWriter, r *http.Request) {
+ locale := store.LocaleFromContext(r.Context())
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)})
+ return
+ }
+
+ users, err := h.tenantStore.ListUsers(r.Context(), tid)
+ if err != nil {
+ slog.Error("tenant_users.list", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")})
+ return
+ }
+ if users == nil {
+ users = []store.TenantUserData{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"users": users})
+}
+
+// deriveUserIDFromContacts returns the first contact's username or sender_id as fallback user_id.
+func (h *ChannelInstancesHandler) deriveUserIDFromContacts(ctx context.Context, contactIDs []uuid.UUID) string {
+ if len(contactIDs) == 0 {
+ return ""
+ }
+ c, err := h.contactStore.GetContactByID(ctx, contactIDs[0])
+ if err != nil {
+ return ""
+ }
+ if c.Username != nil && *c.Username != "" {
+ return *c.Username
+ }
+ return c.SenderID
+}
diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go
deleted file mode 100644
index 5b574cad..00000000
--- a/internal/http/custom_tools.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package http
-
-import (
- "encoding/json"
- "log/slog"
- "net/http"
- "strconv"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/bus"
- "github.com/nextlevelbuilder/goclaw/internal/i18n"
- "github.com/nextlevelbuilder/goclaw/internal/store"
- "github.com/nextlevelbuilder/goclaw/internal/tools"
- "github.com/nextlevelbuilder/goclaw/pkg/protocol"
-)
-
-// CustomToolsHandler handles custom tool CRUD endpoints.
-type CustomToolsHandler struct {
- store store.CustomToolStore
- token string
- msgBus *bus.MessageBus
- toolsReg *tools.Registry // for name collision checking on create
-}
-
-// NewCustomToolsHandler creates a handler for custom tool management endpoints.
-func NewCustomToolsHandler(s store.CustomToolStore, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) *CustomToolsHandler {
- return &CustomToolsHandler{store: s, token: token, msgBus: msgBus, toolsReg: toolsReg}
-}
-
-// RegisterRoutes registers all custom tool routes on the given mux.
-func (h *CustomToolsHandler) RegisterRoutes(mux *http.ServeMux) {
- mux.HandleFunc("GET /v1/tools/custom", h.auth(h.handleList))
- mux.HandleFunc("POST /v1/tools/custom", h.auth(h.handleCreate))
- mux.HandleFunc("GET /v1/tools/custom/{id}", h.auth(h.handleGet))
- mux.HandleFunc("PUT /v1/tools/custom/{id}", h.auth(h.handleUpdate))
- mux.HandleFunc("DELETE /v1/tools/custom/{id}", h.auth(h.handleDelete))
-}
-
-func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
- return requireAuth(h.token, "", next)
-}
-
-func (h *CustomToolsHandler) emitCacheInvalidate(key string) {
- if h.msgBus == nil {
- return
- }
- h.msgBus.Broadcast(bus.Event{
- Name: protocol.EventCacheInvalidate,
- Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindCustomTools, Key: key},
- })
-}
-
-func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- opts := store.CustomToolListOpts{
- Limit: 50,
- Offset: 0,
- }
-
- if v := r.URL.Query().Get("agent_id"); v != "" {
- id, err := uuid.Parse(v)
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
- return
- }
- opts.AgentID = &id
- }
- if v := r.URL.Query().Get("search"); v != "" {
- opts.Search = v
- }
- if v := r.URL.Query().Get("limit"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
- opts.Limit = n
- }
- }
- if v := r.URL.Query().Get("offset"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n >= 0 {
- opts.Offset = n
- }
- }
-
- result, err := h.store.ListPaged(r.Context(), opts)
- if err != nil {
- slog.Error("custom_tools.list", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
- return
- }
-
- total, _ := h.store.CountTools(r.Context(), opts)
-
- writeJSON(w, http.StatusOK, map[string]any{
- "tools": result,
- "total": total,
- "limit": opts.Limit,
- "offset": opts.Offset,
- })
-}
-
-func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- var def store.CustomToolDef
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&def); err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
- return
- }
-
- if def.Name == "" || def.Command == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and command")})
- return
- }
- if !isValidSlug(def.Name) {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
- return
- }
-
- // Check name collision with built-in/MCP tools
- if h.toolsReg != nil {
- if _, exists := h.toolsReg.Get(def.Name); exists {
- writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "tool name", def.Name)})
- return
- }
- }
-
- userID := store.UserIDFromContext(r.Context())
- if userID != "" {
- def.CreatedBy = userID
- }
-
- if def.TimeoutSeconds <= 0 {
- def.TimeoutSeconds = 60
- }
-
- if err := h.store.Create(r.Context(), &def); err != nil {
- slog.Error("custom_tools.create", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- return
- }
-
- emitAudit(h.msgBus, r, "custom_tool.created", "custom_tool", def.ID.String())
- h.emitCacheInvalidate(def.ID.String())
- writeJSON(w, http.StatusCreated, def)
-}
-
-func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- id, err := uuid.Parse(r.PathValue("id"))
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
- return
- }
-
- def, err := h.store.Get(r.Context(), id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", id.String())})
- return
- }
-
- writeJSON(w, http.StatusOK, def)
-}
-
-func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- id, err := uuid.Parse(r.PathValue("id"))
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
- return
- }
-
- var updates map[string]any
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
- return
- }
-
- if name, ok := updates["name"]; ok {
- if s, _ := name.(string); !isValidSlug(s) {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
- return
- }
- }
-
- // Allowlist: only permit known custom tool columns.
- updates = filterAllowedKeys(updates, customToolAllowedFields)
-
- if err := h.store.Update(r.Context(), id, updates); err != nil {
- slog.Error("custom_tools.update", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- return
- }
-
- emitAudit(h.msgBus, r, "custom_tool.updated", "custom_tool", id.String())
- h.emitCacheInvalidate(id.String())
- writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
-}
-
-func (h *CustomToolsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- id, err := uuid.Parse(r.PathValue("id"))
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
- return
- }
-
- if err := h.store.Delete(r.Context(), id); err != nil {
- slog.Error("custom_tools.delete", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- return
- }
-
- emitAudit(h.msgBus, r, "custom_tool.deleted", "custom_tool", id.String())
- h.emitCacheInvalidate(id.String())
- writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
-}
diff --git a/internal/http/delegations.go b/internal/http/delegations.go
deleted file mode 100644
index d72b3870..00000000
--- a/internal/http/delegations.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package http
-
-import (
- "net/http"
- "strconv"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/i18n"
- "github.com/nextlevelbuilder/goclaw/internal/store"
-)
-
-// DelegationsHandler handles delegation history HTTP endpoints.
-type DelegationsHandler struct {
- teamStore store.TeamStore
- token string
-}
-
-func NewDelegationsHandler(teamStore store.TeamStore, token string) *DelegationsHandler {
- return &DelegationsHandler{teamStore: teamStore, token: token}
-}
-
-func (h *DelegationsHandler) RegisterRoutes(mux *http.ServeMux) {
- mux.HandleFunc("GET /v1/delegations", h.authMiddleware(h.handleList))
- mux.HandleFunc("GET /v1/delegations/{id}", h.authMiddleware(h.handleGet))
-}
-
-func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
- return requireAuth(h.token, "", next)
-}
-
-func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request) {
- opts := store.DelegationHistoryListOpts{
- Limit: 50,
- Offset: 0,
- }
-
- if v := r.URL.Query().Get("source_agent_id"); v != "" {
- if id, err := uuid.Parse(v); err == nil {
- opts.SourceAgentID = &id
- }
- }
- if v := r.URL.Query().Get("target_agent_id"); v != "" {
- if id, err := uuid.Parse(v); err == nil {
- opts.TargetAgentID = &id
- }
- }
- if v := r.URL.Query().Get("team_id"); v != "" {
- if id, err := uuid.Parse(v); err == nil {
- opts.TeamID = &id
- }
- }
- if v := r.URL.Query().Get("user_id"); v != "" {
- opts.UserID = v
- }
- if v := r.URL.Query().Get("status"); v != "" {
- opts.Status = v
- }
- if v := r.URL.Query().Get("limit"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
- opts.Limit = n
- }
- }
- if v := r.URL.Query().Get("offset"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n >= 0 {
- opts.Offset = n
- }
- }
-
- records, total, err := h.teamStore.ListDelegationHistory(r.Context(), opts)
- if err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- return
- }
-
- writeJSON(w, http.StatusOK, map[string]any{
- "records": records,
- "total": total,
- })
-}
-
-func (h *DelegationsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
- locale := store.LocaleFromContext(r.Context())
- idStr := r.PathValue("id")
- id, err := uuid.Parse(idStr)
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "delegation")})
- return
- }
-
- record, err := h.teamStore.GetDelegationHistory(r.Context(), id)
- if err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- return
- }
-
- writeJSON(w, http.StatusOK, record)
-}
diff --git a/internal/http/file_token.go b/internal/http/file_token.go
new file mode 100644
index 00000000..45770277
--- /dev/null
+++ b/internal/http/file_token.go
@@ -0,0 +1,89 @@
+package http
+
+import (
+ "crypto/hmac"
+ crypto_rand "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// FileTokenTTL is the default TTL for signed file tokens.
+const FileTokenTTL = 5 * time.Minute
+
+var (
+ fileSigningKey string
+ fileSigningKeyOnce sync.Once
+)
+
+// FileSigningKey returns a random 32-byte key for HMAC file token signing.
+// Generated once at startup, lives in memory only. Tokens expire on restart
+// which is acceptable for the short TTL — clients re-fetch signed URLs on reconnect.
+func FileSigningKey() string {
+ fileSigningKeyOnce.Do(func() {
+ b := make([]byte, 32)
+ crypto_rand.Read(b)
+ fileSigningKey = base64.RawURLEncoding.EncodeToString(b)
+ })
+ return fileSigningKey
+}
+
+// SignFileToken creates a short-lived HMAC token for file access.
+// Token format: {base64url_hmac_16bytes}.{unix_expiry} (~40 chars).
+// The path is bound into the signature so tokens can't be reused for other files.
+func SignFileToken(path, secret string, ttl time.Duration) string {
+ expiry := time.Now().Add(ttl).Unix()
+ sig := fileTokenHMAC(path, secret, expiry)
+ return fmt.Sprintf("%s.%d", sig, expiry)
+}
+
+// VerifyFileToken validates a signed file token against a path and secret.
+// Returns true if the HMAC matches and the token has not expired.
+func VerifyFileToken(token, path, secret string) bool {
+ parts := strings.SplitN(token, ".", 2)
+ if len(parts) != 2 {
+ return false
+ }
+ expiry, err := strconv.ParseInt(parts[1], 10, 64)
+ if err != nil || time.Now().Unix() > expiry {
+ return false
+ }
+ expected := fileTokenHMAC(path, secret, expiry)
+ return hmac.Equal([]byte(parts[0]), []byte(expected))
+}
+
+// fileTokenHMAC computes the HMAC signature component.
+func fileTokenHMAC(path, secret string, expiry int64) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(fmt.Sprintf("%s:%d", path, expiry)))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
+}
+
+// fileURLRe matches /v1/files/... and /v1/media/... URLs in markdown and plain text.
+// Captures the full URL path (stops at whitespace, closing paren, quote, or angle bracket).
+var fileURLRe = regexp.MustCompile(`(/v1/(?:files|media)/[^\s)"'<>]+)`)
+
+// SignFileURLs finds all /v1/files/ and /v1/media/ URLs in content and appends
+// a signed ?ft= token. Used at delivery time (WS events, HTTP responses) to avoid
+// persisting tokens in session messages. Skips URLs that already have ?ft=.
+func SignFileURLs(content, secret string) string {
+ if secret == "" || !strings.Contains(content, "/v1/") {
+ return content
+ }
+ return fileURLRe.ReplaceAllStringFunc(content, func(url string) string {
+ if strings.Contains(url, "ft=") {
+ return url // already signed
+ }
+ ft := SignFileToken(url, secret, FileTokenTTL)
+ sep := "?"
+ if strings.Contains(url, "?") {
+ sep = "&"
+ }
+ return url + sep + "ft=" + ft
+ })
+}
diff --git a/internal/http/files.go b/internal/http/files.go
index 3962b081..49e1130c 100644
--- a/internal/http/files.go
+++ b/internal/http/files.go
@@ -8,7 +8,9 @@ import (
"path/filepath"
"strings"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// FilesHandler serves files over HTTP with Bearer token auth.
@@ -18,12 +20,14 @@ import (
type FilesHandler struct {
token string
workspace string // workspace root for fallback file search
+ dataDir string // data directory root for tenant path validation
}
// NewFilesHandler creates a handler that serves files by absolute path.
// workspace is the root directory used for fallback generated file search.
-func NewFilesHandler(token, workspace string) *FilesHandler {
- return &FilesHandler{token: token, workspace: workspace}
+// dataDir is used for tenant path validation (files must be within tenant's dirs).
+func NewFilesHandler(token, workspace, dataDir string) *FilesHandler {
+ return &FilesHandler{token: token, workspace: workspace, dataDir: dataDir}
}
// RegisterRoutes registers the file serving route.
@@ -33,15 +37,23 @@ func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- // Accept token via Bearer header or ?token= query param (for
).
- provided := extractBearerToken(r)
- if provided == "" {
- provided = r.URL.Query().Get("token")
- }
- if !requireAuthBearer(h.token, "", provided, w, r) {
+ // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token.
+ if ft := r.URL.Query().Get("ft"); ft != "" {
+ path := "/v1/files/" + r.PathValue("path")
+ if VerifyFileToken(ft, path, FileSigningKey()) {
+ next(w, r)
+ return
+ }
+ http.Error(w, "invalid or expired file token", http.StatusUnauthorized)
return
}
- next(w, r)
+ // Priority 2: Bearer header (API clients only).
+ provided := extractBearerToken(r)
+ authedReq, ok := requireAuthBearer(h.token, "", provided, w, r)
+ if !ok {
+ return
+ }
+ next(w, authedReq)
}
}
@@ -81,11 +93,27 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
}
}
+ // Tenant isolation: validate absolute path is within tenant's allowed directories.
+ // Skip for HMAC file tokens — the path is cryptographically bound in the signature,
+ // so it cannot be tampered with. File tokens are generated server-side with the correct path.
+ if r.URL.Query().Get("ft") == "" {
+ tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context()))
+ tenantWs := h.tenantWorkspace(r)
+ if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) &&
+ !strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) &&
+ absPath != tenantData && absPath != tenantWs {
+ http.NotFound(w, r)
+ return
+ }
+ }
+
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
// Fallback: search workspace for file by basename (handles LLM-hallucinated paths).
// Generated filenames (goclaw_gen_*) include nanosecond timestamps and are globally unique.
- if resolved := h.findInWorkspace(filepath.Base(absPath)); resolved != "" {
+ // Workspace scoped to tenant to prevent cross-tenant file discovery.
+ ws := h.tenantWorkspace(r)
+ if resolved := h.findInWorkspace(ws, filepath.Base(absPath)); resolved != "" {
absPath = resolved
info, _ = os.Stat(absPath)
} else {
@@ -104,22 +132,29 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, absPath)
}
+// tenantWorkspace resolves the workspace scoped to the requesting tenant.
+func (h *FilesHandler) tenantWorkspace(r *http.Request) string {
+ tid := store.TenantIDFromContext(r.Context())
+ slug := store.TenantSlugFromContext(r.Context())
+ return config.TenantWorkspace(h.workspace, tid, slug)
+}
+
// findInWorkspace searches the workspace directory tree for a file by basename.
// Returns the absolute path if found, empty string otherwise.
// Searches team directories including generated/ and system/ subdirs.
-func (h *FilesHandler) findInWorkspace(basename string) string {
- if h.workspace == "" || basename == "" {
+func (h *FilesHandler) findInWorkspace(workspace, basename string) string {
+ if workspace == "" || basename == "" {
return ""
}
var found string
- _ = filepath.WalkDir(h.workspace, func(path string, d os.DirEntry, err error) error {
+ _ = filepath.WalkDir(workspace, func(path string, d os.DirEntry, err error) error {
if err != nil {
return filepath.SkipDir
}
if d.IsDir() {
name := d.Name()
// Allow workspace root + known directory structures
- if name == "teams" || name == "generated" || name == "system" || path == h.workspace {
+ if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace {
return nil
}
// Allow date directories (e.g. 2026-03-20)
diff --git a/internal/http/knowledge_graph.go b/internal/http/knowledge_graph.go
index 1606c04f..1cd73a29 100644
--- a/internal/http/knowledge_graph.go
+++ b/internal/http/knowledge_graph.go
@@ -1,6 +1,7 @@
package http
import (
+ "context"
"net/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
@@ -21,11 +22,11 @@ func NewKnowledgeGraphHandler(s store.KnowledgeGraphStore, providerReg *provider
}
// NewExtractor creates an Extractor from the given provider name and model.
-func (h *KnowledgeGraphHandler) NewExtractor(providerName, model string, minConfidence float64) *kg.Extractor {
+func (h *KnowledgeGraphHandler) NewExtractor(ctx context.Context, providerName, model string, minConfidence float64) *kg.Extractor {
if h.providerReg == nil || providerName == "" || model == "" {
return nil
}
- p, err := h.providerReg.Get(providerName)
+ p, err := h.providerReg.Get(ctx, providerName)
if err != nil {
return nil
}
diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go
index 63472969..75e2a241 100644
--- a/internal/http/knowledge_graph_handlers.go
+++ b/internal/http/knowledge_graph_handlers.go
@@ -179,7 +179,7 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req
return
}
- extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf)
+ extractor := h.NewExtractor(r.Context(), body.Provider, body.Model, body.MinConf)
if extractor == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidProviderOrModel)})
return
diff --git a/internal/http/mcp.go b/internal/http/mcp.go
index 8830afe6..85ad6f34 100644
--- a/internal/http/mcp.go
+++ b/internal/http/mcp.go
@@ -18,12 +18,18 @@ type MCPToolLister interface {
ServerToolNames(serverName string) []string
}
+// MCPPoolEvictor evicts pooled connections for a tenant+server (called on credential rotation).
+type MCPPoolEvictor interface {
+ Evict(tenantID uuid.UUID, serverName string)
+}
+
// MCPHandler handles MCP server management HTTP endpoints.
type MCPHandler struct {
- store store.MCPServerStore
- token string
- msgBus *bus.MessageBus
- mgr MCPToolLister // optional, nil when Manager not available
+ store store.MCPServerStore
+ token string
+ msgBus *bus.MessageBus
+ mgr MCPToolLister // optional, nil when Manager not available
+ poolEvictor MCPPoolEvictor // optional, nil when pool not available
}
// NewMCPHandler creates a handler for MCP server management endpoints.
@@ -31,6 +37,9 @@ func NewMCPHandler(s store.MCPServerStore, token string, msgBus *bus.MessageBus,
return &MCPHandler{store: s, token: token, msgBus: msgBus, mgr: mgr}
}
+// SetPoolEvictor sets the pool evictor for credential rotation handling.
+func (h *MCPHandler) SetPoolEvictor(e MCPPoolEvictor) { h.poolEvictor = e }
+
func (h *MCPHandler) emitCacheInvalidate() {
if h.msgBus == nil {
return
@@ -177,12 +186,29 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request)
// Allowlist: only permit known MCP server columns.
updates = filterAllowedKeys(updates, mcpServerAllowedFields)
+ // Read server name before update for pool eviction
+ var serverName string
+ if srv, _ := h.store.GetServer(r.Context(), id); srv != nil {
+ serverName = srv.Name
+ }
+
if err := h.store.UpdateServer(r.Context(), id, updates); err != nil {
slog.Error("mcp.update_server", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
+ // Evict pool connections when credentials change (force reconnect with new creds)
+ if h.poolEvictor != nil && serverName != "" {
+ _, hasKey := updates["api_key"]
+ _, hasHeaders := updates["headers"]
+ _, hasEnv := updates["env"]
+ if hasKey || hasHeaders || hasEnv {
+ tid := store.TenantIDFromContext(r.Context())
+ h.poolEvictor.Evict(tid, serverName)
+ }
+ }
+
h.emitCacheInvalidate()
emitAudit(h.msgBus, r, "mcp_server.updated", "mcp_server", id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
diff --git a/internal/http/mcp_user_credentials.go b/internal/http/mcp_user_credentials.go
new file mode 100644
index 00000000..3e2ddd1d
--- /dev/null
+++ b/internal/http/mcp_user_credentials.go
@@ -0,0 +1,108 @@
+package http
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// MCPUserCredentialsHandler handles per-user MCP credential endpoints.
+type MCPUserCredentialsHandler struct {
+ store store.MCPServerStore
+ token string
+}
+
+// NewMCPUserCredentialsHandler creates a handler for MCP user credential endpoints.
+func NewMCPUserCredentialsHandler(s store.MCPServerStore, token string) *MCPUserCredentialsHandler {
+ return &MCPUserCredentialsHandler{store: s, token: token}
+}
+
+// RegisterRoutes registers MCP user credential routes.
+func (h *MCPUserCredentialsHandler) RegisterRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("PUT /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleSet))
+ mux.HandleFunc("GET /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleGet))
+ mux.HandleFunc("DELETE /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleDelete))
+}
+
+func (h *MCPUserCredentialsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
+ return requireAuth(h.token, "", next)
+}
+
+func (h *MCPUserCredentialsHandler) handleSet(w http.ResponseWriter, r *http.Request) {
+ serverID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+ return
+ }
+
+ userID := store.UserIDFromContext(r.Context())
+ if userID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"})
+ return
+ }
+
+ var creds store.MCPUserCredentials
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&creds); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+ return
+ }
+
+ if err := h.store.SetUserCredentials(r.Context(), serverID, userID, creds); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
+}
+
+func (h *MCPUserCredentialsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+ serverID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+ return
+ }
+
+ userID := store.UserIDFromContext(r.Context())
+ if userID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"})
+ return
+ }
+
+ creds, err := h.store.GetUserCredentials(r.Context(), serverID, userID)
+ if err != nil {
+ writeJSON(w, http.StatusOK, map[string]any{"has_credentials": false})
+ return
+ }
+
+ // Return masked status (never expose actual credentials)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "has_credentials": true,
+ "has_api_key": creds.APIKey != "",
+ "has_headers": len(creds.Headers) > 0,
+ "has_env": len(creds.Env) > 0,
+ })
+}
+
+func (h *MCPUserCredentialsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+ serverID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+ return
+ }
+
+ userID := store.UserIDFromContext(r.Context())
+ if userID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"})
+ return
+ }
+
+ if err := h.store.DeleteUserCredentials(r.Context(), serverID, userID); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
diff --git a/internal/http/media_serve.go b/internal/http/media_serve.go
index 52c71c8a..7e997f08 100644
--- a/internal/http/media_serve.go
+++ b/internal/http/media_serve.go
@@ -29,15 +29,23 @@ func (h *MediaServeHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- // Accept token via Bearer header or ?token= query param (for
).
- provided := extractBearerToken(r)
- if provided == "" {
- provided = r.URL.Query().Get("token")
- }
- if !requireAuthBearer(h.token, "", provided, w, r) {
+ // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token.
+ if ft := r.URL.Query().Get("ft"); ft != "" {
+ mediaID := r.PathValue("id")
+ if VerifyFileToken(ft, "/v1/media/"+mediaID, FileSigningKey()) {
+ next(w, r)
+ return
+ }
+ http.Error(w, "invalid or expired file token", http.StatusUnauthorized)
return
}
- next(w, r)
+ // Priority 2: Bearer header (API clients only).
+ provided := extractBearerToken(r)
+ authedReq, ok := requireAuthBearer(h.token, "", provided, w, r)
+ if !ok {
+ return
+ }
+ next(w, authedReq)
}
}
diff --git a/internal/http/oauth.go b/internal/http/oauth.go
index 0d789089..82350614 100644
--- a/internal/http/oauth.go
+++ b/internal/http/oauth.go
@@ -52,12 +52,16 @@ func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, "", next)
}
-func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource {
- return oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName)
+func (h *OAuthHandler) newTokenSource(r *http.Request) *oauth.DBTokenSource {
+ ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName)
+ if tid := store.TenantIDFromContext(r.Context()); tid != uuid.Nil {
+ ts.WithTenantID(tid)
+ }
+ return ts
}
func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
- ts := h.newTokenSource()
+ ts := h.newTokenSource(r)
if !ts.Exists(r.Context()) {
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
return
@@ -83,7 +87,7 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
defer h.mu.Unlock()
// Already authenticated?
- ts := h.newTokenSource()
+ ts := h.newTokenSource(r)
if ts.Exists(r.Context()) {
if _, err := ts.Token(); err == nil {
writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"})
@@ -191,14 +195,18 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque
}
func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
- ts := h.newTokenSource()
+ ts := h.newTokenSource(r)
if err := ts.Delete(r.Context()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if h.providerReg != nil {
- h.providerReg.Unregister(oauth.DefaultProviderName)
+ tid := store.TenantIDFromContext(r.Context())
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ h.providerReg.UnregisterForTenant(tid, oauth.DefaultProviderName)
}
emitAudit(h.msgBus, r, "oauth.logout", "oauth", "openai")
@@ -207,7 +215,10 @@ func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
// saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory.
func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) {
- ts := h.newTokenSource()
+ ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName)
+ if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
+ ts.WithTenantID(tid)
+ }
providerID, err := ts.SaveOAuthResult(ctx, tokenResp)
if err != nil {
return uuid.Nil, err
diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json
index b274709d..683e1591 100644
--- a/internal/http/openapi_spec.json
+++ b/internal/http/openapi_spec.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "GoClaw Gateway API",
- "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.",
+ "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.",
"version": "0.2.0",
"contact": {
"name": "GoClaw",
diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go
index ec7294f2..bb3b3e93 100644
--- a/internal/http/pending_messages.go
+++ b/internal/http/pending_messages.go
@@ -127,7 +127,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re
}
// Resolve an LLM provider for summarization using the default agent's config
- provider, model := h.resolveProviderAndModel()
+ provider, model := h.resolveProviderAndModel(r.Context())
if provider == nil {
// Fallback: hard delete if no provider available
slog.Warn("compact.no_provider", "channel", req.ChannelName, "key", req.HistoryKey)
@@ -162,14 +162,14 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re
// resolveProviderAndModel resolves the LLM provider+model for pending message compaction.
// Priority: config provider/model > default agent's provider/model > first available provider.
-func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) {
+func (h *PendingMessagesHandler) resolveProviderAndModel(ctx context.Context) (providers.Provider, string) {
if h.providerReg == nil {
return nil, ""
}
// Config-level provider/model override.
if h.cfgProvider != "" {
- if p, err := h.providerReg.Get(h.cfgProvider); err == nil {
+ if p, err := h.providerReg.Get(ctx, h.cfgProvider); err == nil {
model := h.cfgModel
if model == "" {
model = p.DefaultModel()
@@ -182,8 +182,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider,
// Fallback: default agent's provider+model.
if h.agentStore != nil {
- if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" {
- if p, err := h.providerReg.Get(ag.Provider); err == nil {
+ if ag, err := h.agentStore.GetDefault(ctx); err == nil && ag.Provider != "" {
+ if p, err := h.providerReg.GetForTenant(ag.TenantID, ag.Provider); err == nil {
model := ag.Model
if model == "" {
model = p.DefaultModel()
@@ -196,8 +196,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider,
}
// Fallback: first provider with a valid default model
- for _, name := range h.providerReg.List() {
- p, err := h.providerReg.Get(name)
+ for _, name := range h.providerReg.List(ctx) {
+ p, err := h.providerReg.Get(ctx, name)
if err != nil {
continue
}
diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go
index 161dea9a..3cd5aa85 100644
--- a/internal/http/provider_verify.go
+++ b/internal/http/provider_verify.go
@@ -3,6 +3,7 @@ package http
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"os/exec"
"path/filepath"
@@ -82,7 +83,9 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
return
}
- provider, err := h.providerReg.Get(p.Name)
+ // Use provider's own TenantID (not request context) so cross-tenant admins
+ // can verify providers belonging to other tenants.
+ provider, err := h.providerReg.GetForTenant(p.TenantID, p.Name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name})
return
@@ -95,7 +98,7 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
return
}
- ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
_, err = provider.Chat(ctx, providers.ChatRequest{
@@ -171,6 +174,14 @@ func isNonChatModel(model string) bool {
// friendlyVerifyError extracts a human-readable message from provider errors.
// Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}`
func friendlyVerifyError(err error) string {
+ // Timeout / context cancellation → user-friendly message
+ if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "context deadline exceeded") {
+ return "Verification timed out — the provider took too long to respond. Please try again."
+ }
+ if errors.Is(err, context.Canceled) {
+ return "Verification was cancelled. Please try again."
+ }
+
msg := err.Error()
// Try to extract "message" field from embedded JSON
diff --git a/internal/http/providers.go b/internal/http/providers.go
index dabfa56c..cbc29c9f 100644
--- a/internal/http/providers.go
+++ b/internal/http/providers.go
@@ -129,7 +129,7 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
mcpData.AgentMCPLookup = h.mcpLookup
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
- h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
+ h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
return
}
if p.APIKey == "" {
@@ -138,25 +138,25 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
apiBase := h.resolveAPIBase(p)
switch p.ProviderType {
case store.ProviderChatGPTOAuth:
- ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name)
- h.providerReg.Register(providers.NewCodexProvider(p.Name, ts, apiBase, ""))
+ ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID)
+ h.providerReg.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, apiBase, ""))
case store.ProviderAnthropicNative:
- h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey,
+ h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
providers.WithAnthropicBaseURL(apiBase)))
case store.ProviderDashScope:
- h.providerReg.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, ""))
+ h.providerReg.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, ""))
case store.ProviderBailian:
base := apiBase
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
- h.providerReg.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
+ h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "")
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
- h.providerReg.Register(prov)
+ h.providerReg.RegisterForTenant(p.TenantID, prov)
}
}
@@ -311,10 +311,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
// Unregister old name if renamed to prevent ghost entries
if oldName != "" && oldName != updated.Name {
- h.providerReg.Unregister(oldName)
+ h.providerReg.UnregisterForTenant(updated.TenantID, oldName)
}
if !updated.Enabled {
- h.providerReg.Unregister(updated.Name)
+ h.providerReg.UnregisterForTenant(updated.TenantID, updated.Name)
} else {
h.registerInMemory(updated)
}
@@ -341,10 +341,12 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R
return
}
- // Read provider name before deleting so we can unregister it
+ // Read provider before deleting so we can unregister it
var providerName string
+ var providerTenantID uuid.UUID
if p, err := h.store.GetProvider(r.Context(), id); err == nil {
providerName = p.Name
+ providerTenantID = p.TenantID
}
if err := h.store.DeleteProvider(r.Context(), id); err != nil {
@@ -354,7 +356,7 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R
}
if h.providerReg != nil && providerName != "" {
- h.providerReg.Unregister(providerName)
+ h.providerReg.UnregisterForTenant(providerTenantID, providerName)
}
if providerName != "" {
h.emitProviderCacheInvalidate(providerName)
diff --git a/internal/http/responses.go b/internal/http/responses.go
index 94f2e3b8..436235e9 100644
--- a/internal/http/responses.go
+++ b/internal/http/responses.go
@@ -80,7 +80,7 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
agentID := extractAgentID(r, req.Model)
userID := extractUserID(r)
- loop, err := h.agents.Get(agentID)
+ loop, err := h.agents.Get(r.Context(), agentID)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"agent not found: %s"}`, agentID), http.StatusNotFound)
return
diff --git a/internal/http/skills.go b/internal/http/skills.go
index 38f0c75b..737ff01c 100644
--- a/internal/http/skills.go
+++ b/internal/http/skills.go
@@ -1,12 +1,17 @@
package http
import (
+ "context"
"encoding/json"
+ "log/slog"
"net/http"
+ "os"
+ "path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/skills"
@@ -19,15 +24,25 @@ const maxSkillUploadSize = 20 << 20 // 20 MB
// SkillsHandler handles skill management HTTP endpoints.
type SkillsHandler struct {
- skills *pg.PGSkillStore
- baseDir string // filesystem base for skill content
- token string
- msgBus *bus.MessageBus
+ skills *pg.PGSkillStore
+ baseDir string // filesystem base for skill content (skills-store/) — master tenant
+ dataDir string // parent data dir for tenant-scoped skill paths
+ bundledDir string // original bundled skills dir (fallback for broken managed copies)
+ token string
+ msgBus *bus.MessageBus
}
// NewSkillsHandler creates a handler for skill management endpoints.
-func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, token string, msgBus *bus.MessageBus) *SkillsHandler {
- return &SkillsHandler{skills: skills, baseDir: baseDir, token: token, msgBus: msgBus}
+func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir, token string, msgBus *bus.MessageBus) *SkillsHandler {
+ return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, token: token, msgBus: msgBus}
+}
+
+// tenantSkillsDir returns the skills-store directory scoped to the requesting tenant.
+// Master tenant returns h.baseDir unchanged (backward compat).
+func (h *SkillsHandler) tenantSkillsDir(r *http.Request) string {
+ tid := store.TenantIDFromContext(r.Context())
+ slug := store.TenantSlugFromContext(r.Context())
+ return config.TenantSkillsStoreDir(h.dataDir, tid, slug)
}
// emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set.
@@ -56,26 +71,53 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions))
mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile))
mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles))
- mux.HandleFunc("POST /v1/skills/rescan-deps", h.authMiddleware(h.handleRescanDeps))
- mux.HandleFunc("POST /v1/skills/install-deps", h.authMiddleware(h.handleInstallDeps))
- mux.HandleFunc("POST /v1/skills/install-dep", h.authMiddleware(h.handleInstallDep))
- mux.HandleFunc("GET /v1/skills/runtimes", h.authMiddleware(h.handleRuntimes))
- mux.HandleFunc("POST /v1/skills/{id}/toggle", h.authMiddleware(h.handleToggle))
+ // System-level operations: admin + master tenant only.
+ // These execute shell commands (pip/npm install) and affect the entire server.
+ mux.HandleFunc("POST /v1/skills/rescan-deps", h.adminMiddleware(h.handleRescanDeps))
+ mux.HandleFunc("POST /v1/skills/install-deps", h.adminMiddleware(h.handleInstallDeps))
+ mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep))
+ mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes))
+ mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle))
}
func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, "", next)
}
+// adminMiddleware requires admin role — used for system-level operations
+// (rescan deps, install packages, toggle skills) that affect the entire server.
+func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc {
+ return requireAuth(h.token, permissions.RoleAdmin, next)
+}
+
+// requireMasterTenant rejects requests from non-master tenants.
+// System skill management (install packages, rescan deps) is a server-wide operation
+// that should only be accessible to the master tenant or cross-tenant admins.
+func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Request) bool {
+ ctx := r.Context()
+ if store.IsCrossTenant(ctx) {
+ return true
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == store.MasterTenantID {
+ return true
+ }
+ locale := store.LocaleFromContext(ctx)
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": i18n.T(locale, i18n.MsgPermissionDenied, "system skill management"),
+ })
+ return false
+}
+
func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
- skills := h.skills.ListSkills()
+ skills := h.skills.ListSkills(r.Context())
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
}
func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id := r.PathValue("id")
- skill, ok := h.skills.GetSkill(id)
+ skill, ok := h.skills.GetSkill(r.Context(), id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id)})
return
@@ -96,7 +138,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
userID := store.UserIDFromContext(r.Context())
- if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
@@ -114,7 +156,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
delete(updates, "is_system")
delete(updates, "enabled")
- if err := h.skills.UpdateSkill(id, updates); err != nil {
+ if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
@@ -137,13 +179,13 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
userID := store.UserIDFromContext(r.Context())
- if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
}
- if err := h.skills.DeleteSkill(id); err != nil {
+ if err := h.skills.DeleteSkill(r.Context(), id); err != nil {
if err.Error() == "cannot delete system skill" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete system skill"})
return
@@ -159,6 +201,9 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
// handleInstallDeps installs missing dependencies for all system skills, then re-checks status.
func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request) {
+ if !h.requireMasterTenant(w, r) {
+ return
+ }
dirs := h.skills.ListSystemSkillDirs()
if len(dirs) == 0 {
writeJSON(w, http.StatusOK, map[string]string{"message": "no system skills"})
@@ -185,7 +230,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
}
// Re-check all system skills and update status after install
- allSkills := h.skills.ListAllSkills()
+ allSkills := h.skills.ListAllSkills(r.Context())
for _, sk := range allSkills {
if !sk.IsSystem {
continue
@@ -194,8 +239,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
if !exists {
continue
}
- m := skills.ScanSkillDeps(dir)
+ m := h.scanWithFallback(sk)
if m == nil || m.IsEmpty() {
+ _ = dir // dir was used for direct scan; fallback uses sk.BaseDir
continue
}
ok, miss := skills.CheckSkillDeps(m)
@@ -204,7 +250,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
continue
}
if ok && sk.Status == "archived" {
- _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"})
+ _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "active"})
h.skills.BumpVersion()
}
status := "active"
@@ -236,6 +282,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
// handleInstallDep installs a single dependency and re-checks all skill statuses.
// Body: {"dep": "pip:openpyxl"}
func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) {
+ if !h.requireMasterTenant(w, r) {
+ return
+ }
var body struct {
Dep string `json:"dep"`
}
@@ -279,30 +328,40 @@ type depResult struct {
// rescanAndUpdate re-checks all skills and updates their status + missing deps in DB.
func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
- allSkills := h.skills.ListAllSkills()
+ allSkills := h.skills.ListAllSkills(store.WithCrossTenant(context.Background()))
for _, sk := range allSkills {
- manifest := skills.ScanSkillDeps(sk.BaseDir)
- if manifest == nil || manifest.IsEmpty() {
- results = append(results, depResult{Slug: sk.Slug, Status: "ok"})
- continue
- }
+ manifest := h.scanWithFallback(sk)
- ok, missing := skills.CheckSkillDeps(manifest)
id, err := uuid.Parse(sk.ID)
if err != nil {
continue
}
+ if manifest == nil || manifest.IsEmpty() {
+ // No deps needed — if archived, recover to active and clear stale deps.
+ if sk.Status == "archived" {
+ _ = h.skills.StoreMissingDeps(id, nil)
+ _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"})
+ results = append(results, depResult{Slug: sk.Slug, Status: "active"})
+ updated++
+ slog.Debug("rescan: recovered archived skill (no deps)", "slug", sk.Slug)
+ } else {
+ results = append(results, depResult{Slug: sk.Slug, Status: "ok"})
+ }
+ continue
+ }
+
+ ok, missing := skills.CheckSkillDeps(manifest)
_ = h.skills.StoreMissingDeps(id, missing)
switch {
case ok && sk.Status == "archived":
- _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"})
+ _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"})
results = append(results, depResult{Slug: sk.Slug, Status: "active"})
updated++
case !ok && sk.Status == "active":
- _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"})
+ _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "archived"})
results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing})
updated++
case !ok:
@@ -310,6 +369,8 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
default:
results = append(results, depResult{Slug: sk.Slug, Status: "ok"})
}
+
+ slog.Debug("rescan: checked skill", "slug", sk.Slug, "ok", ok, "missing", len(missing))
}
if updated > 0 {
@@ -318,8 +379,49 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
return updated, results
}
+// scanWithFallback scans skill deps from the managed dir, falling back to the
+// bundled dir if the managed copy's scripts/ directory is missing or empty.
+// If a fallback scan succeeds, re-copies the bundled scripts to the managed dir.
+func (h *SkillsHandler) scanWithFallback(sk store.SkillInfo) *skills.SkillManifest {
+ manifest := skills.ScanSkillDeps(sk.BaseDir)
+ if manifest != nil && !manifest.IsEmpty() {
+ return manifest
+ }
+
+ // Fallback: try bundled dir for system skills whose managed copy is broken.
+ if !sk.IsSystem || h.bundledDir == "" {
+ return manifest
+ }
+
+ managedScripts := filepath.Join(sk.BaseDir, "scripts")
+ if _, err := os.Stat(managedScripts); err == nil {
+ // scripts/ exists in managed dir but scanner found nothing — not a copy issue.
+ return manifest
+ }
+
+ bundledSkillDir := filepath.Join(h.bundledDir, sk.Slug)
+ bundledManifest := skills.ScanSkillDeps(bundledSkillDir)
+ if bundledManifest == nil || bundledManifest.IsEmpty() {
+ return manifest
+ }
+
+ slog.Warn("rescan: managed scripts/ missing, using bundled fallback",
+ "slug", sk.Slug, "managed", sk.BaseDir, "bundled", bundledSkillDir)
+
+ // Re-copy bundled scripts to managed dir so future scans work without fallback.
+ bundledScripts := filepath.Join(bundledSkillDir, "scripts")
+ if err := skills.CopyDir(bundledScripts, managedScripts); err != nil {
+ slog.Error("rescan: failed to re-copy bundled scripts", "slug", sk.Slug, "error", err)
+ }
+
+ return bundledManifest
+}
+
// handleRescanDeps re-checks dependencies for all skills (including archived) and updates their status.
func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) {
+ if !h.requireMasterTenant(w, r) {
+ return
+ }
updated, results := h.rescanAndUpdate()
writeJSON(w, http.StatusOK, map[string]any{
"updated": updated,
@@ -336,6 +438,9 @@ func (h *SkillsHandler) handleRuntimes(w http.ResponseWriter, _ *http.Request) {
// Body: {"enabled": bool}
// When enabling: re-checks deps and updates status to "active" or "archived" accordingly.
func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) {
+ if !h.requireMasterTenant(w, r) {
+ return
+ }
locale := store.LocaleFromContext(r.Context())
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
@@ -352,7 +457,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.skills.ToggleSkill(id, body.Enabled); err != nil {
+ if err := h.skills.ToggleSkill(r.Context(), id, body.Enabled); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
@@ -360,9 +465,9 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) {
newStatus := ""
if body.Enabled {
// Re-check deps for this skill so its status reflects reality after being re-enabled.
- sk, ok := h.skills.GetSkillByID(id)
+ sk, ok := h.skills.GetSkillByID(r.Context(), id)
if ok {
- manifest := skills.ScanSkillDeps(sk.BaseDir)
+ manifest := h.scanWithFallback(sk)
if manifest != nil && !manifest.IsEmpty() {
depOk, missing := skills.CheckSkillDeps(manifest)
_ = h.skills.StoreMissingDeps(id, missing)
@@ -374,7 +479,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) {
} else {
newStatus = "active"
}
- _ = h.skills.UpdateSkill(id, map[string]any{"status": newStatus})
+ _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": newStatus})
}
}
diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go
index 3943144d..a19d4825 100644
--- a/internal/http/skills_grants.go
+++ b/internal/http/skills_grants.go
@@ -47,7 +47,7 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
// Ownership check (admins bypass)
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
- if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
@@ -96,7 +96,7 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
userID := store.UserIDFromContext(r.Context())
- if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
@@ -133,7 +133,7 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
// Ownership check (admins bypass)
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
- if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
@@ -179,7 +179,7 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request)
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
userID := store.UserIDFromContext(r.Context())
- if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID {
+ if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
return
}
diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go
index 384763d3..8912444d 100644
--- a/internal/http/skills_upload.go
+++ b/internal/http/skills_upload.go
@@ -117,8 +117,9 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
// Determine version (always increment — includes archived skills so re-upload gets v2+)
version := h.skills.GetNextVersion(slug)
- // Extract to filesystem: baseDir/slug/version/
- destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version))
+ // Extract to filesystem: tenant-scoped skills-store/slug/version/
+ tenantSkillsBase := h.tenantSkillsDir(r)
+ destDir := filepath.Join(tenantSkillsBase, slug, fmt.Sprintf("%d", version))
if err := os.MkdirAll(destDir, 0755); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill directory")})
return
@@ -200,7 +201,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
ok, missing := skills.CheckSkillDeps(manifest)
if !ok {
// Set skill to archived due to missing deps
- _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"})
+ _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "archived"})
response["deps_warning"] = "missing dependencies: " + skills.FormatMissing(missing)
}
}
diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go
index 71579bc2..b63285d7 100644
--- a/internal/http/skills_versions.go
+++ b/internal/http/skills_versions.go
@@ -26,7 +26,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
return
}
- _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
+ _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
@@ -73,7 +73,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
return
}
- _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
+ _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
@@ -162,7 +162,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
return
}
- _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
+ _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
return
diff --git a/internal/http/storage.go b/internal/http/storage.go
index 8f3e2dcf..4cbc94f1 100644
--- a/internal/http/storage.go
+++ b/internal/http/storage.go
@@ -13,24 +13,28 @@ import (
"sync"
"time"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/skills"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// StorageHandler provides HTTP endpoints for browsing and managing
// files inside the ~/.goclaw/ data directory.
// Skills directories are browsable (read-only) but deletion is blocked.
+// sizeCacheEntry holds a cached storage size calculation for one tenant.
+type sizeCacheEntry struct {
+ total int64
+ files int
+ cachedAt time.Time
+}
+
type StorageHandler struct {
- baseDir string // resolved absolute path to ~/.goclaw/
+ baseDir string // global data dir (resolved absolute path to ~/.goclaw/)
token string
- // sizeCache caches the total storage size for 60 minutes.
- sizeCache struct {
- mu sync.Mutex
- total int64
- files int
- cachedAt time.Time
- }
+ // sizeCache caches the total storage size per tenant for 60 minutes.
+ sizeCache sync.Map // tenantBaseDir (string) → *sizeCacheEntry
}
// NewStorageHandler creates a handler for workspace storage management.
@@ -50,6 +54,14 @@ func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(h.token, "", next)
}
+// tenantBaseDir resolves the data directory scoped to the requesting tenant.
+// Master tenant returns the global baseDir (backward compat).
+func (h *StorageHandler) tenantBaseDir(r *http.Request) string {
+ tid := store.TenantIDFromContext(r.Context())
+ slug := store.TenantSlugFromContext(r.Context())
+ return config.TenantDataDir(h.baseDir, tid, slug)
+}
+
// protectedDirs are top-level directories where deletion is blocked
// (managed separately via the Skills page).
var protectedDirs = []string{"skills", "skills-store"}
@@ -91,11 +103,12 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
}
}
- rootDir := h.baseDir
+ base := h.tenantBaseDir(r)
+ rootDir := base
if subPath != "" {
- rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath))
- if !strings.HasPrefix(rootDir, h.baseDir) {
- slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir)
+ rootDir = filepath.Join(base, filepath.Clean(subPath))
+ if !strings.HasPrefix(rootDir, base) {
+ slog.Warn("security.storage_escape", "resolved", rootDir, "root", base)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
@@ -119,7 +132,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
if path == rootDir {
return nil
}
- rel, _ := filepath.Rel(h.baseDir, path)
+ rel, _ := filepath.Rel(base, path)
// Skip symlinks
if d.Type()&os.ModeSymlink != 0 {
@@ -183,7 +196,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"files": entries,
- "baseDir": h.baseDir,
+ "baseDir": base,
})
}
@@ -205,34 +218,33 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
- // Check cache
- h.sizeCache.mu.Lock()
- if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL {
- total := h.sizeCache.total
- files := h.sizeCache.files
- h.sizeCache.mu.Unlock()
- writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true})
- return
+ sizeBase := h.tenantBaseDir(r)
+
+ // Check per-tenant cache
+ if entry, ok := h.sizeCache.Load(sizeBase); ok {
+ ce := entry.(*sizeCacheEntry)
+ if time.Since(ce.cachedAt) < sizeCacheTTL {
+ writeSizeEvent(w, flusher, map[string]any{"total": ce.total, "files": ce.files, "done": true, "cached": true})
+ return
+ }
}
- h.sizeCache.mu.Unlock()
// Walk and stream progress
var total int64
var fileCount int
lastFlush := time.Now()
- filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error {
+ filepath.WalkDir(sizeBase, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
- // Check client disconnect
if r.Context().Err() != nil {
return filepath.SkipAll
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
- rel, _ := filepath.Rel(h.baseDir, path)
+ rel, _ := filepath.Rel(sizeBase, path)
if skills.IsSystemArtifact(rel) {
return nil
}
@@ -240,7 +252,6 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) {
total += info.Size()
fileCount++
}
- // Emit progress every 50 files or 200ms
if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond {
writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount})
lastFlush = time.Now()
@@ -248,12 +259,8 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) {
return nil
})
- // Update cache
- h.sizeCache.mu.Lock()
- h.sizeCache.total = total
- h.sizeCache.files = fileCount
- h.sizeCache.cachedAt = time.Now()
- h.sizeCache.mu.Unlock()
+ // Update per-tenant cache
+ h.sizeCache.Store(sizeBase, &sizeCacheEntry{total: total, files: fileCount, cachedAt: time.Now()})
// Send final event
writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false})
@@ -279,9 +286,10 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
return
}
- absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
- if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
- slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
+ readBase := h.tenantBaseDir(r)
+ absPath := filepath.Join(readBase, filepath.Clean(relPath))
+ if !strings.HasPrefix(absPath, readBase+string(filepath.Separator)) {
+ slog.Warn("security.storage_escape", "resolved", absPath, "root", readBase)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
@@ -346,9 +354,10 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
- absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
- if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
- slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
+ delBase := h.tenantBaseDir(r)
+ absPath := filepath.Join(delBase, filepath.Clean(relPath))
+ if !strings.HasPrefix(absPath, delBase+string(filepath.Separator)) {
+ slog.Warn("security.storage_escape", "resolved", absPath, "root", delBase)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
diff --git a/internal/http/summoner.go b/internal/http/summoner.go
index 8713350f..4e3feeb9 100644
--- a/internal/http/summoner.go
+++ b/internal/http/summoner.go
@@ -72,12 +72,12 @@ const singleCallTimeout = 300 * time.Second
// On retry (resummon), skips files that were already generated (differ from template).
// On success: stores generated files and sets agent status to "active".
// On failure: keeps template files (already seeded) and sets status to store.AgentStatusSummonFailed.
-func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) {
- ctx, cancel := context.WithTimeout(context.Background(), 600*time.Second)
+func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, description string) {
+ ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 600*time.Second)
defer cancel()
s.ensureUserPredefined(ctx, agentID)
- s.emitEvent(agentID, SummonEventStarted, "", "")
+ s.emitEvent(agentID, tenantID, SummonEventStarted, "", "")
// Check which files already exist (from a previous partial run)
existingMap := s.loadExistingFiles(ctx, agentID)
@@ -85,9 +85,9 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
// Skip if all files already generated
if s.isGenerated(existingMap, bootstrap.SoulFile) && s.isGenerated(existingMap, bootstrap.IdentityFile) {
slog.Info("summoning: all files already generated, skipping", "agent", agentID)
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "")
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
- s.finishSummon(ctx, agentID, existingMap[bootstrap.IdentityFile], "", description)
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
+ s.finishSummon(ctx, agentID, tenantID, existingMap[bootstrap.IdentityFile], "", description)
return
}
@@ -98,15 +98,15 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
if err == nil {
slog.Info("summoning: single-call succeeded", "agent", agentID)
- s.storeFiles(ctx, agentID, files)
- s.finishSummon(ctx, agentID, files[bootstrap.IdentityFile], files[frontmatterKey], description)
+ s.storeFiles(ctx, agentID, tenantID, files)
+ s.finishSummon(ctx, agentID, tenantID, files[bootstrap.IdentityFile], files[frontmatterKey], description)
return
}
// Non-retryable error → fail immediately
if !isRetryableError(err) {
slog.Warn("summoning: single-call failed (non-retryable)", "agent", agentID, "error", err)
- s.emitEvent(agentID, SummonEventFailed, "", err.Error())
+ s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error())
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
return
}
@@ -124,12 +124,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
if s.isGenerated(existingMap, bootstrap.SoulFile) {
soulContent = existingMap[bootstrap.SoulFile]
slog.Info("summoning: SOUL.md already generated, skipping", "agent", agentID)
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "")
} else {
soulFiles, soulErr := s.generateFiles(ctx, providerName, model, s.buildSoulPrompt(description))
if soulErr != nil {
slog.Warn("summoning: SOUL.md generation failed", "agent", agentID, "error", soulErr)
- s.emitEvent(agentID, SummonEventFailed, "", soulErr.Error())
+ s.emitEvent(agentID, tenantID, SummonEventFailed, "", soulErr.Error())
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
return
}
@@ -139,7 +139,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.SoulFile, soulContent); storeErr != nil {
slog.Warn("summoning: failed to store SOUL.md", "agent", agentID, "error", storeErr)
} else {
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "")
}
}
}
@@ -152,12 +152,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
if !identityNeeded && !userPredNeeded {
identityContent = existingMap[bootstrap.IdentityFile]
slog.Info("summoning: IDENTITY.md + USER_PREDEFINED.md already generated, skipping", "agent", agentID)
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
} else {
idFiles, idErr := s.generateFiles(ctx, providerName, model, s.buildIdentityPrompt(description, soulContent))
if idErr != nil {
slog.Warn("summoning: IDENTITY.md generation failed", "agent", agentID, "error", idErr)
- s.emitEvent(agentID, SummonEventFailed, "", idErr.Error())
+ s.emitEvent(agentID, tenantID, SummonEventFailed, "", idErr.Error())
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
return
}
@@ -169,23 +169,23 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.IdentityFile, identityContent); storeErr != nil {
slog.Warn("summoning: failed to store IDENTITY.md", "agent", agentID, "error", storeErr)
} else {
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "")
}
}
if upContent := idFiles[bootstrap.UserPredefinedFile]; upContent != "" && userPredNeeded {
if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, upContent); storeErr != nil {
slog.Warn("summoning: failed to store USER_PREDEFINED.md", "agent", agentID, "error", storeErr)
} else {
- s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "")
}
}
}
- s.finishSummon(ctx, agentID, identityContent, frontmatter, description)
+ s.finishSummon(ctx, agentID, tenantID, identityContent, frontmatter, description)
}
// finishSummon saves agent metadata and marks the agent as active.
-func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, identityContent, frontmatter, description string) {
+func (s *AgentSummoner) finishSummon(ctx context.Context, agentID, tenantID uuid.UUID, identityContent, frontmatter, description string) {
updates := map[string]any{}
if frontmatter == "" {
frontmatter = truncateUTF8(description, 200)
@@ -202,7 +202,7 @@ func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, ide
}
}
s.setAgentStatus(ctx, agentID, store.AgentStatusActive)
- s.emitEvent(agentID, SummonEventCompleted, "", "")
+ s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "")
slog.Info("summoning: completed", "agent", agentID)
}
diff --git a/internal/http/summoner_regenerate.go b/internal/http/summoner_regenerate.go
index 937a78cb..9f182dcc 100644
--- a/internal/http/summoner_regenerate.go
+++ b/internal/http/summoner_regenerate.go
@@ -20,19 +20,19 @@ import (
// RegenerateAgent updates context files based on an edit prompt.
// Reads existing files, sends them + edit instructions to LLM, stores results.
// Synchronous — caller should run in goroutine if needed.
-func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) {
- ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
+func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, editPrompt string) {
+ ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 300*time.Second)
defer cancel()
s.ensureUserPredefined(ctx, agentID)
- s.emitEvent(agentID, SummonEventStarted, "", "")
+ s.emitEvent(agentID, tenantID, SummonEventStarted, "", "")
// Read existing files for context
existing, err := s.agents.GetAgentContextFiles(ctx, agentID)
if err != nil {
slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err)
- s.emitEvent(agentID, SummonEventFailed, "", err.Error())
+ s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error())
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
return
}
@@ -42,13 +42,13 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
files, err := s.generateFiles(ctx, providerName, model, prompt)
if err != nil {
slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err)
- s.emitEvent(agentID, SummonEventFailed, "", err.Error())
+ s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error())
// Use fresh context — the original may have timed out, but we still need to update status.
s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed)
return
}
- s.storeFiles(ctx, agentID, files)
+ s.storeFiles(ctx, agentID, tenantID, files)
// Update frontmatter + display_name if IDENTITY.md was regenerated
updates := map[string]any{}
@@ -65,7 +65,7 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
}
s.setAgentStatus(ctx, agentID, store.AgentStatusActive)
- s.emitEvent(agentID, SummonEventCompleted, "", "")
+ s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "")
slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files))
}
@@ -98,7 +98,7 @@ func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName stri
// generateFiles calls the LLM and parses the XML-tagged response into file map.
func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) {
- provider, err := s.resolveProvider(providerName)
+ provider, err := s.resolveProvider(ctx, providerName)
if err != nil {
return nil, fmt.Errorf("resolve provider: %w", err)
}
@@ -139,7 +139,7 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model,
}
// storeFiles saves generated files to agent_context_files and emits progress events.
-func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) {
+func (s *AgentSummoner) storeFiles(ctx context.Context, agentID, tenantID uuid.UUID, files map[string]string) {
for _, name := range summoningFiles {
content, ok := files[name]
if !ok || content == "" {
@@ -149,23 +149,23 @@ func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files
slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err)
continue
}
- s.emitEvent(agentID, SummonEventFileGenerated, name, "")
+ s.emitEvent(agentID, tenantID, SummonEventFileGenerated, name, "")
}
}
-func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) {
+func (s *AgentSummoner) resolveProvider(ctx context.Context, name string) (providers.Provider, error) {
if s.providerReg == nil {
return nil, fmt.Errorf("no provider registry")
}
- provider, err := s.providerReg.Get(name)
+ provider, err := s.providerReg.Get(ctx, name)
if err != nil {
// Fallback to first available provider
- names := s.providerReg.List()
+ names := s.providerReg.List(ctx)
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured")
}
- provider, err = s.providerReg.Get(names[0])
+ provider, err = s.providerReg.Get(ctx, names[0])
if err != nil {
return nil, err
}
@@ -197,7 +197,7 @@ func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, s
}
}
-func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) {
+func (s *AgentSummoner) emitEvent(agentID, tenantID uuid.UUID, eventType, fileName, errMsg string) {
if s.msgBus == nil {
return
}
@@ -211,8 +211,5 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
if errMsg != "" {
payload["error"] = errMsg
}
- s.msgBus.Broadcast(bus.Event{
- Name: protocol.EventAgentSummoning,
- Payload: payload,
- })
+ bus.BroadcastForTenant(s.msgBus, protocol.EventAgentSummoning, tenantID, payload)
}
diff --git a/internal/http/team_attachments.go b/internal/http/team_attachments.go
index 65bfb2dc..90350974 100644
--- a/internal/http/team_attachments.go
+++ b/internal/http/team_attachments.go
@@ -30,14 +30,27 @@ func (h *TeamAttachmentsHandler) RegisterRoutes(mux *http.ServeMux) {
func (h *TeamAttachmentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- provided := extractBearerToken(r)
- if provided == "" {
- provided = r.URL.Query().Get("token")
- }
- if !requireAuthBearer(h.token, "", provided, w, r) {
+ // Priority 1: HMAC-signed file token (?ft=) — no gateway token exposure.
+ if ft := r.URL.Query().Get("ft"); ft != "" {
+ path := r.URL.Path // full path for HMAC binding
+ if VerifyFileToken(ft, path, FileSigningKey()) {
+ next(w, r)
+ return
+ }
+ http.Error(w, "invalid or expired file token", http.StatusUnauthorized)
return
}
- next(w, r)
+ // Priority 2: Bearer header (API clients).
+ provided := extractBearerToken(r)
+ if provided == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ authedReq, ok := requireAuthBearer(h.token, "", provided, w, r)
+ if !ok {
+ return
+ }
+ next(w, authedReq)
}
}
@@ -73,13 +86,18 @@ func (h *TeamAttachmentsHandler) handleDownload(w http.ResponseWriter, r *http.R
return
}
- // Resolve absolute disk path: {dataDir}/teams/{teamID}/{chatID}/{path}
- absPath := filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path)
-
- // Security: path traversal check — resolved path must stay within team workspace.
- teamBase := filepath.Join(h.dataDir, "teams", att.TeamID.String())
- cleanPath := filepath.Clean(absPath)
- if !strings.HasPrefix(cleanPath, teamBase+string(filepath.Separator)) && cleanPath != teamBase {
+ // Resolve disk path. Absolute paths (new) are used directly;
+ // relative paths (legacy) are joined with the team workspace directory.
+ var cleanPath string
+ if filepath.IsAbs(att.Path) {
+ cleanPath = filepath.Clean(att.Path)
+ } else {
+ // Legacy: {workspace}/teams/{teamID}/{chatID}/{relPath}
+ cleanPath = filepath.Clean(filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path))
+ }
+ // Security: file must be within the workspace root to prevent path traversal.
+ wsRoot := filepath.Clean(h.dataDir) + string(filepath.Separator)
+ if !strings.HasPrefix(cleanPath, wsRoot) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid attachment path"})
return
}
diff --git a/internal/http/tenant_cache.go b/internal/http/tenant_cache.go
new file mode 100644
index 00000000..2091b467
--- /dev/null
+++ b/internal/http/tenant_cache.go
@@ -0,0 +1,95 @@
+package http
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// tenantCacheEntry holds a cached tenant lookup result.
+type tenantCacheEntry struct {
+ tenant *store.TenantData
+ fetchedAt time.Time
+}
+
+// tenantCache is a TTL cache for tenant lookups by UUID and slug.
+// Invalidated via bus CacheKindTenants events.
+type tenantCache struct {
+ mu sync.RWMutex
+ byID map[uuid.UUID]*tenantCacheEntry
+ bySlug map[string]*tenantCacheEntry
+ ttl time.Duration
+ store store.TenantStore
+}
+
+func newTenantCache(s store.TenantStore, ttl time.Duration) *tenantCache {
+ return &tenantCache{
+ byID: make(map[uuid.UUID]*tenantCacheEntry),
+ bySlug: make(map[string]*tenantCacheEntry),
+ ttl: ttl,
+ store: s,
+ }
+}
+
+// GetTenant returns a tenant by UUID, using cache when available.
+func (c *tenantCache) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) {
+ c.mu.RLock()
+ if e, ok := c.byID[id]; ok && time.Since(e.fetchedAt) <= c.ttl {
+ c.mu.RUnlock()
+ slog.Debug("tenant_cache.hit", "id", id)
+ return e.tenant, nil
+ }
+ c.mu.RUnlock()
+ slog.Debug("tenant_cache.miss", "id", id)
+
+ t, err := c.store.GetTenant(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if t != nil {
+ c.put(t)
+ }
+ return t, nil
+}
+
+// GetTenantBySlug returns a tenant by slug, using cache when available.
+func (c *tenantCache) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) {
+ c.mu.RLock()
+ if e, ok := c.bySlug[slug]; ok && time.Since(e.fetchedAt) <= c.ttl {
+ c.mu.RUnlock()
+ slog.Debug("tenant_cache.hit", "slug", slug)
+ return e.tenant, nil
+ }
+ c.mu.RUnlock()
+ slog.Debug("tenant_cache.miss", "slug", slug)
+
+ t, err := c.store.GetTenantBySlug(ctx, slug)
+ if err != nil {
+ return nil, err
+ }
+ if t != nil {
+ c.put(t)
+ }
+ return t, nil
+}
+
+func (c *tenantCache) put(t *store.TenantData) {
+ entry := &tenantCacheEntry{tenant: t, fetchedAt: time.Now()}
+ c.mu.Lock()
+ c.byID[t.ID] = entry
+ c.bySlug[t.Slug] = entry
+ c.mu.Unlock()
+}
+
+// invalidateAll clears all cached entries. Called on bus cache.invalidate events.
+func (c *tenantCache) invalidateAll() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ slog.Debug("tenant_cache.invalidated", "entries", len(c.byID))
+ c.byID = make(map[uuid.UUID]*tenantCacheEntry)
+ c.bySlug = make(map[string]*tenantCacheEntry)
+}
diff --git a/internal/http/tenants.go b/internal/http/tenants.go
new file mode 100644
index 00000000..4e9ddc1b
--- /dev/null
+++ b/internal/http/tenants.go
@@ -0,0 +1,312 @@
+package http
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// TenantsHandler handles tenant CRUD and membership endpoints.
+type TenantsHandler struct {
+ tenantStore store.TenantStore
+ token string
+ msgBus *bus.MessageBus
+ workspace string // base workspace directory for tenant dirs
+}
+
+// NewTenantsHandler creates a handler for tenant management endpoints.
+func NewTenantsHandler(tenantStore store.TenantStore, token string, msgBus *bus.MessageBus, workspace string) *TenantsHandler {
+ return &TenantsHandler{tenantStore: tenantStore, token: token, msgBus: msgBus, workspace: workspace}
+}
+
+// RegisterRoutes registers all tenant management routes on the given mux.
+func (h *TenantsHandler) RegisterRoutes(mux *http.ServeMux) {
+ admin := func(next http.HandlerFunc) http.HandlerFunc {
+ return requireAuth(h.token, permissions.RoleAdmin, next)
+ }
+ mux.HandleFunc("GET /v1/tenants", admin(h.handleList))
+ mux.HandleFunc("POST /v1/tenants", admin(h.handleCreate))
+ mux.HandleFunc("GET /v1/tenants/{id}", admin(h.handleGet))
+ mux.HandleFunc("PATCH /v1/tenants/{id}", admin(h.handleUpdate))
+ mux.HandleFunc("GET /v1/tenants/{id}/users", admin(h.handleUsersList))
+ mux.HandleFunc("POST /v1/tenants/{id}/users", admin(h.handleUsersAdd))
+ mux.HandleFunc("DELETE /v1/tenants/{id}/users/{userId}", admin(h.handleUsersRemove))
+}
+
+func (h *TenantsHandler) handleList(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")})
+ return
+ }
+
+ tenants, err := h.tenantStore.ListTenants(r.Context())
+ if err != nil {
+ slog.Error("tenants.list failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenants")})
+ return
+ }
+ if tenants == nil {
+ tenants = []store.TenantData{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tenants": tenants})
+}
+
+func (h *TenantsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")})
+ return
+ }
+
+ var input struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
+ return
+ }
+
+ if input.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
+ return
+ }
+ if input.Slug == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "slug")})
+ return
+ }
+ if !isValidSlug(input.Slug) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "slug")})
+ return
+ }
+
+ tenant := &store.TenantData{
+ ID: store.GenNewID(),
+ Name: input.Name,
+ Slug: input.Slug,
+ Status: store.TenantStatusActive,
+ }
+
+ if err := h.tenantStore.CreateTenant(r.Context(), tenant); err != nil {
+ slog.Error("tenants.create failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())})
+ return
+ }
+
+ // Create workspace directory for the tenant.
+ if h.workspace != "" {
+ tenantDir := filepath.Join(h.workspace, "tenants", tenant.Slug)
+ if err := os.MkdirAll(tenantDir, 0755); err != nil {
+ slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err)
+ }
+ }
+
+ h.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String())
+ emitAudit(h.msgBus, r, "tenant.created", "tenant", tenant.ID.String())
+ writeJSON(w, http.StatusCreated, tenant)
+}
+
+func (h *TenantsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")})
+ return
+ }
+
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")})
+ return
+ }
+
+ tenant, err := h.tenantStore.GetTenant(r.Context(), id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tenant", id.String())})
+ return
+ }
+ writeJSON(w, http.StatusOK, tenant)
+}
+
+func (h *TenantsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")})
+ return
+ }
+
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")})
+ return
+ }
+
+ var input struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Settings map[string]any `json:"settings"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
+ return
+ }
+
+ updates := make(map[string]any)
+ if input.Name != "" {
+ updates["name"] = input.Name
+ }
+ if input.Status != "" {
+ updates["status"] = input.Status
+ }
+ if input.Settings != nil {
+ updates["settings"] = input.Settings
+ }
+
+ if len(updates) == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)})
+ return
+ }
+
+ if err := h.tenantStore.UpdateTenant(r.Context(), id, updates); err != nil {
+ slog.Error("tenants.update failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())})
+ return
+ }
+
+ h.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String())
+ emitAudit(h.msgBus, r, "tenant.updated", "tenant", id.String())
+ writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
+}
+
+func (h *TenantsHandler) handleUsersList(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")})
+ return
+ }
+
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")})
+ return
+ }
+
+ users, err := h.tenantStore.ListUsers(r.Context(), id)
+ if err != nil {
+ slog.Error("tenants.users.list failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")})
+ return
+ }
+ if users == nil {
+ users = []store.TenantUserData{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"users": users})
+}
+
+func (h *TenantsHandler) handleUsersAdd(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")})
+ return
+ }
+
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")})
+ return
+ }
+
+ var input struct {
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
+ return
+ }
+
+ if input.UserID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
+ return
+ }
+ if input.Role == "" {
+ input.Role = store.TenantRoleMember
+ }
+ validRoles := map[string]bool{
+ store.TenantRoleOwner: true, store.TenantRoleAdmin: true,
+ store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true,
+ }
+ if !validRoles[input.Role] {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRole)})
+ return
+ }
+
+ if err := h.tenantStore.AddUser(r.Context(), id, input.UserID, input.Role); err != nil {
+ slog.Error("tenants.users.add failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())})
+ return
+ }
+
+ h.emitCacheInvalidate(bus.CacheKindTenantUsers, input.UserID)
+ emitAudit(h.msgBus, r, "tenant.user.added", "tenant", id.String())
+ writeJSON(w, http.StatusCreated, map[string]string{"ok": "true"})
+}
+
+func (h *TenantsHandler) handleUsersRemove(w http.ResponseWriter, r *http.Request) {
+ locale := extractLocale(r)
+ if !store.IsCrossTenant(r.Context()) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")})
+ return
+ }
+
+ id, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")})
+ return
+ }
+
+ userID := r.PathValue("userId")
+ if userID == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userId")})
+ return
+ }
+
+ if err := h.tenantStore.RemoveUser(r.Context(), id, userID); err != nil {
+ slog.Error("tenants.users.remove failed", "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())})
+ return
+ }
+
+ h.emitCacheInvalidate(bus.CacheKindTenantUsers, userID)
+ emitAudit(h.msgBus, r, "tenant.user.removed", "tenant", id.String())
+
+ // Notify affected user's WS sessions to force logout
+ if h.msgBus != nil {
+ h.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventTenantAccessRevoked,
+ Payload: map[string]string{"user_id": userID, "tenant_id": id.String()},
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
+}
+
+func (h *TenantsHandler) emitCacheInvalidate(kind, key string) {
+ if h.msgBus == nil {
+ return
+ }
+ h.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventCacheInvalidate,
+ Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
+ })
+}
diff --git a/internal/http/traces.go b/internal/http/traces.go
index 90e2311f..383d1ed3 100644
--- a/internal/http/traces.go
+++ b/internal/http/traces.go
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -73,6 +74,13 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) {
}
}
+ // Non-admin callers may only see their own traces.
+ auth := resolveAuth(r, h.token)
+ if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(r.Context())
+ opts.UserID = callerID
+ }
+
traces, err := h.tracing.ListTraces(r.Context(), opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -104,6 +112,16 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
return
}
+ // Non-admin callers may only access their own traces.
+ auth := resolveAuth(r, h.token)
+ if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(r.Context())
+ if trace.UserID != callerID {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceIDStr)})
+ return
+ }
+ }
+
spans, err := h.tracing.GetTraceSpans(r.Context(), traceID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -160,6 +178,21 @@ func (h *TracesHandler) handleExport(w http.ResponseWriter, r *http.Request) {
return
}
+ // Verify ownership before export.
+ rootTrace, err := h.tracing.GetTrace(r.Context(), traceID)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())})
+ return
+ }
+ authExport := resolveAuth(r, h.token)
+ if !permissions.HasMinRole(authExport.Role, permissions.RoleAdmin) {
+ callerID := store.UserIDFromContext(r.Context())
+ if rootTrace.UserID != callerID {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())})
+ return
+ }
+ }
+
entry, err := h.collectTraceTree(r.Context(), traceID, 0)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())})
diff --git a/internal/http/usage.go b/internal/http/usage.go
index 5d75e627..13a923bb 100644
--- a/internal/http/usage.go
+++ b/internal/http/usage.go
@@ -185,6 +185,17 @@ func (h *UsageHandler) queryLiveHour(r *http.Request, from, to time.Time, q stor
args := []any{from, to}
idx := 3
+
+ // Tenant isolation: scope to caller's tenant
+ if !store.IsCrossTenant(r.Context()) {
+ tid := store.TenantIDFromContext(r.Context())
+ if tid != uuid.Nil {
+ query += fmt.Sprintf(" AND tenant_id = $%d", idx)
+ args = append(args, tid)
+ idx++
+ }
+ }
+
if q.AgentID != nil {
query += fmt.Sprintf(" AND agent_id = $%d", idx)
args = append(args, *q.AgentID)
diff --git a/internal/http/validate.go b/internal/http/validate.go
index 73c4c95c..e38178e2 100644
--- a/internal/http/validate.go
+++ b/internal/http/validate.go
@@ -35,7 +35,7 @@ var agentAllowedFields = map[string]bool{
"agent_key": true, "agent_type": true, "display_name": true,
"provider": true, "model": true, "status": true,
"context_window": true, "max_tool_iterations": true,
- "workspace": true, "restrict_to_workspace": true,
+ "workspace": true,
"frontmatter": true, "compaction_config": true,
"memory_config": true, "other_config": true, "tools_config": true,
"is_default": true, "budget_monthly_cents": true, "subagents_config": true,
@@ -59,7 +59,7 @@ var mcpServerAllowedFields = map[string]bool{
"name": true, "transport": true, "command": true, "args": true,
"url": true, "api_key": true, "env": true, "headers": true,
"enabled": true, "tool_prefix": true, "timeout_seconds": true,
- "agent_id": true, "config": true,
+ "agent_id": true, "config": true, "settings": true,
}
var channelInstanceAllowedFields = map[string]bool{
diff --git a/internal/http/wake.go b/internal/http/wake.go
index 5f675b22..3cf3ddc4 100644
--- a/internal/http/wake.go
+++ b/internal/http/wake.go
@@ -93,7 +93,7 @@ func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
return
}
- loop, err := h.agents.Get(agentID)
+ loop, err := h.agents.Get(r.Context(), agentID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)})
return
diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go
index 24de5217..103aef00 100644
--- a/internal/i18n/catalog_en.go
+++ b/internal/i18n/catalog_en.go
@@ -93,9 +93,6 @@ func init() {
MsgAgentIsTeamLead: "agent is already the team lead",
MsgCannotRemoveTeamLead: "cannot remove the team lead",
- // Delegations
- MsgDelegationsUnavailable: "delegations not available",
-
// Channels
MsgCannotDeleteDefaultInst: "cannot delete default channel instance",
@@ -176,5 +173,13 @@ func init() {
MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.",
MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.",
MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.",
+
+ MsgInvalidRole: "invalid role: allowed values are owner, admin, operator, member, viewer",
+
+ MsgContactIDsRequired: "contact_ids is required",
+ MsgMergeTargetRequired: "exactly one of tenant_user_id or create_user is required",
+ MsgTenantUserNotFound: "tenant user not found",
+ MsgTenantMismatch: "tenant user does not belong to this tenant",
+ MsgTenantScopeRequired: "tenant scope is required for this operation",
})
}
diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go
index ae2036ab..49e8720c 100644
--- a/internal/i18n/catalog_vi.go
+++ b/internal/i18n/catalog_vi.go
@@ -93,9 +93,6 @@ func init() {
MsgAgentIsTeamLead: "agent đã là trưởng nhóm",
MsgCannotRemoveTeamLead: "không thể xóa trưởng nhóm",
- // Delegations
- MsgDelegationsUnavailable: "ủy quyền không khả dụng",
-
// Channels
MsgCannotDeleteDefaultInst: "không thể xóa phiên bản channel mặc định",
@@ -176,5 +173,13 @@ func init() {
MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.",
MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.",
MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.",
+
+ MsgInvalidRole: "vai trò không hợp lệ: giá trị cho phép là owner, admin, operator, member, viewer",
+
+ MsgContactIDsRequired: "contact_ids là bắt buộc",
+ MsgMergeTargetRequired: "cần chính xác một trong tenant_user_id hoặc create_user",
+ MsgTenantUserNotFound: "không tìm thấy tenant user",
+ MsgTenantMismatch: "tenant user không thuộc tenant này",
+ MsgTenantScopeRequired: "cần xác định tenant để thực hiện thao tác này",
})
}
diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go
index 2a806dc7..0402aaf8 100644
--- a/internal/i18n/catalog_zh.go
+++ b/internal/i18n/catalog_zh.go
@@ -93,9 +93,6 @@ func init() {
MsgAgentIsTeamLead: "该Agent已是团队负责人",
MsgCannotRemoveTeamLead: "无法移除团队负责人",
- // Delegations
- MsgDelegationsUnavailable: "委派功能不可用",
-
// Channels
MsgCannotDeleteDefaultInst: "无法删除默认Channel实例",
@@ -176,5 +173,13 @@ func init() {
MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。",
MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。",
MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。",
+
+ MsgInvalidRole: "无效角色:允许的值为 owner、admin、operator、member、viewer",
+
+ MsgContactIDsRequired: "contact_ids 为必填项",
+ MsgMergeTargetRequired: "必须提供 tenant_user_id 或 create_user 其中之一",
+ MsgTenantUserNotFound: "未找到租户用户",
+ MsgTenantMismatch: "租户用户不属于此租户",
+ MsgTenantScopeRequired: "此操作需要指定租户范围",
})
}
diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go
index f7909ed2..b090f23b 100644
--- a/internal/i18n/keys.go
+++ b/internal/i18n/keys.go
@@ -94,9 +94,6 @@ const (
MsgAgentIsTeamLead = "error.agent_is_team_lead" // "agent is already the team lead"
MsgCannotRemoveTeamLead = "error.cannot_remove_team_lead" // "cannot remove the team lead"
- // --- Delegations ---
- MsgDelegationsUnavailable = "error.delegations_not_available" // "delegations not available"
-
// --- Channels ---
MsgCannotDeleteDefaultInst = "error.cannot_delete_default_inst" // "cannot delete default channel instance"
@@ -178,4 +175,14 @@ const (
MsgSkillNudgePostscript = "skill.nudge_postscript"
MsgSkillNudge70Pct = "skill.nudge_70_pct"
MsgSkillNudge90Pct = "skill.nudge_90_pct"
+
+ // --- Tenants ---
+ MsgInvalidRole = "error.invalid_role" // "invalid role: allowed values are owner, admin, operator, member, viewer"
+
+ // --- Contact merge ---
+ MsgContactIDsRequired = "error.contact_ids_required" // "contact_ids is required"
+ MsgMergeTargetRequired = "error.merge_target_required" // "exactly one of tenant_user_id or create_user is required"
+ MsgTenantUserNotFound = "error.tenant_user_not_found" // "tenant user not found"
+ MsgTenantMismatch = "error.tenant_mismatch" // "tenant user does not belong to this tenant"
+ MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation"
)
diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go
index f715450d..606dd496 100644
--- a/internal/mcp/manager.go
+++ b/internal/mcp/manager.go
@@ -2,6 +2,7 @@ package mcp
import (
"context"
+ "encoding/json"
"fmt"
"log/slog"
"sync"
@@ -76,6 +77,7 @@ type Manager struct {
pool *Pool
poolServers map[string]struct{} // server names acquired from pool (for cleanup)
poolToolNames map[string][]string // per-agent tool names for pool-backed servers
+ poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release
// Search mode: deferred tools not registered in registry
deferredTools map[string]*BridgeTool // registeredName → BridgeTool
@@ -167,13 +169,69 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st
continue
}
+ // Skip server if it requires per-user credentials and user has none
+ if requireUserCreds(srv.Settings) {
+ if userID == "" {
+ continue
+ }
+ uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID)
+ if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) {
+ slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID)
+ continue
+ }
+ }
+
args := jsonBytesToStringSlice(srv.Args)
env := jsonBytesToStringMap(srv.Env)
headers := jsonBytesToStringMap(srv.Headers)
- if m.pool != nil {
+ // Inject APIKey into headers if present (bug fix: was never passed to connections)
+ if srv.APIKey != "" && headers["Authorization"] == "" {
+ if headers == nil {
+ headers = make(map[string]string)
+ }
+ headers["Authorization"] = "Bearer " + srv.APIKey
+ }
+
+ // Merge per-user credentials (user overrides server defaults)
+ if userID != "" && m.store != nil {
+ if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil {
+ if userCreds.APIKey != "" {
+ if headers == nil {
+ headers = make(map[string]string)
+ }
+ headers["Authorization"] = "Bearer " + userCreds.APIKey
+ }
+ for k, v := range userCreds.Headers {
+ if headers == nil {
+ headers = make(map[string]string)
+ }
+ headers[k] = v
+ }
+ for k, v := range userCreds.Env {
+ if env == nil {
+ env = make(map[string]string)
+ }
+ env[k] = v
+ }
+ }
+ }
+
+ // Per-user credentials change connection params → can't share pool connection.
+ // Fall back to per-agent mode when user has custom credentials.
+ hasUserCreds := userID != "" && m.store != nil
+ if hasUserCreds {
+ if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) {
+ hasUserCreds = true
+ } else {
+ hasUserCreds = false
+ }
+ }
+
+ if m.pool != nil && !hasUserCreds {
// Pool mode: acquire shared connection, create per-agent BridgeTools
- if err := m.connectViaPool(ctx, srv.Name, srv.Transport, srv.Command,
+ tid := store.TenantIDFromContext(ctx)
+ if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command,
args, env, srv.URL, headers, srv.ToolPrefix, srv.TimeoutSec); err != nil {
slog.Warn("mcp.server.connect_failed", "server", srv.Name, "error", err)
continue
@@ -359,7 +417,9 @@ func (m *Manager) Stop() {
m.registry.Unregister(toolName)
}
if m.pool != nil {
- m.pool.Release(name)
+ if pkey, ok := m.poolKeys[name]; ok {
+ m.pool.Release(pkey)
+ }
}
} else {
// Standalone: close connection directly
@@ -398,3 +458,15 @@ func (m *Manager) ServerStatus() []ServerStatus {
}
return statuses
}
+
+// requireUserCreds checks if an MCP server's settings mandate per-user credentials.
+func requireUserCreds(settings json.RawMessage) bool {
+ if len(settings) == 0 {
+ return false
+ }
+ var s struct {
+ RequireUserCredentials bool `json:"require_user_credentials"`
+ }
+ _ = json.Unmarshal(settings, &s)
+ return s.RequireUserCredentials
+}
diff --git a/internal/mcp/manager_connect.go b/internal/mcp/manager_connect.go
index 9d4816cf..590cdeb2 100644
--- a/internal/mcp/manager_connect.go
+++ b/internal/mcp/manager_connect.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
mcpgo "github.com/mark3labs/mcp-go/mcp"
@@ -123,8 +124,8 @@ func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, se
// connectViaPool acquires a shared connection from the pool and creates
// per-agent BridgeTools pointing to the shared client/connected pointers.
-func (m *Manager) connectViaPool(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error {
- entry, err := m.pool.Acquire(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
+func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error {
+ entry, err := m.pool.Acquire(ctx, tenantID, name, transportType, command, args, env, url, headers, timeoutSec)
if err != nil {
return err
}
@@ -132,7 +133,9 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma
// Create per-agent BridgeTools from the pool's shared connection
registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec)
- // Track server state and per-agent tool names
+ // Track server state and per-agent tool names.
+ // poolServers/poolToolNames keyed by plain name for Close() iteration.
+ // poolKeys maps plain name → pool compound key for Release().
m.mu.Lock()
m.servers[name] = entry.state
if m.poolServers == nil {
@@ -143,6 +146,10 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma
m.poolToolNames = make(map[string][]string)
}
m.poolToolNames[name] = registeredNames
+ if m.poolKeys == nil {
+ m.poolKeys = make(map[string]string)
+ }
+ m.poolKeys[name] = poolKey(tenantID, name)
m.mu.Unlock()
if len(registeredNames) > 0 {
diff --git a/internal/mcp/manager_tools.go b/internal/mcp/manager_tools.go
index f469abf3..5ba94c1b 100644
--- a/internal/mcp/manager_tools.go
+++ b/internal/mcp/manager_tools.go
@@ -63,7 +63,9 @@ func (m *Manager) unregisterAllTools() {
m.registry.Unregister(toolName)
}
if m.pool != nil {
- m.pool.Release(name)
+ if pkey, ok := m.poolKeys[name]; ok {
+ m.pool.Release(pkey)
+ }
}
} else {
// Standalone: close connection directly
diff --git a/internal/mcp/pool.go b/internal/mcp/pool.go
index 4786051f..3dc81149 100644
--- a/internal/mcp/pool.go
+++ b/internal/mcp/pool.go
@@ -2,64 +2,126 @@ package mcp
import (
"context"
+ "fmt"
"log/slog"
"sync"
+ "time"
+ "github.com/google/uuid"
mcpgo "github.com/mark3labs/mcp-go/mcp"
)
+// PoolConfig configures the MCP connection pool.
+type PoolConfig struct {
+ MaxSize int // global max connections (default 100)
+ MaxIdle int // max idle connections to keep alive (default 20)
+ IdleTTL time.Duration // close idle connections after this (default 20m)
+ AcquireTimeout time.Duration // wait for pool slot before error (default 60s)
+}
+
+// DefaultPoolConfig returns the default pool configuration.
+func DefaultPoolConfig() PoolConfig {
+ return PoolConfig{
+ MaxSize: 100,
+ MaxIdle: 20,
+ IdleTTL: 20 * time.Minute,
+ AcquireTimeout: 60 * time.Second,
+ }
+}
+
// poolEntry holds a shared connection and its discovered tools.
type poolEntry struct {
state *serverState // connection + health state
tools []mcpgo.Tool // discovered MCP tool definitions
refCount int // number of active Manager references
+ lastUsed time.Time // last Acquire/Release time for idle eviction
}
// Pool manages shared MCP server connections across agents.
-// One physical connection per server name, shared by all agents
-// that have grants to that server. Each agent creates its own
-// BridgeTools pointing to the shared client/connected pointers.
+// Connections are keyed by tenantID/serverName for tenant isolation.
type Pool struct {
mu sync.Mutex
servers map[string]*poolEntry
+ cfg PoolConfig
+ slot chan struct{} // semaphore for MaxSize
+ stopCh chan struct{}
}
-// NewPool creates a shared MCP connection pool.
-func NewPool() *Pool {
- return &Pool{
- servers: make(map[string]*poolEntry),
+// NewPool creates a shared MCP connection pool with idle eviction.
+func NewPool(cfg PoolConfig) *Pool {
+ if cfg.MaxSize <= 0 {
+ cfg.MaxSize = 100
}
+ if cfg.MaxIdle <= 0 {
+ cfg.MaxIdle = 20
+ }
+ if cfg.IdleTTL <= 0 {
+ cfg.IdleTTL = 20 * time.Minute
+ }
+ if cfg.AcquireTimeout <= 0 {
+ cfg.AcquireTimeout = 60 * time.Second
+ }
+
+ p := &Pool{
+ servers: make(map[string]*poolEntry),
+ cfg: cfg,
+ slot: make(chan struct{}, cfg.MaxSize),
+ stopCh: make(chan struct{}),
+ }
+ go p.evictLoop()
+ return p
}
-// Acquire returns a shared connection for the named server.
-// If no connection exists, it connects using the provided config.
-// Increments the reference count.
-func (p *Pool) Acquire(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) {
- p.mu.Lock()
+// poolKey builds a tenant-scoped key for pool lookups.
+func poolKey(tenantID uuid.UUID, name string) string {
+ return tenantID.String() + "/" + name
+}
- if entry, ok := p.servers[name]; ok && entry.state.connected.Load() {
+// Acquire returns a shared connection for the named server scoped to a tenant.
+// If no connection exists, it connects using the provided config.
+// Blocks up to AcquireTimeout if pool is at MaxSize.
+func (p *Pool) Acquire(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) {
+ key := poolKey(tenantID, name)
+
+ p.mu.Lock()
+ if entry, ok := p.servers[key]; ok && entry.state.connected.Load() {
entry.refCount++
+ entry.lastUsed = time.Now()
p.mu.Unlock()
- slog.Debug("mcp.pool.reuse", "server", name, "refCount", entry.refCount)
+ slog.Debug("mcp.pool.reuse", "key", key, "refCount", entry.refCount)
return entry, nil
}
- // If entry exists but disconnected, close old connection first
- if old, ok := p.servers[name]; ok {
+ // If entry exists but disconnected, close old and reclaim slot
+ if old, ok := p.servers[key]; ok {
if old.state.cancel != nil {
old.state.cancel()
}
if old.state.client != nil {
_ = old.state.client.Close()
}
- delete(p.servers, name)
+ delete(p.servers, key)
+ // Return slot to semaphore
+ select {
+ case <-p.slot:
+ default:
+ }
}
-
p.mu.Unlock()
+ // Acquire a slot (blocks if pool full, evicts idle if possible)
+ if err := p.acquireSlot(ctx); err != nil {
+ return nil, fmt.Errorf("mcp pool exhausted: %w", err)
+ }
+
// Connect outside the lock (may be slow)
ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
if err != nil {
+ // Return slot on failure
+ select {
+ case <-p.slot:
+ default:
+ }
return nil, err
}
@@ -72,63 +134,226 @@ func (p *Pool) Acquire(ctx context.Context, name, transportType, command string,
state: ss,
tools: mcpTools,
refCount: 1,
+ lastUsed: time.Now(),
}
p.mu.Lock()
// Check if another goroutine connected while we were connecting
- if existing, ok := p.servers[name]; ok && existing.state.connected.Load() {
- // Use existing, close ours
+ if existing, ok := p.servers[key]; ok && existing.state.connected.Load() {
p.mu.Unlock()
hcancel()
_ = ss.client.Close()
+ // Return our extra slot
+ select {
+ case <-p.slot:
+ default:
+ }
p.mu.Lock()
existing.refCount++
+ existing.lastUsed = time.Now()
p.mu.Unlock()
return existing, nil
}
- p.servers[name] = entry
+ p.servers[key] = entry
p.mu.Unlock()
- slog.Info("mcp.pool.connected", "server", name, "tools", len(mcpTools))
+ slog.Info("mcp.pool.connected", "key", key, "tools", len(mcpTools))
return entry, nil
}
+// acquireSlot tries to acquire a pool slot, evicting idle connections if needed.
+func (p *Pool) acquireSlot(ctx context.Context) error {
+ // Fast path: slot available
+ select {
+ case p.slot <- struct{}{}:
+ return nil
+ default:
+ }
+
+ // Try evicting one idle entry
+ p.mu.Lock()
+ evicted := p.evictOldestIdleLocked()
+ p.mu.Unlock()
+
+ if evicted {
+ select {
+ case p.slot <- struct{}{}:
+ return nil
+ default:
+ }
+ }
+
+ // Wait up to AcquireTimeout
+ timer := time.NewTimer(p.cfg.AcquireTimeout)
+ defer timer.Stop()
+
+ select {
+ case p.slot <- struct{}{}:
+ return nil
+ case <-timer.C:
+ return fmt.Errorf("timeout after %s waiting for pool slot (max %d)", p.cfg.AcquireTimeout, p.cfg.MaxSize)
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
// Release decrements the reference count for a server.
-// The connection is NOT closed when refCount reaches 0 — it stays
-// alive for future agents. Use Stop() to close all connections.
-func (p *Pool) Release(name string) {
+// Accepts the same key format as Acquire (tenantID + name).
+func (p *Pool) Release(key string) {
p.mu.Lock()
defer p.mu.Unlock()
- if entry, ok := p.servers[name]; ok {
+ if entry, ok := p.servers[key]; ok {
entry.refCount--
if entry.refCount < 0 {
entry.refCount = 0
}
- slog.Debug("mcp.pool.release", "server", name, "refCount", entry.refCount)
+ entry.lastUsed = time.Now()
+ slog.Debug("mcp.pool.release", "key", key, "refCount", entry.refCount)
}
}
-// Stop closes all pooled connections. Called on gateway shutdown.
+// Stop closes all pooled connections and stops eviction. Called on gateway shutdown.
func (p *Pool) Stop() {
+ close(p.stopCh)
+
p.mu.Lock()
defer p.mu.Unlock()
- for name, entry := range p.servers {
+ for key, entry := range p.servers {
if entry.state.cancel != nil {
entry.state.cancel()
}
if entry.state.client != nil {
_ = entry.state.client.Close()
}
- slog.Debug("mcp.pool.stopped", "server", name)
+ slog.Debug("mcp.pool.stopped", "key", key)
}
p.servers = make(map[string]*poolEntry)
}
+// Evict closes a specific pooled connection by tenant + server name.
+// Called when server credentials are rotated to force reconnection with new credentials.
+func (p *Pool) Evict(tenantID uuid.UUID, serverName string) {
+ key := poolKey(tenantID, serverName)
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ entry, ok := p.servers[key]
+ if !ok {
+ return
+ }
+ if entry.state.cancel != nil {
+ entry.state.cancel()
+ }
+ if entry.state.client != nil {
+ _ = entry.state.client.Close()
+ }
+ delete(p.servers, key)
+ select {
+ case <-p.slot:
+ default:
+ }
+ slog.Info("mcp.pool.evicted_on_rotation", "key", key)
+}
+
+// evictLoop runs periodically to close idle connections over MaxIdle count.
+func (p *Pool) evictLoop() {
+ ticker := time.NewTicker(60 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-p.stopCh:
+ return
+ case <-ticker.C:
+ p.evictIdle()
+ }
+ }
+}
+
+// evictIdle closes connections idle > IdleTTL when total idle exceeds MaxIdle.
+func (p *Pool) evictIdle() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ now := time.Now()
+ var idleKeys []string
+ for key, entry := range p.servers {
+ if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.IdleTTL {
+ idleKeys = append(idleKeys, key)
+ }
+ }
+
+ // Count total idle (refCount == 0)
+ totalIdle := 0
+ for _, entry := range p.servers {
+ if entry.refCount == 0 {
+ totalIdle++
+ }
+ }
+
+ // Only evict if over MaxIdle
+ toEvict := totalIdle - p.cfg.MaxIdle
+ if toEvict <= 0 && len(idleKeys) == 0 {
+ return
+ }
+
+ // Evict TTL-expired first, then oldest if still over MaxIdle
+ for _, key := range idleKeys {
+ entry := p.servers[key]
+ if entry.state.cancel != nil {
+ entry.state.cancel()
+ }
+ if entry.state.client != nil {
+ _ = entry.state.client.Close()
+ }
+ delete(p.servers, key)
+ // Return slot
+ select {
+ case <-p.slot:
+ default:
+ }
+ slog.Debug("mcp.pool.evicted", "key", key, "reason", "idle_ttl")
+ }
+}
+
+// evictOldestIdleLocked evicts one idle entry (oldest lastUsed). Caller must hold mu.
+func (p *Pool) evictOldestIdleLocked() bool {
+ var oldestKey string
+ var oldestTime time.Time
+
+ for key, entry := range p.servers {
+ if entry.refCount == 0 {
+ if oldestKey == "" || entry.lastUsed.Before(oldestTime) {
+ oldestKey = key
+ oldestTime = entry.lastUsed
+ }
+ }
+ }
+
+ if oldestKey == "" {
+ return false
+ }
+
+ entry := p.servers[oldestKey]
+ if entry.state.cancel != nil {
+ entry.state.cancel()
+ }
+ if entry.state.client != nil {
+ _ = entry.state.client.Close()
+ }
+ delete(p.servers, oldestKey)
+ // Return slot to semaphore
+ select {
+ case <-p.slot:
+ default:
+ }
+ slog.Debug("mcp.pool.evicted", "key", oldestKey, "reason", "make_room")
+ return true
+}
+
// poolHealthLoop is a standalone health loop for pool-managed connections.
-// Unlike Manager.healthLoop, it doesn't trigger reconnection via Manager
-// since the pool owns the connection lifecycle.
func poolHealthLoop(ctx context.Context, ss *serverState) {
ticker := newHealthTicker()
defer ticker.Stop()
diff --git a/internal/media/store.go b/internal/media/store.go
index b6505985..162871b6 100644
--- a/internal/media/store.go
+++ b/internal/media/store.go
@@ -36,7 +36,7 @@ func (s *Store) SaveFile(sessionKey, srcPath, mime string) (id string, dstPath s
}
mediaID := uuid.New().String()
- ext := extFromMime(mime)
+ ext := ExtFromMime(mime)
if ext == "" {
ext = filepath.Ext(srcPath)
}
@@ -88,8 +88,8 @@ func (s *Store) sessionDir(sessionKey string) string {
return filepath.Join(s.baseDir, hash)
}
-// extFromMime returns a file extension (with dot) for a MIME type.
-func extFromMime(mime string) string {
+// ExtFromMime returns a file extension (with dot) for a MIME type.
+func ExtFromMime(mime string) string {
switch {
case strings.HasPrefix(mime, "image/jpeg"):
return ".jpg"
diff --git a/internal/oauth/token.go b/internal/oauth/token.go
index 21f9b015..9e9a7ff2 100644
--- a/internal/oauth/token.go
+++ b/internal/oauth/token.go
@@ -36,6 +36,7 @@ type DBTokenSource struct {
providerStore store.ProviderStore
secretsStore store.ConfigSecretsStore
providerName string
+ tenantID uuid.UUID // tenant context for DB queries
mu sync.Mutex
cachedToken string
@@ -48,9 +49,16 @@ func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSe
providerStore: provStore,
secretsStore: secretsStore,
providerName: providerName,
+ tenantID: store.MasterTenantID,
}
}
+// WithTenantID sets the tenant context for DB queries. Must be called at init time before Token().
+func (ts *DBTokenSource) WithTenantID(tenantID uuid.UUID) *DBTokenSource {
+ ts.tenantID = tenantID
+ return ts
+}
+
// Token returns a valid access token, refreshing if expired or about to expire.
func (ts *DBTokenSource) Token() (string, error) {
ts.mu.Lock()
@@ -61,7 +69,7 @@ func (ts *DBTokenSource) Token() (string, error) {
return ts.cachedToken, nil
}
- ctx := context.Background()
+ ctx := store.WithTenantID(context.Background(), ts.tenantID)
// Load from DB if not cached
if ts.cachedToken == "" {
diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go
index 4bfe6369..0a95cdb8 100644
--- a/internal/permissions/policy.go
+++ b/internal/permissions/policy.go
@@ -37,6 +37,7 @@ const (
ScopeWrite Scope = "operator.write"
ScopeApprovals Scope = "operator.approvals"
ScopePairing Scope = "operator.pairing"
+ ScopeProvision Scope = "operator.provision"
)
// AllScopes is the set of all valid API key scopes.
@@ -46,6 +47,7 @@ var AllScopes = map[Scope]bool{
ScopeWrite: true,
ScopeApprovals: true,
ScopePairing: true,
+ ScopeProvision: true,
}
// ValidScope reports whether s is a recognised API key scope.
diff --git a/internal/providers/registry.go b/internal/providers/registry.go
index 9b26ec59..c06b0dc2 100644
--- a/internal/providers/registry.go
+++ b/internal/providers/registry.go
@@ -1,80 +1,164 @@
package providers
import (
+ "context"
"fmt"
"io"
"log/slog"
+ "strings"
"sync"
+
+ "github.com/google/uuid"
)
-// Registry manages available LLM providers.
+// MasterTenantID is the default tenant for config-based providers.
+var MasterTenantID = uuid.Must(uuid.Parse("0193a5b0-7000-7000-8000-000000000001"))
+
+// Registry manages available LLM providers with per-tenant isolation.
+// Providers are keyed by "tenantID/name". Get falls back to master tenant.
type Registry struct {
- providers map[string]Provider
- mu sync.RWMutex
+ providers map[string]Provider
+ mu sync.RWMutex
+ tenantFromCtx func(context.Context) uuid.UUID // injected to avoid circular import with store
}
-func NewRegistry() *Registry {
+// NewRegistry creates a provider registry.
+// tenantFromCtx extracts tenant UUID from context (pass store.TenantIDFromContext).
+func NewRegistry(tenantFromCtx func(context.Context) uuid.UUID) *Registry {
return &Registry{
- providers: make(map[string]Provider),
+ providers: make(map[string]Provider),
+ tenantFromCtx: tenantFromCtx,
}
}
-// Register adds a provider to the registry.
-// If a provider with the same name already exists, it is closed before replacement.
+// compoundKey returns "tenantID/name" for registry lookup.
+func compoundKey(tenantID uuid.UUID, name string) string {
+ return tenantID.String() + "/" + name
+}
+
+// tenantFromContext resolves the tenant UUID from context, defaulting to MasterTenantID.
+func (r *Registry) tenantFromContext(ctx context.Context) uuid.UUID {
+ if r.tenantFromCtx != nil {
+ if t := r.tenantFromCtx(ctx); t != uuid.Nil {
+ return t
+ }
+ }
+ return MasterTenantID
+}
+
+// Register adds a provider to the registry under the master tenant.
func (r *Registry) Register(provider Provider) {
+ r.RegisterForTenant(MasterTenantID, provider)
+}
+
+// RegisterForTenant adds a provider under a specific tenant.
+// If a provider with the same tenant+name already exists, it is closed before replacement.
+func (r *Registry) RegisterForTenant(tenantID uuid.UUID, provider Provider) {
r.mu.Lock()
defer r.mu.Unlock()
- if old, ok := r.providers[provider.Name()]; ok {
+ key := compoundKey(tenantID, provider.Name())
+ if old, ok := r.providers[key]; ok {
if c, ok := old.(io.Closer); ok {
c.Close()
}
}
- r.providers[provider.Name()] = provider
+ r.providers[key] = provider
}
-// Unregister removes a provider from the registry, closing it if it implements io.Closer.
+// Unregister removes a provider from the master tenant.
func (r *Registry) Unregister(name string) {
+ r.UnregisterForTenant(MasterTenantID, name)
+}
+
+// UnregisterForTenant removes a provider from a specific tenant.
+func (r *Registry) UnregisterForTenant(tenantID uuid.UUID, name string) {
r.mu.Lock()
defer r.mu.Unlock()
- if old, ok := r.providers[name]; ok {
+ key := compoundKey(tenantID, name)
+ if old, ok := r.providers[key]; ok {
if c, ok := old.(io.Closer); ok {
c.Close()
}
- delete(r.providers, name)
+ delete(r.providers, key)
}
}
-// Get returns a provider by name.
-func (r *Registry) Get(name string) (Provider, error) {
+// Get returns a provider by name, using tenant from context (falls back to master).
+func (r *Registry) Get(ctx context.Context, name string) (Provider, error) {
+ return r.GetForTenant(r.tenantFromContext(ctx), name)
+}
+
+// GetForTenant returns a provider by name for a specific tenant.
+// Falls back to master tenant if not found for the given tenant.
+func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
- p, ok := r.providers[name]
- if !ok {
- return nil, fmt.Errorf("provider not found: %s", name)
+
+ // Try tenant-specific first
+ if tenantID != MasterTenantID {
+ if p, ok := r.providers[compoundKey(tenantID, name)]; ok {
+ return p, nil
+ }
}
- return p, nil
+ // Fallback to master tenant
+ if p, ok := r.providers[compoundKey(MasterTenantID, name)]; ok {
+ return p, nil
+ }
+ return nil, fmt.Errorf("provider not found: %s", name)
}
// Close calls Close() on all providers that implement io.Closer.
func (r *Registry) Close() {
- r.mu.RLock()
- defer r.mu.RUnlock()
- for name, p := range r.providers {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for key, p := range r.providers {
if c, ok := p.(io.Closer); ok {
if err := c.Close(); err != nil {
- slog.Warn("provider close error", "name", name, "error", err)
+ slog.Warn("provider close error", "key", key, "error", err)
}
}
}
}
-// List returns all registered provider names.
-func (r *Registry) List() []string {
+// List returns provider names visible to the tenant in context (tenant-specific + master defaults).
+func (r *Registry) List(ctx context.Context) []string {
+ return r.ListForTenant(r.tenantFromContext(ctx))
+}
+
+// ListForTenant returns provider names available to a tenant (tenant-specific + master defaults).
+func (r *Registry) ListForTenant(tenantID uuid.UUID) []string {
r.mu.RLock()
defer r.mu.RUnlock()
- names := make([]string, 0, len(r.providers))
- for name := range r.providers {
- names = append(names, name)
+
+ seen := make(map[string]bool)
+ var names []string
+
+ masterPrefix := MasterTenantID.String() + "/"
+ tenantPrefix := tenantID.String() + "/"
+
+ // Add tenant-specific providers first
+ if tenantID != MasterTenantID {
+ for key := range r.providers {
+ if strings.HasPrefix(key, tenantPrefix) {
+ name := strings.TrimPrefix(key, tenantPrefix)
+ if !seen[name] {
+ seen[name] = true
+ names = append(names, name)
+ }
+ }
+ }
}
+
+ // Add master tenant providers (not already overridden)
+ for key := range r.providers {
+ if strings.HasPrefix(key, masterPrefix) {
+ name := strings.TrimPrefix(key, masterPrefix)
+ if !seen[name] {
+ seen[name] = true
+ names = append(names, name)
+ }
+ }
+ }
+
return names
}
diff --git a/internal/providers/types.go b/internal/providers/types.go
index a0cb1ff3..0b3d204d 100644
--- a/internal/providers/types.go
+++ b/internal/providers/types.go
@@ -85,9 +85,10 @@ type ImageContent struct {
// Stored in session JSONB (~60 bytes each) instead of megabytes for base64.
// On reload, MediaRefs are resolved to file paths and loaded into Images (for images).
type MediaRef struct {
- ID string `json:"id"` // unique media ID (uuid)
- MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf"
- Kind string `json:"kind"` // "image", "video", "audio", "document"
+ ID string `json:"id"` // unique media ID (uuid)
+ MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf"
+ Kind string `json:"kind"` // "image", "video", "audio", "document"
+ Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving)
}
// Message represents a conversation message.
diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go
index 14ef8f19..f54311a5 100644
--- a/internal/sessions/manager.go
+++ b/internal/sessions/manager.go
@@ -1,6 +1,7 @@
package sessions
import (
+ "context"
"encoding/json"
"fmt"
"os"
@@ -63,7 +64,7 @@ func SessionKey(agentID, scopeKey string) string {
}
// GetOrCreate returns an existing session or creates a new one.
-func (m *Manager) GetOrCreate(key string) *Session {
+func (m *Manager) GetOrCreate(_ context.Context, key string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
@@ -82,7 +83,7 @@ func (m *Manager) GetOrCreate(key string) *Session {
}
// AddMessage appends a message to a session.
-func (m *Manager) AddMessage(key string, msg providers.Message) {
+func (m *Manager) AddMessage(_ context.Context, key string, msg providers.Message) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -101,7 +102,7 @@ func (m *Manager) AddMessage(key string, msg providers.Message) {
}
// GetHistory returns a copy of the message history.
-func (m *Manager) GetHistory(key string) []providers.Message {
+func (m *Manager) GetHistory(_ context.Context, key string) []providers.Message {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -116,7 +117,7 @@ func (m *Manager) GetHistory(key string) []providers.Message {
}
// GetSummary returns the session summary.
-func (m *Manager) GetSummary(key string) string {
+func (m *Manager) GetSummary(_ context.Context, key string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[key]; ok {
@@ -126,7 +127,7 @@ func (m *Manager) GetSummary(key string) string {
}
// SetSummary updates the session summary.
-func (m *Manager) SetSummary(key, summary string) {
+func (m *Manager) SetSummary(_ context.Context, key, summary string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -136,7 +137,7 @@ func (m *Manager) SetSummary(key, summary string) {
}
// SetLabel updates the session label.
-func (m *Manager) SetLabel(key, label string) {
+func (m *Manager) SetLabel(_ context.Context, key, label string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -146,7 +147,7 @@ func (m *Manager) SetLabel(key, label string) {
}
// UpdateMetadata sets model/provider/channel metadata on a session.
-func (m *Manager) UpdateMetadata(key, model, provider, channel string) {
+func (m *Manager) UpdateMetadata(_ context.Context, key, model, provider, channel string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -163,7 +164,7 @@ func (m *Manager) UpdateMetadata(key, model, provider, channel string) {
}
// AccumulateTokens adds token counts from a completed run.
-func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) {
+func (m *Manager) AccumulateTokens(_ context.Context, key string, inputTokens, outputTokens int64) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -173,7 +174,7 @@ func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64)
}
// IncrementCompaction bumps the compaction counter after summarization.
-func (m *Manager) IncrementCompaction(key string) {
+func (m *Manager) IncrementCompaction(_ context.Context, key string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -182,7 +183,7 @@ func (m *Manager) IncrementCompaction(key string) {
}
// GetCompactionCount returns the current compaction count for a session.
-func (m *Manager) GetCompactionCount(key string) int {
+func (m *Manager) GetCompactionCount(_ context.Context, key string) int {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[key]; ok {
@@ -192,7 +193,7 @@ func (m *Manager) GetCompactionCount(key string) int {
}
// GetMemoryFlushCompactionCount returns the compaction count at which memory flush last ran.
-func (m *Manager) GetMemoryFlushCompactionCount(key string) int {
+func (m *Manager) GetMemoryFlushCompactionCount(_ context.Context, key string) int {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[key]; ok {
@@ -202,7 +203,7 @@ func (m *Manager) GetMemoryFlushCompactionCount(key string) int {
}
// SetMemoryFlushDone records that memory flush completed at the current compaction count.
-func (m *Manager) SetMemoryFlushDone(key string) {
+func (m *Manager) SetMemoryFlushDone(_ context.Context, key string) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -212,7 +213,7 @@ func (m *Manager) SetMemoryFlushDone(key string) {
}
// SetSpawnInfo sets subagent origin metadata on a session.
-func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) {
+func (m *Manager) SetSpawnInfo(_ context.Context, key, spawnedBy string, depth int) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -222,7 +223,7 @@ func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) {
}
// SetContextWindow caches the agent's context window on the session.
-func (m *Manager) SetContextWindow(key string, cw int) {
+func (m *Manager) SetContextWindow(_ context.Context, key string, cw int) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -231,7 +232,7 @@ func (m *Manager) SetContextWindow(key string, cw int) {
}
// GetContextWindow returns the cached context window for a session (0 if unset).
-func (m *Manager) GetContextWindow(key string) int {
+func (m *Manager) GetContextWindow(_ context.Context, key string) int {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[key]; ok {
@@ -241,7 +242,7 @@ func (m *Manager) GetContextWindow(key string) int {
}
// SetLastPromptTokens records actual prompt tokens from the last LLM response.
-func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) {
+func (m *Manager) SetLastPromptTokens(_ context.Context, key string, tokens, msgCount int) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[key]; ok {
@@ -251,7 +252,7 @@ func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) {
}
// GetLastPromptTokens returns the last known prompt tokens and message count.
-func (m *Manager) GetLastPromptTokens(key string) (int, int) {
+func (m *Manager) GetLastPromptTokens(_ context.Context, key string) (int, int) {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[key]; ok {
@@ -261,7 +262,7 @@ func (m *Manager) GetLastPromptTokens(key string) (int, int) {
}
// TruncateHistory keeps only the last N messages.
-func (m *Manager) TruncateHistory(key string, keepLast int) {
+func (m *Manager) TruncateHistory(_ context.Context, key string, keepLast int) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -279,7 +280,7 @@ func (m *Manager) TruncateHistory(key string, keepLast int) {
}
// SetHistory replaces a session's message history with the given slice.
-func (m *Manager) SetHistory(key string, msgs []providers.Message) {
+func (m *Manager) SetHistory(_ context.Context, key string, msgs []providers.Message) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -290,7 +291,7 @@ func (m *Manager) SetHistory(key string, msgs []providers.Message) {
}
// Reset clears a session's history and summary.
-func (m *Manager) Reset(key string) {
+func (m *Manager) Reset(_ context.Context, key string) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -302,7 +303,7 @@ func (m *Manager) Reset(key string) {
}
// Delete removes a session entirely.
-func (m *Manager) Delete(key string) error {
+func (m *Manager) Delete(_ context.Context, key string) error {
m.mu.Lock()
delete(m.sessions, key)
m.mu.Unlock()
@@ -318,7 +319,7 @@ func (m *Manager) Delete(key string) error {
}
// List returns metadata for all sessions, optionally filtered by agent ID.
-func (m *Manager) List(agentID string) []SessionInfo {
+func (m *Manager) List(_ context.Context, agentID string) []SessionInfo {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -346,7 +347,7 @@ func (m *Manager) List(agentID string) []SessionInfo {
// LastUsedChannel finds the most recently updated channel session for an agent
// and extracts channel + chatID from the key. Returns ("", "") if none found.
-func (m *Manager) LastUsedChannel(agentID string) (channel, chatID string) {
+func (m *Manager) LastUsedChannel(_ context.Context, agentID string) (channel, chatID string) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -392,7 +393,7 @@ type SessionInfo struct {
}
// Save persists a session to disk atomically.
-func (m *Manager) Save(key string) error {
+func (m *Manager) Save(_ context.Context, key string) error {
if m.storage == "" {
return nil
}
diff --git a/internal/skills/dep_scanner.go b/internal/skills/dep_scanner.go
index adcb2b54..b61c7ebb 100644
--- a/internal/skills/dep_scanner.go
+++ b/internal/skills/dep_scanner.go
@@ -1,6 +1,7 @@
package skills
import (
+ "log/slog"
"os"
"path/filepath"
"regexp"
@@ -34,6 +35,7 @@ func scanScriptsDir(scriptsDir string) *SkillManifest {
entries, err := os.ReadDir(scriptsDir)
if err != nil {
+ slog.Debug("dep_scanner: scripts dir not found", "dir", scriptsDir, "error", err)
return m
}
@@ -104,6 +106,11 @@ func scanScriptsDir(scriptsDir string) *SkillManifest {
m.Requires = append(m.Requires, "node")
}
+ if !m.IsEmpty() {
+ slog.Debug("dep_scanner: scanned", "dir", scriptsDir,
+ "bins", len(m.Requires), "py", len(m.RequiresPython), "node", len(m.RequiresNode))
+ }
+
return m
}
diff --git a/internal/skills/loader.go b/internal/skills/loader.go
index 9b43c18a..682546ca 100644
--- a/internal/skills/loader.go
+++ b/internal/skills/loader.go
@@ -10,6 +10,7 @@
package skills
import (
+ "context"
"encoding/json"
"fmt"
"os"
@@ -100,7 +101,7 @@ func (l *Loader) SetManagedDir(dir string) {
// ListSkills returns all available skills, respecting the priority hierarchy.
// Higher-priority sources override lower ones by name.
-func (l *Loader) ListSkills() []Info {
+func (l *Loader) ListSkills(_ context.Context) []Info {
l.mu.Lock()
defer l.mu.Unlock()
@@ -278,7 +279,7 @@ func (l *Loader) findLatestVersion(slug string) (int, string) {
// LoadSkill reads and returns the content of a skill by name (frontmatter stripped).
// The {baseDir} placeholder in SKILL.md is replaced with the skill's absolute directory path.
// Priority: workspace > agents > global > managed > builtin
-func (l *Loader) LoadSkill(name string) (string, bool) {
+func (l *Loader) LoadSkill(_ context.Context, name string) (string, bool) {
// Check flat skill directories (workspace, agents, global) first
for _, dir := range []string{l.workspaceSkills, l.projectAgentSkills, l.personalAgentSkills, l.globalSkills} {
if dir == "" {
@@ -324,12 +325,12 @@ func (l *Loader) LoadSkill(name string) (string, bool) {
// LoadForContext loads multiple skills and formats them for system prompt injection.
// If allowList is nil, all skills are loaded. If non-nil, only listed skills are loaded.
-func (l *Loader) LoadForContext(allowList []string) string {
+func (l *Loader) LoadForContext(ctx context.Context, allowList []string) string {
var names []string
if allowList == nil {
// Load all available skills
- for _, s := range l.ListSkills() {
+ for _, s := range l.ListSkills(ctx) {
names = append(names, s.Name)
}
} else {
@@ -342,7 +343,7 @@ func (l *Loader) LoadForContext(allowList []string) string {
var parts []string
for _, name := range names {
- content, ok := l.LoadSkill(name)
+ content, ok := l.LoadSkill(ctx, name)
if !ok {
continue
}
@@ -359,8 +360,8 @@ func (l *Loader) LoadForContext(allowList []string) string {
// BuildSummary returns an XML summary of skills for context injection.
// If allowList is nil, all skills are included. If non-nil, only listed skills are included.
// The format matches the TS XML used in system prompts.
-func (l *Loader) BuildSummary(allowList []string) string {
- allSkills := l.ListSkills()
+func (l *Loader) BuildSummary(ctx context.Context, allowList []string) string {
+ allSkills := l.ListSkills(ctx)
if len(allSkills) == 0 {
return ""
}
@@ -423,8 +424,8 @@ func (l *Loader) Dirs() []string {
// FilterSkills returns skills filtered by an allowlist.
// If allowList is nil, all skills are returned. If empty slice, none are returned.
-func (l *Loader) FilterSkills(allowList []string) []Info {
- all := l.ListSkills()
+func (l *Loader) FilterSkills(ctx context.Context, allowList []string) []Info {
+ all := l.ListSkills(ctx)
if allowList == nil {
return all
}
@@ -445,9 +446,9 @@ func (l *Loader) FilterSkills(allowList []string) []Info {
}
// GetSkill returns info about a specific skill.
-func (l *Loader) GetSkill(name string) (*Info, bool) {
+func (l *Loader) GetSkill(ctx context.Context, name string) (*Info, bool) {
// Ensure cache is populated
- l.ListSkills()
+ l.ListSkills(ctx)
l.mu.RLock()
defer l.mu.RUnlock()
diff --git a/internal/skills/seeder.go b/internal/skills/seeder.go
index a662282e..3c07975b 100644
--- a/internal/skills/seeder.go
+++ b/internal/skills/seeder.go
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -20,7 +21,7 @@ type SystemSkillStore interface {
UpsertSystemSkill(ctx context.Context, p pg.SkillCreateParams) (uuid.UUID, bool, string, error)
GetNextVersion(slug string) int
BumpVersion()
- UpdateSkill(id uuid.UUID, updates map[string]interface{}) error
+ UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]interface{}) error
StoreMissingDeps(id uuid.UUID, missing []string) error
}
@@ -130,7 +131,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se
// scripts/ dir has content but the managed scripts/ dir is missing or empty.
if needsReCopy(skillDir, actualDir) {
slog.Info("seeder: managed dir incomplete, re-copying", "slug", slug, "dir", actualDir)
- if err := copyDir(skillDir, actualDir); err != nil {
+ if err := CopyDir(skillDir, actualDir); err != nil {
slog.Error("seeder: failed to re-copy skill files", "slug", slug, "error", err)
}
}
@@ -140,7 +141,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se
}
// Copy skill directory to managed dir
- if err := copyDir(skillDir, destDir); err != nil {
+ if err := CopyDir(skillDir, destDir); err != nil {
slog.Error("seeder: failed to copy skill files", "slug", slug, "error", err)
continue
}
@@ -176,7 +177,7 @@ func (s *Seeder) CheckDepsAsync(skills []seededSkill, msgBus *bus.MessageBus) {
status := "active"
if !ok {
status = "archived"
- _ = s.store.UpdateSkill(sk.id, map[string]interface{}{"status": "archived"})
+ _ = s.store.UpdateSkill(store.WithCrossTenant(context.Background()), sk.id, map[string]interface{}{"status": "archived"})
s.store.BumpVersion()
slog.Warn("seeder: skill deps missing", "slug", sk.slug, "missing", FormatMissing(missing))
}
@@ -230,7 +231,7 @@ func (s *Seeder) copySharedDir(name string) {
return
}
- if err := copyDir(src, dst); err != nil {
+ if err := CopyDir(src, dst); err != nil {
slog.Warn("seeder: failed to copy shared dir", "name", name, "error", err)
}
}
@@ -239,7 +240,8 @@ func (s *Seeder) copySharedDir(name string) {
// Resolves the top-level path and any mid-tree symlinks pointing to directories
// so local module symlinks (e.g. scripts/office -> ../../_shared/office)
// are copied as real directories rather than left as dangling entries.
-func copyDir(src, dst string) error {
+// CopyDir recursively copies a directory tree.
+func CopyDir(src, dst string) error {
resolved, err := filepath.EvalSymlinks(src)
if err != nil {
resolved = src
@@ -264,7 +266,7 @@ func copyDir(src, dst string) error {
// Detect and recurse into directory symlinks so local modules are fully copied.
if info.Mode()&os.ModeSymlink != 0 {
if realInfo, statErr := os.Stat(path); statErr == nil && realInfo.IsDir() {
- return copyDir(path, target)
+ return CopyDir(path, target)
}
return nil // skip broken symlinks
}
diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go
index 52b0bf98..3b5e542b 100644
--- a/internal/store/agent_store.go
+++ b/internal/store/agent_store.go
@@ -41,7 +41,8 @@ const (
// AgentData represents an agent in the database.
type AgentData struct {
BaseModel
- AgentKey string `json:"agent_key"`
+ TenantID uuid.UUID `json:"tenant_id"`
+ AgentKey string `json:"agent_key"`
DisplayName string `json:"display_name,omitempty"`
Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt)
OwnerID string `json:"owner_id"`
@@ -315,6 +316,8 @@ type AgentStore interface {
Create(ctx context.Context, agent *AgentData) error
GetByKey(ctx context.Context, agentKey string) (*AgentData, error)
GetByID(ctx context.Context, id uuid.UUID) (*AgentData, error)
+ GetByKeys(ctx context.Context, keys []string) ([]AgentData, error)
+ GetByIDs(ctx context.Context, ids []uuid.UUID) ([]AgentData, error)
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, ownerID string) ([]AgentData, error)
diff --git a/internal/store/api_key_store.go b/internal/store/api_key_store.go
index baaf08dc..af8b245b 100644
--- a/internal/store/api_key_store.go
+++ b/internal/store/api_key_store.go
@@ -10,11 +10,13 @@ import (
// APIKeyData represents a gateway API key with scoped permissions.
type APIKeyData struct {
ID uuid.UUID `json:"id"`
+ TenantID uuid.UUID `json:"tenant_id"` // uuid.Nil when NULL in DB
Name string `json:"name"`
- Prefix string `json:"prefix"` // first 8 chars for display
- KeyHash string `json:"-"` // SHA-256 hex, never serialized
- Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"]
- ExpiresAt *time.Time `json:"expires_at"` // nil = never
+ Prefix string `json:"prefix"` // first 8 chars for display
+ KeyHash string `json:"-"` // SHA-256 hex, never serialized
+ Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"]
+ OwnerID string `json:"owner_id,omitempty"` // bound user; when set, auth forces user_id = owner_id
+ ExpiresAt *time.Time `json:"expires_at"` // nil = never
LastUsedAt *time.Time `json:"last_used_at"`
Revoked bool `json:"revoked"`
CreatedBy string `json:"created_by"`
@@ -30,14 +32,14 @@ type APIKeyStore interface {
// GetByHash looks up an active (non-revoked, non-expired) key by its SHA-256 hash.
GetByHash(ctx context.Context, keyHash string) (*APIKeyData, error)
- // List returns all API keys (for admin display; key hashes are not included).
- List(ctx context.Context) ([]APIKeyData, error)
+ // List returns API keys. If ownerID is non-empty, filters to keys owned by that user.
+ List(ctx context.Context, ownerID string) ([]APIKeyData, error)
- // Revoke marks a key as revoked.
- Revoke(ctx context.Context, id uuid.UUID) error
+ // Revoke marks a key as revoked. If ownerID is non-empty, also enforces owner_id = ownerID.
+ Revoke(ctx context.Context, id uuid.UUID, ownerID string) error
- // Delete permanently removes a key.
- Delete(ctx context.Context, id uuid.UUID) error
+ // Delete permanently removes a key. If ownerID is non-empty, also enforces owner_id = ownerID.
+ Delete(ctx context.Context, id uuid.UUID, ownerID string) error
// TouchLastUsed updates the last_used_at timestamp.
TouchLastUsed(ctx context.Context, id uuid.UUID) error
diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go
index 50b975ab..7e1d6acd 100644
--- a/internal/store/channel_instance_store.go
+++ b/internal/store/channel_instance_store.go
@@ -11,6 +11,7 @@ import (
// ChannelInstanceData represents a channel instance in the database.
type ChannelInstanceData struct {
BaseModel
+ TenantID uuid.UUID `json:"tenant_id,omitempty"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
ChannelType string `json:"channel_type"`
diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go
index 0cc9e9b9..4715b7b8 100644
--- a/internal/store/contact_store.go
+++ b/internal/store/contact_store.go
@@ -49,7 +49,18 @@ type ContactStore interface {
// Returns a map of sender_id → ChannelContact (first match per sender_id).
GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]ChannelContact, error)
- // MergeContacts assigns the same merged_id to all given contact IDs,
- // linking them as the same person across channels.
- MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error
+ // GetContactByID returns a single contact by primary key. Tenant-scoped via context.
+ GetContactByID(ctx context.Context, id uuid.UUID) (*ChannelContact, error)
+
+ // MergeContacts sets merged_id = tenantUserID on all given contact IDs,
+ // linking them to a tenant_users identity. Tenant-scoped via context.
+ MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error
+
+ // UnmergeContacts sets merged_id = NULL on the given contact IDs.
+ // Tenant-scoped via context.
+ UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error
+
+ // GetContactsByMergedID returns all contacts linked to a given merged_id.
+ // Tenant-scoped via context.
+ GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]ChannelContact, error)
}
diff --git a/internal/store/context.go b/internal/store/context.go
index dae3e9d3..86a3a068 100644
--- a/internal/store/context.go
+++ b/internal/store/context.go
@@ -29,6 +29,12 @@ const (
SharedKGKey contextKey = "goclaw_shared_kg"
// ShellDenyGroupsKey holds per-agent shell deny group overrides.
ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups"
+ // TenantIDKey is the context key for the tenant UUID.
+ TenantIDKey contextKey = "goclaw_tenant_id"
+ // CrossTenantKey indicates the caller has cross-tenant access (owner/system admin).
+ CrossTenantKey contextKey = "goclaw_cross_tenant"
+ // TenantSlugKey stores the tenant's URL-safe slug for filesystem paths.
+ TenantSlugKey contextKey = "goclaw_tenant_slug"
)
// WithShellDenyGroups returns a new context with shell deny group overrides.
@@ -159,3 +165,42 @@ func LocaleFromContext(ctx context.Context) string {
}
return "en"
}
+
+// WithTenantID returns a new context with the given tenant UUID.
+func WithTenantID(ctx context.Context, id uuid.UUID) context.Context {
+ return context.WithValue(ctx, TenantIDKey, id)
+}
+
+// TenantIDFromContext extracts the tenant UUID from context.
+// Returns uuid.Nil if not set (fail-closed — callers must check).
+func TenantIDFromContext(ctx context.Context) uuid.UUID {
+ if v, ok := ctx.Value(TenantIDKey).(uuid.UUID); ok {
+ return v
+ }
+ return uuid.Nil
+}
+
+// WithCrossTenant returns a context flagged for cross-tenant access.
+// Used by owner/system admin callers who can access all tenants.
+func WithCrossTenant(ctx context.Context) context.Context {
+ return context.WithValue(ctx, CrossTenantKey, true)
+}
+
+// IsCrossTenant returns true if the caller has cross-tenant access.
+func IsCrossTenant(ctx context.Context) bool {
+ v, _ := ctx.Value(CrossTenantKey).(bool)
+ return v
+}
+
+// WithTenantSlug returns a new context with the given tenant slug.
+func WithTenantSlug(ctx context.Context, slug string) context.Context {
+ return context.WithValue(ctx, TenantSlugKey, slug)
+}
+
+// TenantSlugFromContext extracts the tenant slug from context. Returns "" if not set.
+func TenantSlugFromContext(ctx context.Context) string {
+ if v, ok := ctx.Value(TenantSlugKey).(string); ok {
+ return v
+ }
+ return ""
+}
diff --git a/internal/store/cron_store.go b/internal/store/cron_store.go
index 29e942f9..a5e4bb24 100644
--- a/internal/store/cron_store.go
+++ b/internal/store/cron_store.go
@@ -1,10 +1,16 @@
package store
-import "time"
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+)
// CronJob represents a scheduled job.
type CronJob struct {
ID string `json:"id"`
+ TenantID uuid.UUID `json:"tenantId,omitempty"`
Name string `json:"name"`
AgentID string `json:"agentId,omitempty"`
UserID string `json:"userId,omitempty"`
@@ -84,19 +90,20 @@ type CronEvent struct {
Action string `json:"action"` // "running", "completed", "error"
JobID string `json:"jobId"`
JobName string `json:"jobName,omitempty"`
+ UserID string `json:"userId,omitempty"` // job owner for event filtering
Status string `json:"status,omitempty"` // final status for completed/error
Error string `json:"error,omitempty"`
}
// CronStore manages scheduled jobs.
type CronStore interface {
- AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error)
- GetJob(jobID string) (*CronJob, bool)
- ListJobs(includeDisabled bool, agentID, userID string) []CronJob
- RemoveJob(jobID string) error
- UpdateJob(jobID string, patch CronJobPatch) (*CronJob, error)
- EnableJob(jobID string, enabled bool) error
- GetRunLog(jobID string, limit, offset int) ([]CronRunLogEntry, int)
+ AddJob(ctx context.Context, name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error)
+ GetJob(ctx context.Context, jobID string) (*CronJob, bool)
+ ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []CronJob
+ RemoveJob(ctx context.Context, jobID string) error
+ UpdateJob(ctx context.Context, jobID string, patch CronJobPatch) (*CronJob, error)
+ EnableJob(ctx context.Context, jobID string, enabled bool) error
+ GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]CronRunLogEntry, int)
Status() map[string]any
// Lifecycle
@@ -106,7 +113,7 @@ type CronStore interface {
// Job execution
SetOnJob(handler func(job *CronJob) (*CronJobResult, error))
SetOnEvent(handler func(event CronEvent))
- RunJob(jobID string, force bool) (ran bool, reason string, err error)
+ RunJob(ctx context.Context, jobID string, force bool) (ran bool, reason string, err error)
SetDefaultTimezone(tz string)
// Due job detection (for scheduler)
diff --git a/internal/store/custom_tool_store.go b/internal/store/custom_tool_store.go
deleted file mode 100644
index eb054198..00000000
--- a/internal/store/custom_tool_store.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package store
-
-import (
- "context"
- "encoding/json"
-
- "github.com/google/uuid"
-)
-
-// CustomToolDef represents a custom tool definition in the database.
-type CustomToolDef struct {
- BaseModel
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters json.RawMessage `json:"parameters"`
- Command string `json:"command"`
- WorkingDir string `json:"working_dir,omitempty"`
- TimeoutSeconds int `json:"timeout_seconds"`
- Env []byte `json:"-"` // encrypted JSONB — never serialized to API
- AgentID *uuid.UUID `json:"agent_id,omitempty"`
- Enabled bool `json:"enabled"`
- CreatedBy string `json:"created_by"`
-}
-
-// CustomToolListOpts configures custom tool listing with optional pagination and filtering.
-type CustomToolListOpts struct {
- AgentID *uuid.UUID
- Search string
- Limit int
- Offset int
-}
-
-// CustomToolStore manages custom tool definitions.
-type CustomToolStore interface {
- Create(ctx context.Context, def *CustomToolDef) error
- Get(ctx context.Context, id uuid.UUID) (*CustomToolDef, error)
- Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
- Delete(ctx context.Context, id uuid.UUID) error
- ListGlobal(ctx context.Context) ([]CustomToolDef, error)
- ListByAgent(ctx context.Context, agentID uuid.UUID) ([]CustomToolDef, error)
- ListAll(ctx context.Context) ([]CustomToolDef, error)
- ListPaged(ctx context.Context, opts CustomToolListOpts) ([]CustomToolDef, error)
- CountTools(ctx context.Context, opts CustomToolListOpts) (int, error)
-}
diff --git a/internal/store/mcp_store.go b/internal/store/mcp_store.go
index bd673a33..f736f2f5 100644
--- a/internal/store/mcp_store.go
+++ b/internal/store/mcp_store.go
@@ -76,6 +76,13 @@ type MCPAccessInfo struct {
ToolDeny []string `json:"tool_deny,omitempty"` // effective deny list
}
+// MCPUserCredentials holds per-user credential overrides for an MCP server.
+type MCPUserCredentials struct {
+ APIKey string `json:"api_key,omitempty"` // decrypted
+ Headers map[string]string `json:"headers,omitempty"` // decrypted
+ Env map[string]string `json:"env,omitempty"` // decrypted
+}
+
// MCPServerStore manages MCP server configs and access grants.
type MCPServerStore interface {
// Server CRUD
@@ -106,4 +113,9 @@ type MCPServerStore interface {
CreateRequest(ctx context.Context, req *MCPAccessRequest) error
ListPendingRequests(ctx context.Context) ([]MCPAccessRequest, error)
ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error
+
+ // Per-user credentials
+ GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*MCPUserCredentials, error)
+ SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds MCPUserCredentials) error
+ DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error
}
diff --git a/internal/store/pairing_store.go b/internal/store/pairing_store.go
index 6f824628..2427b4ad 100644
--- a/internal/store/pairing_store.go
+++ b/internal/store/pairing_store.go
@@ -1,5 +1,7 @@
package store
+import "context"
+
// PairingRequest represents a pending pairing code.
type PairingRequestData struct {
Code string `json:"code"`
@@ -24,11 +26,11 @@ type PairedDeviceData struct {
// PairingStore manages device pairing.
type PairingStore interface {
- RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error)
- ApprovePairing(code, approvedBy string) (*PairedDeviceData, error)
- DenyPairing(code string) error
- RevokePairing(senderID, channel string) error
- IsPaired(senderID, channel string) (bool, error)
- ListPending() []PairingRequestData
- ListPaired() []PairedDeviceData
+ RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error)
+ ApprovePairing(ctx context.Context, code, approvedBy string) (*PairedDeviceData, error)
+ DenyPairing(ctx context.Context, code string) error
+ RevokePairing(ctx context.Context, senderID, channel string) error
+ IsPaired(ctx context.Context, senderID, channel string) (bool, error)
+ ListPending(ctx context.Context) []PairingRequestData
+ ListPaired(ctx context.Context) []PairedDeviceData
}
diff --git a/internal/store/pg/activity_store.go b/internal/store/pg/activity_store.go
index bbb3cc0d..05d9e584 100644
--- a/internal/store/pg/activity_store.go
+++ b/internal/store/pg/activity_store.go
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -20,17 +22,21 @@ func NewPGActivityStore(db *sql.DB) *PGActivityStore {
}
func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
_, err := s.db.ExecContext(ctx,
- `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address)
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
entry.ActorType, entry.ActorID, entry.Action,
- entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress,
+ entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID,
)
return err
}
func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) {
- where, args := buildActivityWhere(opts)
+ where, args := buildActivityWhere(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
@@ -61,7 +67,7 @@ func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts)
}
func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) {
- where, args := buildActivityWhere(opts)
+ where, args := buildActivityWhere(ctx, opts)
query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where)
var count int
@@ -69,11 +75,20 @@ func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts
return count, err
}
-func buildActivityWhere(opts store.ActivityListOpts) (string, []any) {
+func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) {
var conditions []string
var args []any
idx := 1
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx))
+ args = append(args, tenantID)
+ idx++
+ }
+ }
+
if opts.ActorType != "" {
conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx))
args = append(args, opts.ActorType)
diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go
index e08d9bfa..1614b3bc 100644
--- a/internal/store/pg/agent_links.go
+++ b/internal/store/pg/agent_links.go
@@ -55,18 +55,31 @@ func (s *PGAgentLinkStore) CreateLink(ctx context.Context, link *store.AgentLink
settings = json.RawMessage(`{}`)
}
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_links (id, source_agent_id, target_agent_id, direction, team_id, description,
- max_concurrent, settings, status, created_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
+ max_concurrent, settings, status, created_by, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
link.ID, link.SourceAgentID, link.TargetAgentID, link.Direction, link.TeamID, link.Description,
- link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now,
+ link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, tenantID,
)
return err
}
func (s *PGAgentLinkStore) DeleteLink(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for delete")
+ }
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tid)
return err
}
@@ -75,16 +88,33 @@ func (s *PGAgentLinkStore) UpdateLink(ctx context.Context, id uuid.UUID, updates
return nil
}
updates["updated_at"] = time.Now()
- return execMapUpdate(ctx, s.db, "agent_links", id, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "agent_links", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "agent_links", updates, id, tid)
}
func (s *PGAgentLinkStore) GetLink(ctx context.Context, id uuid.UUID) (*store.AgentLinkData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id)
+ return scanLinkRow(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, fmt.Errorf("link not found: %w", sql.ErrNoRows)
+ }
row := s.db.QueryRowContext(ctx,
- `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id)
+ `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tenantID)
return scanLinkRow(row)
}
func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) {
+ tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.source_agent_id = $1")
rows, err := s.db.QueryContext(ctx,
`SELECT `+linkSelectColsJoined+`,
sa.agent_key AS source_agent_key,
@@ -98,8 +128,8 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID)
JOIN agents sa ON sa.id = l.source_agent_id
JOIN agents ta ON ta.id = l.target_agent_id
LEFT JOIN agent_teams tm ON tm.id = l.team_id
- WHERE l.source_agent_id = $1
- ORDER BY l.created_at`, agentID)
+ WHERE `+tenantClause+`
+ ORDER BY l.created_at`, qArgs...)
if err != nil {
return nil, err
}
@@ -108,6 +138,7 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID)
}
func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) {
+ tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.target_agent_id = $1")
rows, err := s.db.QueryContext(ctx,
`SELECT `+linkSelectColsJoined+`,
sa.agent_key AS source_agent_key,
@@ -121,8 +152,8 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) (
JOIN agents sa ON sa.id = l.source_agent_id
JOIN agents ta ON ta.id = l.target_agent_id
LEFT JOIN agent_teams tm ON tm.id = l.team_id
- WHERE l.target_agent_id = $1
- ORDER BY l.created_at`, agentID)
+ WHERE `+tenantClause+`
+ ORDER BY l.created_at`, qArgs...)
if err != nil {
return nil, err
}
@@ -130,6 +161,22 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) (
return scanLinkRowsJoined(rows)
}
+// linkTenantClause builds the WHERE clause for agent_links queries.
+// baseCondition must use $1 for the agentID parameter.
+// Returns the full WHERE clause and args slice.
+func linkTenantClause(ctx context.Context, agentID uuid.UUID, baseCondition string) (string, []any) {
+ args := []any{agentID}
+ if store.IsCrossTenant(ctx) {
+ return baseCondition, args
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ // fail-closed: return impossible condition
+ return baseCondition + " AND l.tenant_id = $2", append(args, uuid.Nil)
+ }
+ return baseCondition + " AND l.tenant_id = $2", append(args, tenantID)
+}
+
func (s *PGAgentLinkStore) CanDelegate(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx,
diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go
index 4ac5e869..e66774f2 100644
--- a/internal/store/pg/agents.go
+++ b/internal/store/pg/agents.go
@@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
- agent_type, is_default, status, budget_monthly_cents, created_at, updated_at`
+ agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id`
func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
if agent.ID == uuid.Nil {
@@ -113,18 +113,22 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
now := time.Now()
agent.CreatedAt = now
agent.UpdatedAt = now
+ tenantID := agent.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
_, err := s.db.ExecContext(ctx,
`INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model,
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
- agent_type, is_default, status, budget_monthly_cents, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
+ agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`,
agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model,
agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig),
- agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now,
+ agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID,
)
if err != nil {
return err
@@ -138,9 +142,20 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
}
func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) {
- row := s.db.QueryRowContext(ctx,
- `SELECT `+agentSelectCols+`
- FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey)
+ var row *sql.Row
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: %s", agentKey)
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE agent_key = $1 AND deleted_at IS NULL AND tenant_id = $2`, agentKey, tid)
+ }
d, err := scanAgentRow(row)
if err != nil {
return nil, fmt.Errorf("agent not found: %s", agentKey)
@@ -149,9 +164,20 @@ func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.Ag
}
func (s *PGAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
- row := s.db.QueryRowContext(ctx,
- `SELECT `+agentSelectCols+`
- FROM agents WHERE id = $1 AND deleted_at IS NULL`, id)
+ var row *sql.Row
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE id = $1 AND deleted_at IS NULL`, id)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: %s", id)
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2`, id, tid)
+ }
d, err := scanAgentRow(row)
if err != nil {
return nil, fmt.Errorf("agent not found: %s", id)
@@ -165,20 +191,39 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
return nil
}
- // If setting this agent as default, unset any existing default first.
+ // If setting this agent as default, unset any existing default first (scoped to same tenant).
if v, ok := updates["is_default"]; ok {
if isDefault, _ := v.(bool); isDefault {
- if _, err := s.db.ExecContext(ctx,
- "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil {
- slog.Warn("agents.unset_default", "error", err)
+ if store.IsCrossTenant(ctx) {
+ if _, err := s.db.ExecContext(ctx,
+ "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil {
+ slog.Warn("agents.unset_default", "error", err)
+ }
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ if _, err := s.db.ExecContext(ctx,
+ "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL AND tenant_id = $2", id, tid); err != nil {
+ slog.Warn("agents.unset_default", "error", err)
+ }
+ }
}
}
}
updates["updated_at"] = time.Now()
- err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id)
- if err != nil {
- return err
+ if store.IsCrossTenant(ctx) {
+ if err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id); err != nil {
+ return err
+ }
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("agent not found: %s", id)
+ }
+ if err := execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid); err != nil {
+ return err
+ }
}
// Regenerate embedding when frontmatter changes
@@ -194,22 +239,37 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
}
func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("agent not found: %s", id)
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1 AND tenant_id = $2", id, tid)
return err
}
func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) {
- var rows *sql.Rows
- var err error
+ q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL`
+ var args []any
+ argIdx := 1
+
if ownerID != "" {
- rows, err = s.db.QueryContext(ctx,
- `SELECT `+agentSelectCols+`
- FROM agents WHERE deleted_at IS NULL AND owner_id = $1 ORDER BY created_at DESC`, ownerID)
- } else {
- rows, err = s.db.QueryContext(ctx,
- `SELECT `+agentSelectCols+`
- FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC`)
+ q += fmt.Sprintf(" AND owner_id = $%d", argIdx)
+ args = append(args, ownerID)
+ argIdx++
}
+
+ if clause, targs, err := tenantClauseN(ctx, argIdx); err == nil && clause != "" {
+ q += clause
+ args = append(args, targs...)
+ argIdx++
+ }
+
+ q += " ORDER BY created_at DESC"
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -218,10 +278,21 @@ func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentD
}
func (s *PGAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE deleted_at IS NULL
+ ORDER BY is_default DESC, created_at ASC LIMIT 1`)
+ return scanAgentRow(row)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("agent not found: default")
+ }
row := s.db.QueryRowContext(ctx,
`SELECT `+agentSelectCols+`
- FROM agents WHERE deleted_at IS NULL
- ORDER BY is_default DESC, created_at ASC LIMIT 1`)
+ FROM agents WHERE deleted_at IS NULL AND tenant_id = $1
+ ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid)
return scanAgentRow(row)
}
@@ -234,24 +305,43 @@ func (s *PGAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID
if err := store.ValidateUserID(grantedBy); err != nil {
return err
}
+ tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, created_at)
- VALUES ($1, $2, $3, $4, $5, $6)
+ `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (agent_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`,
- store.GenNewID(), agentID, userID, role, grantedBy, time.Now(),
+ store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(),
)
return err
}
func (s *PGAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error {
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx,
+ "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
_, err := s.db.ExecContext(ctx,
- "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID)
+ "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid)
return err
}
func (s *PGAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) {
- rows, err := s.db.QueryContext(ctx,
- "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1", agentID)
+ q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1"
+ args := []any{agentID}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = $2"
+ args = append(args, tid)
+ }
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -272,9 +362,20 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID
// Check ownership + default flag
var ownerID string
var isDefault bool
- err := s.db.QueryRowContext(ctx,
- "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID,
- ).Scan(&ownerID, &isDefault)
+ var err error
+ if store.IsCrossTenant(ctx) {
+ err = s.db.QueryRowContext(ctx,
+ "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID,
+ ).Scan(&ownerID, &isDefault)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return false, "", fmt.Errorf("agent not found")
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2", agentID, tid,
+ ).Scan(&ownerID, &isDefault)
+ }
if err != nil {
return false, "", fmt.Errorf("agent not found")
}
@@ -289,9 +390,19 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID
}
// Check shares
var role string
- err = s.db.QueryRowContext(ctx,
- "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID,
- ).Scan(&role)
+ if store.IsCrossTenant(ctx) {
+ err = s.db.QueryRowContext(ctx,
+ "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID,
+ ).Scan(&role)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return false, "", nil
+ }
+ err = s.db.QueryRowContext(ctx,
+ "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid,
+ ).Scan(&role)
+ }
if err != nil {
return false, "", nil
}
@@ -299,18 +410,49 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID
}
func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) {
+ if store.IsCrossTenant(ctx) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents
+ WHERE deleted_at IS NULL AND (
+ owner_id = $1
+ OR is_default = true
+ OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1)
+ OR (
+ agent_type = 'predefined'
+ AND id IN (
+ SELECT agent_id FROM channel_instances ci
+ WHERE ci.enabled = true
+ AND EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af
+ WHERE af = $1
+ )
+ )
+ )
+ )
+ ORDER BY created_at DESC`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents
- WHERE deleted_at IS NULL AND (
+ WHERE deleted_at IS NULL AND tenant_id = $2 AND (
owner_id = $1
OR is_default = true
- OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1)
+ OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1 AND tenant_id = $2)
OR (
agent_type = 'predefined'
AND id IN (
SELECT agent_id FROM channel_instances ci
- WHERE ci.enabled = true
+ WHERE ci.enabled = true AND ci.tenant_id = $2
AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af
WHERE af = $1
@@ -318,7 +460,7 @@ func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]sto
)
)
)
- ORDER BY created_at DESC`, userID)
+ ORDER BY created_at DESC`, userID, tid)
if err != nil {
return nil, err
}
@@ -340,7 +482,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
&d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
- &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt)
+ &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt, &d.TenantID)
if err != nil {
return nil, err
}
@@ -443,3 +585,29 @@ func replaceIDX(s, replacement string) string {
}
return result.String()
}
+
+// execMapUpdateWhereTenant is like execMapUpdateWhere but appends an AND tenant_id = $N filter.
+// Column names are validated to prevent SQL injection.
+func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error {
+ if len(updates) == 0 {
+ return nil
+ }
+ var setClauses []string
+ var args []any
+ i := 1
+ for col, val := range updates {
+ if !validColumnName.MatchString(col) {
+ slog.Warn("security.invalid_column_name", "table", table, "column", col)
+ return fmt.Errorf("invalid column name: %q", col)
+ }
+ setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, i))
+ args = append(args, val)
+ i++
+ }
+ // $i = id, $i+1 = tenantID
+ args = append(args, id, tenantID)
+ q := fmt.Sprintf("UPDATE %s SET %s WHERE id = $%d AND tenant_id = $%d",
+ table, joinStrings(setClauses, ", "), i, i+1)
+ _, err := db.ExecContext(ctx, q, args...)
+ return err
+}
diff --git a/internal/store/pg/agents_batch.go b/internal/store/pg/agents_batch.go
new file mode 100644
index 00000000..4d999164
--- /dev/null
+++ b/internal/store/pg/agents_batch.go
@@ -0,0 +1,76 @@
+package pg
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/lib/pq"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// GetByKeys returns agents matching the given keys in a single query.
+// Results are tenant-scoped unless cross-tenant context is set.
+func (s *PGAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) {
+ if len(keys) == 0 {
+ return nil, nil
+ }
+
+ var rows *sql.Rows
+ var err error
+
+ if store.IsCrossTenant(ctx) {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL`,
+ pq.Array(keys))
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("no tenant context for batch agent lookup")
+ }
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`,
+ pq.Array(keys), tid)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("batch agent key lookup: %w", err)
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
+
+// GetByIDs returns agents matching the given UUIDs in a single query.
+// Results are tenant-scoped unless cross-tenant context is set.
+func (s *PGAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+
+ var rows *sql.Rows
+ var err error
+
+ if store.IsCrossTenant(ctx) {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE id = ANY($1) AND deleted_at IS NULL`,
+ pq.Array(ids))
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("no tenant context for batch agent lookup")
+ }
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT `+agentSelectCols+`
+ FROM agents WHERE id = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`,
+ pq.Array(ids), tid)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("batch agent ID lookup: %w", err)
+ }
+ defer rows.Close()
+ return scanAgentRows(rows)
+}
diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go
index abafc779..de4376b0 100644
--- a/internal/store/pg/agents_context.go
+++ b/internal/store/pg/agents_context.go
@@ -17,8 +17,13 @@ import (
// --- Agent-level Context Files ---
func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
- "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1", agentID)
+ "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause,
+ append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -37,10 +42,10 @@ func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UU
func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error {
_, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at)
- VALUES ($1, $2, $3, $4, $5)
+ `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (agent_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`,
- store.GenNewID(), agentID, fileName, content, time.Now(),
+ store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx),
)
return err
}
@@ -48,8 +53,13 @@ func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUI
// --- Per-user Context Files ---
func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
- "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2", agentID, userID)
+ "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause,
+ append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -68,18 +78,22 @@ func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUI
func (s *PGAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error {
_, err := s.db.ExecContext(ctx,
- `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6)
+ `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`,
- store.GenNewID(), agentID, userID, fileName, content, time.Now(),
+ store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx),
)
return err
}
func (s *PGAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error {
- _, err := s.db.ExecContext(ctx,
- "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3",
- agentID, userID, fileName)
+ tClause, tArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3"+tClause,
+ append([]any{agentID, userID, fileName}, tArgs...)...)
return err
}
@@ -96,11 +110,11 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.
var isInserted bool
var storedWorkspace sql.NullString
err := s.db.QueryRowContext(ctx, `
- INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at)
- VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW())
+ INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id)
+ VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW(), $4)
ON CONFLICT (agent_id, user_id) DO UPDATE SET last_seen_at = NOW()
RETURNING (xmax = 0), workspace
- `, agentID, userID, effectiveWs).Scan(&isInserted, &storedWorkspace)
+ `, agentID, userID, effectiveWs, tenantIDForInsert(ctx)).Scan(&isInserted, &storedWorkspace)
if err != nil {
return false, effectiveWs, err
}
@@ -115,16 +129,25 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.
// Used when admin manually adds a contact as an agent instance via the UI.
func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error {
_, err := s.db.ExecContext(ctx, `
- INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at)
- VALUES ($1, $2, NOW(), NOW())
+ INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id)
+ VALUES ($1, $2, NOW(), NOW(), $3)
ON CONFLICT (agent_id, user_id) DO NOTHING
- `, agentID, userID)
+ `, agentID, userID, tenantIDForInsert(ctx))
return err
}
// --- User Instances ---
func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
+ // Tenant-scope the file count subquery to prevent cross-tenant leakage.
+ subTenantFilter := ""
+ if !store.IsCrossTenant(ctx) {
+ subTenantFilter = " AND tenant_id = $2"
+ }
rows, err := s.db.QueryContext(ctx, `
SELECT p.user_id,
TO_CHAR(p.first_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS first_seen_at,
@@ -135,12 +158,12 @@ func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID)
LEFT JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM user_context_files
- WHERE agent_id = $1
+ WHERE agent_id = $1`+subTenantFilter+`
GROUP BY user_id
) fc ON fc.user_id = p.user_id
- WHERE p.agent_id = $1
+ WHERE p.agent_id = $1`+tClause+`
ORDER BY p.last_seen_at DESC
- `, agentID)
+ `, append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -166,10 +189,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu
if err != nil {
return err
}
+ tClause, tArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return err
+ }
_, err = s.db.ExecContext(ctx,
`UPDATE user_agent_profiles SET metadata = COALESCE(metadata, '{}') || $3::jsonb
- WHERE agent_id = $1 AND user_id = $2`,
- agentID, userID, metaJSON,
+ WHERE agent_id = $1 AND user_id = $2`+tClause,
+ append([]any{agentID, userID, metaJSON}, tArgs...)...,
)
return err
}
@@ -177,10 +204,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu
// --- User Overrides ---
func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
var d store.UserAgentOverrideData
- err := s.db.QueryRowContext(ctx,
- "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2",
- agentID, userID,
+ err = s.db.QueryRowContext(ctx,
+ "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2"+tClause,
+ append([]any{agentID, userID}, tArgs...)...,
).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -193,10 +224,10 @@ func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, u
func (s *PGAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error {
_, err := s.db.ExecContext(ctx,
- `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model)
- VALUES ($1, $2, $3, $4, $5)
+ `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = EXCLUDED.provider, model = EXCLUDED.model`,
- store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model,
+ store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx),
)
return err
}
diff --git a/internal/store/pg/api_keys.go b/internal/store/pg/api_keys.go
index b81ee289..d6248d5f 100644
--- a/internal/store/pg/api_keys.go
+++ b/internal/store/pg/api_keys.go
@@ -3,6 +3,8 @@ package pg
import (
"context"
"database/sql"
+ "fmt"
+ "strings"
"time"
"github.com/google/uuid"
@@ -22,18 +24,26 @@ func NewPGAPIKeyStore(db *sql.DB) *PGAPIKeyStore {
}
func (s *PGAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error {
+ var ownerID *string
+ if key.OwnerID != "" {
+ ownerID = &key.OwnerID
+ }
+ var tenantID *uuid.UUID
+ if key.TenantID != uuid.Nil {
+ tenantID = &key.TenantID
+ }
_, err := s.db.ExecContext(ctx,
- `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, expires_at, created_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
key.ID, key.Name, key.Prefix, key.KeyHash, pq.Array(key.Scopes),
- key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
+ ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt,
)
return err
}
func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) {
row := s.db.QueryRowContext(ctx,
- `SELECT id, name, prefix, key_hash, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at
+ `SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
FROM api_keys
WHERE key_hash = $1 AND NOT revoked AND (expires_at IS NULL OR expires_at > now())`,
keyHash,
@@ -41,9 +51,11 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A
var k store.APIKeyData
var createdBy *string
+ var ownerID *string
+ var tenantID *uuid.UUID
err := row.Scan(
&k.ID, &k.Name, &k.Prefix, &k.KeyHash, pq.Array(&k.Scopes),
- &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
+ &ownerID, &tenantID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
&k.CreatedAt, &k.UpdatedAt,
)
if err != nil {
@@ -52,15 +64,47 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A
if createdBy != nil {
k.CreatedBy = *createdBy
}
+ if ownerID != nil {
+ k.OwnerID = *ownerID
+ }
+ if tenantID != nil {
+ k.TenantID = *tenantID
+ }
return &k, nil
}
-func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) {
+func (s *PGAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) {
+ var conditions []string
+ var args []any
+ argIdx := 1
+
+ if ownerID != "" {
+ conditions = append(conditions, fmt.Sprintf("owner_id = $%d", argIdx))
+ args = append(args, ownerID)
+ argIdx++
+ }
+
+ // Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id)
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("(tenant_id = $%d OR tenant_id IS NULL)", argIdx))
+ args = append(args, tid)
+ argIdx++
+ }
+ }
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+
rows, err := s.db.QueryContext(ctx,
- `SELECT id, name, prefix, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at
- FROM api_keys
+ `SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at
+ FROM api_keys`+where+`
ORDER BY created_at DESC`,
+ args...,
)
if err != nil {
return nil, err
@@ -71,9 +115,11 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) {
for rows.Next() {
var k store.APIKeyData
var createdBy *string
+ var oID *string
+ var tID *uuid.UUID
if err := rows.Scan(
&k.ID, &k.Name, &k.Prefix, pq.Array(&k.Scopes),
- &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
+ &oID, &tID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy,
&k.CreatedAt, &k.UpdatedAt,
); err != nil {
return nil, err
@@ -81,16 +127,36 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) {
if createdBy != nil {
k.CreatedBy = *createdBy
}
+ if oID != nil {
+ k.OwnerID = *oID
+ }
+ if tID != nil {
+ k.TenantID = *tID
+ }
keys = append(keys, k)
}
return keys, rows.Err()
}
-func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error {
- res, err := s.db.ExecContext(ctx,
- `UPDATE api_keys SET revoked = true, updated_at = $2 WHERE id = $1`,
- id, time.Now(),
- )
+func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error {
+ q := "UPDATE api_keys SET revoked = true, updated_at = $1 WHERE id = $2"
+ args := []any{time.Now(), id}
+ argIdx := 3
+
+ if ownerID != "" {
+ q += fmt.Sprintf(" AND owner_id = $%d", argIdx)
+ args = append(args, ownerID)
+ argIdx++
+ }
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx)
+ args = append(args, tid)
+ }
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
@@ -101,10 +167,25 @@ func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error {
return nil
}
-func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID) error {
- res, err := s.db.ExecContext(ctx,
- `DELETE FROM api_keys WHERE id = $1`, id,
- )
+func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error {
+ q := "DELETE FROM api_keys WHERE id = $1"
+ args := []any{id}
+ argIdx := 2
+
+ if ownerID != "" {
+ q += fmt.Sprintf(" AND owner_id = $%d", argIdx)
+ args = append(args, ownerID)
+ argIdx++
+ }
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx)
+ args = append(args, tid)
+ }
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go
index d63a4ada..84517b68 100644
--- a/internal/store/pg/channel_contacts.go
+++ b/internal/store/pg/channel_contacts.go
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
+
// PGContactStore implements store.ContactStore backed by Postgres.
type PGContactStore struct {
db *sql.DB
@@ -22,26 +23,39 @@ func NewPGContactStore(db *sql.DB) *PGContactStore {
}
func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
_, err := s.db.ExecContext(ctx, `
- INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind)
- VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''))
- ON CONFLICT (channel_type, sender_id) DO UPDATE SET
+ INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id)
+ VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8)
+ ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET
display_name = COALESCE(NULLIF($5,''), channel_contacts.display_name),
username = COALESCE(NULLIF($6,''), channel_contacts.username),
user_id = COALESCE(NULLIF($4,''), channel_contacts.user_id),
channel_instance = COALESCE(NULLIF($2,''), channel_contacts.channel_instance),
peer_kind = COALESCE(NULLIF($7,''), channel_contacts.peer_kind),
last_seen_at = NOW()`,
- channelType, channelInstance, senderID, userID, displayName, username, peerKind,
+ channelType, channelInstance, senderID, userID, displayName, username, peerKind, tenantID,
)
return err
}
-func contactWhereClause(opts store.ContactListOpts) (string, []any, int) {
+func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string, []any, int) {
var conditions []string
var args []any
argIdx := 1
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx))
+ args = append(args, tenantID)
+ argIdx++
+ }
+ }
+
if opts.ChannelType != "" {
conditions = append(conditions, fmt.Sprintf("channel_type = $%d", argIdx))
args = append(args, opts.ChannelType)
@@ -71,7 +85,7 @@ func contactWhereClause(opts store.ContactListOpts) (string, []any, int) {
}
func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
- where, args, argIdx := contactWhereClause(opts)
+ where, args, argIdx := contactWhereClause(ctx, opts)
query := `SELECT id, channel_type, channel_instance, sender_id, user_id,
display_name, username, avatar_url, peer_kind, merged_id,
@@ -113,7 +127,7 @@ func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactLis
}
func (s *PGContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) {
- where, args, _ := contactWhereClause(opts)
+ where, args, _ := contactWhereClause(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count)
return count, err
@@ -160,12 +174,30 @@ func (s *PGContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs [
return result, rows.Err()
}
-func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error {
- if len(contactIDs) < 2 {
+func (s *PGContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) {
+ tid := store.TenantIDFromContext(ctx)
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, channel_type, channel_instance, sender_id, user_id,
+ display_name, username, avatar_url, peer_kind, merged_id,
+ first_seen_at, last_seen_at
+ FROM channel_contacts WHERE id = $1 AND tenant_id = $2`, id, tid)
+ var c store.ChannelContact
+ if err := row.Scan(
+ &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
+ &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
+ &c.FirstSeenAt, &c.LastSeenAt,
+ ); err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error {
+ if len(contactIDs) == 0 {
return nil
}
+ tid := store.TenantIDFromContext(ctx)
- // Check if any of the contacts already has a merged_id; reuse it.
placeholders := make([]string, len(contactIDs))
args := make([]any, len(contactIDs))
for i, id := range contactIDs {
@@ -174,22 +206,65 @@ func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UU
}
inClause := strings.Join(placeholders, ",")
- var existingMergedID *uuid.UUID
- err := s.db.QueryRowContext(ctx,
- fmt.Sprintf("SELECT merged_id FROM channel_contacts WHERE id IN (%s) AND merged_id IS NOT NULL LIMIT 1", inClause),
- args...,
- ).Scan(&existingMergedID)
-
- mergedID := uuid.New()
- if err == nil && existingMergedID != nil {
- mergedID = *existingMergedID
- }
-
- // Update all contacts with the merged_id.
- args = append(args, mergedID)
- _, err = s.db.ExecContext(ctx,
- fmt.Sprintf("UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s)", len(args), inClause),
- args...,
+ // $N+1 = tenantUserID, $N+2 = tenant_id
+ args = append(args, tenantUserID, tid)
+ q := fmt.Sprintf(
+ "UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s) AND tenant_id = $%d",
+ len(args)-1, inClause, len(args),
)
+ _, err := s.db.ExecContext(ctx, q, args...)
return err
}
+
+func (s *PGContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error {
+ if len(contactIDs) == 0 {
+ return nil
+ }
+ tid := store.TenantIDFromContext(ctx)
+
+ placeholders := make([]string, len(contactIDs))
+ args := make([]any, len(contactIDs))
+ for i, id := range contactIDs {
+ placeholders[i] = fmt.Sprintf("$%d", i+1)
+ args[i] = id
+ }
+ inClause := strings.Join(placeholders, ",")
+
+ args = append(args, tid)
+ q := fmt.Sprintf(
+ "UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = $%d",
+ inClause, len(args),
+ )
+ _, err := s.db.ExecContext(ctx, q, args...)
+ return err
+}
+
+func (s *PGContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) {
+ tid := store.TenantIDFromContext(ctx)
+
+ q := `SELECT id, channel_type, channel_instance, sender_id, user_id,
+ display_name, username, avatar_url, peer_kind, merged_id,
+ first_seen_at, last_seen_at
+ FROM channel_contacts WHERE merged_id = $1 AND tenant_id = $2
+ ORDER BY last_seen_at DESC`
+
+ rows, err := s.db.QueryContext(ctx, q, mergedID, tid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var contacts []store.ChannelContact
+ for rows.Next() {
+ var c store.ChannelContact
+ if err := rows.Scan(
+ &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID,
+ &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID,
+ &c.FirstSeenAt, &c.LastSeenAt,
+ ); err != nil {
+ return nil, err
+ }
+ contacts = append(contacts, c)
+ }
+ return contacts, rows.Err()
+}
diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go
index 15ee29d0..6807ce2f 100644
--- a/internal/store/pg/channel_instances.go
+++ b/internal/store/pg/channel_instances.go
@@ -28,7 +28,7 @@ func NewPGChannelInstanceStore(db *sql.DB, encryptionKey string) *PGChannelInsta
}
const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id,
- credentials, config, enabled, created_by, created_at, updated_at`
+ credentials, config, enabled, created_by, created_at, updated_at, tenant_id`
func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error {
if err := store.ValidateUserID(inst.CreatedBy); err != nil {
@@ -54,26 +54,49 @@ func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.Channel
inst.CreatedAt = now
inst.UpdatedAt = now
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
_, err := s.db.ExecContext(ctx,
`INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id,
- credentials, config, enabled, created_by, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
+ credentials, config, enabled, created_by, created_at, updated_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID,
credsBytes, jsonOrEmpty(inst.Config),
- inst.Enabled, inst.CreatedBy, now, now,
+ inst.Enabled, inst.CreatedBy, now, now, tenantID,
)
return err
}
func (s *PGChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id)
+ return s.scanInstance(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
row := s.db.QueryRowContext(ctx,
- `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id)
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1 AND tenant_id = $2`, id, tenantID)
return s.scanInstance(row)
}
func (s *PGChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name)
+ return s.scanInstance(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
row := s.db.QueryRowContext(ctx,
- `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name)
+ `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1 AND tenant_id = $2`, name, tenantID)
return s.scanInstance(row)
}
@@ -86,7 +109,7 @@ func (s *PGChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInsta
err := row.Scan(
&inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
&creds, &config,
- &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt,
+ &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID,
)
if err != nil {
return nil, err
@@ -124,7 +147,7 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI
if err := rows.Scan(
&inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID,
&creds, &config,
- &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt,
+ &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID,
); err != nil {
continue
}
@@ -196,7 +219,14 @@ func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updat
updates["credentials"] = credsBytes
}
updates["updated_at"] = time.Now()
- return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
}
// loadExistingCreds reads and decrypts the current credentials for merging.
@@ -222,13 +252,31 @@ func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.
}
func (s *PGChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1 AND tenant_id = $2", id, tid)
return err
}
func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = true ORDER BY name`)
+ query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = true`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ query += ` AND tenant_id = $1`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
@@ -236,19 +284,38 @@ func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.Chann
}
func (s *PGChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`)
+ query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ query += ` WHERE tenant_id = $1`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
return s.scanInstances(rows)
}
-func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []any) {
+func buildChannelInstanceWhere(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) {
var conditions []string
var args []any
argIdx := 1
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx))
+ args = append(args, tenantID)
+ argIdx++
+ }
+ }
+
if opts.Search != "" {
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR display_name ILIKE $%d ESCAPE '\\' OR channel_type ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx))
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
@@ -263,7 +330,7 @@ func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []an
}
func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
- where, args := buildChannelInstanceWhere(opts)
+ where, args := buildChannelInstanceWhere(ctx, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
@@ -279,7 +346,7 @@ func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.Chann
}
func (s *PGChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) {
- where, args := buildChannelInstanceWhere(opts)
+ where, args := buildChannelInstanceWhere(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count)
return count, err
diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go
index 042199bb..5dfe9d75 100644
--- a/internal/store/pg/config_permissions.go
+++ b/internal/store/pg/config_permissions.go
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "fmt"
"strconv"
"strings"
"sync"
@@ -65,7 +66,11 @@ func (s *PGConfigPermissionStore) InvalidateCache() {
// CheckPermission evaluates deny-first, allow-second permission with Go-level wildcard matching.
func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) {
- cacheKey := agentID.String() + ":" + userID
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ cacheKey := tid.String() + ":" + agentID.String() + ":" + userID
// Check cache.
s.mu.RLock()
@@ -76,10 +81,14 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u
s.mu.RUnlock()
// Fetch from DB.
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return false, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT scope, config_type, permission FROM agent_config_permissions
- WHERE agent_id = $1 AND user_id = $2`,
- agentID, userID,
+ WHERE agent_id = $1 AND user_id = $2`+tClause,
+ append([]any{agentID, userID}, tArgs...)...,
)
if err != nil {
return false, err
@@ -146,14 +155,14 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
+ `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8,$9)
ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET
permission = EXCLUDED.permission,
granted_by = EXCLUDED.granted_by,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`,
- perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now,
+ perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, tenantIDForInsert(ctx),
)
if err == nil {
s.InvalidateCache()
@@ -162,9 +171,13 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP
}
func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error {
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`,
- agentID, scope, configType, userID,
+ tClause, tArgs, err := tenantClauseN(ctx, 5)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`+tClause,
+ append([]any{agentID, scope, configType, userID}, tArgs...)...,
)
if err == nil {
s.InvalidateCache()
@@ -173,6 +186,10 @@ func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID,
}
func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 0) // paramN unused; we append manually
+ if err != nil {
+ return nil, err
+ }
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions WHERE agent_id = $1`
args := []any{agentID}
@@ -185,6 +202,10 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
args = append(args, scope)
query += ` AND scope = $` + itoa(len(args))
}
+ if tClause != "" {
+ args = append(args, tArgs...)
+ query += fmt.Sprintf(" AND tenant_id = $%d", len(args))
+ }
query += ` ORDER BY created_at`
rows, err := s.db.QueryContext(ctx, query, args...)
@@ -199,7 +220,11 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope.
// Hot-path: called during system prompt injection for every group message.
func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
- cacheKey := agentID.String() + ":" + scope
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ cacheKey := tid.String() + ":" + agentID.String() + ":" + scope
s.fwMu.RLock()
if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
@@ -208,12 +233,16 @@ func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID u
}
s.fwMu.RUnlock()
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
FROM agent_config_permissions
- WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'
+ WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'`+tClause+`
ORDER BY created_at`,
- agentID, scope,
+ append([]any{agentID, scope}, tArgs...)...,
)
if err != nil {
return nil, err
diff --git a/internal/store/pg/config_secrets.go b/internal/store/pg/config_secrets.go
index e5281239..fb222ff4 100644
--- a/internal/store/pg/config_secrets.go
+++ b/internal/store/pg/config_secrets.go
@@ -21,9 +21,10 @@ func NewPGConfigSecretsStore(db *sql.DB, encryptionKey string) *PGConfigSecretsS
}
func (s *PGConfigSecretsStore) Get(ctx context.Context, key string) (string, error) {
+ tid := tenantIDForInsert(ctx) // fallback to master
var value []byte
err := s.db.QueryRowContext(ctx,
- `SELECT value FROM config_secrets WHERE key = $1`, key).Scan(&value)
+ `SELECT value FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid).Scan(&value)
if err != nil {
return "", err
}
@@ -50,21 +51,24 @@ func (s *PGConfigSecretsStore) Set(ctx context.Context, key, value string) error
stored = []byte(value)
}
+ tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
- `INSERT INTO config_secrets (key, value, updated_at) VALUES ($1, $2, $3)
- ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = $3`,
- key, stored, time.Now(),
+ `INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (key, tenant_id) DO UPDATE SET value = $2, updated_at = $3`,
+ key, stored, time.Now(), tid,
)
return err
}
func (s *PGConfigSecretsStore) Delete(ctx context.Context, key string) error {
- _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1`, key)
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid)
return err
}
func (s *PGConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) {
- rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets`)
+ tid := tenantIDForInsert(ctx)
+ rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets WHERE tenant_id = $1`, tid)
if err != nil {
return nil, err
}
diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go
index 4e695ab7..bebe05e1 100644
--- a/internal/store/pg/cron_crud.go
+++ b/internal/store/pg/cron_crud.go
@@ -1,8 +1,10 @@
package pg
import (
+ "context"
"encoding/json"
"fmt"
+ "log/slog"
"time"
"github.com/google/uuid"
@@ -10,7 +12,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
-func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
+func (s *PGCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
// Apply default timezone for cron expressions when not set per-job.
if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" {
schedule.TZ = s.defaultTZ
@@ -65,10 +67,10 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s
nextRun := computeNextRun(&schedule, now, s.defaultTZ)
_, err := s.db.Exec(
- `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ `INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, true, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
- id, agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz,
+ VALUES ($1, $2, $3, $4, $5, true, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
+ id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz,
intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now,
)
if err != nil {
@@ -77,24 +79,24 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s
s.cacheLoaded = false // invalidate cache
- job, _ := s.GetJob(id.String())
+ job, _ := s.GetJob(ctx, id.String())
return job, nil
}
-func (s *PGCronStore) GetJob(jobID string) (*store.CronJob, bool) {
+func (s *PGCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) {
id, err := uuid.Parse(jobID)
if err != nil {
return nil, false
}
- job, err := s.scanJob(id)
+ job, err := s.scanJob(ctx, id)
if err != nil {
return nil, false
}
return job, true
}
-func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []store.CronJob {
- q := `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+func (s *PGCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob {
+ q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE 1=1`
@@ -118,6 +120,16 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s
args = append(args, userID)
argIdx++
}
+ clause, targs, tErr := tenantClauseN(ctx, argIdx)
+ if tErr != nil {
+ slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr)
+ return nil
+ }
+ if clause != "" {
+ q += clause
+ args = append(args, targs...)
+ argIdx++
+ }
q += " ORDER BY created_at DESC"
@@ -138,28 +150,60 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s
return result
}
-func (s *PGCronStore) RemoveJob(jobID string) error {
+func (s *PGCronStore) RemoveJob(ctx context.Context, jobID string) error {
id, err := uuid.Parse(jobID)
if err != nil {
return fmt.Errorf("invalid job ID: %s", jobID)
}
- _, err = s.db.Exec("DELETE FROM cron_jobs WHERE id = $1", id)
+
+ q := "DELETE FROM cron_jobs WHERE id = $1"
+ args := []any{id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
+ if n, _ := res.RowsAffected(); n == 0 {
+ return fmt.Errorf("job not found")
+ }
s.cacheLoaded = false
return nil
}
-func (s *PGCronStore) EnableJob(jobID string, enabled bool) error {
+func (s *PGCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error {
id, err := uuid.Parse(jobID)
if err != nil {
return fmt.Errorf("invalid job ID: %s", jobID)
}
- _, err = s.db.Exec("UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3", enabled, time.Now(), id)
+
+ q := "UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3"
+ args := []any{enabled, time.Now(), id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+
+ res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
+ if n, _ := res.RowsAffected(); n == 0 {
+ return fmt.Errorf("job not found")
+ }
s.cacheLoaded = false
return nil
}
diff --git a/internal/store/pg/cron_exec.go b/internal/store/pg/cron_exec.go
index 9a7bec99..0c8da01c 100644
--- a/internal/store/pg/cron_exec.go
+++ b/internal/store/pg/cron_exec.go
@@ -1,6 +1,7 @@
package pg
import (
+ "context"
"database/sql"
"fmt"
"time"
@@ -10,8 +11,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
-func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) {
- job, ok := s.GetJob(jobID)
+func (s *PGCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) {
+ job, ok := s.GetJob(ctx, jobID)
if !ok {
return false, "", fmt.Errorf("job %s not found", jobID)
}
@@ -32,7 +33,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) {
s.cacheLoaded = false
s.mu.Unlock()
- s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name})
+ s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID})
// Use executeOneJob for proper state updates, run logging, and retry
s.executeOneJob(*job, handler)
@@ -42,7 +43,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) {
return true, "", nil
}
-func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
+func (s *PGCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
if limit <= 0 {
limit = 20
}
@@ -50,24 +51,47 @@ func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRu
offset = 0
}
- const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)"
+ const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)"
+
+ // Build tenant-aware WHERE clause via JOIN with cron_jobs.
+ var tenantJoin, tenantWhere string
+ var tenantArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id"
+ tenantWhere = " AND j.tenant_id = $1"
+ tenantArgs = append(tenantArgs, tid)
+ }
+ }
var total int
var rows *sql.Rows
var err error
+
if jobID != "" {
id, parseErr := uuid.Parse(jobID)
if parseErr != nil {
return nil, 0
}
- s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total)
- rows, err = s.db.Query(
- "SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3",
- id, limit, offset)
+ argIdx := len(tenantArgs) + 1
+ countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = $%d%s", tenantJoin, argIdx, tenantWhere)
+ countArgs := append(tenantArgs, id)
+ s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
+
+ dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = $%d%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d",
+ cols, tenantJoin, argIdx, tenantWhere, argIdx+1, argIdx+2)
+ dataArgs := append(tenantArgs, id, limit, offset)
+ rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
} else {
- s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total)
- rows, err = s.db.Query(
- "SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset)
+ argIdx := len(tenantArgs) + 1
+ countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere)
+ s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total)
+
+ dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d",
+ cols, tenantJoin, tenantWhere, argIdx, argIdx+1)
+ dataArgs := append(tenantArgs, limit, offset)
+ rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
}
if err != nil {
return nil, 0
diff --git a/internal/store/pg/cron_scan.go b/internal/store/pg/cron_scan.go
index 8d9d7ff0..4ea4217a 100644
--- a/internal/store/pg/cron_scan.go
+++ b/internal/store/pg/cron_scan.go
@@ -1,8 +1,10 @@
package pg
import (
+ "context"
"database/sql"
"encoding/json"
+ "fmt"
"time"
"github.com/adhocore/gronx"
@@ -11,11 +13,23 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
-func (s *PGCronStore) scanJob(id uuid.UUID) (*store.CronJob, error) {
- row := s.db.QueryRow(
- `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+// scanJob fetches a single cron job by ID with tenant filtering.
+func (s *PGCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) {
+ q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
- created_at, updated_at FROM cron_jobs WHERE id = $1`, id)
+ created_at, updated_at FROM cron_jobs WHERE id = $1`
+ args := []any{id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+
+ row := s.db.QueryRowContext(ctx, q, args...)
return scanCronSingleRow(row)
}
@@ -27,6 +41,7 @@ type cronRowScanner interface {
func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
var id uuid.UUID
+ var tenantID uuid.UUID
var agentID *uuid.UUID
var userID *string
var name, scheduleKind string
@@ -37,7 +52,7 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
var payloadJSON []byte
var createdAt, updatedAt time.Time
- err := row.Scan(&id, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz,
+ err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz,
&intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError,
&createdAt, &updatedAt)
if err != nil {
@@ -48,9 +63,10 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) {
json.Unmarshal(payloadJSON, &payload)
job := &store.CronJob{
- ID: id.String(),
- Name: name,
- Enabled: enabled,
+ ID: id.String(),
+ TenantID: tenantID,
+ Name: name,
+ Enabled: enabled,
Schedule: store.CronSchedule{
Kind: scheduleKind,
},
diff --git a/internal/store/pg/cron_scheduler.go b/internal/store/pg/cron_scheduler.go
index 442a530c..e8c97c0b 100644
--- a/internal/store/pg/cron_scheduler.go
+++ b/internal/store/pg/cron_scheduler.go
@@ -34,7 +34,7 @@ func (s *PGCronStore) GetDueJobs(now time.Time) []store.CronJob {
// refreshJobCache reloads all enabled jobs from DB. Must be called with mu held.
func (s *PGCronStore) refreshJobCache() {
rows, err := s.db.Query(
- `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+ `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE enabled = true`)
if err != nil {
@@ -244,7 +244,7 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C
}
// Emit completion event
- evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, Status: status}
+ evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status}
if err != nil {
evt.Action = "error"
evt.Error = err.Error()
diff --git a/internal/store/pg/cron_update.go b/internal/store/pg/cron_update.go
index 01e1f545..f845da7e 100644
--- a/internal/store/pg/cron_update.go
+++ b/internal/store/pg/cron_update.go
@@ -12,7 +12,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
-func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
+func (s *PGCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
id, err := uuid.Parse(jobID)
if err != nil {
return nil, fmt.Errorf("invalid job ID: %s", jobID)
@@ -25,14 +25,25 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.
if patch.Enabled != nil {
updates["enabled"] = *patch.Enabled
}
+ // Build tenant WHERE suffix for internal reads (defense-in-depth).
+ tenantSuffix := ""
+ var tenantArg []any
+ if !store.IsCrossTenant(ctx) {
+ if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
+ tenantSuffix = " AND tenant_id = $2"
+ tenantArg = append(tenantArg, tid)
+ }
+ }
+
if patch.Schedule != nil {
// Fetch current schedule to merge with patch (partial updates)
var curKind string
var curExpr, curTZ *string
var curIntervalMS *int64
var curRunAt *time.Time
- s.db.QueryRow(
- "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1", id,
+ s.db.QueryRowContext(ctx,
+ "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1"+tenantSuffix,
+ append([]any{id}, tenantArg...)...,
).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt)
// Resolve the effective schedule kind
@@ -151,7 +162,7 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.
needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil
if needsPayloadUpdate {
var payloadJSON []byte
- if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil {
+ if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = $1"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil {
var payload store.CronPayload
json.Unmarshal(payloadJSON, &payload)
@@ -178,11 +189,22 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.
updates["updated_at"] = time.Now()
- if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil {
+ var execErr error
+ if store.IsCrossTenant(ctx) {
+ execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ execErr = fmt.Errorf("tenant_id required for update")
+ } else {
+ execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid)
+ }
+ }
+ if err := execErr; err != nil {
return nil, err
}
s.cacheLoaded = false
- job, _ := s.scanJob(id)
+ job, _ := s.scanJob(ctx, id)
return job, nil
}
diff --git a/internal/store/pg/custom_tools.go b/internal/store/pg/custom_tools.go
deleted file mode 100644
index 37026bce..00000000
--- a/internal/store/pg/custom_tools.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package pg
-
-import (
- "context"
- "database/sql"
- "fmt"
- "log/slog"
- "strings"
- "time"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/crypto"
- "github.com/nextlevelbuilder/goclaw/internal/store"
-)
-
-// PGCustomToolStore implements store.CustomToolStore backed by Postgres.
-type PGCustomToolStore struct {
- db *sql.DB
- encKey string
-}
-
-func NewPGCustomToolStore(db *sql.DB, encryptionKey string) *PGCustomToolStore {
- return &PGCustomToolStore{db: db, encKey: encryptionKey}
-}
-
-const customToolSelectCols = `id, name, description, parameters, command, working_dir,
- timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at`
-
-func (s *PGCustomToolStore) Create(ctx context.Context, def *store.CustomToolDef) error {
- if err := store.ValidateUserID(def.CreatedBy); err != nil {
- return err
- }
- if def.ID == uuid.Nil {
- def.ID = store.GenNewID()
- }
-
- // Encrypt env if provided
- var envBytes []byte
- if len(def.Env) > 0 && s.encKey != "" {
- encrypted, err := crypto.Encrypt(string(def.Env), s.encKey)
- if err != nil {
- return fmt.Errorf("encrypt env: %w", err)
- }
- envBytes = []byte(encrypted)
- } else {
- envBytes = def.Env
- }
-
- now := time.Now()
- def.CreatedAt = now
- def.UpdatedAt = now
-
- _, err := s.db.ExecContext(ctx,
- `INSERT INTO custom_tools (id, name, description, parameters, command, working_dir,
- timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
- def.ID, def.Name, def.Description,
- jsonOrEmpty(def.Parameters),
- def.Command, nilStr(def.WorkingDir),
- def.TimeoutSeconds, envBytes,
- nilUUID(def.AgentID), def.Enabled,
- def.CreatedBy, now, now,
- )
- return err
-}
-
-func (s *PGCustomToolStore) Get(ctx context.Context, id uuid.UUID) (*store.CustomToolDef, error) {
- row := s.db.QueryRowContext(ctx,
- `SELECT `+customToolSelectCols+` FROM custom_tools WHERE id = $1`, id)
- return s.scanTool(row)
-}
-
-func (s *PGCustomToolStore) scanTool(row *sql.Row) (*store.CustomToolDef, error) {
- var def store.CustomToolDef
- var workingDir *string
- var agentID *uuid.UUID
- var params *[]byte // pgx workaround: can't scan NULL JSONB into *json.RawMessage
- var env []byte
-
- err := row.Scan(
- &def.ID, &def.Name, &def.Description, ¶ms,
- &def.Command, &workingDir,
- &def.TimeoutSeconds, &env, &agentID,
- &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt,
- )
- if err != nil {
- return nil, err
- }
-
- def.WorkingDir = derefStr(workingDir)
- def.AgentID = agentID
- if params != nil {
- def.Parameters = *params
- }
-
- // Decrypt env
- if len(env) > 0 && s.encKey != "" {
- decrypted, err := crypto.Decrypt(string(env), s.encKey)
- if err != nil {
- slog.Warn("custom_tools: failed to decrypt env", "tool", def.Name, "error", err)
- } else {
- def.Env = []byte(decrypted)
- }
- } else {
- def.Env = env
- }
-
- return &def, nil
-}
-
-func (s *PGCustomToolStore) scanTools(rows *sql.Rows) ([]store.CustomToolDef, error) {
- defer rows.Close()
- var result []store.CustomToolDef
- for rows.Next() {
- var def store.CustomToolDef
- var workingDir *string
- var agentID *uuid.UUID
- var params *[]byte
- var env []byte
-
- if err := rows.Scan(
- &def.ID, &def.Name, &def.Description, ¶ms,
- &def.Command, &workingDir,
- &def.TimeoutSeconds, &env, &agentID,
- &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt,
- ); err != nil {
- continue
- }
-
- def.WorkingDir = derefStr(workingDir)
- def.AgentID = agentID
- if params != nil {
- def.Parameters = *params
- }
- if len(env) > 0 && s.encKey != "" {
- if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil {
- def.Env = []byte(decrypted)
- }
- } else {
- def.Env = env
- }
-
- result = append(result, def)
- }
- return result, nil
-}
-
-func (s *PGCustomToolStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
- // Encrypt env if present
- if envVal, ok := updates["env"]; ok {
- if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" {
- encrypted, err := crypto.Encrypt(envStr, s.encKey)
- if err != nil {
- return fmt.Errorf("encrypt env: %w", err)
- }
- updates["env"] = []byte(encrypted)
- }
- }
- updates["updated_at"] = time.Now()
- return execMapUpdate(ctx, s.db, "custom_tools", id, updates)
-}
-
-func (s *PGCustomToolStore) Delete(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM custom_tools WHERE id = $1", id)
- return err
-}
-
-func (s *PGCustomToolStore) ListGlobal(ctx context.Context) ([]store.CustomToolDef, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id IS NULL AND enabled = true ORDER BY name`)
- if err != nil {
- return nil, err
- }
- return s.scanTools(rows)
-}
-
-func (s *PGCustomToolStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.CustomToolDef, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id = $1 AND enabled = true ORDER BY name`, agentID)
- if err != nil {
- return nil, err
- }
- return s.scanTools(rows)
-}
-
-func (s *PGCustomToolStore) ListAll(ctx context.Context) ([]store.CustomToolDef, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+customToolSelectCols+` FROM custom_tools WHERE enabled = true ORDER BY name`)
- if err != nil {
- return nil, err
- }
- return s.scanTools(rows)
-}
-
-func buildCustomToolWhere(opts store.CustomToolListOpts) (string, []any) {
- conditions := []string{"enabled = true"}
- var args []any
- argIdx := 1
-
- if opts.AgentID != nil {
- conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
- args = append(args, *opts.AgentID)
- argIdx++
- }
- if opts.Search != "" {
- conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR description ILIKE $%d ESCAPE '\\')", argIdx, argIdx))
- escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search)
- args = append(args, "%"+escaped+"%")
- }
-
- return " WHERE " + strings.Join(conditions, " AND "), args
-}
-
-func (s *PGCustomToolStore) ListPaged(ctx context.Context, opts store.CustomToolListOpts) ([]store.CustomToolDef, error) {
- where, args := buildCustomToolWhere(opts)
- limit := opts.Limit
- if limit <= 0 {
- limit = 50
- }
- q := `SELECT ` + customToolSelectCols + ` FROM custom_tools` + where +
- fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit)
-
- rows, err := s.db.QueryContext(ctx, q, args...)
- if err != nil {
- return nil, err
- }
- return s.scanTools(rows)
-}
-
-func (s *PGCustomToolStore) CountTools(ctx context.Context, opts store.CustomToolListOpts) (int, error) {
- where, args := buildCustomToolWhere(opts)
- var count int
- err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM custom_tools"+where, args...).Scan(&count)
- return count, err
-}
diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go
index dd777c1e..563e4c54 100644
--- a/internal/store/pg/factory.go
+++ b/internal/store/pg/factory.go
@@ -32,7 +32,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
Providers: NewPGProviderStore(db, cfg.EncryptionKey),
Tracing: NewPGTracingStore(db),
MCP: NewPGMCPServerStore(db, cfg.EncryptionKey),
- CustomTools: NewPGCustomToolStore(db, cfg.EncryptionKey),
ChannelInstances: NewPGChannelInstanceStore(db, cfg.EncryptionKey),
ConfigSecrets: NewPGConfigSecretsStore(db, cfg.EncryptionKey),
AgentLinks: NewPGAgentLinkStore(db),
@@ -46,6 +45,9 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey),
APIKeys: NewPGAPIKeyStore(db),
Heartbeats: NewPGHeartbeatStore(db),
- ConfigPermissions: NewPGConfigPermissionStore(db),
+ ConfigPermissions: NewPGConfigPermissionStore(db),
+ Tenants: NewPGTenantStore(db),
+ BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db),
+ SkillTenantCfgs: NewPGSkillTenantConfigStore(db),
}, nil
}
diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go
index 683a80bb..ff748d21 100644
--- a/internal/store/pg/helpers.go
+++ b/internal/store/pg/helpers.go
@@ -11,6 +11,8 @@ import (
"time"
"github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// validColumnName matches safe SQL identifiers (letters, digits, underscores).
@@ -195,15 +197,50 @@ func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID,
// tablesWithUpdatedAt lists tables that have an updated_at column.
var tablesWithUpdatedAt = map[string]bool{
"agents": true, "llm_providers": true, "sessions": true,
- "channel_instances": true, "cron_jobs": true, "custom_tools": true,
+ "channel_instances": true, "cron_jobs": true,
"skills": true, "mcp_servers": true, "agent_links": true,
"agent_teams": true, "team_tasks": true, "builtin_tools": true,
"agent_context_files": true, "user_context_files": true,
"user_agent_overrides": true, "config_secrets": true,
"memory_documents": true, "memory_chunks": true, "embedding_cache": true,
- "secure_cli_binaries": true,
+ "secure_cli_binaries": true, "tenants": true,
}
func tableHasUpdatedAt(table string) bool {
return tablesWithUpdatedAt[table]
}
+
+// --- Tenant filter helpers ---
+
+// tenantClauseN returns an " AND tenant_id = $N" clause and the tenant UUID as the arg.
+// Returns ("", nil, nil) for cross-tenant callers (skip filter).
+// Returns error if tenant is missing from context (fail-closed).
+func tenantClauseN(ctx context.Context, paramN int) (clause string, args []any, err error) {
+ if store.IsCrossTenant(ctx) {
+ return "", nil, nil
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return "", nil, fmt.Errorf("tenant_id required")
+ }
+ return fmt.Sprintf(" AND tenant_id = $%d", paramN), []any{tid}, nil
+}
+
+// tenantIDForInsert returns the tenant UUID for INSERT operations.
+// Falls back to MasterTenantID when no tenant in context.
+func tenantIDForInsert(ctx context.Context) uuid.UUID {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return store.MasterTenantID
+ }
+ return tid
+}
+
+// requireTenantID returns the tenant UUID or an error if missing (fail-closed).
+func requireTenantID(ctx context.Context) (uuid.UUID, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return uuid.Nil, fmt.Errorf("tenant_id required")
+ }
+ return tid, nil
+}
diff --git a/internal/store/pg/knowledge_graph.go b/internal/store/pg/knowledge_graph.go
index e7b75877..55bfe98d 100644
--- a/internal/store/pg/knowledge_graph.go
+++ b/internal/store/pg/knowledge_graph.go
@@ -39,10 +39,11 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store.
}
now := time.Now()
id := uuid.Must(uuid.NewV7())
+ tid := tenantIDForInsert(ctx)
_, err = s.db.ExecContext(ctx, `
INSERT INTO kg_entities
- (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
+ (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET
name = EXCLUDED.name,
entity_type = EXCLUDED.entity_type,
@@ -50,9 +51,10 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store.
properties = EXCLUDED.properties,
source_id = EXCLUDED.source_id,
confidence = EXCLUDED.confidence,
+ tenant_id = EXCLUDED.tenant_id,
updated_at = EXCLUDED.updated_at`,
id, aid, entity.UserID, entity.ExternalID, entity.Name, entity.EntityType,
- entity.Description, props, entity.SourceID, entity.Confidence, now,
+ entity.Description, props, entity.SourceID, entity.Confidence, tid, now,
)
return err
}
@@ -62,20 +64,28 @@ func (s *PGKnowledgeGraphStore) GetEntity(ctx context.Context, agentID, userID,
eid := mustParseUUID(entityID)
if store.IsSharedKG(ctx) {
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
row := s.db.QueryRowContext(ctx, `
SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at
- FROM kg_entities WHERE id = $1 AND agent_id = $2`,
- eid, aid,
+ FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc,
+ append([]any{eid, aid}, tcArgs...)...,
)
return scanEntity(row)
}
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
row := s.db.QueryRowContext(ctx, `
SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at
- FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`,
- eid, aid, userID,
+ FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc,
+ append([]any{eid, aid, userID}, tcArgs...)...,
)
return scanEntity(row)
}
@@ -84,15 +94,23 @@ func (s *PGKnowledgeGraphStore) DeleteEntity(ctx context.Context, agentID, userI
aid := mustParseUUID(agentID)
eid := mustParseUUID(entityID)
if store.IsSharedKG(ctx) {
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`,
- eid, aid,
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc,
+ append([]any{eid, aid}, tcArgs...)...,
)
return err
}
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`,
- eid, aid, userID,
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc,
+ append([]any{eid, aid, userID}, tcArgs...)...,
)
return err
}
@@ -118,6 +136,15 @@ func (s *PGKnowledgeGraphStore) ListEntities(ctx context.Context, agentID, userI
args = append(args, opts.EntityType)
idx++
}
+ tc, tcArgs, err := tenantClauseN(ctx, idx)
+ if err != nil {
+ return nil, err
+ }
+ if tc != "" {
+ where += tc
+ args = append(args, tcArgs...)
+ idx++
+ }
args = append(args, limit, opts.Offset)
query := fmt.Sprintf(`
SELECT id, agent_id, user_id, external_id, name, entity_type, description,
@@ -201,6 +228,15 @@ func (s *PGKnowledgeGraphStore) ilikeSearchEntities(ctx context.Context, agentID
args = append(args, userID)
idx++
}
+ tc, tcArgs, err := tenantClauseN(ctx, idx)
+ if err != nil {
+ return nil, err
+ }
+ if tc != "" {
+ where += tc
+ args = append(args, tcArgs...)
+ idx++
+ }
args = append(args, pattern, limit)
q := fmt.Sprintf(`
SELECT id, agent_id, user_id, external_id, name, entity_type, description,
@@ -247,6 +283,15 @@ func (s *PGKnowledgeGraphStore) vectorSearchEntities(ctx context.Context, embedd
args = append(args, userID)
idx++
}
+ tc, tcArgs, err := tenantClauseN(ctx, idx)
+ if err != nil {
+ return nil, err
+ }
+ if tc != "" {
+ where += tc
+ args = append(args, tcArgs...)
+ idx++
+ }
args = append(args, vecStr, limit)
q := fmt.Sprintf(`
SELECT id, agent_id, user_id, external_id, name, entity_type, description,
@@ -330,14 +375,16 @@ func (s *PGKnowledgeGraphStore) UpsertRelation(ctx context.Context, relation *st
}
id := uuid.Must(uuid.NewV7())
now := time.Now()
+ tid := tenantIDForInsert(ctx)
_, err = s.db.ExecContext(ctx, `
INSERT INTO kg_relations
- (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET
confidence = EXCLUDED.confidence,
- properties = EXCLUDED.properties`,
- id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, now,
+ properties = EXCLUDED.properties,
+ tenant_id = EXCLUDED.tenant_id`,
+ id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, tid, now,
)
return err
}
@@ -346,15 +393,23 @@ func (s *PGKnowledgeGraphStore) DeleteRelation(ctx context.Context, agentID, use
aid := mustParseUUID(agentID)
rid := mustParseUUID(relationID)
if store.IsSharedKG(ctx) {
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`,
- rid, aid,
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`+tc,
+ append([]any{rid, aid}, tcArgs...)...,
)
return err
}
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`,
- rid, aid, userID,
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc,
+ append([]any{rid, aid, userID}, tcArgs...)...,
)
return err
}
@@ -366,21 +421,29 @@ func (s *PGKnowledgeGraphStore) ListRelations(ctx context.Context, agentID, user
var q string
var args []any
if store.IsSharedKG(ctx) {
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id,
confidence, properties, created_at
FROM kg_relations
WHERE agent_id = $1
- AND (source_entity_id = $2 OR target_entity_id = $2)
+ AND (source_entity_id = $2 OR target_entity_id = $2)` + tc + `
ORDER BY created_at DESC`
- args = []any{aid, eid}
+ args = append([]any{aid, eid}, tcArgs...)
} else {
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id,
confidence, properties, created_at
FROM kg_relations
WHERE agent_id = $1 AND user_id = $2
- AND (source_entity_id = $3 OR target_entity_id = $3)
+ AND (source_entity_id = $3 OR target_entity_id = $3)` + tc + `
ORDER BY created_at DESC`
- args = []any{aid, userID, eid}
+ args = append([]any{aid, userID, eid}, tcArgs...)
}
rows, err := s.db.QueryContext(ctx, q, args...)
@@ -404,6 +467,15 @@ func (s *PGKnowledgeGraphStore) ListAllRelations(ctx context.Context, agentID, u
args = append(args, userID)
idx++
}
+ tc, tcArgs, err := tenantClauseN(ctx, idx)
+ if err != nil {
+ return nil, err
+ }
+ if tc != "" {
+ where += tc
+ args = append(args, tcArgs...)
+ idx++
+ }
args = append(args, limit)
q := fmt.Sprintf(`
SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id,
@@ -427,6 +499,7 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u
aid := mustParseUUID(agentID)
now := time.Now()
+ tid := tenantIDForInsert(ctx)
// Upsert entities and build external_id → DB UUID lookup for relations
extIDToUUID := make(map[string]uuid.UUID, len(entities))
@@ -440,8 +513,8 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u
var actualID uuid.UUID
if err := tx.QueryRowContext(ctx, `
INSERT INTO kg_entities
- (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
+ (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)
ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET
name = EXCLUDED.name,
entity_type = EXCLUDED.entity_type,
@@ -449,10 +522,11 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u
properties = EXCLUDED.properties,
source_id = EXCLUDED.source_id,
confidence = EXCLUDED.confidence,
+ tenant_id = EXCLUDED.tenant_id,
updated_at = EXCLUDED.updated_at
RETURNING id`,
id, aid, userID, e.ExternalID, e.Name, e.EntityType,
- e.Description, props, e.SourceID, e.Confidence, now,
+ e.Description, props, e.SourceID, e.Confidence, tid, now,
).Scan(&actualID); err != nil {
return err
}
@@ -500,12 +574,13 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u
id := uuid.Must(uuid.NewV7())
if _, err := tx.ExecContext(ctx, `
INSERT INTO kg_relations
- (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET
confidence = EXCLUDED.confidence,
- properties = EXCLUDED.properties`,
- id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, now,
+ properties = EXCLUDED.properties,
+ tenant_id = EXCLUDED.tenant_id`,
+ id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, tid, now,
); err != nil {
return err
}
@@ -519,14 +594,22 @@ func (s *PGKnowledgeGraphStore) PruneByConfidence(ctx context.Context, agentID,
var res sql.Result
var err error
if store.IsSharedKG(ctx) {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return 0, tcErr
+ }
res, err = s.db.ExecContext(ctx,
- `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`,
- aid, minConfidence,
+ `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`+tc,
+ append([]any{aid, minConfidence}, tcArgs...)...,
)
} else {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 4)
+ if tcErr != nil {
+ return 0, tcErr
+ }
res, err = s.db.ExecContext(ctx,
- `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`,
- aid, userID, minConfidence,
+ `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`+tc,
+ append([]any{aid, userID, minConfidence}, tcArgs...)...,
)
}
if err != nil {
@@ -542,24 +625,32 @@ func (s *PGKnowledgeGraphStore) Stats(ctx context.Context, agentID, userID strin
userFilter := ""
args := []any{aid}
+ idx := 2
if userID != "" {
- userFilter = " AND user_id = $2"
+ userFilter = fmt.Sprintf(" AND user_id = $%d", idx)
args = append(args, userID)
+ idx++
}
+ tc, tcArgs, err := tenantClauseN(ctx, idx)
+ if err != nil {
+ return nil, err
+ }
+ tenantFilter := tc
+ args = append(args, tcArgs...)
if err := s.db.QueryRowContext(ctx,
- `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter, args...,
+ `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter, args...,
).Scan(&stats.EntityCount); err != nil {
return nil, err
}
if err := s.db.QueryRowContext(ctx,
- `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter, args...,
+ `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter+tenantFilter, args...,
).Scan(&stats.RelationCount); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx,
- `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+` GROUP BY entity_type`, args...,
+ `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter+` GROUP BY entity_type`, args...,
)
if err != nil {
return nil, err
diff --git a/internal/store/pg/knowledge_graph_embedding.go b/internal/store/pg/knowledge_graph_embedding.go
index 53cc3aa1..96dcaea2 100644
--- a/internal/store/pg/knowledge_graph_embedding.go
+++ b/internal/store/pg/knowledge_graph_embedding.go
@@ -2,6 +2,7 @@ package pg
import (
"context"
+ "fmt"
"log/slog"
"github.com/google/uuid"
@@ -17,12 +18,20 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int,
const batchSize = 50
total := 0
+ // $1=batchSize (LIMIT), tenant at $2 if not cross-tenant
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 2)
+ if tcErr != nil {
+ return 0, tcErr
+ }
+ batchQ := fmt.Sprintf(`SELECT id, name, description FROM kg_entities
+ WHERE embedding IS NULL%s
+ ORDER BY created_at DESC
+ LIMIT $1`, tc)
+ batchBaseArgs := tcArgs // tenant uuid(s) prepended after $1
+
for {
- rows, err := s.db.QueryContext(ctx,
- `SELECT id, name, description FROM kg_entities
- WHERE embedding IS NULL
- ORDER BY created_at DESC
- LIMIT $1`, batchSize)
+ queryArgs := append([]any{batchSize}, batchBaseArgs...)
+ rows, err := s.db.QueryContext(ctx, batchQ, queryArgs...)
if err != nil {
return total, err
}
diff --git a/internal/store/pg/knowledge_graph_traversal.go b/internal/store/pg/knowledge_graph_traversal.go
index 2f40f7e9..669470f4 100644
--- a/internal/store/pg/knowledge_graph_traversal.go
+++ b/internal/store/pg/knowledge_graph_traversal.go
@@ -3,6 +3,7 @@ package pg
import (
"context"
"encoding/json"
+ "fmt"
"time"
"github.com/lib/pq"
@@ -33,7 +34,13 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
var q string
var args []any
if store.IsSharedKG(ctx) {
- q = `
+ // fixed params: $1=startID, $2=aid; tenant at $3 (if needed); maxDepth last
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return nil, tcErr
+ }
+ depthN := 3 + len(tcArgs)
+ q = fmt.Sprintf(`
WITH RECURSIVE paths AS (
SELECT
e.id, e.agent_id, e.user_id, e.external_id,
@@ -44,7 +51,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
ARRAY[e.id::text] AS path,
''::text AS via
FROM kg_entities e
- WHERE e.id = $1 AND e.agent_id = $2
+ WHERE e.id = $1 AND e.agent_id = $2%s
UNION ALL
@@ -59,7 +66,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
FROM paths p
JOIN kg_relations r ON p.id = r.source_entity_id AND r.agent_id = $2
JOIN kg_entities e ON r.target_entity_id = e.id AND e.agent_id = $2
- WHERE p.depth < $3
+ WHERE p.depth < $%d
AND NOT e.id::text = ANY(p.path)
)
SELECT
@@ -68,10 +75,17 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
properties, source_id, confidence,
created_at, updated_at,
depth, path, via
- FROM paths WHERE depth > 1`
- args = []any{startID, aid, maxDepth}
+ FROM paths WHERE depth > 1`, tc, depthN)
+ args = append([]any{startID, aid}, tcArgs...)
+ args = append(args, maxDepth)
} else {
- q = `
+ // fixed params: $1=startID, $2=aid, $3=userID; tenant at $4 (if needed); maxDepth last
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 4)
+ if tcErr != nil {
+ return nil, tcErr
+ }
+ depthN := 4 + len(tcArgs)
+ q = fmt.Sprintf(`
WITH RECURSIVE paths AS (
SELECT
e.id, e.agent_id, e.user_id, e.external_id,
@@ -82,7 +96,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
ARRAY[e.id::text] AS path,
''::text AS via
FROM kg_entities e
- WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3
+ WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3%s
UNION ALL
@@ -97,7 +111,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
FROM paths p
JOIN kg_relations r ON p.id = r.source_entity_id AND r.user_id = $3
JOIN kg_entities e ON r.target_entity_id = e.id AND e.user_id = $3
- WHERE p.depth < $4
+ WHERE p.depth < $%d
AND NOT e.id::text = ANY(p.path)
)
SELECT
@@ -106,8 +120,9 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s
properties, source_id, confidence,
created_at, updated_at,
depth, path, via
- FROM paths WHERE depth > 1`
- args = []any{startID, aid, userID, maxDepth}
+ FROM paths WHERE depth > 1`, tc, depthN)
+ args = append([]any{startID, aid, userID}, tcArgs...)
+ args = append(args, maxDepth)
}
rows, err := tx.QueryContext(ctx, q, args...)
diff --git a/internal/store/pg/mcp_servers.go b/internal/store/pg/mcp_servers.go
index acd30c6c..60c6e347 100644
--- a/internal/store/pg/mcp_servers.go
+++ b/internal/store/pg/mcp_servers.go
@@ -48,30 +48,56 @@ func (s *PGMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServe
srv.UpdatedAt = now
encHeaders := s.encryptJSONB(jsonOrEmpty(srv.Headers))
encEnv := s.encryptJSONB(jsonOrEmpty(srv.Env))
+
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env,
- api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command),
jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv,
nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec,
- jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now,
+ jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID,
)
return err
}
func (s *PGMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) {
+ if store.IsCrossTenant(ctx) {
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE id = $1`, id))
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
- FROM mcp_servers WHERE id = $1`, id))
+ FROM mcp_servers WHERE id = $1 AND tenant_id = $2`, id, tenantID))
}
func (s *PGMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) {
+ if store.IsCrossTenant(ctx) {
+ return s.scanServer(s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
+ FROM mcp_servers WHERE name = $1`, name))
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
return s.scanServer(s.db.QueryRowContext(ctx,
`SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
- FROM mcp_servers WHERE name = $1`, name))
+ FROM mcp_servers WHERE name = $1 AND tenant_id = $2`, name, tenantID))
}
func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) {
@@ -108,10 +134,20 @@ func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error
}
func (s *PGMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT id, name, display_name, transport, command, args, url, headers, env,
+ query := `SELECT id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at
- FROM mcp_servers ORDER BY name`)
+ FROM mcp_servers`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return []store.MCPServerData{}, nil
+ }
+ query += ` WHERE tenant_id = $1`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
@@ -178,11 +214,26 @@ func (s *PGMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updat
}
}
updates["updated_at"] = time.Now()
- return execMapUpdate(ctx, s.db, "mcp_servers", id, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "mcp_servers", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid)
}
func (s *PGMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1 AND tenant_id = $2", id, tid)
return err
}
diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go
index 72adca4d..2484da13 100644
--- a/internal/store/pg/mcp_servers_access.go
+++ b/internal/store/pg/mcp_servers_access.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strings"
"time"
"github.com/google/uuid"
@@ -23,29 +24,39 @@ func (s *PGMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGr
}
g.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (server_id, agent_id) DO UPDATE SET
enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow,
tool_deny = EXCLUDED.tool_deny, config_overrides = EXCLUDED.config_overrides,
granted_by = EXCLUDED.granted_by`,
g.ID, g.ServerID, g.AgentID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
- g.GrantedBy, g.CreatedAt,
+ g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *PGMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error {
- _, err := s.db.ExecContext(ctx,
- "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2", serverID, agentID)
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2"+tClause,
+ append([]any{serverID, agentID}, tArgs...)...)
return err
}
func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at
- FROM mcp_agent_grants WHERE agent_id = $1`, agentID)
+ FROM mcp_agent_grants WHERE agent_id = $1`+tClause,
+ append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -64,11 +75,16 @@ func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUI
}
func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, enabled,
COALESCE(tool_allow, '[]'::jsonb), COALESCE(tool_deny, '[]'::jsonb),
COALESCE(config_overrides, '{}'::jsonb), granted_by, created_at
- FROM mcp_agent_grants WHERE server_id = $1 ORDER BY created_at`, serverID)
+ FROM mcp_agent_grants WHERE server_id = $1`+tClause+` ORDER BY created_at`,
+ append([]any{serverID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -89,8 +105,13 @@ func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.U
// --- Counts ---
func (s *PGMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 1)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
- `SELECT server_id, COUNT(*) FROM mcp_agent_grants GROUP BY server_id`)
+ `SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`,
+ tArgs...)
if err != nil {
return nil, err
}
@@ -122,27 +143,36 @@ func (s *PGMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGran
}
g.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+ `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (server_id, user_id) DO UPDATE SET
enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow,
tool_deny = EXCLUDED.tool_deny, granted_by = EXCLUDED.granted_by`,
g.ID, g.ServerID, g.UserID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny),
- g.GrantedBy, g.CreatedAt,
+ g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error {
- _, err := s.db.ExecContext(ctx,
- "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2", serverID, userID)
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2"+tClause,
+ append([]any{serverID, userID}, tArgs...)...)
return err
}
// --- Resolution ---
func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at,
@@ -151,8 +181,9 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID
INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true
LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2
WHERE ms.enabled = true
- AND (mug.id IS NULL OR mug.enabled = true)`,
- agentID, userID)
+ AND (mug.id IS NULL OR mug.enabled = true)`+
+ strings.Replace(tClause, "tenant_id", "ms.tenant_id", 1),
+ append([]any{agentID, userID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -213,20 +244,25 @@ func (s *PGMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAcce
req.Status = "pending"
req.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+ `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID),
req.Scope, req.Status, nilStr(req.Reason),
- jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt,
+ jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *PGMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 1)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by,
reviewed_by, reviewed_at, review_note, created_at
- FROM mcp_access_requests WHERE status = 'pending' ORDER BY created_at`)
+ FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`,
+ tArgs...)
if err != nil {
return nil, err
}
@@ -266,9 +302,14 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI
var req store.MCPAccessRequest
var agentID *uuid.UUID
var userID *string
+ tClause, tArgs, err2 := tenantClauseN(ctx, 2)
+ if err2 != nil {
+ return err2
+ }
err = tx.QueryRowContext(ctx,
`SELECT id, server_id, agent_id, user_id, scope, status, tool_allow
- FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`, requestID,
+ FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`+tClause,
+ append([]any{requestID}, tArgs...)...,
).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow)
if err != nil {
return fmt.Errorf("request not found or not pending: %w", err)
@@ -297,20 +338,20 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI
return fmt.Errorf("agent_id required for agent scope")
}
_, err = tx.ExecContext(ctx,
- `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at)
- VALUES ($1,$2,$3,true,$4,$5,$6)
+ `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id)
+ VALUES ($1,$2,$3,true,$4,$5,$6,$7)
ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`,
- store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now,
+ store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
)
case "user":
if userID == nil || *userID == "" {
return fmt.Errorf("user_id required for user scope")
}
_, err = tx.ExecContext(ctx,
- `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at)
- VALUES ($1,$2,$3,true,$4,$5,$6)
+ `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id)
+ VALUES ($1,$2,$3,true,$4,$5,$6,$7)
ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`,
- store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now,
+ store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx),
)
default:
return fmt.Errorf("unknown scope: %s", req.Scope)
diff --git a/internal/store/pg/mcp_user_credentials.go b/internal/store/pg/mcp_user_credentials.go
new file mode 100644
index 00000000..06bda59c
--- /dev/null
+++ b/internal/store/pg/mcp_user_credentials.go
@@ -0,0 +1,100 @@
+package pg
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// GetUserCredentials returns per-user credential overrides for an MCP server.
+// Returns (nil, nil) if no per-user credentials exist.
+func (s *PGMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) {
+ tid := tenantIDForInsert(ctx)
+ var apiKey sql.NullString
+ var headersEnc, envEnc []byte
+
+ err := s.db.QueryRowContext(ctx,
+ `SELECT api_key, headers, env FROM mcp_user_credentials
+ WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`,
+ serverID, userID, tid,
+ ).Scan(&apiKey, &headersEnc, &envEnc)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ creds := &store.MCPUserCredentials{}
+ if apiKey.Valid && apiKey.String != "" && s.encKey != "" {
+ if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil {
+ creds.APIKey = dec
+ }
+ } else if apiKey.Valid {
+ creds.APIKey = apiKey.String
+ }
+ if len(headersEnc) > 0 {
+ dec := s.decryptJSONB(headersEnc)
+ json.Unmarshal(dec, &creds.Headers)
+ }
+ if len(envEnc) > 0 {
+ dec := s.decryptJSONB(envEnc)
+ json.Unmarshal(dec, &creds.Env)
+ }
+
+ return creds, nil
+}
+
+// SetUserCredentials creates or updates per-user MCP credentials.
+func (s *PGMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error {
+ tid := tenantIDForInsert(ctx)
+
+ var apiKeyEnc sql.NullString
+ if creds.APIKey != "" && s.encKey != "" {
+ enc, err := crypto.Encrypt(creds.APIKey, s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt mcp user api_key: %w", err)
+ }
+ apiKeyEnc = sql.NullString{String: enc, Valid: true}
+ } else if creds.APIKey != "" {
+ apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true}
+ }
+
+ var headersEnc, envEnc []byte
+ if len(creds.Headers) > 0 {
+ raw, _ := json.Marshal(creds.Headers)
+ headersEnc = s.encryptJSONB(raw)
+ }
+ if len(creds.Env) > 0 {
+ raw, _ := json.Marshal(creds.Env)
+ envEnc = s.encryptJSONB(raw)
+ }
+
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
+ ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET
+ api_key = $4, headers = $5, env = $6, updated_at = $8`,
+ uuid.Must(uuid.NewV7()), serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now,
+ )
+ return err
+}
+
+// DeleteUserCredentials removes per-user MCP credentials.
+func (s *PGMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error {
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM mcp_user_credentials WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`,
+ serverID, userID, tid,
+ )
+ return err
+}
diff --git a/internal/store/pg/memory_admin.go b/internal/store/pg/memory_admin.go
index 2adf19db..45764dc2 100644
--- a/internal/store/pg/memory_admin.go
+++ b/internal/store/pg/memory_admin.go
@@ -9,10 +9,20 @@ import (
// ListAllDocumentsGlobal returns all documents across all agents (for admin overview).
func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) {
+ var whereClause string
+ var args []any
+ if !store.IsCrossTenant(ctx) {
+ tid, err := requireTenantID(ctx)
+ if err != nil {
+ return nil, err
+ }
+ whereClause = "WHERE tenant_id = $1"
+ args = []any{tid}
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT agent_id, path, hash, user_id, updated_at
- FROM memory_documents
- ORDER BY updated_at DESC`)
+ FROM memory_documents `+whereClause+`
+ ORDER BY updated_at DESC`, args...)
if err != nil {
return nil, err
}
@@ -43,10 +53,14 @@ func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.Doc
// ListAllDocuments returns all documents for an agent across all users (global + personal).
func (s *PGMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) {
aid := mustParseUUID(agentID)
+ tc, tcArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT agent_id, path, hash, user_id, updated_at
- FROM memory_documents WHERE agent_id = $1
- ORDER BY updated_at DESC`, aid)
+ FROM memory_documents WHERE agent_id = $1`+tc+`
+ ORDER BY updated_at DESC`, append([]any{aid}, tcArgs...)...)
if err != nil {
return nil, err
}
@@ -81,23 +95,31 @@ func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID,
var q string
var args []any
if userID == "" {
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
COUNT(c.id) AS chunk_count,
COUNT(c.embedding) AS embedded_count
FROM memory_documents d
LEFT JOIN memory_chunks c ON c.document_id = d.id
- WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL
+ WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + `
GROUP BY d.id`
- args = []any{aid, path}
+ args = append([]any{aid, path}, tcArgs...)
} else {
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
COUNT(c.id) AS chunk_count,
COUNT(c.embedding) AS embedded_count
FROM memory_documents d
LEFT JOIN memory_chunks c ON c.document_id = d.id
- WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3
+ WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + `
GROUP BY d.id`
- args = []any{aid, path, userID}
+ args = append([]any{aid, path, userID}, tcArgs...)
}
var detail store.DocumentDetail
@@ -126,23 +148,31 @@ func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path st
var q string
var args []any
if userID == "" {
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT c.id, c.start_line, c.end_line,
c.text AS text_preview,
(c.embedding IS NOT NULL) AS has_embedding
FROM memory_chunks c
JOIN memory_documents d ON c.document_id = d.id
- WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL
+ WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + `
ORDER BY c.start_line`
- args = []any{aid, path}
+ args = append([]any{aid, path}, tcArgs...)
} else {
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
q = `SELECT c.id, c.start_line, c.end_line,
c.text AS text_preview,
(c.embedding IS NOT NULL) AS has_embedding
FROM memory_chunks c
JOIN memory_documents d ON c.document_id = d.id
- WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3
+ WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + `
ORDER BY c.start_line`
- args = []any{aid, path, userID}
+ args = append([]any{aid, path, userID}, tcArgs...)
}
rows, err := s.db.QueryContext(ctx, q, args...)
diff --git a/internal/store/pg/memory_docs.go b/internal/store/pg/memory_docs.go
index 846216a6..c06f551b 100644
--- a/internal/store/pg/memory_docs.go
+++ b/internal/store/pg/memory_docs.go
@@ -48,13 +48,21 @@ func (s *PGMemoryStore) GetDocument(ctx context.Context, agentID, userID, path s
var err error
if userID == "" {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return "", tcErr
+ }
err = s.db.QueryRowContext(ctx,
- "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL",
- aid, path).Scan(&content)
+ "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc,
+ append([]any{aid, path}, tcArgs...)...).Scan(&content)
} else {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 4)
+ if tcErr != nil {
+ return "", tcErr
+ }
err = s.db.QueryRowContext(ctx,
- "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3",
- aid, path, userID).Scan(&content)
+ "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc,
+ append([]any{aid, path, userID}, tcArgs...)...).Scan(&content)
}
if err != nil {
return "", err
@@ -67,6 +75,7 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path,
hash := memory.ContentHash(content)
id := uuid.Must(uuid.NewV7())
now := time.Now()
+ tid := tenantIDForInsert(ctx)
var uid *string
if userID != "" {
@@ -74,11 +83,11 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path,
}
_, err := s.db.ExecContext(ctx,
- `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (agent_id, COALESCE(user_id, ''), path)
- DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, updated_at = EXCLUDED.updated_at`,
- id, aid, uid, path, content, hash, now,
+ DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at`,
+ id, aid, uid, path, content, hash, tid, now,
)
return err
}
@@ -88,13 +97,21 @@ func (s *PGMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, pat
var res sql.Result
var err error
if userID == "" {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return tcErr
+ }
res, err = s.db.ExecContext(ctx,
- "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL",
- aid, path)
+ "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc,
+ append([]any{aid, path}, tcArgs...)...)
} else {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 4)
+ if tcErr != nil {
+ return tcErr
+ }
res, err = s.db.ExecContext(ctx,
- "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3",
- aid, path, userID)
+ "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc,
+ append([]any{aid, path, userID}, tcArgs...)...)
}
if err != nil {
return err
@@ -112,11 +129,21 @@ func (s *PGMemoryStore) ListDocuments(ctx context.Context, agentID, userID strin
var rows *sql.Rows
var err error
if userID == "" {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 2)
+ if tcErr != nil {
+ return nil, tcErr
+ }
rows, err = s.db.QueryContext(ctx,
- "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL", aid)
+ "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL"+tc,
+ append([]any{aid}, tcArgs...)...)
} else {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return nil, tcErr
+ }
rows, err = s.db.QueryContext(ctx,
- "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)", aid, userID)
+ "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)"+tc,
+ append([]any{aid, userID}, tcArgs...)...)
}
if err != nil {
return nil, err
@@ -157,13 +184,21 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path
// Get document ID
var docID uuid.UUID
if userID == "" {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 3)
+ if tcErr != nil {
+ return tcErr
+ }
err = s.db.QueryRowContext(ctx,
- "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL",
- aid, path).Scan(&docID)
+ "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc,
+ append([]any{aid, path}, tcArgs...)...).Scan(&docID)
} else {
+ tc, tcArgs, tcErr := tenantClauseN(ctx, 4)
+ if tcErr != nil {
+ return tcErr
+ }
err = s.db.QueryRowContext(ctx,
- "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3",
- aid, path, userID).Scan(&docID)
+ "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc,
+ append([]any{aid, path, userID}, tcArgs...)...).Scan(&docID)
}
if err != nil {
return err
@@ -268,6 +303,7 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path
}
// Insert chunks
+ tid := tenantIDForInsert(ctx)
for i, tc := range chunks {
hash := memory.ContentHash(tc.Text)
chunkID := uuid.Must(uuid.NewV7())
@@ -281,17 +317,17 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path
if embeddings != nil && i < len(embeddings) && embeddings[i] != nil {
// Insert with embedding via raw SQL (pgvector)
s.db.ExecContext(ctx,
- `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11)`,
+ `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, tenant_id, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12)`,
chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text,
- vectorToString(embeddings[i]), now,
+ vectorToString(embeddings[i]), tid, now,
)
} else {
s.db.ExecContext(ctx,
- `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT DO NOTHING`,
- chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, now,
+ chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now,
)
}
}
diff --git a/internal/store/pg/memory_embedding_cache.go b/internal/store/pg/memory_embedding_cache.go
index a0e2070e..e43e04a4 100644
--- a/internal/store/pg/memory_embedding_cache.go
+++ b/internal/store/pg/memory_embedding_cache.go
@@ -67,6 +67,7 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed
}
now := time.Now()
+ tenantID := tenantIDForInsert(ctx)
// Process in batches of 100 to avoid exceeding max query params
const batchSize = 100
@@ -78,16 +79,16 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed
batch := entries[start:end]
var sb strings.Builder
- sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at) VALUES `)
- args := make([]any, 0, len(batch)*6)
+ sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at, tenant_id) VALUES `)
+ args := make([]any, 0, len(batch)*7)
for i, e := range batch {
if i > 0 {
sb.WriteByte(',')
}
- base := i * 6
- fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d)",
- base+1, base+2, base+3, base+4, base+5, base+6, base+6)
- args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now)
+ base := i * 7
+ fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d,$%d)",
+ base+1, base+2, base+3, base+4, base+5, base+6, base+6, base+7)
+ args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now, tenantID)
}
sb.WriteString(` ON CONFLICT (hash, provider, model) DO UPDATE SET embedding = EXCLUDED.embedding, dims = EXCLUDED.dims, updated_at = EXCLUDED.updated_at`)
diff --git a/internal/store/pg/memory_search.go b/internal/store/pg/memory_search.go
index aec71906..6d55585c 100644
--- a/internal/store/pg/memory_search.go
+++ b/internal/store/pg/memory_search.go
@@ -2,6 +2,7 @@ package pg
import (
"context"
+ "fmt"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -81,21 +82,37 @@ func (s *PGMemoryStore) ftsSearch(ctx context.Context, query string, agentID any
var args []any
if userID != "" {
- q = `SELECT path, start_line, end_line, text, user_id,
+ // fixed params: $1=query, $2=agentID, $3=query, $4=userID
+ // tenant clause appended at $5 (if filtered), then LIMIT at $5 or $6
+ tc, tcArgs, err := tenantClauseN(ctx, 5)
+ if err != nil {
+ return nil, err
+ }
+ limitN := 5 + len(tcArgs)
+ q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
FROM memory_chunks
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3)
- AND (user_id IS NULL OR user_id = $4)
- ORDER BY score DESC LIMIT $5`
- args = []any{query, agentID, query, userID, limit}
+ AND (user_id IS NULL OR user_id = $4)%s
+ ORDER BY score DESC LIMIT $%d`, tc, limitN)
+ args = append([]any{query, agentID, query, userID}, tcArgs...)
+ args = append(args, limit)
} else {
- q = `SELECT path, start_line, end_line, text, user_id,
+ // fixed params: $1=query, $2=agentID, $3=query
+ // tenant clause at $4 (if filtered), then LIMIT at $4 or $5
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
+ limitN := 4 + len(tcArgs)
+ q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
FROM memory_chunks
WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3)
- AND user_id IS NULL
- ORDER BY score DESC LIMIT $4`
- args = []any{query, agentID, query, limit}
+ AND user_id IS NULL%s
+ ORDER BY score DESC LIMIT $%d`, tc, limitN)
+ args = append([]any{query, agentID, query}, tcArgs...)
+ args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
@@ -120,21 +137,39 @@ func (s *PGMemoryStore) vectorSearch(ctx context.Context, embedding []float32, a
var args []any
if userID != "" {
- q = `SELECT path, start_line, end_line, text, user_id,
+ // fixed params: $1=vec, $2=agentID, $3=userID
+ // tenant clause at $4, then ORDER vec at $4+len(tcArgs), LIMIT after
+ tc, tcArgs, err := tenantClauseN(ctx, 4)
+ if err != nil {
+ return nil, err
+ }
+ orderN := 4 + len(tcArgs)
+ limitN := orderN + 1
+ q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
1 - (embedding <=> $1::vector) AS score
FROM memory_chunks
WHERE agent_id = $2 AND embedding IS NOT NULL
- AND (user_id IS NULL OR user_id = $3)
- ORDER BY embedding <=> $4::vector LIMIT $5`
- args = []any{vecStr, agentID, userID, vecStr, limit}
+ AND (user_id IS NULL OR user_id = $3)%s
+ ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN)
+ args = append([]any{vecStr, agentID, userID}, tcArgs...)
+ args = append(args, vecStr, limit)
} else {
- q = `SELECT path, start_line, end_line, text, user_id,
+ // fixed params: $1=vec, $2=agentID
+ // tenant clause at $3, then ORDER vec at $3+len(tcArgs), LIMIT after
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
+ orderN := 3 + len(tcArgs)
+ limitN := orderN + 1
+ q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id,
1 - (embedding <=> $1::vector) AS score
FROM memory_chunks
WHERE agent_id = $2 AND embedding IS NOT NULL
- AND user_id IS NULL
- ORDER BY embedding <=> $3::vector LIMIT $4`
- args = []any{vecStr, agentID, vecStr, limit}
+ AND user_id IS NULL%s
+ ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN)
+ args = append([]any{vecStr, agentID}, tcArgs...)
+ args = append(args, vecStr, limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go
index dc040b3b..aedde459 100644
--- a/internal/store/pg/pairing.go
+++ b/internal/store/pg/pairing.go
@@ -1,6 +1,7 @@
package pg
import (
+ "context"
"crypto/rand"
"database/sql"
"encoding/json"
@@ -35,20 +36,22 @@ func (s *PGPairingStore) SetOnRequest(cb func(code, senderID, channel, chatID st
s.onRequest = cb
}
-func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
- // Prune expired
- s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
+func (s *PGPairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
+ tid := tenantIDForInsert(ctx)
- // Check max pending
+ // Prune expired
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
+
+ // Check max pending (per tenant)
var count int64
- s.db.QueryRow("SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1", accountID).Scan(&count)
+ s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1 AND tenant_id = $2", accountID, tid).Scan(&count)
if count >= maxPendingPerAccount {
return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount)
}
- // Check existing
+ // Check existing (per tenant)
var existingCode string
- err := s.db.QueryRow("SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2", senderID, channel).Scan(&existingCode)
+ err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid).Scan(&existingCode)
if err == nil {
return existingCode, nil
}
@@ -60,10 +63,10 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str
code := generatePairingCode()
now := time.Now()
- _, err = s.db.Exec(
- `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
- uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON,
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
+ uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid,
)
if err != nil {
return "", fmt.Errorf("create pairing request: %w", err)
@@ -74,30 +77,34 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str
return code, nil
}
-func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedDeviceData, error) {
+// ApprovePairing looks up by code (globally unique random token) and creates paired device.
+// The approver's tenant context determines paired_devices.tenant_id.
+func (s *PGPairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
// Prune expired
- s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
var reqID uuid.UUID
var senderID, channel, chatID string
var metaJSON []byte
- err := s.db.QueryRow(
- "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}') FROM pairing_requests WHERE code = $1", code,
- ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON)
+ var reqTenantID uuid.UUID
+ // Code lookup is cross-tenant (random token, approver is WS/HTTP user)
+ err := s.db.QueryRowContext(ctx,
+ "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = $1", code,
+ ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID)
if err != nil {
return nil, fmt.Errorf("pairing code %s not found or expired", code)
}
// Remove from pending
- s.db.Exec("DELETE FROM pairing_requests WHERE id = $1", reqID)
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = $1", reqID)
- // Add to paired (with expiry for defense-in-depth)
+ // Add to paired — use the request's tenant (the channel that initiated pairing)
now := time.Now()
expiresAt := now.Add(pairedDeviceTTL)
- _, err = s.db.Exec(
- `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
- uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt,
+ _, err = s.db.ExecContext(ctx,
+ `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID,
)
if err != nil {
return nil, fmt.Errorf("create paired device: %w", err)
@@ -118,8 +125,9 @@ func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedD
}, nil
}
-func (s *PGPairingStore) DenyPairing(code string) error {
- result, err := s.db.Exec("DELETE FROM pairing_requests WHERE code = $1", code)
+func (s *PGPairingStore) DenyPairing(ctx context.Context, code string) error {
+ // Code lookup is cross-tenant (random token)
+ result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = $1", code)
if err != nil {
return err
}
@@ -130,8 +138,9 @@ func (s *PGPairingStore) DenyPairing(code string) error {
return nil
}
-func (s *PGPairingStore) RevokePairing(senderID, channel string) error {
- result, err := s.db.Exec("DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2", senderID, channel)
+func (s *PGPairingStore) RevokePairing(ctx context.Context, senderID, channel string) error {
+ tid := tenantIDForInsert(ctx)
+ result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid)
if err != nil {
return err
}
@@ -142,11 +151,12 @@ func (s *PGPairingStore) RevokePairing(senderID, channel string) error {
return nil
}
-func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) {
+func (s *PGPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
+ tid := tenantIDForInsert(ctx)
var count int64
- err := s.db.QueryRow(
- "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND (expires_at IS NULL OR expires_at > NOW())",
- senderID, channel,
+ err := s.db.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3 AND (expires_at IS NULL OR expires_at > NOW())",
+ senderID, channel, tid,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("pairing check query: %w", err)
@@ -154,12 +164,15 @@ func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) {
return count > 0, nil
}
-func (s *PGPairingStore) ListPending() []store.PairingRequestData {
- // Prune expired
- s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
+func (s *PGPairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
+ tid := tenantIDForInsert(ctx)
- rows, err := s.db.Query(
- "SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') FROM pairing_requests ORDER BY created_at DESC")
+ // Prune expired
+ s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now())
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}')
+ FROM pairing_requests WHERE tenant_id = $1 ORDER BY created_at DESC`, tid)
if err != nil {
return nil
}
@@ -186,11 +199,15 @@ func (s *PGPairingStore) ListPending() []store.PairingRequestData {
return result
}
-func (s *PGPairingStore) ListPaired() []store.PairedDeviceData {
- // Prune expired paired devices
- s.db.Exec("DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()")
+func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
+ tid := tenantIDForInsert(ctx)
- rows, err := s.db.Query("SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') FROM paired_devices ORDER BY paired_at DESC")
+ // Prune expired paired devices
+ s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()")
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}')
+ FROM paired_devices WHERE tenant_id = $1 ORDER BY paired_at DESC`, tid)
if err != nil {
return nil
}
diff --git a/internal/store/pg/pending_message_store.go b/internal/store/pg/pending_message_store.go
index 4736a480..ec340fa4 100644
--- a/internal/store/pg/pending_message_store.go
+++ b/internal/store/pg/pending_message_store.go
@@ -27,25 +27,26 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe
return nil
}
- // Build multi-row INSERT: VALUES ($1,$2,...,$10), ($11,$12,...,$20), ...
- const cols = 10
+ // Build multi-row INSERT: VALUES ($1,$2,...,$11), ($12,$13,...,$22), ...
+ const cols = 11
placeholders := make([]string, len(msgs))
args := make([]any, 0, len(msgs)*cols)
now := time.Now()
+ tid := tenantIDForInsert(ctx)
for i := range msgs {
if msgs[i].ID == uuid.Nil {
msgs[i].ID = uuid.Must(uuid.NewV7())
}
base := i * cols
- placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
- base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10)
+ placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)",
+ base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11)
args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey,
- msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now)
+ msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid)
}
_, err := s.db.ExecContext(ctx,
- `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at)
+ `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
VALUES `+strings.Join(placeholders, ","),
args...,
)
@@ -53,12 +54,16 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe
}
func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at
FROM channel_pending_messages
- WHERE channel_name = $1 AND history_key = $2
+ WHERE channel_name = $1 AND history_key = $2`+tClause+`
ORDER BY created_at ASC`,
- channelName, historyKey,
+ append([]any{channelName, historyKey}, tArgs...)...,
)
if err != nil {
return nil, err
@@ -77,9 +82,13 @@ func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, hist
}
func (s *PGPendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error {
- _, err := s.db.ExecContext(ctx,
- `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`,
- channelName, historyKey,
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause,
+ append([]any{channelName, historyKey}, tArgs...)...,
)
return err
}
@@ -123,9 +132,9 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU
}
now := time.Now()
_, err = tx.ExecContext(ctx,
- `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
- summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now,
+ `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
+ summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx),
)
if err != nil {
return fmt.Errorf("compact insert summary: %w", err)
@@ -136,9 +145,10 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU
func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan)
+ tid := tenantIDForInsert(ctx)
result, err := s.db.ExecContext(ctx,
- `DELETE FROM channel_pending_messages WHERE updated_at < $1`,
- cutoff,
+ `DELETE FROM channel_pending_messages WHERE updated_at < $1 AND tenant_id = $2`,
+ cutoff, tid,
)
if err != nil {
return 0, err
@@ -147,6 +157,14 @@ func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time.
}
func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 1)
+ if err != nil {
+ return nil, err
+ }
+ var where string
+ if tClause != "" {
+ where = ` WHERE m.tenant_id = $1`
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT channel_name, history_key,
COUNT(*) AS message_count,
@@ -165,9 +183,10 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending
)
) AS has_summary,
MAX(created_at) AS last_activity
- FROM channel_pending_messages m
+ FROM channel_pending_messages m`+where+`
GROUP BY channel_name, history_key
ORDER BY last_activity DESC`,
+ tArgs...,
)
if err != nil {
return nil, err
@@ -186,16 +205,30 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending
}
func (s *PGPendingMessageStore) CountAll(ctx context.Context) (int64, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 1)
+ if err != nil {
+ return 0, err
+ }
var count int64
- err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_pending_messages`).Scan(&count)
+ var query string
+ if tClause != "" {
+ query = `SELECT COUNT(*) FROM channel_pending_messages WHERE tenant_id = $1`
+ } else {
+ query = `SELECT COUNT(*) FROM channel_pending_messages`
+ }
+ err = s.db.QueryRowContext(ctx, query, tArgs...).Scan(&count)
return count, err
}
func (s *PGPendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return 0, err
+ }
var count int
- err := s.db.QueryRowContext(ctx,
- `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`,
- channelName, historyKey,
+ err = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause,
+ append([]any{channelName, historyKey}, tArgs...)...,
).Scan(&count)
return count, err
}
@@ -216,11 +249,22 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
args = append(args, g.ChannelName, g.HistoryKey)
}
+ tenantFilter := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ argIdx := len(args) + 1
+ tenantFilter = fmt.Sprintf(" AND tenant_id = $%d", argIdx)
+ args = append(args, tid)
+ }
+
rows, err := s.db.QueryContext(ctx,
- `SELECT session_key, metadata->>'chat_title'
- FROM sessions
- WHERE metadata->>'chat_title' != ''
- AND (`+strings.Join(conditions, " OR ")+`)`,
+ "SELECT session_key, metadata->>'chat_title'"+
+ " FROM sessions"+
+ " WHERE metadata->>'chat_title' != ''"+
+ " AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter,
args...,
)
if err != nil {
diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go
index 4db4aa26..18b10c35 100644
--- a/internal/store/pg/providers.go
+++ b/internal/store/pg/providers.go
@@ -50,21 +50,28 @@ func (s *PGProviderStore) CreateProvider(ctx context.Context, p *store.LLMProvid
now := time.Now()
p.CreatedAt = now
p.UpdatedAt = now
+ tid := tenantIDForInsert(ctx)
+ p.TenantID = tid
_, err := s.db.ExecContext(ctx,
- `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
- p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now,
+ `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
+ p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid,
)
return err
}
func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
var p store.LLMProviderData
var apiKey string
- err := s.db.QueryRowContext(ctx,
- `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at
- FROM llm_providers WHERE id = $1`, id,
- ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt)
+ err = s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE id = $1`+tClause,
+ append([]any{id}, tArgs...)...,
+ ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID)
if err != nil {
return nil, fmt.Errorf("provider not found: %s", id)
}
@@ -73,12 +80,17 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store
}
func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
var p store.LLMProviderData
var apiKey string
- err := s.db.QueryRowContext(ctx,
- `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at
- FROM llm_providers WHERE name = $1`, name,
- ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt)
+ err = s.db.QueryRowContext(ctx,
+ `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE name = $1`+tClause,
+ append([]any{name}, tArgs...)...,
+ ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID)
if err != nil {
return nil, fmt.Errorf("provider not found: %s", name)
}
@@ -87,9 +99,13 @@ func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*
}
func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at
- FROM llm_providers ORDER BY name`)
+ tClause, tArgs, err := tenantClauseN(ctx, 1)
+ if err != nil {
+ return nil, err
+ }
+ q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id
+ FROM llm_providers WHERE true` + tClause + ` ORDER BY name`
+ rows, err := s.db.QueryContext(ctx, q, tArgs...)
if err != nil {
return nil, err
}
@@ -99,7 +115,7 @@ func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProvide
for rows.Next() {
var p store.LLMProviderData
var apiKey string
- if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil {
+ if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID); err != nil {
continue
}
p.APIKey = s.decryptKey(apiKey, p.Name)
@@ -118,11 +134,25 @@ func (s *PGProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, upda
updates["api_key"] = encrypted
}
}
- return execMapUpdate(ctx, s.db, "llm_providers", id, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "llm_providers", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid)
}
func (s *PGProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM llm_providers WHERE id = $1", id)
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM llm_providers WHERE id = $1"+tClause,
+ append([]any{id}, tArgs...)...,
+ )
return err
}
diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go
index 5ed8e6eb..462d173b 100644
--- a/internal/store/pg/secure_cli.go
+++ b/internal/store/pg/secure_cli.go
@@ -50,23 +50,37 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary)
b.CreatedAt = now
b.UpdatedAt = now
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env,
- deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
+ deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at, tenant_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description,
envBytes,
jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose),
b.TimeoutSeconds, b.Tips,
nilUUID(b.AgentID), b.Enabled,
- b.CreatedBy, now, now,
+ b.CreatedBy, now, now, tenantID,
)
return err
}
func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id)
+ return s.scanRow(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
row := s.db.QueryRowContext(ctx,
- `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id)
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2`, id, tenantID)
return s.scanRow(row)
}
@@ -179,17 +193,42 @@ func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map
}
}
updates["updated_at"] = time.Now()
- return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "secure_cli_binaries", updates, id, tid)
}
func (s *PGSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2", id, tid)
return err
}
func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries ORDER BY binary_name, agent_id NULLS LAST`)
+ query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ query += ` WHERE tenant_id = $1`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY binary_name, agent_id NULLS LAST`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
@@ -197,10 +236,24 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e
}
func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) {
+ if store.IsCrossTenant(ctx) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
+ WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true
+ ORDER BY binary_name, agent_id NULLS LAST`, agentID)
+ if err != nil {
+ return nil, err
+ }
+ return s.scanRows(rows)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
- WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true
- ORDER BY binary_name, agent_id NULLS LAST`, agentID)
+ WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true AND tenant_id = $2
+ ORDER BY binary_name, agent_id NULLS LAST`, agentID, tenantID)
if err != nil {
return nil, err
}
@@ -212,15 +265,35 @@ func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) (
func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) {
var row *sql.Row
if agentID != nil {
- row = s.db.QueryRowContext(ctx,
- `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
- WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true
- ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID)
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
+ WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true
+ ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, nil
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
+ WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true AND tenant_id = $3
+ ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID, tid)
+ }
} else {
- row = s.db.QueryRowContext(ctx,
- `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
- WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true
- LIMIT 1`, binaryName)
+ if store.IsCrossTenant(ctx) {
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
+ WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true LIMIT 1`, binaryName)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, nil
+ }
+ row = s.db.QueryRowContext(ctx,
+ `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
+ WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true AND tenant_id = $2 LIMIT 1`, binaryName, tid)
+ }
}
b, err := s.scanRow(row)
if err == sql.ErrNoRows {
@@ -230,9 +303,18 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
}
func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries
- WHERE enabled = true ORDER BY binary_name`)
+ query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true`
+ var qArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ query += ` AND tenant_id = $1`
+ qArgs = append(qArgs, tenantID)
+ }
+ query += ` ORDER BY binary_name`
+ rows, err := s.db.QueryContext(ctx, query, qArgs...)
if err != nil {
return nil, err
}
diff --git a/internal/store/pg/sessions.go b/internal/store/pg/sessions.go
index d7a53730..3ea7c243 100644
--- a/internal/store/pg/sessions.go
+++ b/internal/store/pg/sessions.go
@@ -1,6 +1,7 @@
package pg
import (
+ "context"
"database/sql"
"encoding/json"
"log/slog"
@@ -58,17 +59,27 @@ func (s *PGSessionStore) migrateLegacyWSKeys() {
}
}
-func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData {
+// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions.
+// Two tenants with the same agent_key produce different cache keys.
+func sessionCacheKey(ctx context.Context, key string) string {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ return tid.String() + ":" + key
+}
+
+func (s *PGSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData {
s.mu.Lock()
defer s.mu.Unlock()
- if cached, ok := s.cache[key]; ok {
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
- data := s.loadFromDB(key)
+ data := s.loadFromDB(ctx, key)
if data != nil {
- s.cache[key] = data
+ s.cache[sessionCacheKey(ctx, key)] = data
return data
}
@@ -89,30 +100,54 @@ func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData {
data.TeamID = teamID
}
}
- s.cache[key] = data
+ s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.Exec(
- `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id)
- VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (session_key) DO NOTHING`,
- uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID,
+ `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
+ uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx),
)
return data
}
-func (s *PGSessionStore) AddMessage(key string, msg providers.Message) {
+// Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
+func (s *PGSessionStore) Get(ctx context.Context, key string) *store.SessionData {
+ s.mu.RLock()
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ s.mu.RUnlock()
+ return cached
+ }
+ s.mu.RUnlock()
+
s.mu.Lock()
defer s.mu.Unlock()
- data := s.getOrInit(key)
+ // Double-check after acquiring write lock
+ if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
+ return cached
+ }
+
+ data := s.loadFromDB(ctx, key)
+ if data != nil {
+ s.cache[sessionCacheKey(ctx, key)] = data
+ }
+ return data
+}
+
+func (s *PGSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ data := s.getOrInit(ctx, key)
data.Messages = append(data.Messages, msg)
data.Updated = time.Now()
}
-func (s *PGSessionStore) GetHistory(key string) []providers.Message {
+func (s *PGSessionStore) GetHistory(ctx context.Context, key string) []providers.Message {
s.mu.RLock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
s.mu.RUnlock()
@@ -125,62 +160,62 @@ func (s *PGSessionStore) GetHistory(key string) []providers.Message {
defer s.mu.Unlock()
// Double-check after acquiring write lock
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
- data := s.loadFromDB(key)
+ data := s.loadFromDB(ctx, key)
if data == nil {
return nil
}
- s.cache[key] = data
+ s.cache[sessionCacheKey(ctx, key)] = data
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
-func (s *PGSessionStore) GetSummary(key string) string {
+func (s *PGSessionStore) GetSummary(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Summary
}
return ""
}
-func (s *PGSessionStore) SetSummary(key, summary string) {
+func (s *PGSessionStore) SetSummary(ctx context.Context, key, summary string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Summary = summary
data.Updated = time.Now()
}
}
-func (s *PGSessionStore) GetLabel(key string) string {
+func (s *PGSessionStore) GetLabel(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Label
}
return ""
}
-func (s *PGSessionStore) SetLabel(key, label string) {
+func (s *PGSessionStore) SetLabel(ctx context.Context, key, label string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Label = label
data.Updated = time.Now()
}
}
-func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string {
+func (s *PGSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok && data.Metadata != nil {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil {
out := make(map[string]string, len(data.Metadata))
maps.Copy(out, data.Metadata)
return out
@@ -188,10 +223,10 @@ func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string {
return nil
}
-func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]string) {
+func (s *PGSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
- data := s.getOrInit(key)
+ data := s.getOrInit(ctx, key)
if data.Metadata == nil {
data.Metadata = make(map[string]string)
}
@@ -199,10 +234,10 @@ func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]stri
data.Updated = time.Now()
}
-func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID string) {
+func (s *PGSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) {
s.mu.Lock()
defer s.mu.Unlock()
- data := s.getOrInit(key)
+ data := s.getOrInit(ctx, key)
if agentUUID != uuid.Nil {
data.AgentUUID = agentUUID
}
@@ -211,10 +246,10 @@ func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID st
}
}
-func (s *PGSessionStore) UpdateMetadata(key, model, provider, channel string) {
+func (s *PGSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if model != "" {
data.Model = model
}
diff --git a/internal/store/pg/sessions_list.go b/internal/store/pg/sessions_list.go
index 05e63005..9e6192cc 100644
--- a/internal/store/pg/sessions_list.go
+++ b/internal/store/pg/sessions_list.go
@@ -1,7 +1,7 @@
package pg
import (
- "database/sql"
+ "context"
"encoding/json"
"fmt"
"strings"
@@ -41,6 +41,12 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string,
args = append(args, opts.UserID)
idx++
}
+ if opts.TenantID != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("%stenant_id = $%d", prefix, idx))
+ args = append(args, opts.TenantID)
+ idx++
+ }
+ _ = idx // consumed
if len(conditions) == 0 {
return "", nil
@@ -48,17 +54,34 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string,
return " WHERE " + strings.Join(conditions, " AND "), args
}
-func (s *PGSessionStore) List(agentID string) []store.SessionInfo {
- var rows *sql.Rows
- var err error
+func (s *PGSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo {
+ var conditions []string
+ var args []any
+ idx := 1
+
if agentID != "" {
- prefix := "agent:" + agentID + ":%"
- rows, err = s.db.Query(
- "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC", prefix)
- } else {
- rows, err = s.db.Query(
- "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions ORDER BY updated_at DESC")
+ conditions = append(conditions, fmt.Sprintf("session_key LIKE $%d", idx))
+ args = append(args, "agent:"+agentID+":%")
+ idx++
}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx))
+ args = append(args, tid)
+ idx++
+ }
+ }
+ _ = idx
+
+ where := ""
+ if len(conditions) > 0 {
+ where = " WHERE " + strings.Join(conditions, " AND ")
+ }
+
+ rows, err := s.db.QueryContext(ctx,
+ "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC",
+ args...)
if err != nil {
return nil
}
@@ -94,7 +117,7 @@ func (s *PGSessionStore) List(agentID string) []store.SessionInfo {
return result
}
-func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionListResult {
+func (s *PGSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
@@ -106,7 +129,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList
// Count total
var total int
countQ := "SELECT COUNT(*) FROM sessions" + where
- if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil {
+ if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0}
}
@@ -116,7 +139,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList
FROM sessions%s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d`, where, nextIdx, nextIdx+1)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
- rows, err := s.db.Query(selectQ, selectArgs...)
+ rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total}
}
@@ -154,7 +177,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList
}
// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
-func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.SessionListRichResult {
+func (s *PGSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
@@ -166,7 +189,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
// Count total
var total int
countQ := "SELECT COUNT(*) FROM sessions s" + where
- if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil {
+ if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
}
@@ -185,7 +208,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
%s ORDER BY s.updated_at DESC LIMIT $%d OFFSET $%d`, richCols, where, nextIdx, nextIdx+1)
selectArgs := append(append([]any{}, whereArgs...), limit, offset)
- rows, err := s.db.Query(selectQ, selectArgs...)
+ rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...)
if err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
}
@@ -238,9 +261,9 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
return store.SessionListRichResult{Sessions: result, Total: total}
}
-func (s *PGSessionStore) Save(key string) error {
+func (s *PGSessionStore) Save(ctx context.Context, key string) error {
s.mu.RLock()
- data, ok := s.cache[key]
+ data, ok := s.cache[sessionCacheKey(ctx, key)]
if !ok {
s.mu.RUnlock()
return nil
@@ -258,7 +281,7 @@ func (s *PGSessionStore) Save(key string) error {
metaJSON, _ = json.Marshal(snapshot.Metadata)
}
- _, err := s.db.Exec(
+ _, err := s.db.ExecContext(ctx,
`UPDATE sessions SET
messages = $1, summary = $2, model = $3, provider = $4, channel = $5,
input_tokens = $6, output_tokens = $7, compaction_count = $8,
@@ -266,30 +289,33 @@ func (s *PGSessionStore) Save(key string) error {
label = $11, spawned_by = $12, spawn_depth = $13,
agent_id = $14, user_id = $15, metadata = $16, updated_at = $17,
team_id = $18
- WHERE session_key = $19`,
+ WHERE session_key = $19 AND tenant_id = $20`,
msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel),
snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount,
snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt,
nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth,
nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated,
snapshot.TeamID,
- key,
+ key, tenantIDForInsert(ctx),
)
return err
}
-func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) {
+func (s *PGSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) {
prefix := "agent:" + agentID + ":%"
+ tid := tenantIDForInsert(ctx)
var sessionKey string
- err := s.db.QueryRow(
+ err := s.db.QueryRowContext(ctx,
`SELECT session_key FROM sessions
WHERE session_key LIKE $1
AND session_key NOT LIKE $2
AND session_key NOT LIKE $3
+ AND tenant_id = $4
ORDER BY updated_at DESC LIMIT 1`,
prefix,
"agent:"+agentID+":cron:%",
"agent:"+agentID+":subagent:%",
+ tid,
).Scan(&sessionKey)
if err != nil {
return "", ""
@@ -303,15 +329,15 @@ func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) {
// --- helpers ---
-func (s *PGSessionStore) getOrInit(key string) *store.SessionData {
- if data, ok := s.cache[key]; ok {
+func (s *PGSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data
}
// Try loading from DB first to avoid overwriting existing messages
- data := s.loadFromDB(key)
+ data := s.loadFromDB(ctx, key)
if data != nil {
- s.cache[key] = data
+ s.cache[sessionCacheKey(ctx, key)] = data
return data
}
@@ -323,18 +349,18 @@ func (s *PGSessionStore) getOrInit(key string) *store.SessionData {
Created: now,
Updated: now,
}
- s.cache[key] = data
+ s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.Exec(
- `INSERT INTO sessions (id, session_key, messages, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_key) DO NOTHING`,
- uuid.Must(uuid.NewV7()), key, msgsJSON, now, now,
+ `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
+ uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx),
)
return data
}
-func (s *PGSessionStore) loadFromDB(key string) *store.SessionData {
+func (s *PGSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData {
var sessionKey string
var msgsJSON []byte
var summary, model, provider, channel, label, spawnedBy, userID *string
@@ -345,13 +371,14 @@ func (s *PGSessionStore) loadFromDB(key string) *store.SessionData {
var createdAt, updatedAt time.Time
var metaJSON *[]byte
- err := s.db.QueryRow(
+ tid := tenantIDForInsert(ctx)
+ err := s.db.QueryRowContext(ctx,
`SELECT session_key, messages, summary, model, provider, channel,
input_tokens, output_tokens, compaction_count,
memory_flush_compaction_count, memory_flush_at,
label, spawned_by, spawn_depth, agent_id, user_id,
COALESCE(metadata, '{}'), created_at, updated_at, team_id
- FROM sessions WHERE session_key = $1`, key,
+ FROM sessions WHERE session_key = $1 AND tenant_id = $2`, key, tid,
).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel,
&inputTokens, &outputTokens, &compactionCount,
&memoryFlushCompactionCount, &memoryFlushAt,
diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go
index 5033b6de..f835c871 100644
--- a/internal/store/pg/sessions_ops.go
+++ b/internal/store/pg/sessions_ops.go
@@ -1,15 +1,16 @@
package pg
import (
+ "context"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
-func (s *PGSessionStore) TruncateHistory(key string, keepLast int) {
+func (s *PGSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if keepLast <= 0 {
data.Messages = []providers.Message{}
} else if len(data.Messages) > keepLast {
@@ -19,28 +20,28 @@ func (s *PGSessionStore) TruncateHistory(key string, keepLast int) {
}
}
-func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) {
+func (s *PGSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Messages = msgs
data.Updated = time.Now()
}
}
-func (s *PGSessionStore) Reset(key string) {
+func (s *PGSessionStore) Reset(ctx context.Context, key string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Messages = []providers.Message{}
data.Summary = ""
data.Updated = time.Now()
}
}
-func (s *PGSessionStore) Delete(key string) error {
+func (s *PGSessionStore) Delete(ctx context.Context, key string) error {
s.mu.Lock()
- delete(s.cache, key)
+ delete(s.cache, sessionCacheKey(ctx, key))
s.mu.Unlock()
// Clean up associated media files before deleting from DB.
@@ -48,6 +49,7 @@ func (s *PGSessionStore) Delete(key string) error {
s.OnDelete(key)
}
- _, err := s.db.Exec("DELETE FROM sessions WHERE session_key = $1", key)
+ tid := tenantIDForInsert(ctx)
+ _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = $1 AND tenant_id = $2", key, tid)
return err
}
diff --git a/internal/store/pg/sessions_tokens.go b/internal/store/pg/sessions_tokens.go
index 2aace97c..45fd7c76 100644
--- a/internal/store/pg/sessions_tokens.go
+++ b/internal/store/pg/sessions_tokens.go
@@ -1,90 +1,93 @@
package pg
-import "time"
+import (
+ "context"
+ "time"
+)
-func (s *PGSessionStore) AccumulateTokens(key string, input, output int64) {
+func (s *PGSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.InputTokens += input
data.OutputTokens += output
}
}
-func (s *PGSessionStore) IncrementCompaction(key string) {
+func (s *PGSessionStore) IncrementCompaction(ctx context.Context, key string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.CompactionCount++
}
}
-func (s *PGSessionStore) GetCompactionCount(key string) int {
+func (s *PGSessionStore) GetCompactionCount(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.CompactionCount
}
return 0
}
-func (s *PGSessionStore) GetMemoryFlushCompactionCount(key string) int {
+func (s *PGSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.MemoryFlushCompactionCount
}
return -1
}
-func (s *PGSessionStore) SetMemoryFlushDone(key string) {
+func (s *PGSessionStore) SetMemoryFlushDone(ctx context.Context, key string) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.MemoryFlushCompactionCount = data.CompactionCount
data.MemoryFlushAt = time.Now().UnixMilli()
}
}
-func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) {
+func (s *PGSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.SpawnedBy = spawnedBy
data.SpawnDepth = depth
}
}
-func (s *PGSessionStore) SetContextWindow(key string, cw int) {
+func (s *PGSessionStore) SetContextWindow(ctx context.Context, key string, cw int) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.ContextWindow = cw
}
}
-func (s *PGSessionStore) GetContextWindow(key string) int {
+func (s *PGSessionStore) GetContextWindow(ctx context.Context, key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.ContextWindow
}
return 0
}
-func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) {
+func (s *PGSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) {
s.mu.Lock()
defer s.mu.Unlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.LastPromptTokens = tokens
data.LastMessageCount = msgCount
}
}
-func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) {
+func (s *PGSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) {
s.mu.RLock()
defer s.mu.RUnlock()
- if data, ok := s.cache[key]; ok {
+ if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.LastPromptTokens, data.LastMessageCount
}
return 0, 0
diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go
index 4b860069..fbb5f84d 100644
--- a/internal/store/pg/skills.go
+++ b/internal/store/pg/skills.go
@@ -1,6 +1,7 @@
package pg
import (
+ "context"
"database/sql"
"encoding/json"
"log/slog"
@@ -27,22 +28,29 @@ type PGSkillStore struct {
cache map[string]*store.SkillInfo
version atomic.Int64
- // List cache: cached result of ListSkills() with version + TTL validation
- listCache []store.SkillInfo
- listVer int64
- listTime time.Time
+ // List cache: per-tenant cached result of ListSkills() with version + TTL validation.
+ // Key is tenant UUID; uuid.Nil = cross-tenant (system admin).
+ listCache map[uuid.UUID]*listCacheEntry
ttl time.Duration
// Embedding provider for vector-based skill search
embProvider store.EmbeddingProvider
}
+// listCacheEntry holds per-tenant cached skill list with version + TTL.
+type listCacheEntry struct {
+ skills []store.SkillInfo
+ ver int64
+ time time.Time
+}
+
func NewPGSkillStore(db *sql.DB, baseDir string) *PGSkillStore {
return &PGSkillStore{
- db: db,
- baseDir: baseDir,
- cache: make(map[string]*store.SkillInfo),
- ttl: defaultSkillsCacheTTL,
+ db: db,
+ baseDir: baseDir,
+ cache: make(map[string]*store.SkillInfo),
+ listCache: make(map[uuid.UUID]*listCacheEntry),
+ ttl: defaultSkillsCacheTTL,
}
}
@@ -50,12 +58,17 @@ func (s *PGSkillStore) Version() int64 { return s.version.Load() }
func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} }
-func (s *PGSkillStore) ListSkills() []store.SkillInfo {
+func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
currentVer := s.version.Load()
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil && !store.IsCrossTenant(ctx) {
+ tid = store.MasterTenantID
+ }
+ // Check per-tenant cache
s.mu.RLock()
- if s.listCache != nil && s.listVer == currentVer && time.Since(s.listTime) < s.ttl {
- result := s.listCache
+ if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl {
+ result := entry.skills
s.mu.RUnlock()
return result
}
@@ -64,8 +77,19 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo {
// Cache miss or TTL expired → query DB
// Returns active + archived + system skills. Archived skills are shown dimmed in the UI
// so admins can see missing deps and re-activate after installing them.
- rows, err := s.db.Query(
- `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`)
+ // Tenant filter: system skills visible globally, custom skills scoped to tenant.
+ var rows *sql.Rows
+ var err error
+ if store.IsCrossTenant(ctx) {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path
+ FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`)
+ } else {
+ rows, err = s.db.QueryContext(ctx,
+ `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path
+ FROM skills WHERE (status IN ('active', 'archived') OR is_system = true) AND (is_system = true OR tenant_id = $1)
+ ORDER BY name`, tid)
+ }
if err != nil {
return nil
}
@@ -100,9 +124,7 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo {
}
s.mu.Lock()
- s.listCache = result
- s.listVer = currentVer
- s.listTime = time.Now()
+ s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()}
s.mu.Unlock()
return result
@@ -110,8 +132,8 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo {
// ListAllSkills returns all enabled skills regardless of status (for admin operations like rescan-deps).
// Disabled skills are excluded — no point scanning or updating them.
-func (s *PGSkillStore) ListAllSkills() []store.SkillInfo {
- rows, err := s.db.Query(
+func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
+ rows, err := s.db.QueryContext(ctx,
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path FROM skills WHERE enabled = true AND status != 'deleted' ORDER BY name`)
if err != nil {
return nil
diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go
index e041cdc5..6e157176 100644
--- a/internal/store/pg/skills_content.go
+++ b/internal/store/pg/skills_content.go
@@ -1,6 +1,7 @@
package pg
import (
+ "context"
"fmt"
"os"
"regexp"
@@ -15,13 +16,22 @@ import (
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
-func (s *PGSkillStore) LoadSkill(name string) (string, bool) {
+func (s *PGSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) {
var slug string
var version int
var filePath *string
- err := s.db.QueryRow(
- "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'", name,
- ).Scan(&slug, &version, &filePath)
+ // Tenant filter: system skills visible globally, custom skills scoped to tenant.
+ q := "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'"
+ args := []any{name}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath)
if err != nil {
return "", false
}
@@ -33,14 +43,14 @@ func (s *PGSkillStore) LoadSkill(name string) (string, bool) {
return content, true
}
-func (s *PGSkillStore) LoadForContext(allowList []string) string {
- skills := s.FilterSkills(allowList)
+func (s *PGSkillStore) LoadForContext(ctx context.Context, allowList []string) string {
+ skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
var parts []string
for _, sk := range skills {
- content, ok := s.LoadSkill(sk.Name)
+ content, ok := s.LoadSkill(ctx, sk.Name)
if !ok {
continue
}
@@ -60,8 +70,8 @@ func (s *PGSkillStore) LoadForContext(allowList []string) string {
return result.String()
}
-func (s *PGSkillStore) BuildSummary(allowList []string) string {
- skills := s.FilterSkills(allowList)
+func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) string {
+ skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
@@ -78,7 +88,7 @@ func (s *PGSkillStore) BuildSummary(allowList []string) string {
return result.String()
}
-func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
+func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
var id uuid.UUID
var skillName, slug, visibility string
var desc *string
@@ -86,9 +96,17 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
var version int
var isSystem bool
var filePath *string
- err := s.db.QueryRow(
- "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'", name,
- ).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath)
+ q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'"
+ args := []any{name}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath)
if err != nil {
return nil, false
}
@@ -99,8 +117,8 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
return &info, true
}
-func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo {
- all := s.ListSkills()
+func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
+ all := s.ListSkills(ctx)
var filtered []store.SkillInfo
if allowList == nil {
// No allowList → return all enabled skills (for agent injection)
@@ -128,7 +146,8 @@ func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo {
// GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag.
// Used by admin operations (e.g. toggle) that need full skill info.
-func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) {
+// Tenant filter: system skills visible globally, custom skills scoped to tenant.
+func (s *PGSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) {
var name, slug, visibility, status string
var desc *string
var tags []string
@@ -136,11 +155,18 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) {
var isSystem, enabled bool
var depsRaw []byte
var filePath *string
- err := s.db.QueryRow(
- `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
- FROM skills WHERE id = $1`,
- id,
- ).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath)
+ q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
+ FROM skills WHERE id = $1`
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath)
if err != nil {
return store.SkillInfo{}, false
}
@@ -156,10 +182,19 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) {
// GetSkillOwnerID returns the owner_id for a skill by UUID.
// Returns ("", false) if the skill does not exist.
-func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) {
+func (s *PGSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) {
+ q := "SELECT owner_id FROM skills WHERE id = $1"
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
var ownerID string
- err := s.db.QueryRow("SELECT owner_id FROM skills WHERE id = $1", id).Scan(&ownerID)
- if err != nil {
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
@@ -167,10 +202,19 @@ func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) {
// GetSkillOwnerIDBySlug returns the owner_id for a skill by slug.
// Returns ("", false) if the skill does not exist or is archived.
-func (s *PGSkillStore) GetSkillOwnerIDBySlug(slug string) (string, bool) {
+func (s *PGSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) {
+ q := "SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'"
+ args := []any{slug}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
var ownerID string
- err := s.db.QueryRow("SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'", slug).Scan(&ownerID)
- if err != nil {
+ if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go
index b6cd7dea..8c79a856 100644
--- a/internal/store/pg/skills_crud.go
+++ b/internal/store/pg/skills_crud.go
@@ -29,9 +29,9 @@ type SkillCreateParams struct {
func (s *PGSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error {
id := store.GenNewID()
_, err := s.db.Exec(
- `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, NOW(), NOW())`,
- id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash,
+ `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())`,
+ id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID,
)
if err == nil {
s.BumpVersion()
@@ -39,42 +39,68 @@ func (s *PGSkillStore) CreateSkill(name, slug string, description *string, owner
return err
}
-func (s *PGSkillStore) UpdateSkill(id uuid.UUID, updates map[string]any) error {
- if err := execMapUpdate(context.Background(), s.db, "skills", id, updates); err != nil {
+func (s *PGSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ var err error
+ if store.IsCrossTenant(ctx) {
+ err = execMapUpdate(ctx, s.db, "skills", id, updates)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ } else {
+ err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid)
+ }
+ }
+ if err != nil {
return err
}
s.BumpVersion()
return nil
}
-func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error {
+func (s *PGSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error {
// Reject deletion of system skills
var isSystem bool
- if err := s.db.QueryRow("SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil {
+ if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil {
return fmt.Errorf("check skill: %w", err)
}
if isSystem {
return fmt.Errorf("cannot delete system skill")
}
- tx, err := s.db.Begin()
+ // Tenant-scoped delete: only delete skill owned by this tenant.
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ var skillTenantID uuid.UUID
+ if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = $1", id).Scan(&skillTenantID); err != nil {
+ return fmt.Errorf("skill not found")
+ }
+ if skillTenantID != tid {
+ return fmt.Errorf("skill not found")
+ }
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Cascade: remove all agent grants for this skill
- if _, err := tx.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill grants: %w", err)
}
// Cascade: remove all user grants for this skill
- if _, err := tx.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil {
+ if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil {
return fmt.Errorf("delete skill user grants: %w", err)
}
// Soft-delete the skill (use 'deleted' status, distinct from 'archived' which means missing deps)
- if _, err := tx.Exec("UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil {
+ if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil {
return fmt.Errorf("delete skill: %w", err)
}
@@ -132,12 +158,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara
return uuid.Nil, fmt.Errorf("get next version: %w", err)
}
+ // Resolve tenant_id: default to MasterTenantID if no tenant in context.
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
id := store.GenNewID()
var returnedID uuid.UUID
err = tx.QueryRowContext(ctx,
- `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())
- ON CONFLICT (slug) DO UPDATE SET
+ `INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $11, $12, NOW(), NOW())
+ ON CONFLICT (tenant_id, slug) DO UPDATE SET
name = EXCLUDED.name, description = EXCLUDED.description,
version = EXCLUDED.version, frontmatter = EXCLUDED.frontmatter,
file_path = EXCLUDED.file_path,
@@ -145,7 +177,7 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara
visibility = CASE WHEN skills.status IN ('archived', 'deleted') THEN 'private' ELSE skills.visibility END,
status = 'active', updated_at = NOW()
RETURNING id`,
- id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, version,
+ id, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version,
fmJSON, p.FilePath, p.FileSize, p.FileHash,
).Scan(&returnedID)
if err != nil {
@@ -168,10 +200,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara
}
// GetSkillFilePath returns the filesystem path and version for a skill by UUID.
-func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) {
- err := s.db.QueryRow(
- "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id,
- ).Scan(&filePath, &slug, &version)
+func (s *PGSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, ok bool) {
+ q := "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'"
+ args := []any{id}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ tid = store.MasterTenantID
+ }
+ q += " AND (is_system = true OR tenant_id = $2)"
+ args = append(args, tid)
+ }
+ err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version)
return filePath, slug, version, err == nil
}
@@ -209,11 +249,19 @@ func (s *PGSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (i
}
// ToggleSkill enables or disables a skill by UUID.
-func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error {
- _, err := s.db.Exec(
- `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`,
- enabled, id,
- )
+func (s *PGSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error {
+ q := `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`
+ args := []any{enabled, id}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+ }
+
+ _, err := s.db.ExecContext(ctx, q, args...)
if err == nil {
s.BumpVersion()
}
diff --git a/internal/store/pg/skills_embedding.go b/internal/store/pg/skills_embedding.go
index 41af1fc0..468ad105 100644
--- a/internal/store/pg/skills_embedding.go
+++ b/internal/store/pg/skills_embedding.go
@@ -22,15 +22,27 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3
}
vecStr := vectorToString(embedding)
- rows, err := s.db.QueryContext(ctx,
- `SELECT name, slug, COALESCE(description, ''), version, file_path,
- 1 - (embedding <=> $1::vector) AS score
- FROM skills
- WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL
- AND visibility != 'private'
- ORDER BY embedding <=> $2::vector
- LIMIT $3`,
- vecStr, vecStr, limit,
+ // $1=vec, $2=vec → tenant at $3 (if needed), ORDER vec at $3+len(tcArgs), LIMIT after
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
+ tenantCond := ""
+ if tc != "" {
+ tenantCond = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", 3)
+ }
+ orderN := 3 + len(tcArgs)
+ limitN := orderN + 1
+ q := fmt.Sprintf(`SELECT name, slug, COALESCE(description, ''), version, file_path,
+ 1 - (embedding <=> $1::vector) AS score
+ FROM skills
+ WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL
+ AND visibility != 'private'%s
+ ORDER BY embedding <=> $%d::vector
+ LIMIT $%d`, tenantCond, orderN, limitN)
+
+ rows, err := s.db.QueryContext(ctx, q,
+ append(append([]any{vecStr}, tcArgs...), vecStr, limit)...,
)
if err != nil {
return nil, fmt.Errorf("embedding skill search: %w", err)
diff --git a/internal/store/pg/skills_grants.go b/internal/store/pg/skills_grants.go
index ed0a3c7d..f5273b4e 100644
--- a/internal/store/pg/skills_grants.go
+++ b/internal/store/pg/skills_grants.go
@@ -2,6 +2,7 @@ package pg
import (
"context"
+ "fmt"
"log/slog"
"time"
@@ -18,10 +19,10 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U
return err
}
_, err := s.db.ExecContext(ctx,
- `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at)
- VALUES ($1, $2, $3, $4, $5, $6)
+ `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = EXCLUDED.pinned_version`,
- store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(),
+ store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), tenantIDForInsert(ctx),
)
if err != nil {
return err
@@ -43,8 +44,13 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U
// RevokeFromAgent revokes a skill grant from an agent.
// Auto-demotes visibility from 'internal' back to 'private' when no agent grants remain.
func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error {
- _, err := s.db.ExecContext(ctx,
- "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2", skillID, agentID)
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2"+tClause,
+ append([]any{skillID, agentID}, tArgs...)...)
if err != nil {
return err
}
@@ -67,8 +73,13 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui
// ListAgentGrants returns all skill grants for an agent.
func (s *PGSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
rows, err := s.db.QueryContext(ctx,
- "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1", agentID)
+ "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1"+tClause,
+ append([]any{agentID}, tArgs...)...)
if err != nil {
return nil, err
}
@@ -95,35 +106,60 @@ func (s *PGSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userI
return err
}
_, err := s.db.ExecContext(ctx,
- `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at)
- VALUES ($1, $2, $3, $4, $5)
+ `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (skill_id, user_id) DO NOTHING`,
- store.GenNewID(), skillID, userID, grantedBy, time.Now(),
+ store.GenNewID(), skillID, userID, grantedBy, time.Now(), tenantIDForInsert(ctx),
)
return err
}
// RevokeFromUser revokes a skill grant from a user.
func (s *PGSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error {
- _, err := s.db.ExecContext(ctx,
- "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2", skillID, userID)
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2"+tClause,
+ append([]any{skillID, userID}, tArgs...)...)
return err
}
// ListAccessible returns skills accessible to a given agent+user combination.
// Access logic: public → all, private → owner only, internal → check grants.
+// System skills (is_system=true) are always visible regardless of tenant.
func (s *PGSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) {
+ // tenant filter: system skills bypass it, tenant-owned skills are filtered
+ tc, tcArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return nil, err
+ }
+ tenantCond := ""
+ if tc != "" {
+ // tc is " AND tenant_id = $3"; we need it as an OR condition inside the WHERE
+ tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 3)
+ _ = tc // tcArgs carries the value
+ }
+ // LEFT JOIN skill_tenant_configs to exclude per-tenant disabled skills.
+ // stc.enabled = false → skill explicitly disabled for this tenant.
+ stcJoin := ""
+ stcFilter := ""
+ if len(tcArgs) > 0 {
+ stcJoin = fmt.Sprintf(" LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = $%d", 3)
+ stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = true)"
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1
- LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2
- WHERE s.status = 'active' AND (
+ LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2`+stcJoin+`
+ WHERE s.status = 'active'`+tenantCond+stcFilter+` AND (
s.is_system = true
OR s.visibility = 'public'
OR (s.visibility = 'private' AND s.owner_id = $2)
OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL))
)
- ORDER BY s.name`, agentID, userID)
+ ORDER BY s.name`, append([]any{agentID, userID}, tcArgs...)...)
if err != nil {
return nil, err
}
@@ -166,6 +202,14 @@ type SkillWithGrantStatus struct {
// ListWithGrantStatus returns all active skills with grant status for a specific agent.
func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]SkillWithGrantStatus, error) {
+ tc, tcArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
+ tenantCond := ""
+ if tc != "" {
+ tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 2)
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version,
(sag.id IS NOT NULL) AS granted,
@@ -173,8 +217,8 @@ func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUI
s.is_system
FROM skills s
LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1
- WHERE s.status = 'active'
- ORDER BY s.name`, agentID)
+ WHERE s.status = 'active'`+tenantCond+`
+ ORDER BY s.name`, append([]any{agentID}, tcArgs...)...)
if err != nil {
return nil, err
}
diff --git a/internal/store/pg/snapshot.go b/internal/store/pg/snapshot.go
index 06646cfd..40125058 100644
--- a/internal/store/pg/snapshot.go
+++ b/internal/store/pg/snapshot.go
@@ -7,6 +7,8 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -19,7 +21,7 @@ func NewPGSnapshotStore(db *sql.DB) *PGSnapshotStore {
return &PGSnapshotStore{db: db}
}
-const snapshotFieldCount = 21
+const snapshotFieldCount = 22
// maxBatchRows limits each INSERT to stay under PG's 65535 param limit (65535 / 21 ≈ 3120).
const maxBatchRows = 3000
@@ -38,6 +40,11 @@ func (s *PGSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store
}
func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
var vals []string
var args []any
for i, snap := range snapshots {
@@ -53,6 +60,7 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa
snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount,
snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS,
snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations,
+ tenantID,
)
}
@@ -61,9 +69,10 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa
input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens,
total_cost, request_count, llm_call_count, tool_call_count,
error_count, unique_users, avg_duration_ms,
- memory_docs, memory_chunks, kg_entities, kg_relations
+ memory_docs, memory_chunks, kg_entities, kg_relations,
+ tenant_id
) VALUES ` + strings.Join(vals, ", ") + `
- ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel)
+ ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel, tenant_id)
DO UPDATE SET
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
@@ -92,7 +101,7 @@ func (s *PGSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQue
bucketExpr = "date_trunc('day', bucket_hour)"
}
- where, args := buildSnapshotWhere(q)
+ where, args := buildSnapshotWhere(ctx, q)
query := fmt.Sprintf(`SELECT
bucket_time,
@@ -187,7 +196,7 @@ func (s *PGSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuer
extraFilter = " AND provider != '' AND model != ''"
}
- where, args := buildSnapshotWhere(q)
+ where, args := buildSnapshotWhere(ctx, q)
if where == "" {
where = " WHERE 1=1"
}
@@ -250,11 +259,20 @@ func (s *PGSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, erro
}
// buildSnapshotWhere builds a dynamic WHERE clause from SnapshotQuery filters.
-func buildSnapshotWhere(q store.SnapshotQuery) (string, []any) {
+func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) {
var conds []string
var args []any
idx := 1
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conds = append(conds, fmt.Sprintf("tenant_id = $%d", idx))
+ args = append(args, tenantID)
+ idx++
+ }
+ }
+
if !q.From.IsZero() {
conds = append(conds, fmt.Sprintf("bucket_hour >= $%d", idx))
args = append(args, q.From)
diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go
index d7617b5a..c16e8bf2 100644
--- a/internal/store/pg/teams.go
+++ b/internal/store/pg/teams.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "fmt"
"time"
"github.com/google/uuid"
@@ -48,38 +49,78 @@ func (s *PGTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) erro
settings = json.RawMessage(`{}`)
}
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
+
_, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
team.ID, team.Name, team.LeadAgentID, team.Description,
- team.Status, settings, team.CreatedBy, now, now,
+ team.Status, settings, team.CreatedBy, now, now, tenantID,
)
return err
}
func (s *PGTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) {
+ if store.IsCrossTenant(ctx) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID)
+ return scanTeamRow(row)
+ }
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
row := s.db.QueryRowContext(ctx,
- `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID)
+ `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tenantID)
return scanTeamRow(row)
}
func (s *PGTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error {
- return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid)
}
func (s *PGTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error {
- _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID)
+ if store.IsCrossTenant(ctx) {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for delete")
+ }
+ _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tid)
return err
}
func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
+ var tenantFilter string
+ var queryArgs []any
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ tenantFilter = " WHERE t.tenant_id = $1"
+ queryArgs = append(queryArgs, tenantID)
+ }
+
rows, err := s.db.QueryContext(ctx,
`SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at,
COALESCE(a.agent_key, '') AS lead_agent_key,
COALESCE(a.display_name, '') AS lead_display_name
FROM agent_teams t
- LEFT JOIN agents a ON a.id = t.lead_agent_id
- ORDER BY t.created_at`)
+ LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+`
+ ORDER BY t.created_at`, queryArgs...)
if err != nil {
return nil, err
}
@@ -148,10 +189,10 @@ func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
func (s *PGTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error {
_, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at)
- VALUES ($1, $2, $3, $4)
+ `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (team_id, agent_id) DO UPDATE SET role = EXCLUDED.role`,
- teamID, agentID, role, time.Now(),
+ teamID, agentID, role, time.Now(), tenantIDForInsert(ctx),
)
return err
}
@@ -165,16 +206,27 @@ func (s *PGTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUI
}
func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
+ q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
COALESCE(a.agent_key, '') AS agent_key,
COALESCE(a.display_name, '') AS display_name,
COALESCE(a.frontmatter, '') AS frontmatter,
COALESCE(a.other_config->>'emoji', '') AS emoji
FROM agent_team_members m
JOIN agents a ON a.id = m.agent_id
- WHERE m.team_id = $1 AND a.status = 'active'
- ORDER BY m.joined_at`, teamID)
+ JOIN agent_teams at2 ON at2.id = m.team_id
+ WHERE m.team_id = $1 AND a.status = 'active'`
+ args := []any{teamID}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY m.joined_at`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -195,20 +247,31 @@ func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]stor
}
func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
+ q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at,
COALESCE(a.agent_key, '') AS agent_key,
COALESCE(a.display_name, '') AS display_name,
COALESCE(a.frontmatter, '') AS frontmatter,
COALESCE(a.other_config->>'emoji', '') AS emoji
FROM agent_team_members m
JOIN agents a ON a.id = m.agent_id
+ JOIN agent_teams at2 ON at2.id = m.team_id
WHERE m.team_id = $1 AND a.status = 'active' AND m.role != $2
AND NOT EXISTS (
- SELECT 1 FROM team_tasks t
- WHERE t.owner_agent_id = m.agent_id AND t.team_id = $1 AND t.status = $3
- )
- ORDER BY m.joined_at`, teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress)
+ SELECT 1 FROM team_tasks tt
+ WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = $1 AND tt.status = $3
+ )`
+ args := []any{teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY m.joined_at`
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -229,16 +292,24 @@ func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]
}
func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) {
- row := s.db.QueryRowContext(ctx,
- `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
+ q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
FROM agent_teams t
WHERE (
t.lead_agent_id = $1
OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = $1)
- ) AND t.status = $2
- ORDER BY (t.lead_agent_id = $1) DESC
- LIMIT 1`, agentID, store.TeamStatusActive)
+ ) AND t.status = $2`
+ args := []any{agentID, store.TeamStatusActive}
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+ }
+ q += ` ORDER BY (t.lead_agent_id = $1) DESC LIMIT 1`
+
+ row := s.db.QueryRowContext(ctx, q, args...)
d, err := scanTeamRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -250,13 +321,24 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit
if limit <= 0 {
limit = 100
}
- rows, err := s.db.QueryContext(ctx,
- `SELECT DISTINCT s.user_id
+ q := `SELECT DISTINCT s.user_id
FROM sessions s
JOIN agent_team_members m ON m.agent_id = s.agent_id
- WHERE m.team_id = $1 AND s.user_id != ''
- ORDER BY s.user_id
- LIMIT $2`, teamID, limit)
+ JOIN agent_teams at2 ON at2.id = m.team_id
+ WHERE m.team_id = $1 AND s.user_id != ''`
+ args := []any{teamID}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+ }
+ q += fmt.Sprintf(" ORDER BY s.user_id LIMIT $%d", len(args)+1)
+ args = append(args, limit)
+
+ rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -273,6 +355,110 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit
return users, rows.Err()
}
+// ============================================================
+// Team user grants
+// ============================================================
+
+func (s *PGTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`,
+ store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx),
+ )
+ return err
+}
+
+func (s *PGTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx,
+ `DELETE FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause,
+ append([]any{teamID, userID}, tArgs...)...)
+ return err
+}
+
+func (s *PGTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 2)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at
+ FROM team_user_grants WHERE team_id = $1`+tClause+` ORDER BY created_at DESC`,
+ append([]any{teamID}, tArgs...)...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var result []store.TeamUserGrant
+ for rows.Next() {
+ var g store.TeamUserGrant
+ if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &g.CreatedAt); err != nil {
+ return nil, err
+ }
+ result = append(result, g)
+ }
+ return result, rows.Err()
+}
+
+func (s *PGTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) {
+ baseQuery := `SELECT ` + teamSelectCols + `
+ FROM agent_teams t
+ WHERE t.status = $1
+ AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = $2)`
+ args := []any{store.TeamStatusActive, userID}
+
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, nil
+ }
+ baseQuery += ` AND t.tenant_id = $3`
+ args = append(args, tenantID)
+ }
+ baseQuery += ` ORDER BY t.created_at DESC`
+
+ rows, err := s.db.QueryContext(ctx, baseQuery, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var teams []store.TeamData
+ for rows.Next() {
+ var d store.TeamData
+ var desc sql.NullString
+ if err := rows.Scan(
+ &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
+ &d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ if desc.Valid {
+ d.Description = desc.String
+ }
+ teams = append(teams, d)
+ }
+ return teams, rows.Err()
+}
+
+func (s *PGTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) {
+ tClause, tArgs, err := tenantClauseN(ctx, 3)
+ if err != nil {
+ return false, err
+ }
+ var exists bool
+ err = s.db.QueryRowContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause+`)`,
+ append([]any{teamID, userID}, tArgs...)...,
+ ).Scan(&exists)
+ return exists, err
+}
+
// ============================================================
// Scan helpers
// ============================================================
diff --git a/internal/store/pg/teams_delegation.go b/internal/store/pg/teams_delegation.go
deleted file mode 100644
index 8d64aa10..00000000
--- a/internal/store/pg/teams_delegation.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package pg
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "fmt"
- "time"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/store"
-)
-
-// ============================================================
-// Delegation History
-// ============================================================
-
-func (s *PGTeamStore) SaveDelegationHistory(ctx context.Context, record *store.DelegationHistoryData) error {
- if record.ID == uuid.Nil {
- record.ID = store.GenNewID()
- }
- now := time.Now()
- record.CreatedAt = now
-
- metadata, _ := json.Marshal(record.Metadata)
- if len(metadata) == 0 {
- metadata = []byte(`{}`)
- }
-
- _, err := s.db.ExecContext(ctx,
- `INSERT INTO delegation_history (id, source_agent_id, target_agent_id, team_id, team_task_id, user_id, task, mode, status, result, error, iterations, trace_id, duration_ms, metadata, created_at, completed_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
- record.ID, record.SourceAgentID, record.TargetAgentID,
- record.TeamID, record.TeamTaskID,
- record.UserID, record.Task, record.Mode, record.Status,
- record.Result, record.Error, record.Iterations,
- record.TraceID, record.DurationMS, metadata, now, record.CompletedAt,
- )
- return err
-}
-
-func (s *PGTeamStore) ListDelegationHistory(ctx context.Context, opts store.DelegationHistoryListOpts) ([]store.DelegationHistoryData, int, error) {
- where := "WHERE 1=1"
- args := []any{}
- argN := 0
-
- nextArg := func(v any) string {
- argN++
- args = append(args, v)
- return fmt.Sprintf("$%d", argN)
- }
-
- if opts.SourceAgentID != nil {
- where += " AND d.source_agent_id = " + nextArg(*opts.SourceAgentID)
- }
- if opts.TargetAgentID != nil {
- where += " AND d.target_agent_id = " + nextArg(*opts.TargetAgentID)
- }
- if opts.TeamID != nil {
- where += " AND d.team_id = " + nextArg(*opts.TeamID)
- }
- if opts.UserID != "" {
- where += " AND d.user_id = " + nextArg(opts.UserID)
- }
- if opts.Status != "" {
- where += " AND d.status = " + nextArg(opts.Status)
- }
-
- // Count total
- var total int
- countSQL := "SELECT COUNT(*) FROM delegation_history d " + where
- if err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
- return nil, 0, err
- }
-
- // Fetch rows
- limit := opts.Limit
- if limit <= 0 || limit > 200 {
- limit = 50
- }
- offset := max(opts.Offset, 0)
-
- query := fmt.Sprintf(
- `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id,
- d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations,
- d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at,
- COALESCE(sa.agent_key, '') AS source_agent_key,
- COALESCE(ta.agent_key, '') AS target_agent_key
- FROM delegation_history d
- LEFT JOIN agents sa ON sa.id = d.source_agent_id
- LEFT JOIN agents ta ON ta.id = d.target_agent_id
- %s
- ORDER BY d.created_at DESC
- LIMIT %s OFFSET %s`,
- where, nextArg(limit), nextArg(offset))
-
- rows, err := s.db.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, 0, err
- }
- defer rows.Close()
-
- var records []store.DelegationHistoryData
- for rows.Next() {
- var d store.DelegationHistoryData
- var result, errStr sql.NullString
- var completedAt sql.NullTime
- var metadata json.RawMessage
- if err := rows.Scan(
- &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID,
- &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations,
- &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt,
- &d.SourceAgentKey, &d.TargetAgentKey,
- ); err != nil {
- return nil, 0, err
- }
- if result.Valid {
- d.Result = &result.String
- }
- if errStr.Valid {
- d.Error = &errStr.String
- }
- if completedAt.Valid {
- d.CompletedAt = &completedAt.Time
- }
- if len(metadata) > 0 && string(metadata) != "{}" {
- _ = json.Unmarshal(metadata, &d.Metadata)
- }
- records = append(records, d)
- }
- return records, total, rows.Err()
-}
-
-func (s *PGTeamStore) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*store.DelegationHistoryData, error) {
- var d store.DelegationHistoryData
- var result, errStr sql.NullString
- var completedAt sql.NullTime
- var metadata json.RawMessage
-
- err := s.db.QueryRowContext(ctx,
- `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id,
- d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations,
- d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at,
- COALESCE(sa.agent_key, '') AS source_agent_key,
- COALESCE(ta.agent_key, '') AS target_agent_key
- FROM delegation_history d
- LEFT JOIN agents sa ON sa.id = d.source_agent_id
- LEFT JOIN agents ta ON ta.id = d.target_agent_id
- WHERE d.id = $1`, id).Scan(
- &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID,
- &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations,
- &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt,
- &d.SourceAgentKey, &d.TargetAgentKey,
- )
- if err != nil {
- return nil, err
- }
- if result.Valid {
- d.Result = &result.String
- }
- if errStr.Valid {
- d.Error = &errStr.String
- }
- if completedAt.Valid {
- d.CompletedAt = &completedAt.Time
- }
- if len(metadata) > 0 && string(metadata) != "{}" {
- _ = json.Unmarshal(metadata, &d.Metadata)
- }
- return &d, nil
-}
diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go
index 8657e459..d879ce0c 100644
--- a/internal/store/pg/teams_tasks.go
+++ b/internal/store/pg/teams_tasks.go
@@ -42,10 +42,20 @@ const maxListTasksRows = 30
// ============================================================
func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) {
+ args := []any{teamID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT channel, chat_id FROM team_tasks
- WHERE team_id = $1 AND channel IS NOT NULL AND channel != ''
- ORDER BY channel, chat_id`, teamID)
+ WHERE team_id = $1 AND channel IS NOT NULL AND channel != ''`+tenantWhere+`
+ ORDER BY channel, chat_id`, args...)
if err != nil {
return nil, err
}
@@ -53,11 +63,11 @@ func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]s
var scopes []store.ScopeEntry
for rows.Next() {
- var s store.ScopeEntry
- if err := rows.Scan(&s.Channel, &s.ChatID); err != nil {
+ var sc store.ScopeEntry
+ if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil {
return nil, err
}
- scopes = append(scopes, s)
+ scopes = append(scopes, sc)
}
return scopes, rows.Err()
}
@@ -117,8 +127,8 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData)
// INSERT with all fields in one statement.
_, err = tx.ExecContext(ctx,
`INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel,
- task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
+ task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
task.ID, task.TeamID, task.Subject, task.Description,
task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy),
task.Priority, task.Result,
@@ -129,7 +139,7 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData)
sql.NullString{String: task.ChatID, Valid: task.ChatID != ""},
metaJSON,
task.LockedAt, task.LockExpiresAt,
- now, now,
+ now, now, tenantIDForInsert(ctx),
)
if err != nil {
return err
@@ -171,8 +181,18 @@ func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates
updates["blocked_by"] = pq.Array(v)
}
updates["updated_at"] = time.Now()
- if err := execMapUpdate(ctx, s.db, "team_tasks", taskID, updates); err != nil {
- return err
+ var updateErr error
+ if store.IsCrossTenant(ctx) {
+ updateErr = execMapUpdate(ctx, s.db, "team_tasks", taskID, updates)
+ } else {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ updateErr = execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid)
+ }
+ if updateErr != nil {
+ return updateErr
}
// Re-embed when subject changes.
@@ -207,12 +227,26 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s
// Scope filter: always bind $4/$5 but only enforce when non-empty.
scopeWhere := "AND ($4 = '' OR COALESCE(t.channel,'') = $4) AND ($5 = '' OR COALESCE(t.chat_id,'') = $5)"
+ // Base args: $1=teamID, $2=userID, $3=limit+1, $4=channel, $5=chatID, $6=offset
+ // Tenant clause appended as $7 when needed (after offset which stays at $6).
+ args := []any{teamID, userID, limit + 1, channel, chatID, offset}
+
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
+
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
- WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+`
+ WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+tenantWhere+`
ORDER BY `+orderClause+`
- LIMIT $3 OFFSET $6`, teamID, userID, limit+1, channel, chatID, offset)
+ LIMIT $3 OFFSET $6`, args...)
if err != nil {
return nil, err
}
@@ -221,10 +255,20 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s
}
func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
+ args := []any{taskID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
- WHERE t.id = $1`, taskID)
+ WHERE t.id = $1`+tenantWhere, args...)
if err != nil {
return nil, err
}
@@ -243,10 +287,20 @@ func (s *PGTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]sto
if len(ids) == 0 {
return nil, nil
}
+ args := []any{pq.Array(ids)}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
- WHERE t.id = ANY($1)`, pq.Array(ids))
+ WHERE t.id = ANY($1)`+tenantWhere, args...)
if err != nil {
return nil, err
}
@@ -287,12 +341,23 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s
if s.embProvider != nil {
ftsLimit = limit * 2 // fetch more for hybrid merge
}
+ // Base args: $1=teamID, $2=tsq, $3=ftsLimit, $4=userID
+ ftsArgs := []any{teamID, tsq, ftsLimit, userID}
+ ftsTenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ ftsTenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(ftsArgs)+1)
+ ftsArgs = append(ftsArgs, tid)
+ }
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
- WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4)
+ WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4)`+ftsTenantWhere+`
ORDER BY ts_rank(t.tsv, to_tsquery('simple', $2)) DESC
- LIMIT $3`, teamID, tsq, ftsLimit, userID)
+ LIMIT $3`, ftsArgs...)
if err != nil {
return nil, err
}
@@ -335,9 +400,19 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s
}
func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error {
+ args := []any{taskID, teamID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
res, err := s.db.ExecContext(ctx,
- `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`,
- taskID, teamID)
+ `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere,
+ args...)
if err != nil {
return err
}
@@ -352,11 +427,20 @@ func (s *PGTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, team
if len(taskIDs) == 0 {
return nil, nil
}
+ args := []any{pq.Array(taskIDs), teamID}
+ tenantWhere := ""
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
+ args = append(args, tid)
+ }
rows, err := s.db.QueryContext(ctx,
`DELETE FROM team_tasks
- WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')
- RETURNING id`,
- taskIDs, teamID)
+ WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere+`
+ RETURNING id`, args...)
if err != nil {
return nil, err
}
diff --git a/internal/store/pg/teams_tasks_activity.go b/internal/store/pg/teams_tasks_activity.go
index 0cde2567..775e81ac 100644
--- a/internal/store/pg/teams_tasks_activity.go
+++ b/internal/store/pg/teams_tasks_activity.go
@@ -21,29 +21,30 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas
}
comment.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at)
- VALUES ($1, $2, $3, $4, $5, $6)`,
+ `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
comment.ID, comment.TaskID, comment.AgentID,
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
- comment.Content, comment.CreatedAt,
+ comment.Content, comment.CreatedAt, tenantIDForInsert(ctx),
)
if err != nil {
return err
}
// Increment denormalized comment count.
_, _ = s.db.ExecContext(ctx,
- `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1`, comment.TaskID)
+ `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1 AND tenant_id = $2`, comment.TaskID, tenantIDForInsert(ctx))
return nil
}
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
- WHERE c.task_id = $1
- ORDER BY c.created_at ASC`, taskID)
+ WHERE c.task_id = $1 AND c.tenant_id = $2
+ ORDER BY c.created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
@@ -69,14 +70,15 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([
// ListRecentTaskComments returns the N most recent comments for a task (DESC order).
// Used by dispatch to include context without fetching all comments.
func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
COALESCE(a.agent_key, '') AS agent_key
FROM team_task_comments c
LEFT JOIN agents a ON a.id = c.agent_id
- WHERE c.task_id = $1
+ WHERE c.task_id = $1 AND c.tenant_id = $3
ORDER BY c.created_at DESC
- LIMIT $2`, taskID, limit)
+ LIMIT $2`, taskID, limit, tid)
if err != nil {
return nil, err
}
@@ -116,19 +118,20 @@ func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTask
}
event.CreatedAt = time.Now()
_, err := s.db.ExecContext(ctx,
- `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
- event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt,
+ `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx),
)
return err
}
func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
FROM team_task_events
- WHERE task_id = $1
- ORDER BY created_at ASC`, taskID)
+ WHERE task_id = $1 AND tenant_id = $2
+ ORDER BY created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
@@ -151,14 +154,15 @@ func (s *PGTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limi
if limit <= 0 || limit > 100 {
limit = 50
}
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
FROM team_task_events e
JOIN team_tasks t ON t.id = e.task_id
- WHERE t.team_id = $1
+ WHERE t.team_id = $1 AND t.tenant_id = $4
ORDER BY e.created_at DESC
LIMIT $2 OFFSET $3`,
- teamID, limit, offset)
+ teamID, limit, offset, tid)
if err != nil {
return nil, err
}
@@ -190,13 +194,13 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA
att.Metadata = json.RawMessage(`{}`)
}
res, err := s.db.ExecContext(ctx,
- `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (task_id, path) DO NOTHING`,
att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path,
att.FileSize, att.MimeType, att.CreatedByAgentID,
sql.NullString{String: att.CreatedBySenderID, Valid: att.CreatedBySenderID != ""},
- att.Metadata, att.CreatedAt,
+ att.Metadata, att.CreatedAt, tenantIDForInsert(ctx),
)
if err != nil {
return err
@@ -204,7 +208,7 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA
// Increment denormalized count only if a row was actually inserted (not conflict).
if n, _ := res.RowsAffected(); n > 0 {
_, _ = s.db.ExecContext(ctx,
- `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1`, att.TaskID)
+ `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1 AND tenant_id = $2`, att.TaskID, tenantIDForInsert(ctx))
}
return nil
}
@@ -214,10 +218,11 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID)
var agentID *uuid.UUID
var senderID sql.NullString
var metadata json.RawMessage
+ tid := tenantIDForInsert(ctx)
err := s.db.QueryRowContext(ctx,
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
created_by_agent_id, created_by_sender_id, metadata, created_at
- FROM team_task_attachments WHERE id = $1`, attachmentID,
+ FROM team_task_attachments WHERE id = $1 AND tenant_id = $2`, attachmentID, tid,
).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
&agentID, &senderID, &metadata, &a.CreatedAt)
if err != nil {
@@ -232,12 +237,13 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID)
}
func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
created_by_agent_id, created_by_sender_id, metadata, created_at
FROM team_task_attachments
- WHERE task_id = $1
- ORDER BY created_at ASC`, taskID)
+ WHERE task_id = $1 AND tenant_id = $2
+ ORDER BY created_at ASC`, taskID, tid)
if err != nil {
return nil, err
}
@@ -264,9 +270,10 @@ func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID)
}
func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error {
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
- `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2`,
- taskID, path,
+ `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2 AND tenant_id = $3`,
+ taskID, path, tid,
)
if err != nil {
return err
@@ -274,7 +281,7 @@ func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID,
// Decrement denormalized count only if a row was actually deleted.
if n, _ := res.RowsAffected(); n > 0 {
_, _ = s.db.ExecContext(ctx,
- `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1`, taskID)
+ `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1 AND tenant_id = $2`, taskID, tid)
}
return nil
}
diff --git a/internal/store/pg/teams_tasks_embedding.go b/internal/store/pg/teams_tasks_embedding.go
index 38d916a0..ee21a027 100644
--- a/internal/store/pg/teams_tasks_embedding.go
+++ b/internal/store/pg/teams_tasks_embedding.go
@@ -115,15 +115,17 @@ func (s *PGTeamStore) SearchTasksByEmbedding(ctx context.Context, teamID uuid.UU
limit = 5
}
vecStr := vectorToString(embedding)
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1 AND t.embedding IS NOT NULL
AND ($4 = '' OR t.user_id = $4)
+ AND t.tenant_id = $5
ORDER BY t.embedding <=> $2::vector
LIMIT $3`,
- teamID, vecStr, limit, userID)
+ teamID, vecStr, limit, userID, tid)
if err != nil {
return nil, err
}
diff --git a/internal/store/pg/teams_tasks_followup.go b/internal/store/pg/teams_tasks_followup.go
index 97a3a9cf..72bf8a6c 100644
--- a/internal/store/pg/teams_tasks_followup.go
+++ b/internal/store/pg/teams_tasks_followup.go
@@ -16,11 +16,12 @@ import (
func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6
- WHERE id = $7 AND team_id = $8 AND status = $9`,
+ WHERE id = $7 AND team_id = $8 AND status = $9 AND tenant_id = $10`,
followupAt, max, message, channel, chatID, now,
- taskID, teamID, store.TeamTaskStatusInProgress,
+ taskID, teamID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return err
@@ -36,10 +37,11 @@ func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.U
}
func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1
- WHERE id = $2`,
- time.Now(), taskID,
+ WHERE id = $2 AND tenant_id = $3`,
+ time.Now(), taskID, tid,
)
return err
}
@@ -67,22 +69,24 @@ func (s *PGTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.Team
}
func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
+ tid := tenantIDForInsert(ctx)
_, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2
- WHERE id = $3`,
- nextAt, time.Now(), taskID,
+ WHERE id = $3 AND tenant_id = $4`,
+ nextAt, time.Now(), taskID, tid,
)
return err
}
func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW()
WHERE followup_channel = $1 AND followup_chat_id = $2
- AND followup_at IS NOT NULL AND status = $3`,
- channel, chatID, store.TeamTaskStatusInProgress,
+ AND followup_at IS NOT NULL AND status = $3 AND tenant_id = $4`,
+ channel, chatID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return 0, err
@@ -92,6 +96,7 @@ func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID
}
func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks
SET followup_at = $4, followup_max = $5, followup_message = $6,
@@ -99,12 +104,13 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid
WHERE team_id = $1
AND status = $7
AND followup_at IS NULL
+ AND tenant_id = $8
AND (
(COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3)
OR followup_channel = $2
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
)`,
- teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress,
+ teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return 0, err
@@ -114,6 +120,7 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid
}
func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
+ tid := tenantIDForInsert(ctx)
var exists bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(
@@ -122,9 +129,10 @@ func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID
AND owner_agent_id IS NOT NULL
AND owner_agent_id != $2
AND status IN ($3, $4, $5)
+ AND tenant_id = $6
)`,
teamID, excludeAgentID,
- store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked,
+ store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid,
).Scan(&exists)
return exists, err
}
diff --git a/internal/store/pg/teams_tasks_lifecycle.go b/internal/store/pg/teams_tasks_lifecycle.go
index d474eeee..70885423 100644
--- a/internal/store/pg/teams_tasks_lifecycle.go
+++ b/internal/store/pg/teams_tasks_lifecycle.go
@@ -14,11 +14,12 @@ import (
func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
- WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`,
+ WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7 AND tenant_id = $8`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
- taskID, store.TeamTaskStatusPending, teamID,
+ taskID, store.TeamTaskStatusPending, teamID, tid,
)
if err != nil {
return err
@@ -36,11 +37,12 @@ func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uui
func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
- WHERE id = $5 AND team_id = $6 AND status = $7`,
+ WHERE id = $5 AND team_id = $6 AND status = $7 AND tenant_id = $8`,
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
- taskID, teamID, store.TeamTaskStatusPending,
+ taskID, teamID, store.TeamTaskStatusPending, tid,
)
if err != nil {
return err
@@ -62,14 +64,15 @@ func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID
}
defer tx.Rollback()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL,
updated_at = $3
- WHERE id = $4 AND status = $5 AND team_id = $6`,
+ WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`,
store.TeamTaskStatusCompleted, result, time.Now(),
- taskID, store.TeamTaskStatusInProgress, teamID,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
@@ -96,14 +99,15 @@ func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID,
defer tx.Rollback()
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL,
updated_at = $3
- WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`,
+ WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6 AND tenant_id = $7`,
store.TeamTaskStatusCancelled, reason, now,
- taskID, store.TeamTaskStatusCompleted, teamID,
+ taskID, store.TeamTaskStatusCompleted, teamID, tid,
)
if err != nil {
return err
@@ -130,14 +134,15 @@ func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, er
defer tx.Rollback()
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL,
updated_at = $3
- WHERE id = $4 AND status = $5 AND team_id = $6`,
+ WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
- taskID, store.TeamTaskStatusInProgress, teamID,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
@@ -165,12 +170,13 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U
defer tx.Rollback()
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
progress_percent = NULL, updated_at = $3
- WHERE id = $4 AND status IN ($5, $6) AND team_id = $7`,
+ WHERE id = $4 AND status IN ($5, $6) AND team_id = $7 AND tenant_id = $8`,
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
- taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID,
+ taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid,
)
if err != nil {
return err
@@ -191,24 +197,27 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U
// unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending
// when all blockers are resolved. Must be called within a transaction.
+// Scoped by tenant_id to prevent cross-tenant unblocking.
func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
_, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET
blocked_by = array_remove(blocked_by, $1),
status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END,
updated_at = $2
- WHERE $1 = ANY(blocked_by)`,
- taskID, time.Now(),
+ WHERE $1 = ANY(blocked_by) AND tenant_id = $3`,
+ taskID, time.Now(), tid,
)
return err
}
func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, updated_at = $2
- WHERE id = $3 AND status = $4 AND team_id = $5`,
+ WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`,
store.TeamTaskStatusInReview, time.Now(),
- taskID, store.TeamTaskStatusInProgress, teamID,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
@@ -231,14 +240,15 @@ func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID,
defer tx.Rollback()
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL,
updated_at = $2
- WHERE id = $3 AND status = $4 AND team_id = $5`,
+ WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`,
store.TeamTaskStatusCompleted, now,
- taskID, store.TeamTaskStatusInReview, teamID,
+ taskID, store.TeamTaskStatusInReview, teamID, tid,
)
if err != nil {
return err
@@ -265,14 +275,15 @@ func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID,
defer tx.Rollback()
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := tx.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
progress_percent = NULL,
updated_at = $3
- WHERE id = $4 AND status = $5 AND team_id = $6`,
+ WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`,
store.TeamTaskStatusCancelled, reason, now,
- taskID, store.TeamTaskStatusInReview, teamID,
+ taskID, store.TeamTaskStatusInReview, teamID, tid,
)
if err != nil {
return err
diff --git a/internal/store/pg/teams_tasks_progress.go b/internal/store/pg/teams_tasks_progress.go
index 56e682ef..df0a97cb 100644
--- a/internal/store/pg/teams_tasks_progress.go
+++ b/internal/store/pg/teams_tasks_progress.go
@@ -21,11 +21,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui
// Also renews lock_expires_at as a heartbeat.
now := time.Now()
lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4
- WHERE id = $5 AND status = $6 AND team_id = $7`,
+ WHERE id = $5 AND status = $6 AND team_id = $7 AND tenant_id = $8`,
percent, step, lockExpires, now,
- taskID, store.TeamTaskStatusInProgress, teamID,
+ taskID, store.TeamTaskStatusInProgress, teamID, tid,
)
if err != nil {
return err
@@ -46,11 +47,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui
func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
lockExpires := now.Add(taskLockDuration)
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET lock_expires_at = $1, updated_at = $2
- WHERE id = $3 AND team_id = $4 AND status = $5`,
+ WHERE id = $3 AND team_id = $4 AND status = $5 AND tenant_id = $6`,
lockExpires, now,
- taskID, teamID, store.TeamTaskStatusInProgress,
+ taskID, teamID, store.TeamTaskStatusInProgress, tid,
)
if err != nil {
return err
@@ -84,7 +86,7 @@ func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Recover
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3
AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2
- RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
+ RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
if err != nil {
@@ -106,7 +108,7 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3
- RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
+ RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
if err != nil {
@@ -120,17 +122,19 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover
// Used by DispatchUnblockedTasks after task completion.
func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
now := time.Now()
+ tid := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
WHERE t.team_id = $1
+ AND t.tenant_id = $6
AND (
t.status = $2
OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4)
)
ORDER BY t.priority DESC, t.created_at
LIMIT $5`,
- teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
+ teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows, tid)
if err != nil {
return nil, err
}
@@ -148,7 +152,7 @@ func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3 AND t.updated_at < $4
- RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
+ RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
)
if err != nil {
@@ -166,7 +170,7 @@ func scanRecoveredTaskInfoRows(rows interface {
var out []store.RecoveredTaskInfo
for rows.Next() {
var info store.RecoveredTaskInfo
- if err := rows.Scan(&info.ID, &info.TeamID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil {
+ if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil {
return nil, err
}
out = append(out, info)
@@ -179,12 +183,13 @@ func scanRecoveredTaskInfoRows(rows interface {
func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
+ tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL,
progress_percent = NULL, progress_step = NULL, updated_at = $2
- WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`,
+ WHERE id = $3 AND team_id = $4 AND status IN ($5, $6) AND tenant_id = $7`,
store.TeamTaskStatusPending, now,
- taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
+ taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, tid,
)
if err != nil {
return err
diff --git a/internal/store/pg/tenant_configs.go b/internal/store/pg/tenant_configs.go
new file mode 100644
index 00000000..7ae29f34
--- /dev/null
+++ b/internal/store/pg/tenant_configs.go
@@ -0,0 +1,126 @@
+package pg
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// PGBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore.
+type PGBuiltinToolTenantConfigStore struct {
+ db *sql.DB
+}
+
+func NewPGBuiltinToolTenantConfigStore(db *sql.DB) *PGBuiltinToolTenantConfigStore {
+ return &PGBuiltinToolTenantConfigStore{db: db}
+}
+
+func (s *PGBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = $1 AND enabled = false`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, err
+ }
+ names = append(names, name)
+ }
+ return names, rows.Err()
+}
+
+func (s *PGBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = $1`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := make(map[string]bool)
+ for rows.Next() {
+ var name string
+ var enabled bool
+ if err := rows.Scan(&name, &enabled); err != nil {
+ return nil, err
+ }
+ result[name] = enabled
+ }
+ return result, rows.Err()
+}
+
+func (s *PGBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`,
+ toolName, tenantID, enabled, time.Now(),
+ )
+ return err
+}
+
+func (s *PGBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM builtin_tool_tenant_configs WHERE tool_name = $1 AND tenant_id = $2`,
+ toolName, tenantID,
+ )
+ return err
+}
+
+// PGSkillTenantConfigStore implements store.SkillTenantConfigStore.
+type PGSkillTenantConfigStore struct {
+ db *sql.DB
+}
+
+func NewPGSkillTenantConfigStore(db *sql.DB) *PGSkillTenantConfigStore {
+ return &PGSkillTenantConfigStore{db: db}
+}
+
+func (s *PGSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = $1 AND enabled = false`,
+ tenantID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var ids []uuid.UUID
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, rows.Err()
+}
+
+func (s *PGSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`,
+ skillID, tenantID, enabled, time.Now(),
+ )
+ return err
+}
+
+func (s *PGSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM skill_tenant_configs WHERE skill_id = $1 AND tenant_id = $2`,
+ skillID, tenantID,
+ )
+ return err
+}
diff --git a/internal/store/pg/tenant_store.go b/internal/store/pg/tenant_store.go
new file mode 100644
index 00000000..7aafb994
--- /dev/null
+++ b/internal/store/pg/tenant_store.go
@@ -0,0 +1,229 @@
+package pg
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// PGTenantStore implements store.TenantStore backed by Postgres.
+type PGTenantStore struct {
+ db *sql.DB
+}
+
+// NewPGTenantStore creates a new PostgreSQL-backed tenant store.
+func NewPGTenantStore(db *sql.DB) *PGTenantStore {
+ return &PGTenantStore{db: db}
+}
+
+// ============================================================
+// Tenant CRUD
+// ============================================================
+
+func (s *PGTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error {
+ if tenant.ID == uuid.Nil {
+ tenant.ID = store.GenNewID()
+ }
+ now := time.Now()
+ tenant.CreatedAt = now
+ tenant.UpdatedAt = now
+
+ settings := tenant.Settings
+ if len(settings) == 0 {
+ settings = json.RawMessage(`{}`)
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now,
+ )
+ return err
+}
+
+func (s *PGTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants WHERE id = $1`, id)
+ return scanTenantRow(row)
+}
+
+func (s *PGTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants WHERE slug = $1`, slug)
+ return scanTenantRow(row)
+}
+
+func (s *PGTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, name, slug, status, settings, created_at, updated_at
+ FROM tenants ORDER BY created_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tenants []store.TenantData
+ for rows.Next() {
+ d, err := scanTenantRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ tenants = append(tenants, *d)
+ }
+ return tenants, rows.Err()
+}
+
+func (s *PGTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ return execMapUpdate(ctx, s.db, "tenants", id, updates)
+}
+
+// ============================================================
+// Tenant-user membership
+// ============================================================
+
+func (s *PGTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error {
+ now := time.Now()
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role, updated_at = EXCLUDED.updated_at`,
+ store.GenNewID(), tenantID, userID, role, now, now,
+ )
+ return err
+}
+
+func (s *PGTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE id = $1`, id)
+ var d store.TenantUserData
+ if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
+
+func (s *PGTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) {
+ now := time.Now()
+ var dn *string
+ if displayName != "" {
+ dn = &displayName
+ }
+ row := s.db.QueryRowContext(ctx,
+ `INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (tenant_id, user_id) DO UPDATE SET
+ display_name = COALESCE(EXCLUDED.display_name, tenant_users.display_name),
+ updated_at = EXCLUDED.updated_at
+ RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`,
+ store.GenNewID(), tenantID, userID, dn, role, now, now,
+ )
+ var d store.TenantUserData
+ if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
+
+func (s *PGTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`,
+ tenantID, userID,
+ )
+ return err
+}
+
+func (s *PGTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
+ var role string
+ err := s.db.QueryRowContext(ctx,
+ `SELECT role FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`,
+ tenantID, userID,
+ ).Scan(&role)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return role, err
+}
+
+func (s *PGTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE tenant_id = $1 ORDER BY created_at`, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTenantUserRows(rows)
+}
+
+func (s *PGTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at
+ FROM tenant_users WHERE user_id = $1 ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanTenantUserRows(rows)
+}
+
+func (s *PGTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) {
+ var tenantID uuid.UUID
+ err := s.db.QueryRowContext(ctx,
+ `SELECT tenant_id FROM tenant_users WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
+ userID,
+ ).Scan(&tenantID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return store.MasterTenantID, nil
+ }
+ if err != nil {
+ return uuid.Nil, err
+ }
+ return tenantID, nil
+}
+
+// ============================================================
+// Scan helpers
+// ============================================================
+
+func scanTenantRow(row *sql.Row) (*store.TenantData, error) {
+ var d store.TenantData
+ err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
+
+type tenantRowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanTenantRows(row tenantRowScanner) (*store.TenantData, error) {
+ var d store.TenantData
+ err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
+
+func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) {
+ var result []store.TenantUserData
+ for rows.Next() {
+ var d store.TenantUserData
+ if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil {
+ return nil, err
+ }
+ result = append(result, d)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go
index c34af61f..bb99c2e7 100644
--- a/internal/store/pg/tracing.go
+++ b/internal/store/pg/tracing.go
@@ -26,24 +26,35 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData
if trace.ID == uuid.Nil {
trace.ID = store.GenNewID()
}
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
_, err := s.db.ExecContext(ctx,
`INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count,
- status, error, metadata, tags, team_id, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`,
+ status, error, metadata, tags, team_id, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`,
trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey),
nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime),
nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel),
nilStr(trace.InputPreview), nilStr(trace.OutputPreview),
trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
- trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt,
+ trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID,
)
return err
}
func (s *PGTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error {
- return execMapUpdate(ctx, s.db, "traces", traceID, updates)
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "traces", traceID, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid)
}
func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) {
@@ -55,13 +66,22 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor
var metadata *[]byte
var tags []byte
- err := s.db.QueryRowContext(ctx,
- `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
+ query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at
- FROM traces WHERE id = $1`, traceID,
- ).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
+ FROM traces WHERE id = $1`
+ qArgs := []any{traceID}
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return nil, sql.ErrNoRows
+ }
+ query += ` AND tenant_id = $2`
+ qArgs = append(qArgs, tenantID)
+ }
+
+ err := s.db.QueryRowContext(ctx, query, qArgs...).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
&durationMS, &name, &channel, &inputPreview, &outputPreview,
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
&d.Status, &errStr, &metadata, &tags, &teamID, &d.CreatedAt)
@@ -91,11 +111,21 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor
return &d, nil
}
-func buildTraceWhere(opts store.TraceListOpts) (string, []any) {
+func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) {
var conditions []string
var args []any
argIdx := 1
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID == uuid.Nil {
+ return " WHERE 1=0", nil // fail-closed: no tenant = no results
+ }
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx))
+ args = append(args, tenantID)
+ argIdx++
+ }
+
if opts.AgentID != nil {
conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, *opts.AgentID)
@@ -130,14 +160,14 @@ func buildTraceWhere(opts store.TraceListOpts) (string, []any) {
}
func (s *PGTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) {
- where, args := buildTraceWhere(opts)
+ where, args := buildTraceWhere(ctx, opts)
var count int
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count)
return count, err
}
func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) {
- where, args := buildTraceWhere(opts)
+ where, args := buildTraceWhere(ctx, opts)
q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
duration_ms, name, channel, input_preview, output_preview,
@@ -203,9 +233,20 @@ func (s *PGTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid
duration_ms, name, channel, input_preview, output_preview,
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
status, error, metadata, tags, team_id, created_at
- FROM traces WHERE parent_trace_id = $1 ORDER BY created_at`
+ FROM traces WHERE parent_trace_id = $1`
+ qArgs := []any{parentTraceID}
- rows, err := s.db.QueryContext(ctx, q, parentTraceID)
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required")
+ }
+ q += " AND tenant_id = $2"
+ qArgs = append(qArgs, tid)
+ }
+ q += " ORDER BY created_at"
+
+ rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, err
}
@@ -256,18 +297,22 @@ func (s *PGTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) e
if span.ID == uuid.Nil {
span.ID = store.GenNewID()
}
+ tenantID := span.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
_, err := s.db.ExecContext(ctx,
`INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name,
start_time, end_time, duration_ms, status, error, level,
model, provider, input_tokens, output_tokens, finish_reason,
model_params, tool_name, tool_call_id, input_preview, output_preview,
- metadata, team_id, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`,
+ metadata, team_id, created_at, tenant_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`,
span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name),
span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
- jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt,
+ jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
)
return err
}
@@ -349,7 +394,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa
}
// Build multi-row INSERT
- const cols = 25
+ const cols = 26
valueGroups := make([]string, len(spans))
args := make([]any, 0, len(spans)*cols)
@@ -358,6 +403,10 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa
span.ID = store.GenNewID()
spans[i].ID = span.ID
}
+ tenantID := span.TenantID
+ if tenantID == uuid.Nil {
+ tenantID = store.MasterTenantID
+ }
base := i * cols
placeholders := make([]string, cols)
for j := range cols {
@@ -370,7 +419,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa
span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level,
nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason),
jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview),
- jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt,
+ jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID,
)
}
@@ -378,7 +427,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa
start_time, end_time, duration_ms, status, error, level,
model, provider, input_tokens, output_tokens, finish_reason,
model_params, tool_name, tool_call_id, input_preview, output_preview,
- metadata, team_id, created_at)
+ metadata, team_id, created_at, tenant_id)
VALUES ` + strings.Join(valueGroups, ", ")
_, err := s.db.ExecContext(ctx, q, args...)
@@ -423,12 +472,21 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID
func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) {
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
end := start.AddDate(0, 1, 0)
+
+ q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces
+ WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`
+ qArgs := []any{agentID, start, end}
+
+ if !store.IsCrossTenant(ctx) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid != uuid.Nil {
+ q += " AND tenant_id = $4"
+ qArgs = append(qArgs, tid)
+ }
+ }
+
var cost float64
- err := s.db.QueryRowContext(ctx,
- `SELECT COALESCE(SUM(total_cost), 0) FROM traces
- WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`,
- agentID, start, end,
- ).Scan(&cost)
+ err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost)
return cost, err
}
@@ -440,6 +498,15 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm
// Only root traces (not delegations)
conditions = append(conditions, "parent_trace_id IS NULL")
+ if !store.IsCrossTenant(ctx) {
+ tenantID := store.TenantIDFromContext(ctx)
+ if tenantID != uuid.Nil {
+ conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx))
+ args = append(args, tenantID)
+ argIdx++
+ }
+ }
+
if opts.AgentID != nil {
conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
args = append(args, *opts.AgentID)
diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go
index e597d017..eae7047c 100644
--- a/internal/store/provider_store.go
+++ b/internal/store/provider_store.go
@@ -62,6 +62,7 @@ var ValidProviderTypes = map[string]bool{
// LLMProviderData represents an LLM provider configuration.
type LLMProviderData struct {
BaseModel
+ TenantID uuid.UUID `json:"tenant_id,omitempty"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
ProviderType string `json:"provider_type"`
diff --git a/internal/store/session_store.go b/internal/store/session_store.go
index 51074d49..80ac9797 100644
--- a/internal/store/session_store.go
+++ b/internal/store/session_store.go
@@ -1,6 +1,7 @@
package store
import (
+ "context"
"time"
"github.com/google/uuid"
@@ -52,11 +53,12 @@ type SessionInfo struct {
// SessionListOpts holds pagination options for ListPaged.
type SessionListOpts struct {
- AgentID string
- Channel string // optional: filter by channel prefix ("ws", "telegram", etc.)
- UserID string // optional: filter by user_id
- Limit int
- Offset int
+ AgentID string
+ Channel string // optional: filter by channel prefix ("ws", "telegram", etc.)
+ UserID string // optional: filter by user_id
+ TenantID uuid.UUID // optional: filter by tenant (uuid.Nil = no filter)
+ Limit int
+ Offset int
}
// SessionListResult is the paginated result of ListPaged.
@@ -86,34 +88,36 @@ type SessionListRichResult struct {
// SessionStore manages conversation sessions.
type SessionStore interface {
- GetOrCreate(key string) *SessionData
- AddMessage(key string, msg providers.Message)
- GetHistory(key string) []providers.Message
- GetSummary(key string) string
- SetSummary(key, summary string)
- GetLabel(key string) string
- SetLabel(key, label string)
- SetAgentInfo(key string, agentUUID uuid.UUID, userID string)
- UpdateMetadata(key, model, provider, channel string)
- AccumulateTokens(key string, input, output int64)
- IncrementCompaction(key string)
- GetCompactionCount(key string) int
- GetMemoryFlushCompactionCount(key string) int
- SetMemoryFlushDone(key string)
- GetSessionMetadata(key string) map[string]string
- SetSessionMetadata(key string, metadata map[string]string)
- SetSpawnInfo(key, spawnedBy string, depth int)
- SetContextWindow(key string, cw int)
- GetContextWindow(key string) int
- SetLastPromptTokens(key string, tokens, msgCount int)
- GetLastPromptTokens(key string) (tokens, msgCount int)
- TruncateHistory(key string, keepLast int)
- SetHistory(key string, msgs []providers.Message)
- Reset(key string)
- Delete(key string) error
- List(agentID string) []SessionInfo
- ListPaged(opts SessionListOpts) SessionListResult
- ListPagedRich(opts SessionListOpts) SessionListRichResult
- Save(key string) error
- LastUsedChannel(agentID string) (channel, chatID string)
+ GetOrCreate(ctx context.Context, key string) *SessionData
+ // Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
+ Get(ctx context.Context, key string) *SessionData
+ AddMessage(ctx context.Context, key string, msg providers.Message)
+ GetHistory(ctx context.Context, key string) []providers.Message
+ GetSummary(ctx context.Context, key string) string
+ SetSummary(ctx context.Context, key, summary string)
+ GetLabel(ctx context.Context, key string) string
+ SetLabel(ctx context.Context, key, label string)
+ SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string)
+ UpdateMetadata(ctx context.Context, key, model, provider, channel string)
+ AccumulateTokens(ctx context.Context, key string, input, output int64)
+ IncrementCompaction(ctx context.Context, key string)
+ GetCompactionCount(ctx context.Context, key string) int
+ GetMemoryFlushCompactionCount(ctx context.Context, key string) int
+ SetMemoryFlushDone(ctx context.Context, key string)
+ GetSessionMetadata(ctx context.Context, key string) map[string]string
+ SetSessionMetadata(ctx context.Context, key string, metadata map[string]string)
+ SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int)
+ SetContextWindow(ctx context.Context, key string, cw int)
+ GetContextWindow(ctx context.Context, key string) int
+ SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int)
+ GetLastPromptTokens(ctx context.Context, key string) (tokens, msgCount int)
+ TruncateHistory(ctx context.Context, key string, keepLast int)
+ SetHistory(ctx context.Context, key string, msgs []providers.Message)
+ Reset(ctx context.Context, key string)
+ Delete(ctx context.Context, key string) error
+ List(ctx context.Context, agentID string) []SessionInfo
+ ListPaged(ctx context.Context, opts SessionListOpts) SessionListResult
+ ListPagedRich(ctx context.Context, opts SessionListOpts) SessionListRichResult
+ Save(ctx context.Context, key string) error
+ LastUsedChannel(ctx context.Context, agentID string) (channel, chatID string)
}
diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go
index c637150d..93b0226a 100644
--- a/internal/store/skill_store.go
+++ b/internal/store/skill_store.go
@@ -37,12 +37,12 @@ type SkillSearchResult struct {
// SkillStore manages skill discovery and loading.
// Backed by Postgres (PGSkillStore) or filesystem (FileSkillStore).
type SkillStore interface {
- ListSkills() []SkillInfo
- LoadSkill(name string) (string, bool)
- LoadForContext(allowList []string) string
- BuildSummary(allowList []string) string
- GetSkill(name string) (*SkillInfo, bool)
- FilterSkills(allowList []string) []SkillInfo
+ ListSkills(ctx context.Context) []SkillInfo
+ LoadSkill(ctx context.Context, name string) (string, bool)
+ LoadForContext(ctx context.Context, allowList []string) string
+ BuildSummary(ctx context.Context, allowList []string) string
+ GetSkill(ctx context.Context, name string) (*SkillInfo, bool)
+ FilterSkills(ctx context.Context, allowList []string) []SkillInfo
Version() int64
BumpVersion()
Dirs() []string
diff --git a/internal/store/stores.go b/internal/store/stores.go
index 58c0909a..93de55c7 100644
--- a/internal/store/stores.go
+++ b/internal/store/stores.go
@@ -14,7 +14,6 @@ type Stores struct {
Providers ProviderStore
Tracing TracingStore
MCP MCPServerStore
- CustomTools CustomToolStore
ChannelInstances ChannelInstanceStore
ConfigSecrets ConfigSecretsStore
AgentLinks AgentLinkStore
@@ -28,5 +27,8 @@ type Stores struct {
SecureCLI SecureCLIStore
APIKeys APIKeyStore
Heartbeats HeartbeatStore
- ConfigPermissions ConfigPermissionStore
+ ConfigPermissions ConfigPermissionStore
+ Tenants TenantStore
+ BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
+ SkillTenantCfgs SkillTenantConfigStore
}
diff --git a/internal/store/team_store.go b/internal/store/team_store.go
index 18494ab3..60195ce0 100644
--- a/internal/store/team_store.go
+++ b/internal/store/team_store.go
@@ -13,6 +13,7 @@ import (
type RecoveredTaskInfo struct {
ID uuid.UUID
TeamID uuid.UUID
+ TenantID uuid.UUID
TaskNumber int
Subject string
Channel string // task's origin channel for notification routing
@@ -171,41 +172,17 @@ type TeamTaskAttachmentData struct {
CreatedBySenderID string `json:"created_by_sender_id,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
+ DownloadURL string `json:"download_url,omitempty"` // signed URL, populated at delivery time
}
-// DelegationHistoryData represents a persisted delegation record.
-type DelegationHistoryData struct {
- BaseModel
- SourceAgentID uuid.UUID `json:"source_agent_id"`
- TargetAgentID uuid.UUID `json:"target_agent_id"`
- TeamID *uuid.UUID `json:"team_id,omitempty"`
- TeamTaskID *uuid.UUID `json:"team_task_id,omitempty"`
- UserID string `json:"user_id,omitempty"`
- Task string `json:"task"`
- Mode string `json:"mode"`
- Status string `json:"status"`
- Result *string `json:"result,omitempty"`
- Error *string `json:"error,omitempty"`
- Iterations int `json:"iterations"`
- TraceID *uuid.UUID `json:"trace_id,omitempty"`
- DurationMS int `json:"duration_ms"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- Metadata map[string]any `json:"metadata,omitempty"`
-
- // Joined fields
- SourceAgentKey string `json:"source_agent_key,omitempty"`
- TargetAgentKey string `json:"target_agent_key,omitempty"`
-}
-
-// DelegationHistoryListOpts configures delegation history queries.
-type DelegationHistoryListOpts struct {
- SourceAgentID *uuid.UUID
- TargetAgentID *uuid.UUID
- TeamID *uuid.UUID
- UserID string
- Status string // "completed", "failed", "" = all
- Limit int
- Offset int
+// TeamUserGrant represents a user's access grant to a team.
+type TeamUserGrant struct {
+ ID uuid.UUID `json:"id"`
+ TeamID uuid.UUID `json:"team_id"`
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ GrantedBy string `json:"granted_by,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
// ScopeEntry represents a unique channel+chatID scope across tasks.
@@ -349,8 +326,10 @@ type TeamStore interface {
// ResetTaskStatus resets a stale or failed task back to pending for retry.
ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error
- // Delegation history
- SaveDelegationHistory(ctx context.Context, record *DelegationHistoryData) error
- ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error)
- GetDelegationHistory(ctx context.Context, id uuid.UUID) (*DelegationHistoryData, error)
+ // Team user grants
+ GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error
+ RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error
+ ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]TeamUserGrant, error)
+ ListUserTeams(ctx context.Context, userID string) ([]TeamData, error)
+ HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error)
}
diff --git a/internal/store/tenant_config_store.go b/internal/store/tenant_config_store.go
new file mode 100644
index 00000000..0a78180a
--- /dev/null
+++ b/internal/store/tenant_config_store.go
@@ -0,0 +1,43 @@
+package store
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+// BuiltinToolTenantConfig represents a per-tenant override for a builtin tool.
+type BuiltinToolTenantConfig struct {
+ ToolName string `json:"tool_name"`
+ TenantID uuid.UUID `json:"tenant_id"`
+ Enabled *bool `json:"enabled,omitempty"` // nil = use default, false = disabled, true = enabled
+}
+
+// BuiltinToolTenantConfigStore manages per-tenant builtin tool overrides.
+type BuiltinToolTenantConfigStore interface {
+ // ListDisabled returns tool names disabled for a tenant.
+ ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error)
+ // ListAll returns all tenant overrides (tool_name → enabled) for a tenant.
+ ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error)
+ // Set creates or updates a tenant tool config.
+ Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error
+ // Delete removes a tenant tool config (reverts to default).
+ Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error
+}
+
+// SkillTenantConfig represents a per-tenant override for a skill.
+type SkillTenantConfig struct {
+ SkillID uuid.UUID `json:"skill_id"`
+ TenantID uuid.UUID `json:"tenant_id"`
+ Enabled bool `json:"enabled"`
+}
+
+// SkillTenantConfigStore manages per-tenant skill visibility.
+type SkillTenantConfigStore interface {
+ // ListDisabledSkillIDs returns skill IDs disabled for a tenant.
+ ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error)
+ // Set creates or updates a tenant skill config.
+ Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error
+ // Delete removes a tenant skill config (reverts to default).
+ Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error
+}
diff --git a/internal/store/tenant_store.go b/internal/store/tenant_store.go
new file mode 100644
index 00000000..a4761fb3
--- /dev/null
+++ b/internal/store/tenant_store.go
@@ -0,0 +1,81 @@
+package store
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// MasterTenantID is the fixed UUID v7 for the default/master tenant.
+// All existing data defaults to this tenant during migration.
+var MasterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
+
+// Tenant status constants.
+const (
+ TenantStatusActive = "active"
+ TenantStatusSuspended = "suspended"
+ TenantStatusArchived = "archived"
+)
+
+// Tenant role constants (hierarchy: owner > admin > operator > member > viewer).
+const (
+ TenantRoleOwner = "owner"
+ TenantRoleAdmin = "admin"
+ TenantRoleOperator = "operator"
+ TenantRoleMember = "member"
+ TenantRoleViewer = "viewer"
+)
+
+// TenantData represents a tenant in the database.
+type TenantData struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Status string `json:"status"`
+ Settings json.RawMessage `json:"settings,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// TenantUserData represents a user's membership in a tenant.
+type TenantUserData struct {
+ ID uuid.UUID `json:"id"`
+ TenantID uuid.UUID `json:"tenant_id"`
+ UserID string `json:"user_id"`
+ DisplayName *string `json:"display_name,omitempty"`
+ Role string `json:"role"`
+ Metadata json.RawMessage `json:"metadata,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// TenantStore manages tenants and tenant-user membership.
+type TenantStore interface {
+ // Tenant CRUD
+ CreateTenant(ctx context.Context, tenant *TenantData) error
+ GetTenant(ctx context.Context, id uuid.UUID) (*TenantData, error)
+ GetTenantBySlug(ctx context.Context, slug string) (*TenantData, error)
+ ListTenants(ctx context.Context) ([]TenantData, error)
+ UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error
+
+ // Tenant-user membership
+ AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error
+ RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error
+ GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error)
+ ListUsers(ctx context.Context, tenantID uuid.UUID) ([]TenantUserData, error)
+ ListUserTenants(ctx context.Context, userID string) ([]TenantUserData, error)
+
+ // ResolveUserTenant returns the tenant_id for a user.
+ // If user belongs to multiple tenants, returns the first (by created_at).
+ // If no membership, returns MasterTenantID (backward compat).
+ ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error)
+
+ // GetTenantUser returns a single tenant_user by primary key.
+ GetTenantUser(ctx context.Context, id uuid.UUID) (*TenantUserData, error)
+
+ // CreateTenantUserReturning creates a tenant_user and returns the row.
+ // On conflict (tenant_id, user_id), updates role/display_name and returns existing row.
+ CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*TenantUserData, error)
+}
diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go
index 55a707e9..1f97a80a 100644
--- a/internal/store/tracing_store.go
+++ b/internal/store/tracing_store.go
@@ -93,6 +93,7 @@ type SpanData struct {
OutputPreview string `json:"output_preview,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
TeamID *uuid.UUID `json:"team_id,omitempty"`
+ TenantID uuid.UUID `json:"tenant_id"`
CreatedAt time.Time `json:"created_at"`
}
diff --git a/internal/tasks/task_ticker.go b/internal/tasks/task_ticker.go
index e2d7fa88..97ec1f16 100644
--- a/internal/tasks/task_ticker.go
+++ b/internal/tasks/task_ticker.go
@@ -140,9 +140,10 @@ func (t *TaskTicker) recoverAll(forceRecover bool) {
// ============================================================
type taskScope struct {
- TeamID uuid.UUID
- Channel string // from task's origin channel
- ChatID string
+ TeamID uuid.UUID
+ TenantID uuid.UUID
+ Channel string // from task's origin channel
+ ChatID string
}
// notifyLeaders sends a batched system message per (teamID, channel, chatID) scope to the leader.
@@ -154,7 +155,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT
// Group by (team_id, channel, chat_id) → one message per scope.
byScope := map[taskScope][]store.RecoveredTaskInfo{}
for _, task := range tasks {
- key := taskScope{TeamID: task.TeamID, Channel: task.Channel, ChatID: task.ChatID}
+ key := taskScope{TeamID: task.TeamID, TenantID: task.TenantID, Channel: task.Channel, ChatID: task.ChatID}
byScope[key] = append(byScope[key], task)
}
@@ -205,6 +206,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT
ChatID: chatID,
AgentID: lead.AgentKey,
UserID: team.CreatedBy,
+ TenantID: scope.TenantID,
Content: content,
}) {
slog.Warn("task_ticker: inbound buffer full, notification dropped",
@@ -225,15 +227,12 @@ func (t *TaskTicker) broadcastStaleEvents(ctx context.Context, tasks []store.Rec
continue
}
seen[task.TeamID] = true
- t.msgBus.Broadcast(bus.Event{
- Name: protocol.EventTeamTaskStale,
- Payload: protocol.TeamTaskEventPayload{
- TeamID: task.TeamID.String(),
- Status: store.TeamTaskStatusStale,
- Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
- ActorType: "system",
- ActorID: "task_ticker",
- },
+ bus.BroadcastForTenant(t.msgBus, protocol.EventTeamTaskStale, task.TenantID, protocol.TeamTaskEventPayload{
+ TeamID: task.TeamID.String(),
+ Status: store.TeamTaskStatusStale,
+ Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
+ ActorType: "system",
+ ActorID: "task_ticker",
})
}
}
diff --git a/internal/tools/announce_queue.go b/internal/tools/announce_queue.go
index 1166c892..6b7cce6f 100644
--- a/internal/tools/announce_queue.go
+++ b/internal/tools/announce_queue.go
@@ -6,6 +6,8 @@ import (
"sync"
"time"
+ "github.com/google/uuid"
+
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
@@ -28,6 +30,7 @@ type AnnounceMetadata struct {
OriginLocalKey string // composite key with topic/thread suffix for routing
OriginUserID string
OriginSessionKey string // exact parent session key (WS uses non-standard format)
+ OriginTenantID uuid.UUID // parent tenant for announce routing
ParentAgent string
OriginTraceID string // parent trace UUID for announce linking
OriginRootSpanID string // parent agent's root span UUID
diff --git a/internal/tools/boundary_test.go b/internal/tools/boundary_test.go
index d60476b2..9001e50c 100644
--- a/internal/tools/boundary_test.go
+++ b/internal/tools/boundary_test.go
@@ -259,6 +259,66 @@ func TestCheckDeniedPath(t *testing.T) {
}
}
+func TestResolvePathWithAllowed_TenantScoping(t *testing.T) {
+ // Simulate: tenant workspace is a subdirectory of global workspace.
+ // Paths outside tenant workspace but inside global should be BLOCKED.
+ globalWs := t.TempDir()
+ tenantWs := filepath.Join(globalWs, "tenants", "acme")
+ otherTenantWs := filepath.Join(globalWs, "tenants", "evil")
+ if err := os.MkdirAll(tenantWs, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(otherTenantWs, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(otherTenantWs, "secret.txt"), []byte("secret"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(tenantWs, "ok.txt"), []byte("ok"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Using tenant workspace as base: should allow files inside tenant workspace
+ _, err := resolvePathWithAllowed("ok.txt", tenantWs, true, nil)
+ if err != nil {
+ t.Fatalf("expected success for file in tenant workspace, got: %v", err)
+ }
+
+ // Using tenant workspace as base: should BLOCK files in other tenant's workspace
+ _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), tenantWs, true, nil)
+ if err == nil {
+ t.Fatal("expected error for path in another tenant's workspace, got nil")
+ }
+
+ // Using GLOBAL workspace as base (the bug): would wrongly allow cross-tenant access
+ _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), globalWs, true, nil)
+ if err != nil {
+ t.Fatal("global workspace allows all children (demonstrates why tenant scoping matters)")
+ }
+}
+
+func TestResolvePathWithAllowed_TeamWorkspaceAccess(t *testing.T) {
+ // Agent workspace and team workspace are separate directories.
+ // Team workspace should be accessible via allowed prefixes.
+ agentWs := t.TempDir()
+ teamWs := t.TempDir()
+ if err := os.WriteFile(filepath.Join(teamWs, "shared.txt"), []byte("shared"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Without team workspace in allowed: should BLOCK
+ _, err := resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, nil)
+ if err == nil {
+ t.Fatal("expected error without team workspace in allowed prefixes, got nil")
+ }
+
+ // With team workspace in allowed: should ALLOW
+ _, err = resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, []string{teamWs})
+ if err != nil {
+ t.Fatalf("expected success with team workspace in allowed prefixes, got: %v", err)
+ }
+}
+
func TestIsPathInside(t *testing.T) {
tests := []struct {
child, parent string
diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go
index 21abf50e..3c37c0a2 100644
--- a/internal/tools/context_file_interceptor_test.go
+++ b/internal/tools/context_file_interceptor_test.go
@@ -45,6 +45,12 @@ func (s *stubAgentStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _
func (s *stubAgentStore) Create(_ context.Context, _ *store.AgentData) error { return nil }
func (s *stubAgentStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil }
func (s *stubAgentStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil }
+func (s *stubAgentStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
+ return nil, nil
+}
+func (s *stubAgentStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
+ return nil, nil
+}
func (s *stubAgentStore) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil }
func (s *stubAgentStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
func (s *stubAgentStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go
index e38240c6..a6881ec7 100644
--- a/internal/tools/context_keys.go
+++ b/internal/tools/context_keys.go
@@ -174,10 +174,9 @@ func RestrictFromCtx(ctx context.Context) (bool, bool) {
}
func effectiveRestrict(ctx context.Context, toolDefault bool) bool {
- if v, ok := RestrictFromCtx(ctx); ok {
- return v
- }
- return toolDefault
+ // Multi-tenant security: always restrict agents to their workspace.
+ // Agents must not access files outside their tenant-scoped workspace.
+ return true
}
// --- Parent agent model (for subagent inheritance) ---
@@ -392,11 +391,14 @@ func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch {
func InjectTeamDispatch(ctx context.Context, postTurn PostTurnProcessor) (context.Context, func()) {
ptd := NewPendingTeamDispatch()
ctx = WithPendingTeamDispatch(ctx, ptd)
+ // Detach from caller's cancel/deadline but keep values (tenant_id, user_id, etc.)
+ // so post-turn dispatch isn't aborted when the HTTP request or WS handler returns.
+ detached := context.WithoutCancel(ctx)
drain := func() {
ptd.ReleaseTeamLock()
if postTurn != nil {
for teamID, taskIDs := range ptd.Drain() {
- if err := postTurn.ProcessPendingTasks(context.Background(), teamID, taskIDs); err != nil {
+ if err := postTurn.ProcessPendingTasks(detached, teamID, taskIDs); err != nil {
slog.Warn("post_turn: dispatch failed", "team_id", teamID, "error", err)
}
}
diff --git a/internal/tools/cron.go b/internal/tools/cron.go
index 20efcf87..7eaab3ad 100644
--- a/internal/tools/cron.go
+++ b/internal/tools/cron.go
@@ -156,17 +156,17 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result {
case "status":
return t.handleStatus()
case "list":
- return t.handleList(args, agentID, userID)
+ return t.handleList(ctx, args, agentID, userID)
case "add":
return t.handleAdd(ctx, args, agentID, userID)
case "update":
- return t.handleUpdate(args, agentID, userID)
+ return t.handleUpdate(ctx, args, agentID, userID)
case "remove":
- return t.handleRemove(args, agentID, userID)
+ return t.handleRemove(ctx, args, agentID, userID)
case "run":
- return t.handleRun(args, agentID, userID)
+ return t.handleRun(ctx, args, agentID, userID)
case "runs":
- return t.handleRuns(args, agentID, userID)
+ return t.handleRuns(ctx, args, agentID, userID)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
@@ -178,9 +178,9 @@ func (t *CronTool) handleStatus() *Result {
return NewResult(string(data))
}
-func (t *CronTool) handleList(args map[string]any, agentID, userID string) *Result {
+func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result {
includeDisabled, _ := args["includeDisabled"].(bool)
- jobs := t.cronStore.ListJobs(includeDisabled, agentID, userID)
+ jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID)
result := map[string]any{
"jobs": jobs,
@@ -288,7 +288,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID,
agentID = explicit
}
- job, err := t.cronStore.AddJob(name, schedule, message, deliver, channel, to, agentID, userID)
+ job, err := t.cronStore.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create cron job: %v", err))
}
@@ -296,7 +296,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID,
// Set wake_heartbeat if requested (triggers heartbeat after cron job completes)
if wh, _ := jobObj["wake_heartbeat"].(bool); wh {
wakeTrue := true
- if updated, uErr := t.cronStore.UpdateJob(job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil {
+ if updated, uErr := t.cronStore.UpdateJob(ctx, job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil {
job = updated
}
}
@@ -307,8 +307,8 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID,
// checkJobOwnership validates that the job belongs to the current agent+user scope.
// When agentID/userID is empty, all jobs are accessible.
-func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.CronJob, *Result) {
- job, ok := t.cronStore.GetJob(jobID)
+func (t *CronTool) checkJobOwnership(ctx context.Context, jobID, agentID, userID string) (*store.CronJob, *Result) {
+ job, ok := t.cronStore.GetJob(ctx, jobID)
if !ok {
return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID))
}
@@ -324,13 +324,13 @@ func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.Cron
return job, nil
}
-func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Result {
+func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentID, userID string) *Result {
jobID := resolveJobID(args)
if jobID == "" {
return ErrorResult("jobId is required for update action")
}
- if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil {
+ if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
return errResult
}
@@ -351,7 +351,7 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re
}
}
- job, err := t.cronStore.UpdateJob(jobID, patch)
+ job, err := t.cronStore.UpdateJob(ctx, jobID, patch)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err))
}
@@ -360,17 +360,17 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re
return NewResult(string(data))
}
-func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Result {
+func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentID, userID string) *Result {
jobID := resolveJobID(args)
if jobID == "" {
return ErrorResult("jobId is required for remove action")
}
- if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil {
+ if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
return errResult
}
- if err := t.cronStore.RemoveJob(jobID); err != nil {
+ if err := t.cronStore.RemoveJob(ctx, jobID); err != nil {
return ErrorResult(fmt.Sprintf("failed to remove cron job: %v", err))
}
@@ -378,20 +378,20 @@ func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Re
return NewResult(string(data))
}
-func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Result {
+func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID, userID string) *Result {
jobID := resolveJobID(args)
if jobID == "" {
return ErrorResult("jobId is required for run action")
}
- if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil {
+ if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
return errResult
}
runMode, _ := args["runMode"].(string)
force := runMode == "force"
- ran, reason, err := t.cronStore.RunJob(jobID, force)
+ ran, reason, err := t.cronStore.RunJob(ctx, jobID, force)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to run cron job: %v", err))
}
@@ -407,12 +407,12 @@ func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Resul
return NewResult(string(data))
}
-func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Result {
+func (t *CronTool) handleRuns(ctx context.Context, args map[string]any, agentID, userID string) *Result {
jobID := resolveJobID(args)
// Validate ownership if a specific job is requested
if jobID != "" {
- if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil {
+ if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil {
return errResult
}
}
@@ -422,7 +422,7 @@ func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Resu
limit = int(v)
}
- entries, total := t.cronStore.GetRunLog(jobID, limit, 0)
+ entries, total := t.cronStore.GetRunLog(ctx, jobID, limit, 0)
result := map[string]any{
"entries": entries,
diff --git a/internal/tools/dynamic_loader.go b/internal/tools/dynamic_loader.go
deleted file mode 100644
index 3e504c2b..00000000
--- a/internal/tools/dynamic_loader.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package tools
-
-import (
- "context"
- "log/slog"
- "sync"
-
- "github.com/google/uuid"
-
- "github.com/nextlevelbuilder/goclaw/internal/store"
-)
-
-// DynamicToolLoader loads custom tools from the database and registers them into tool registries.
-type DynamicToolLoader struct {
- store store.CustomToolStore
- workspace string
- mu sync.Mutex
- // Track names of globally registered custom tools (for reload/unregister).
- globalNames map[string]bool
-}
-
-// NewDynamicToolLoader creates a loader for custom tools from the database.
-func NewDynamicToolLoader(s store.CustomToolStore, workspace string) *DynamicToolLoader {
- return &DynamicToolLoader{
- store: s,
- workspace: workspace,
- globalNames: make(map[string]bool),
- }
-}
-
-// LoadGlobal loads all global custom tools (agent_id IS NULL) and registers them into the registry.
-// Skips tools whose names collide with existing built-in or MCP tools.
-func (l *DynamicToolLoader) LoadGlobal(ctx context.Context, reg *Registry) error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- defs, err := l.store.ListGlobal(ctx)
- if err != nil {
- return err
- }
-
- registered := 0
- for _, def := range defs {
- if _, exists := reg.Get(def.Name); exists {
- slog.Warn("custom_tools: skipping global tool (name collision with built-in/MCP)",
- "tool", def.Name)
- continue
- }
- reg.Register(NewDynamicTool(def, l.workspace))
- l.globalNames[def.Name] = true
- registered++
- }
-
- if registered > 0 {
- slog.Info("custom_tools: loaded global tools", "count", registered)
- }
- return nil
-}
-
-// LoadForAgent loads per-agent custom tools and returns a cloned registry with them added.
-// Returns nil if the agent has no custom tools (caller should use global registry).
-func (l *DynamicToolLoader) LoadForAgent(ctx context.Context, globalReg *Registry, agentID uuid.UUID) (*Registry, error) {
- defs, err := l.store.ListByAgent(ctx, agentID)
- if err != nil {
- return nil, err
- }
- if len(defs) == 0 {
- return nil, nil // no per-agent tools
- }
-
- clone := globalReg.Clone()
- for _, def := range defs {
- clone.Register(NewDynamicTool(def, l.workspace))
- }
-
- slog.Debug("custom_tools: loaded per-agent tools", "agent_id", agentID, "count", len(defs))
- return clone, nil
-}
-
-// ReloadGlobal unregisters all previously loaded global tools and re-loads from DB.
-// Used on cache invalidation events.
-func (l *DynamicToolLoader) ReloadGlobal(ctx context.Context, reg *Registry) {
- l.mu.Lock()
- // Unregister old global custom tools
- for name := range l.globalNames {
- reg.Unregister(name)
- }
- l.globalNames = make(map[string]bool)
- l.mu.Unlock()
-
- // Re-load (LoadGlobal acquires its own lock)
- if err := l.LoadGlobal(ctx, reg); err != nil {
- slog.Warn("custom_tools: failed to reload global tools", "error", err)
- }
-}
diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go
deleted file mode 100644
index 58e19ae1..00000000
--- a/internal/tools/dynamic_tool.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package tools
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "os/exec"
- "strings"
- "time"
-
- "github.com/nextlevelbuilder/goclaw/internal/store"
-)
-
-// DynamicTool wraps a CustomToolDef from the database and implements the Tool interface.
-// Command templates use {{.key}} placeholders — all LLM-provided values are shell-escaped.
-type DynamicTool struct {
- def store.CustomToolDef
- workspace string
- params map[string]any
-}
-
-// NewDynamicTool creates a Tool from a DB-stored custom tool definition.
-func NewDynamicTool(def store.CustomToolDef, workspace string) *DynamicTool {
- var params map[string]any
- if len(def.Parameters) > 0 {
- json.Unmarshal(def.Parameters, ¶ms)
- }
- if params == nil {
- params = map[string]any{
- "type": "object",
- "properties": map[string]any{},
- }
- }
- return &DynamicTool{def: def, workspace: workspace, params: params}
-}
-
-func (t *DynamicTool) Name() string { return t.def.Name }
-func (t *DynamicTool) Description() string { return t.def.Description }
-func (t *DynamicTool) Parameters() map[string]any { return t.params }
-
-func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result {
- // Render command template with shell-escaped args
- command := renderCommand(t.def.Command, args)
-
- // Check deny patterns (uses all defaults for dynamic tools — no per-agent override)
- for _, pattern := range DefaultDenyPatterns() {
- if pattern.MatchString(command) {
- return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String()))
- }
- }
-
- // Timeout
- timeout := time.Duration(t.def.TimeoutSeconds) * time.Second
- if timeout <= 0 {
- timeout = 60 * time.Second
- }
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- // Working directory — per-user workspace from context, fallback to tool's workspace.
- // Explicit WorkingDir on the tool definition still overrides everything.
- cwd := ToolWorkspaceFromCtx(ctx)
- if cwd == "" {
- cwd = t.workspace
- }
- if t.def.WorkingDir != "" {
- cwd = t.def.WorkingDir
- }
-
- cmd := exec.CommandContext(ctx, "sh", "-c", command)
- cmd.Dir = cwd
-
- // Decrypt and apply env vars
- if len(t.def.Env) > 0 {
- var envMap map[string]string
- if json.Unmarshal(t.def.Env, &envMap) == nil {
- for k, v := range envMap {
- cmd.Env = append(cmd.Env, k+"="+v)
- }
- }
- }
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- err := cmd.Run()
-
- var result string
- if stdout.Len() > 0 {
- result = stdout.String()
- }
- if stderr.Len() > 0 {
- if result != "" {
- result += "\n"
- }
- result += "STDERR:\n" + stderr.String()
- }
-
- if err != nil {
- if ctx.Err() == context.DeadlineExceeded {
- return ErrorResult(fmt.Sprintf("command timed out after %s", timeout))
- }
- if result == "" {
- result = err.Error()
- }
- return ErrorResult(result)
- }
-
- if result == "" {
- result = "(command completed with no output)"
- }
-
- return SilentResult(result)
-}
-
-// renderCommand replaces {{.key}} placeholders with shell-escaped arg values.
-// Uses simple string replacement (NOT Go text/template) for security.
-func renderCommand(tmpl string, args map[string]any) string {
- result := tmpl
- for key, val := range args {
- placeholder := "{{." + key + "}}"
- escaped := shellEscape(fmt.Sprint(val))
- result = strings.ReplaceAll(result, placeholder, escaped)
- }
- return result
-}
-
-// shellEscape wraps a value in single quotes, escaping embedded single quotes.
-func shellEscape(s string) string {
- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
-}
diff --git a/internal/tools/media_provider_chain.go b/internal/tools/media_provider_chain.go
index 9e9d04f0..c706af19 100644
--- a/internal/tools/media_provider_chain.go
+++ b/internal/tools/media_provider_chain.go
@@ -84,7 +84,7 @@ func ResolveMediaProviderChain(
}
// 3. Hardcoded default chain — use first available provider
- return buildDefaultChain(defaultPriority, defaultModels, registry)
+ return buildDefaultChain(ctx, defaultPriority, defaultModels, registry)
}
// parseChainSettings parses the settings JSON into a chain, handling both new
@@ -116,13 +116,14 @@ func parseChainSettings(raw []byte, defaultModels map[string]string) []MediaProv
// buildDefaultChain creates a chain from the hardcoded priority list,
// including only providers that are currently registered.
func buildDefaultChain(
+ ctx context.Context,
priority []string,
defaultModels map[string]string,
registry *providers.Registry,
) []MediaProviderEntry {
var chain []MediaProviderEntry
for _, name := range priority {
- if _, err := registry.Get(name); err == nil {
+ if _, err := registry.Get(ctx, name); err == nil {
entry := MediaProviderEntry{
Provider: name,
Model: defaultModels[name],
@@ -162,7 +163,7 @@ func ExecuteWithChain(
var lastErr error
for _, entry := range chain {
- p, err := registry.Get(entry.Provider)
+ p, err := registry.Get(ctx, entry.Provider)
if err != nil {
slog.Warn("media_chain: provider not found, skipping",
"provider", entry.Provider, "error", err)
diff --git a/internal/tools/read_audio_resolve.go b/internal/tools/read_audio_resolve.go
index 63cab3cf..2adc823a 100644
--- a/internal/tools/read_audio_resolve.go
+++ b/internal/tools/read_audio_resolve.go
@@ -48,9 +48,17 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p
ref = &refs[len(refs)-1]
}
- p, err := t.mediaLoader.LoadPath(ref.ID)
- if err != nil {
- return "", "", fmt.Errorf("audio file not found: %v", err)
+ // Prefer persisted workspace path; fall back to legacy .media/ lookup.
+ p := ref.Path
+ if p == "" {
+ var err error
+ if t.mediaLoader == nil {
+ return "", "", fmt.Errorf("no media storage configured")
+ }
+ p, err = t.mediaLoader.LoadPath(ref.ID)
+ if err != nil {
+ return "", "", fmt.Errorf("audio file not found: %v", err)
+ }
}
mime = ref.MimeType
@@ -99,7 +107,7 @@ func (t *ReadAudioTool) callProvider(ctx context.Context, cp credentialProvider,
}
// Other providers: try standard Chat API with base64 audio as image_url (best effort).
- p, err := t.registry.Get(providerName)
+ p, err := t.registry.Get(ctx, providerName)
if err != nil {
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
}
diff --git a/internal/tools/read_document_resolve.go b/internal/tools/read_document_resolve.go
index 67269a09..8c899718 100644
--- a/internal/tools/read_document_resolve.go
+++ b/internal/tools/read_document_resolve.go
@@ -13,10 +13,6 @@ import (
// resolveDocumentFile finds the document file path from context MediaRefs.
func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID string) (path, mime string, err error) {
- if t.mediaLoader == nil {
- return "", "", fmt.Errorf("no media storage configured — cannot access document files")
- }
-
refs := MediaDocRefsFromCtx(ctx)
if len(refs) == 0 {
return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.")
@@ -39,9 +35,17 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID stri
ref = &refs[len(refs)-1]
}
- p, err := t.mediaLoader.LoadPath(ref.ID)
- if err != nil {
- return "", "", fmt.Errorf("document file not found: %v", err)
+ // Prefer persisted workspace path; fall back to legacy .media/ lookup.
+ p := ref.Path
+ if p == "" {
+ var err error
+ if t.mediaLoader == nil {
+ return "", "", fmt.Errorf("no media storage configured")
+ }
+ p, err = t.mediaLoader.LoadPath(ref.ID)
+ if err != nil {
+ return "", "", fmt.Errorf("document file not found: %v", err)
+ }
}
// Determine MIME type: prefer ref's stored MIME, fall back to extension.
@@ -75,7 +79,7 @@ func (t *ReadDocumentTool) callProvider(ctx context.Context, cp credentialProvid
}
// Other providers: use standard Chat API with document as base64 image_url.
- p, err := t.registry.Get(providerName)
+ p, err := t.registry.Get(ctx, providerName)
if err != nil {
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
}
diff --git a/internal/tools/read_image.go b/internal/tools/read_image.go
index d0b92aaf..5b4fee4d 100644
--- a/internal/tools/read_image.go
+++ b/internal/tools/read_image.go
@@ -125,7 +125,7 @@ func (t *ReadImageTool) callProvider(ctx context.Context, cp credentialProvider,
images, _ := params["images"].([]providers.ImageContent)
// Get the full provider for Chat() access
- p, err := t.registry.Get(providerName)
+ p, err := t.registry.Get(ctx, providerName)
if err != nil {
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
}
diff --git a/internal/tools/read_video_resolve.go b/internal/tools/read_video_resolve.go
index 85bb3682..8a67d925 100644
--- a/internal/tools/read_video_resolve.go
+++ b/internal/tools/read_video_resolve.go
@@ -37,9 +37,17 @@ func (t *ReadVideoTool) resolveVideoFile(ctx context.Context, mediaID string) (p
ref = &refs[len(refs)-1]
}
- p, err := t.mediaLoader.LoadPath(ref.ID)
- if err != nil {
- return "", "", fmt.Errorf("video file not found: %v", err)
+ // Prefer persisted workspace path; fall back to legacy .media/ lookup.
+ p := ref.Path
+ if p == "" {
+ var err error
+ if t.mediaLoader == nil {
+ return "", "", fmt.Errorf("no media storage configured")
+ }
+ p, err = t.mediaLoader.LoadPath(ref.ID)
+ if err != nil {
+ return "", "", fmt.Errorf("video file not found: %v", err)
+ }
}
mime = ref.MimeType
@@ -70,7 +78,7 @@ func (t *ReadVideoTool) callProvider(ctx context.Context, cp credentialProvider,
}
// Other providers: try standard Chat API with base64 as image_url (best effort).
- p, err := t.registry.Get(providerName)
+ p, err := t.registry.Get(ctx, providerName)
if err != nil {
return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err)
}
diff --git a/internal/tools/sessions.go b/internal/tools/sessions.go
index 6c93fe91..42c8ae80 100644
--- a/internal/tools/sessions.go
+++ b/internal/tools/sessions.go
@@ -59,7 +59,7 @@ func (t *SessionsListTool) Execute(ctx context.Context, args map[string]any) *Re
}
agentID := resolveAgentIDString(ctx)
- sessions := t.sessions.List(agentID)
+ sessions := t.sessions.List(ctx, agentID)
// Filter by active_minutes
if activeMinutes > 0 {
@@ -148,7 +148,7 @@ func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]any) *R
return ErrorResult("access denied: session belongs to a different agent")
}
- data := t.sessions.GetOrCreate(sessionKey)
+ data := t.sessions.GetOrCreate(ctx, sessionKey)
var lines []string
lines = append(lines, fmt.Sprintf("Session: %s", data.Key))
diff --git a/internal/tools/sessions_history.go b/internal/tools/sessions_history.go
index 5ae5823f..0b6855dd 100644
--- a/internal/tools/sessions_history.go
+++ b/internal/tools/sessions_history.go
@@ -76,7 +76,7 @@ func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]any)
return ErrorResult("access denied: session belongs to a different agent")
}
- history := t.sessions.GetHistory(sessionKey)
+ history := t.sessions.GetHistory(ctx, sessionKey)
if history == nil {
return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`)
}
diff --git a/internal/tools/sessions_send.go b/internal/tools/sessions_send.go
index c9134843..b8ea77f7 100644
--- a/internal/tools/sessions_send.go
+++ b/internal/tools/sessions_send.go
@@ -72,10 +72,10 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re
// Resolve by label if needed
if sessionKey == "" && label != "" {
- sessions := t.sessions.List(agentID)
+ sessions := t.sessions.List(ctx, agentID)
for _, s := range sessions {
// Check if label matches by loading session data
- data := t.sessions.GetOrCreate(s.Key)
+ data := t.sessions.GetOrCreate(ctx, s.Key)
if data.Label == label {
sessionKey = s.Key
break
@@ -98,6 +98,7 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re
ChatID: sessionKey,
Content: message,
PeerKind: "direct",
+ TenantID: store.TenantIDFromContext(ctx),
})
return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey))
diff --git a/internal/tools/shell.go b/internal/tools/shell.go
index 706b5179..73272357 100644
--- a/internal/tools/shell.go
+++ b/internal/tools/shell.go
@@ -198,14 +198,23 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
}
}
- // Use per-user workspace from context if available, fallback to struct field
+ // Use per-user workspace from context if available, fallback to struct field.
+ // The context workspace is tenant-scoped; t.workspace is the global (master) workspace.
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if wd, _ := args["working_dir"].(string); wd != "" {
if effectiveRestrict(ctx, t.restrict) {
- resolved, err := resolvePath(wd, t.workspace, true)
+ // Validate working_dir against the tenant-scoped workspace (not the
+ // global workspace) so non-master tenants can't escape their scope.
+ // Also allow team workspace as a valid target (same as filesystem tools).
+ wsBase := ToolWorkspaceFromCtx(ctx)
+ if wsBase == "" {
+ wsBase = t.workspace
+ }
+ allowed := allowedWithTeamWorkspace(ctx, nil)
+ resolved, err := resolvePathWithAllowed(wd, wsBase, true, allowed)
if err != nil {
return ErrorResult(err.Error())
}
diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go
index 1d088c17..7f9e8eec 100644
--- a/internal/tools/skill_manage.go
+++ b/internal/tools/skill_manage.go
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
@@ -22,13 +23,21 @@ import (
// Complements publish_skill (directory-based) with a content-string interface
// so agents can create/patch/delete skills without pre-writing files to disk.
type SkillManageTool struct {
- skills *pg.PGSkillStore
- base string // skills-store/ base directory
- loader *skills.Loader // cache invalidation
+ skills *pg.PGSkillStore
+ base string // skills-store/ base directory (master tenant)
+ dataDir string // parent data dir for tenant-scoped skill paths
+ loader *skills.Loader // cache invalidation
}
-func NewSkillManageTool(skills *pg.PGSkillStore, baseDir string, loader *skills.Loader) *SkillManageTool {
- return &SkillManageTool{skills: skills, base: baseDir, loader: loader}
+func NewSkillManageTool(skills *pg.PGSkillStore, baseDir, dataDir string, loader *skills.Loader) *SkillManageTool {
+ return &SkillManageTool{skills: skills, base: baseDir, dataDir: dataDir, loader: loader}
+}
+
+// tenantSkillsDir returns the skills-store directory scoped to the calling agent's tenant.
+func (t *SkillManageTool) tenantSkillsDir(ctx context.Context) string {
+ tid := store.TenantIDFromContext(ctx)
+ slug := store.TenantSlugFromContext(ctx)
+ return config.TenantSkillsStoreDir(t.dataDir, tid, slug)
}
func (t *SkillManageTool) Name() string { return "skill_manage" }
@@ -124,9 +133,9 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any
return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug))
}
- // Version + destination
+ // Version + destination (tenant-scoped)
version := t.skills.GetNextVersion(slug)
- destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", version))
+ destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", version))
if err := os.MkdirAll(destDir, 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create skill directory: %v", err))
}
@@ -217,7 +226,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
return ErrorResult("find is required for action=patch")
}
- info, ok := t.skills.GetSkill(slug)
+ info, ok := t.skills.GetSkill(ctx, slug)
if !ok {
return ErrorResult(fmt.Sprintf("skill %q not found or archived", slug))
}
@@ -227,7 +236,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
// Ownership check: only the skill owner can patch
userID := store.UserIDFromContext(ctx)
- if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != userID {
+ if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != userID {
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
}
@@ -254,7 +263,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr))
}
defer commitLock() //nolint:errcheck
- destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", newVer))
+ destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", newVer))
if err := os.MkdirAll(destDir, 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create new version directory: %v", err))
}
@@ -281,7 +290,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err))
}
- if err := t.skills.UpdateSkill(skillID, map[string]any{
+ if err := t.skills.UpdateSkill(ctx, skillID, map[string]any{
"version": newVer,
"file_path": destDir,
"file_size": fileSize,
@@ -307,7 +316,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any
return ErrorResult("slug is required for action=delete")
}
- info, ok := t.skills.GetSkill(slug)
+ info, ok := t.skills.GetSkill(ctx, slug)
if !ok {
return ErrorResult(fmt.Sprintf("skill %q not found or already archived", slug))
}
@@ -317,17 +326,18 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any
// Ownership check: only the skill owner can delete
deleteUserID := store.UserIDFromContext(ctx)
- if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != deleteUserID {
+ if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != deleteUserID {
return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug))
}
// Soft-delete on disk: move to .trash/.
- trashDir := filepath.Join(t.base, ".trash")
+ skillsDir := t.tenantSkillsDir(ctx)
+ trashDir := filepath.Join(skillsDir, ".trash")
if err := os.MkdirAll(trashDir, 0755); err != nil {
slog.Warn("skill_manage: failed to create .trash dir", "error", err)
} else {
timestamp := fmt.Sprintf("%d", time.Now().Unix())
- src := filepath.Join(t.base, slug)
+ src := filepath.Join(skillsDir, slug)
dst := filepath.Join(trashDir, slug+"."+timestamp)
if err := os.Rename(src, dst); err != nil {
// Cross-device rename fails on some setups — log and continue (DB archive is primary)
@@ -340,7 +350,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any
if err != nil {
return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err))
}
- if err := t.skills.DeleteSkill(skillID); err != nil {
+ if err := t.skills.DeleteSkill(ctx, skillID); err != nil {
return ErrorResult(fmt.Sprintf("failed to archive skill in database: %v", err))
}
diff --git a/internal/tools/skill_search.go b/internal/tools/skill_search.go
index 37f0f636..8d659e52 100644
--- a/internal/tools/skill_search.go
+++ b/internal/tools/skill_search.go
@@ -33,7 +33,7 @@ type SkillSearchTool struct {
func NewSkillSearchTool(loader *skills.Loader) *SkillSearchTool {
idx := skills.NewIndex()
t := &SkillSearchTool{index: idx, loader: loader}
- t.rebuildIndex()
+ t.rebuildIndex(store.WithCrossTenant(context.Background()))
return t
}
@@ -49,18 +49,18 @@ func (t *SkillSearchTool) SetSkillAccessStore(sas store.SkillAccessStore) {
}
// rebuildIndex refreshes the BM25 index from the current skill set.
-func (t *SkillSearchTool) rebuildIndex() {
- allSkills := t.loader.ListSkills()
+func (t *SkillSearchTool) rebuildIndex(ctx context.Context) {
+ allSkills := t.loader.ListSkills(ctx)
t.index.Build(allSkills)
t.lastVersion = t.loader.Version()
slog.Info("skill_search index rebuilt", "docs", len(allSkills), "version", t.lastVersion)
}
// ensureIndex rebuilds the BM25 index if skills have changed since last build.
-func (t *SkillSearchTool) ensureIndex() {
+func (t *SkillSearchTool) ensureIndex(ctx context.Context) {
current := t.loader.Version()
if current > t.lastVersion {
- t.rebuildIndex()
+ t.rebuildIndex(ctx)
}
}
@@ -99,7 +99,7 @@ func (t *SkillSearchTool) Execute(ctx context.Context, args map[string]any) *Res
}
// Lazy rebuild: check if skills changed since last index build
- t.ensureIndex()
+ t.ensureIndex(ctx)
// BM25 search (always available)
bm25Results := t.index.Search(query, maxResults*2)
diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go
index a52c01a5..3e2ea0dd 100644
--- a/internal/tools/subagent.go
+++ b/internal/tools/subagent.go
@@ -62,6 +62,7 @@ type SubagentTask struct {
CreatedAt int64 `json:"createdAt"`
CompletedAt int64 `json:"completedAt,omitempty"`
Media []bus.MediaFile `json:"-"` // media files from tool results
+ OriginTenantID uuid.UUID `json:"-"` // parent's tenant for announce routing
OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking
OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID
cancelFunc context.CancelFunc `json:"-"` // per-task context cancel
@@ -274,6 +275,7 @@ func (sm *SubagentManager) Spawn(
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
OriginUserID: store.UserIDFromContext(ctx),
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
+ OriginTenantID: store.TenantIDFromContext(ctx),
OriginTraceID: tracing.TraceIDFromContext(ctx),
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
CreatedAt: time.Now().UnixMilli(),
diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go
index 9c35f400..74316884 100644
--- a/internal/tools/subagent_exec.go
+++ b/internal/tools/subagent_exec.go
@@ -40,6 +40,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
OriginLocalKey: task.OriginLocalKey,
OriginUserID: task.OriginUserID,
OriginSessionKey: task.OriginSessionKey,
+ OriginTenantID: task.OriginTenantID,
ParentAgent: task.ParentID,
OriginTraceID: task.OriginTraceID.String(),
OriginRootSpanID: task.OriginRootSpanID.String(),
@@ -75,6 +76,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
ChatID: task.OriginChatID,
Content: announceContent,
UserID: task.OriginUserID,
+ TenantID: task.OriginTenantID,
Metadata: announceMeta,
Media: task.Media,
})
@@ -167,7 +169,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask)
activeProvider := sm.provider
if sm.providerReg != nil {
if parentProviderName := ParentProviderFromCtx(ctx); parentProviderName != "" {
- if p, err := sm.providerReg.Get(parentProviderName); err == nil {
+ if p, err := sm.providerReg.Get(ctx, parentProviderName); err == nil {
activeProvider = p
}
}
diff --git a/internal/tools/subagent_spawn_tool.go b/internal/tools/subagent_spawn_tool.go
index 5cecff88..6a309303 100644
--- a/internal/tools/subagent_spawn_tool.go
+++ b/internal/tools/subagent_spawn_tool.go
@@ -5,6 +5,9 @@ import (
"fmt"
"strings"
"time"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
)
// SpawnTool spawns subagent clones to handle tasks in the background.
@@ -97,6 +100,15 @@ func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Resu
agentKey, agentKey))
}
+ // Validate tenant isolation: non-cross-tenant callers must have a tenant in context.
+ // Self-clone subagents inherit caller's context (WithoutCancel), so tenant propagates automatically.
+ if !store.IsCrossTenant(ctx) {
+ callerTenant := store.TenantIDFromContext(ctx)
+ if callerTenant == uuid.Nil {
+ return ErrorResult("spawn requires tenant context: no tenant ID found in request context")
+ }
+ }
+
task, _ := args["task"].(string)
if task == "" {
return ErrorResult("task parameter is required")
diff --git a/internal/tools/subagent_tracing.go b/internal/tools/subagent_tracing.go
index 2de91f89..1f5f3254 100644
--- a/internal/tools/subagent_tracing.go
+++ b/internal/tools/subagent_tracing.go
@@ -43,6 +43,10 @@ func (sm *SubagentManager) emitLLMSpanStart(ctx context.Context, start time.Time
span.ParentSpanID = &parentID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
if collector.Verbose() && len(messages) > 0 {
if b, err := json.Marshal(messages); err == nil {
span.InputPreview = truncate(string(b), 100000)
@@ -131,6 +135,10 @@ func (sm *SubagentManager) emitToolSpanStart(ctx context.Context, start time.Tim
span.ParentSpanID = &parentID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
collector.EmitSpan(span)
return spanID
}
@@ -198,6 +206,10 @@ func (sm *SubagentManager) emitSubagentSpanStart(ctx context.Context, spanID uui
span.ParentSpanID = &parentSpanID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
+ span.TenantID = store.TenantIDFromContext(ctx)
+ if span.TenantID == uuid.Nil {
+ span.TenantID = store.MasterTenantID
+ }
collector.EmitSpan(span)
}
diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go
index 274a7df2..e2a09378 100644
--- a/internal/tools/team_tasks_followup.go
+++ b/internal/tools/team_tasks_followup.go
@@ -117,8 +117,11 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
- if task.Status != store.TeamTaskStatusStale && task.Status != store.TeamTaskStatusFailed {
- return ErrorResult(fmt.Sprintf("retry only works on stale or failed tasks (current status: %s)", task.Status))
+ switch task.Status {
+ case store.TeamTaskStatusStale, store.TeamTaskStatusFailed, store.TeamTaskStatusCompleted:
+ // OK — can retry/reopen these statuses
+ default:
+ return ErrorResult(fmt.Sprintf("retry only works on completed, stale, or failed tasks (current status: %s)", task.Status))
}
if task.OwnerAgentID == nil {
return ErrorResult("task has no assignee — assign it first via update")
@@ -133,7 +136,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
return ErrorResult("failed to retry task: " + err.Error())
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -151,5 +154,6 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
// Dispatch immediately (retry is an explicit action, not during a turn).
t.manager.dispatchTaskToAgent(ctx, task, team.ID, *task.OwnerAgentID)
- return NewResult(fmt.Sprintf("Task %s retried and dispatched to %s.", taskID, t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)))
+ assignee := t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)
+ return NewResult(fmt.Sprintf("Task #%d \"%s\" (id: %s) retried and dispatched to %s. The assignee will receive the task with your recent comments.", task.TaskNumber, task.Subject, taskID, assignee))
}
diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go
index 06f6f711..2d38a2eb 100644
--- a/internal/tools/team_tasks_lifecycle.go
+++ b/internal/tools/team_tasks_lifecycle.go
@@ -25,7 +25,7 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInProgress,
@@ -82,7 +82,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
taskNumber = completedTask.TaskNumber
taskSubject = completedTask.Subject
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: taskNumber,
@@ -135,7 +135,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
return ErrorResult("failed to cancel task: " + err.Error())
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusCancelled,
@@ -181,7 +181,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusInReview,
@@ -245,7 +245,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
newStatus = approved.Status
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -313,7 +313,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
return ErrorResult("failed to reject task: " + err.Error())
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go
index 863434eb..513e0e3a 100644
--- a/internal/tools/team_tasks_mutations.go
+++ b/internal/tools/team_tasks_mutations.go
@@ -4,11 +4,13 @@ import (
"context"
"fmt"
"log/slog"
+ "path/filepath"
"strings"
"time"
"github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -98,7 +100,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
if assigneeKey == "" {
return ErrorResult("assignee is required — specify which team member should handle this task")
}
- assigneeID, err := t.manager.resolveAgentByKey(assigneeKey)
+ assigneeID, err := t.manager.resolveAgentByKey(ctx, assigneeKey)
if err != nil {
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
}
@@ -140,10 +142,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
wsChat = ""
}
- // Compute the team workspace directory so member agents write files to the
- // shared team folder instead of their own personal workspace.
+ // Compute the team workspace directory (tenant-scoped) so member agents
+ // write files to the shared team folder instead of their own personal workspace.
taskMeta := make(map[string]any)
- if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil {
+ tenantBase := config.TenantWorkspace(t.manager.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx))
+ if teamWsDir, err := WorkspaceDir(tenantBase, team.ID, wsChat); err == nil {
taskMeta["team_workspace"] = teamWsDir
}
// Auto-collect media files from current run to team workspace.
@@ -231,7 +234,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
}
agentKey := t.manager.agentKeyFromID(ctx, agentID)
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
@@ -253,7 +256,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
} else {
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
@@ -314,7 +317,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
return ErrorResult("failed to add comment: " + err.Error())
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -328,7 +331,19 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
- return NewResult(fmt.Sprintf("Comment added to task %s.", taskID))
+ isLead := agentID == team.LeadAgentID
+ msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)
+ switch {
+ case isLead && task.Status == store.TeamTaskStatusInProgress:
+ msg += " Note: the assignee is currently working and won't see this comment during execution. To redirect, wait for completion then use retry action with this task_id."
+ case isLead && task.Status == store.TeamTaskStatusCompleted:
+ msg += " Task is completed. Use retry action with this task_id to reopen and re-dispatch with your feedback."
+ case isLead && task.Status == store.TeamTaskStatusFailed:
+ msg += " Task failed. Use retry action with this task_id to re-dispatch with your feedback."
+ case !isLead && task.OwnerAgentID != nil && *task.OwnerAgentID == agentID:
+ msg += " Your comment will be included in the task report sent to the leader. Continue working on the task."
+ }
+ return NewResult(msg)
}
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
@@ -376,7 +391,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any
if task.OwnerAgentID != nil {
ownerKey = t.manager.agentKeyFromID(ctx, *task.OwnerAgentID)
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
@@ -410,6 +425,14 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
return ErrorResult("path is required for attach action")
}
+ // Resolve to absolute path within team workspace.
+ if !filepath.IsAbs(filePath) {
+ if ws := ToolTeamWorkspaceFromCtx(ctx); ws != "" {
+ filePath = filepath.Join(ws, filePath)
+ }
+ }
+ filePath = filepath.Clean(filePath)
+
// Verify task belongs to team.
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
@@ -430,7 +453,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
return ErrorResult("failed to attach file: " + err.Error())
}
- return NewResult(fmt.Sprintf("File attached to task %s.", taskID))
+ return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
}
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
@@ -511,7 +534,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
return ErrorResult("failed to update task: " + err.Error())
}
- t.manager.broadcastTeamEvent(protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
+ t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
Subject: task.Subject,
@@ -524,5 +547,5 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any)
ActorID: t.manager.agentKeyFromID(ctx, agentID),
})
- return NewResult(fmt.Sprintf("Task %s updated.", taskID))
+ return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID))
}
diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go
index cacf5c5d..1b711a66 100644
--- a/internal/tools/team_tasks_read.go
+++ b/internal/tools/team_tasks_read.go
@@ -188,6 +188,13 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
tasks = tasks[:listPageSize]
}
+ // Pre-warm agent cache to avoid N+1 queries for display names.
+ agentKeys := make([]string, 0, len(tasks)*2)
+ for _, task := range tasks {
+ agentKeys = append(agentKeys, task.OwnerAgentKey, task.CreatedByAgentKey)
+ }
+ t.manager.preWarmAgentKeyCache(ctx, agentKeys)
+
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, t.toListItem(ctx, task))
@@ -261,12 +268,24 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
}
}
+ // Pre-warm cache for task owner + creator display names.
+ t.manager.preWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey})
+
detail := t.toDetailItem(ctx, task)
// Load and slim comments/events/attachments
resp := map[string]any{"task": detail}
if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 {
+ // Pre-warm agent cache to avoid N+1 queries for comment agent keys.
+ commentAgentIDs := make([]uuid.UUID, 0, len(comments))
+ for _, c := range comments {
+ if c.AgentID != nil {
+ commentAgentIDs = append(commentAgentIDs, *c.AgentID)
+ }
+ }
+ t.manager.preWarmAgentIDCache(ctx, commentAgentIDs)
+
slim := make([]slimComment, 0, len(comments))
for _, c := range comments {
key := ""
diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go
index f116ea86..d2521129 100644
--- a/internal/tools/team_tool_cache.go
+++ b/internal/tools/team_tool_cache.go
@@ -104,6 +104,20 @@ func (m *TeamToolManager) InvalidateAgentCache() {
m.agentKeyCache.Range(func(k, _ any) bool { m.agentKeyCache.Delete(k); return true })
}
+// agentKeyCacheKey builds a tenant-scoped cache key for agentKeyCache.
+// Agent keys (e.g. "my-agent") are unique per-tenant, not globally,
+// so the cache key must include tenant to prevent cross-tenant pollution.
+func agentKeyCacheKey(ctx context.Context, key string) string {
+ if store.IsCrossTenant(ctx) {
+ return "*:" + key
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return "*:" + key
+ }
+ return tid.String() + ":" + key
+}
+
// cachedGetAgentByID returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) {
if entry, ok := m.agentCache.Load(id); ok {
@@ -120,18 +134,19 @@ func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID)
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
m.agentCache.Store(id, e)
- m.agentKeyCache.Store(ag.AgentKey, e)
+ m.agentKeyCache.Store(agentKeyCacheKey(ctx, ag.AgentKey), e)
return ag, nil
}
// cachedGetAgentByKey returns agent data from cache or DB with TTL.
func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (*store.AgentData, error) {
- if entry, ok := m.agentKeyCache.Load(key); ok {
+ ck := agentKeyCacheKey(ctx, key)
+ if entry, ok := m.agentKeyCache.Load(ck); ok {
ce := entry.(*agentCacheEntry)
if time.Since(ce.cachedAt) < teamCacheTTL {
return ce.agent, nil
}
- m.agentKeyCache.Delete(key)
+ m.agentKeyCache.Delete(ck)
}
ag, err := m.agentStore.GetByKey(ctx, key)
if err != nil {
@@ -139,7 +154,7 @@ func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (
}
now := time.Now()
e := &agentCacheEntry{agent: ag, cachedAt: now}
- m.agentKeyCache.Store(key, e)
+ m.agentKeyCache.Store(ck, e)
m.agentCache.Store(ag.ID, e)
return ag, nil
}
@@ -155,9 +170,87 @@ func (m *TeamToolManager) cachedListMembers(ctx context.Context, teamID uuid.UUI
return m.teamStore.ListMembers(ctx, teamID)
}
+// preWarmAgentKeyCache batch-fetches agents by key and populates the cache.
+// Reduces N+1 queries when rendering task lists with agent display names.
+func (m *TeamToolManager) preWarmAgentKeyCache(ctx context.Context, keys []string) {
+ // Deduplicate and filter already-cached keys.
+ unique := make(map[string]bool)
+ var missing []string
+ for _, k := range keys {
+ if k == "" || unique[k] {
+ continue
+ }
+ unique[k] = true
+ ck := agentKeyCacheKey(ctx, k)
+ if entry, ok := m.agentKeyCache.Load(ck); ok {
+ ce := entry.(*agentCacheEntry)
+ if time.Since(ce.cachedAt) < teamCacheTTL {
+ continue // still valid
+ }
+ }
+ missing = append(missing, k)
+ }
+ if len(missing) == 0 {
+ return
+ }
+
+ agents, err := m.agentStore.GetByKeys(ctx, missing)
+ if err != nil {
+ slog.Debug("preWarmAgentKeyCache: batch fetch failed", "error", err)
+ return
+ }
+ now := time.Now()
+ for i := range agents {
+ ag := &agents[i]
+ e := &agentCacheEntry{agent: ag, cachedAt: now}
+ ck := agentKeyCacheKey(ctx, ag.AgentKey)
+ m.agentKeyCache.Store(ck, e)
+ m.agentCache.Store(ag.ID, e)
+ }
+}
+
+// preWarmAgentIDCache batch-fetches agents by UUID and populates the cache.
+// Reduces N+1 queries when rendering task comments with agent keys.
+func (m *TeamToolManager) preWarmAgentIDCache(ctx context.Context, ids []uuid.UUID) {
+ // Deduplicate and filter already-cached IDs.
+ seen := make(map[uuid.UUID]bool)
+ var missing []uuid.UUID
+ for _, id := range ids {
+ if id == uuid.Nil || seen[id] {
+ continue
+ }
+ seen[id] = true
+ if entry, ok := m.agentCache.Load(id); ok {
+ ce := entry.(*agentCacheEntry)
+ if time.Since(ce.cachedAt) < teamCacheTTL {
+ continue
+ }
+ }
+ missing = append(missing, id)
+ }
+ if len(missing) == 0 {
+ return
+ }
+
+ agents, err := m.agentStore.GetByIDs(ctx, missing)
+ if err != nil {
+ slog.Debug("preWarmAgentIDCache: batch fetch failed", "error", err)
+ return
+ }
+ now := time.Now()
+ for i := range agents {
+ ag := &agents[i]
+ e := &agentCacheEntry{agent: ag, cachedAt: now}
+ m.agentCache.Store(ag.ID, e)
+ ck := agentKeyCacheKey(ctx, ag.AgentKey)
+ m.agentKeyCache.Store(ck, e)
+ }
+}
+
// resolveAgentByKey looks up an agent by key and returns its UUID.
-func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) {
- ag, err := m.cachedGetAgentByKey(context.Background(), key)
+// Uses the caller's tenant-scoped context for isolation.
+func (m *TeamToolManager) resolveAgentByKey(ctx context.Context, key string) (uuid.UUID, error) {
+ ag, err := m.cachedGetAgentByKey(ctx, key)
if err != nil {
return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err)
}
diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go
index cc6ccf4d..aed8c5fd 100644
--- a/internal/tools/team_tool_dispatch.go
+++ b/internal/tools/team_tool_dispatch.go
@@ -166,6 +166,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
ChatID: teamID.String(),
Content: content,
UserID: originUserID,
+ TenantID: store.TenantIDFromContext(ctx),
AgentID: ag.AgentKey,
Metadata: meta,
}) {
@@ -304,7 +305,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui
continue
}
dispatched[ownerID] = true
- m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
+ m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
diff --git a/internal/tools/team_tool_helpers.go b/internal/tools/team_tool_helpers.go
index 7e533829..fbb42a0a 100644
--- a/internal/tools/team_tool_helpers.go
+++ b/internal/tools/team_tool_helpers.go
@@ -13,14 +13,12 @@ import (
)
// broadcastTeamEvent sends a real-time event via the message bus for team activity visibility.
-func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) {
+// Includes tenant_id from context for proper WS event filtering.
+func (m *TeamToolManager) broadcastTeamEvent(ctx context.Context, name string, payload any) {
if m.msgBus == nil {
return
}
- m.msgBus.Broadcast(bus.Event{
- Name: name,
- Payload: payload,
- })
+ bus.BroadcastForTenant(m.msgBus, name, store.TenantIDFromContext(ctx), payload)
}
// resolveTeamRole returns the calling agent's role in the team.
@@ -194,7 +192,7 @@ func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.
return ErrorResult("failed to create escalation task: " + err.Error())
}
- m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
+ m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
Subject: subject,
diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go
index d7b3c469..390f8bd4 100644
--- a/internal/tools/team_tool_validation.go
+++ b/internal/tools/team_tool_validation.go
@@ -59,7 +59,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U
if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil {
slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err)
}
- m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
+ m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: taskID.String(),
Status: store.TeamTaskStatusFailed,
@@ -91,7 +91,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U
slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err)
continue
}
- m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
+ m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
@@ -133,7 +133,7 @@ func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID,
if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil {
slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err)
}
- m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
+ m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: id.String(),
Status: store.TeamTaskStatusFailed,
@@ -177,6 +177,7 @@ func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uui
ChatID: chatID,
AgentID: leadAgent.AgentKey,
UserID: team.CreatedBy,
+ TenantID: store.TenantIDFromContext(ctx),
Content: content,
})
}
diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go
index fe06ddf7..6bb27243 100644
--- a/internal/tools/workspace_dir.go
+++ b/internal/tools/workspace_dir.go
@@ -16,11 +16,12 @@ const (
)
// WorkspaceDir returns the disk directory for a team workspace scope.
-// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode)
-// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode)
+// - chatID="" → team root: {baseDir}/teams/{teamID}/ (shared mode)
+// - chatID="x" → per-chat: {baseDir}/teams/{teamID}/{chatID}/ (isolated mode)
+// baseDir should already be tenant-scoped (via config.TenantDataDir for non-master tenants).
// Creates directory with 0750 if not exists.
-func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) {
- dir := filepath.Join(dataDir, "teams", teamID.String())
+func WorkspaceDir(baseDir string, teamID uuid.UUID, chatID string) (string, error) {
+ dir := filepath.Join(baseDir, "teams", teamID.String())
if chatID != "" {
dir = filepath.Join(dir, chatID)
}
diff --git a/internal/tools/workspace_interceptor.go b/internal/tools/workspace_interceptor.go
index 44a3ca9b..c0ca08bd 100644
--- a/internal/tools/workspace_interceptor.go
+++ b/internal/tools/workspace_interceptor.go
@@ -128,7 +128,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti
}
// Broadcast workspace file change event for real-time UI updates.
- w.teamMgr.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{
+ w.teamMgr.broadcastTeamEvent(ctx, protocol.EventWorkspaceFileChanged, map[string]string{
"team_id": teamIDStr,
"channel": "",
"chat_id": chatID,
@@ -151,11 +151,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti
return
}
- // Compute relative path from workspace root for storage.
- relPath, err := filepath.Rel(filepath.Clean(teamWs), filepath.Clean(path))
- if err != nil {
- relPath = fileName
- }
+ absPath := filepath.Clean(path)
switch action {
case "write":
@@ -173,24 +169,79 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti
TaskID: taskID,
TeamID: teamID,
ChatID: chatID,
- Path: relPath,
+ Path: absPath,
FileSize: fileSize,
MimeType: mimeType,
CreatedByAgentID: &agentID,
}
if err := w.teamMgr.teamStore.AttachFileToTask(ctx, att); err != nil {
- slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", relPath, "error", err)
+ slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", absPath, "error", err)
} else {
- slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", relPath)
+ slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", absPath)
}
case "delete":
- if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, relPath); err != nil {
- slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", relPath, "error", err)
+ if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, absPath); err != nil {
+ slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", absPath, "error", err)
}
}
}
+// AutoAttachWorkspaceFile attaches a file inside the team workspace to the
+// current task. No-op when the file is outside workspace or no task context exists.
+// Safe to call redundantly — DB uses ON CONFLICT DO NOTHING for dedup.
+func AutoAttachWorkspaceFile(ctx context.Context, teamStore store.TeamStore, workspace, absPath string) {
+ if teamStore == nil || workspace == "" {
+ return
+ }
+ cleanWs := filepath.Clean(workspace)
+ cleanPath := filepath.Clean(absPath)
+ if !strings.HasPrefix(cleanPath, cleanWs) {
+ return
+ }
+ taskIDStr := TeamTaskIDFromCtx(ctx)
+ teamIDStr := ToolTeamIDFromCtx(ctx)
+ if taskIDStr == "" || teamIDStr == "" {
+ return
+ }
+ taskID, err := uuid.Parse(taskIDStr)
+ if err != nil {
+ return
+ }
+ teamID, err := uuid.Parse(teamIDStr)
+ if err != nil {
+ return
+ }
+
+ var fileSize int64
+ if info, err := os.Stat(cleanPath); err == nil {
+ fileSize = info.Size()
+ }
+
+ chatID := ToolChatIDFromCtx(ctx)
+ if chatID == "" {
+ chatID = store.UserIDFromContext(ctx)
+ }
+ agentID := store.AgentIDFromContext(ctx)
+
+ att := &store.TeamTaskAttachmentData{
+ TaskID: taskID,
+ TeamID: teamID,
+ ChatID: chatID,
+ Path: cleanPath,
+ FileSize: fileSize,
+ MimeType: mimeFromExt(filepath.Ext(cleanPath)),
+ }
+ if agentID != uuid.Nil {
+ att.CreatedByAgentID = &agentID
+ }
+ if err := teamStore.AttachFileToTask(ctx, att); err != nil {
+ slog.Warn("workspace: auto-attach media failed", "task_id", taskIDStr, "path", cleanPath, "error", err)
+ } else {
+ slog.Debug("workspace: auto-attached media to task", "task_id", taskIDStr, "path", cleanPath)
+ }
+}
+
// mimeFromExt returns a MIME type for a file extension using the stdlib registry.
func mimeFromExt(ext string) string {
if t := mime.TypeByExtension(ext); t != "" {
diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go
index 14f18775..80e0c495 100644
--- a/internal/upgrade/version.go
+++ b/internal/upgrade/version.go
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
-const RequiredSchemaVersion uint = 25
+const RequiredSchemaVersion uint = 27
diff --git a/migrations/000026_api_key_user_binding.down.sql b/migrations/000026_api_key_user_binding.down.sql
new file mode 100644
index 00000000..4df19e39
--- /dev/null
+++ b/migrations/000026_api_key_user_binding.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS team_user_grants;
+DROP INDEX IF EXISTS idx_api_keys_owner_id;
+ALTER TABLE api_keys DROP COLUMN IF EXISTS owner_id;
+-- Note: handoff_routes and delegation_history are not recreated (dead code removed)
diff --git a/migrations/000026_api_key_user_binding.up.sql b/migrations/000026_api_key_user_binding.up.sql
new file mode 100644
index 00000000..25a91f19
--- /dev/null
+++ b/migrations/000026_api_key_user_binding.up.sql
@@ -0,0 +1,21 @@
+-- API key owner binding for identity enforcement
+ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id VARCHAR(255);
+COMMENT ON COLUMN api_keys.owner_id IS 'User who owns this key. When set, auth via this key forces user_id = owner_id.';
+CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL;
+
+-- Team user grants for access control
+CREATE TABLE IF NOT EXISTS team_user_grants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ role VARCHAR(50) NOT NULL DEFAULT 'viewer',
+ granted_by VARCHAR(255),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(team_id, user_id)
+);
+CREATE INDEX IF NOT EXISTS idx_team_user_grants_user ON team_user_grants(user_id);
+CREATE INDEX IF NOT EXISTS idx_team_user_grants_team ON team_user_grants(team_id);
+
+-- Drop unused legacy tables
+DROP TABLE IF EXISTS handoff_routes;
+DROP TABLE IF EXISTS delegation_history;
diff --git a/migrations/000027_tenant_foundation.down.sql b/migrations/000027_tenant_foundation.down.sql
new file mode 100644
index 00000000..000c4130
--- /dev/null
+++ b/migrations/000027_tenant_foundation.down.sql
@@ -0,0 +1,112 @@
+-- Rollback: Plan 2 Tenant Foundation
+
+-- Phase I rollback: Restore original UNIQUE constraints
+DROP INDEX IF EXISTS idx_agents_tenant_agent_key_active;
+CREATE UNIQUE INDEX idx_agents_agent_key_active ON agents(agent_key) WHERE deleted_at IS NULL;
+
+DROP INDEX IF EXISTS idx_sessions_tenant_session_key;
+ALTER TABLE sessions ADD CONSTRAINT sessions_session_key_key UNIQUE (session_key);
+
+DROP INDEX IF EXISTS idx_skills_tenant_slug;
+ALTER TABLE skills ADD CONSTRAINT skills_slug_key UNIQUE (slug);
+
+DROP INDEX IF EXISTS idx_mcp_servers_tenant_name;
+ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_name_key UNIQUE (name);
+
+DROP INDEX IF EXISTS idx_channel_contacts_tenant_type_sender;
+ALTER TABLE channel_contacts ADD CONSTRAINT channel_contacts_channel_type_sender_id_key UNIQUE (channel_type, sender_id);
+
+-- Restore llm_providers global UNIQUE(name)
+DROP INDEX IF EXISTS idx_llm_providers_tenant_name;
+ALTER TABLE llm_providers ADD CONSTRAINT llm_providers_name_key UNIQUE (name);
+
+-- Restore config_secrets PK(key)
+ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey;
+ALTER TABLE config_secrets ADD PRIMARY KEY (key);
+
+-- Restore paired_devices global UNIQUE(sender_id, channel)
+DROP INDEX IF EXISTS idx_paired_devices_tenant_sender_channel;
+ALTER TABLE paired_devices ADD CONSTRAINT paired_devices_sender_id_channel_key UNIQUE (sender_id, channel);
+
+-- Restore channel_instances global UNIQUE(name)
+DROP INDEX IF EXISTS idx_channel_instances_tenant_name;
+ALTER TABLE channel_instances ADD CONSTRAINT channel_instances_name_key UNIQUE (name);
+
+-- Restore original usage_snapshots unique index (without tenant_id)
+DROP INDEX IF EXISTS idx_usage_snapshots_unique;
+CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots (
+ bucket_hour,
+ COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'),
+ provider, model, channel
+);
+
+-- Drop new tables
+DROP TABLE IF EXISTS mcp_user_credentials;
+DROP TABLE IF EXISTS skill_tenant_configs;
+DROP TABLE IF EXISTS builtin_tool_tenant_configs;
+DROP TABLE IF EXISTS tenant_users;
+
+-- Drop tenant_id from all tables (reverse order)
+ALTER TABLE team_task_attachments DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE team_task_events DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE team_task_comments DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_team_members DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE embedding_cache DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE spans DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE team_tasks DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE mcp_agent_grants DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE skill_agent_grants DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_context_files DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE secure_cli_binaries DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE config_secrets DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE llm_providers DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE channel_contacts DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE channel_pending_messages DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE paired_devices DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE pairing_requests DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE team_user_grants DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_teams DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE mcp_access_requests DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE mcp_user_grants DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE mcp_servers DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE usage_snapshots DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE activity_logs DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE traces DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE cron_jobs DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE skill_user_grants DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE skills DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE kg_relations DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE kg_entities DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE memory_chunks DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE memory_documents DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE channel_instances DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_links DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_config_permissions DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE user_agent_profiles DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE user_context_files DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agent_shares DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE api_keys DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE sessions DROP COLUMN IF EXISTS tenant_id;
+ALTER TABLE agents DROP COLUMN IF EXISTS tenant_id;
+
+-- Recreate custom_tools table (restore from 000001 schema)
+CREATE TABLE IF NOT EXISTS custom_tools (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT '',
+ parameters JSONB DEFAULT '{}',
+ command TEXT NOT NULL,
+ working_dir TEXT DEFAULT '',
+ timeout_seconds INT DEFAULT 30,
+ env BYTEA,
+ agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
+ enabled BOOLEAN DEFAULT true,
+ created_by VARCHAR(255),
+ metadata JSONB DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Drop tenants table last (FKs already removed)
+DROP TABLE IF EXISTS tenants;
diff --git a/migrations/000027_tenant_foundation.up.sql b/migrations/000027_tenant_foundation.up.sql
new file mode 100644
index 00000000..995f6b02
--- /dev/null
+++ b/migrations/000027_tenant_foundation.up.sql
@@ -0,0 +1,358 @@
+-- Plan 2: Tenant Foundation
+-- Master tenant UUID v7: 0193a5b0-7000-7000-8000-000000000001
+
+-- ============================================================
+-- Phase A: Create tenants + tenant_users tables
+-- ============================================================
+
+CREATE TABLE tenants (
+ id UUID PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ slug VARCHAR(100) NOT NULL UNIQUE,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ settings JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_tenants_slug ON tenants(slug);
+CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active';
+
+-- Seed master tenant
+INSERT INTO tenants (id, name, slug, status)
+VALUES ('0193a5b0-7000-7000-8000-000000000001', 'Master', 'master', 'active');
+
+CREATE TABLE tenant_users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255),
+ role VARCHAR(20) NOT NULL DEFAULT 'member',
+ metadata JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(tenant_id, user_id)
+);
+
+CREATE INDEX idx_tenant_users_user ON tenant_users(user_id);
+CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id);
+
+-- ============================================================
+-- Phase B: ALTER ADD tenant_id to 30 tables
+-- All default to master tenant UUID (PG 11+ metadata-only op)
+-- ============================================================
+
+-- Core tables
+ALTER TABLE agents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE sessions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+-- api_keys: NULLABLE (NULL = system-level cross-tenant key)
+ALTER TABLE api_keys ADD COLUMN tenant_id UUID REFERENCES tenants(id);
+
+-- Agent ecosystem
+ALTER TABLE agent_shares ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE user_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE user_agent_profiles ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE user_agent_overrides ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE agent_config_permissions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE agent_links ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE channel_instances ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Memory + KG
+ALTER TABLE memory_documents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE memory_chunks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE kg_entities ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE kg_relations ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Skills
+ALTER TABLE skills ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE skill_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Cron
+ALTER TABLE cron_jobs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Tracing + Activity
+ALTER TABLE traces ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE activity_logs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE usage_snapshots ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- MCP
+ALTER TABLE mcp_servers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE mcp_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE mcp_access_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Teams
+ALTER TABLE agent_teams ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE team_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Pairing + Channels
+ALTER TABLE pairing_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE paired_devices ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE channel_pending_messages ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE channel_contacts ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- LLM Providers + Config Secrets
+ALTER TABLE llm_providers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE config_secrets ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Other
+ALTER TABLE secure_cli_binaries ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Grant tables
+ALTER TABLE agent_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE skill_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE mcp_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Tasks + Tracing
+ALTER TABLE team_tasks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE spans ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Cache
+ALTER TABLE embedding_cache ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- Team activity tables
+ALTER TABLE agent_team_members ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE team_task_comments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE team_task_events ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+ALTER TABLE team_task_attachments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id);
+
+-- ============================================================
+-- Phase C: Drop defaults (force explicit tenant_id for new rows)
+-- ============================================================
+
+ALTER TABLE agents ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE sessions ALTER COLUMN tenant_id DROP DEFAULT;
+-- api_keys: no default to drop (nullable, no DEFAULT set)
+ALTER TABLE agent_shares ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE user_context_files ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE user_agent_profiles ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE user_agent_overrides ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE agent_config_permissions ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE agent_links ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE channel_instances ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE memory_documents ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE memory_chunks ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE kg_entities ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE kg_relations ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE skills ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE skill_user_grants ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE cron_jobs ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE traces ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE activity_logs ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE usage_snapshots ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE mcp_servers ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE mcp_user_grants ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE mcp_access_requests ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE agent_teams ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE team_user_grants ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE pairing_requests ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE paired_devices ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE channel_pending_messages ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE channel_contacts ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE llm_providers ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE config_secrets ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE secure_cli_binaries ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE agent_context_files ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE skill_agent_grants ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE mcp_agent_grants ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE team_tasks ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE spans ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE embedding_cache ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE agent_team_members ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE team_task_comments ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE team_task_events ALTER COLUMN tenant_id DROP DEFAULT;
+ALTER TABLE team_task_attachments ALTER COLUMN tenant_id DROP DEFAULT;
+
+-- ============================================================
+-- Phase D: Indexes
+-- ============================================================
+
+-- Per-table tenant indexes
+CREATE INDEX idx_agents_tenant ON agents(tenant_id);
+CREATE INDEX idx_sessions_tenant ON sessions(tenant_id);
+CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE tenant_id IS NOT NULL;
+CREATE INDEX idx_agent_shares_tenant ON agent_shares(tenant_id);
+CREATE INDEX idx_user_context_files_tenant ON user_context_files(tenant_id);
+CREATE INDEX idx_user_agent_profiles_tenant ON user_agent_profiles(tenant_id);
+CREATE INDEX idx_user_agent_overrides_tenant ON user_agent_overrides(tenant_id);
+CREATE INDEX idx_agent_config_permissions_tenant ON agent_config_permissions(tenant_id);
+CREATE INDEX idx_agent_links_tenant ON agent_links(tenant_id);
+CREATE INDEX idx_channel_instances_tenant ON channel_instances(tenant_id);
+CREATE INDEX idx_memory_documents_tenant ON memory_documents(tenant_id);
+CREATE INDEX idx_memory_chunks_tenant ON memory_chunks(tenant_id);
+CREATE INDEX idx_kg_entities_tenant ON kg_entities(tenant_id);
+CREATE INDEX idx_kg_relations_tenant ON kg_relations(tenant_id);
+CREATE INDEX idx_skills_tenant ON skills(tenant_id);
+CREATE INDEX idx_skill_user_grants_tenant ON skill_user_grants(tenant_id);
+CREATE INDEX idx_cron_jobs_tenant ON cron_jobs(tenant_id);
+CREATE INDEX idx_traces_tenant ON traces(tenant_id);
+CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id);
+CREATE INDEX idx_usage_snapshots_tenant ON usage_snapshots(tenant_id);
+CREATE INDEX idx_mcp_servers_tenant ON mcp_servers(tenant_id);
+CREATE INDEX idx_mcp_user_grants_tenant ON mcp_user_grants(tenant_id);
+CREATE INDEX idx_mcp_access_requests_tenant ON mcp_access_requests(tenant_id);
+CREATE INDEX idx_agent_teams_tenant ON agent_teams(tenant_id);
+CREATE INDEX idx_team_user_grants_tenant ON team_user_grants(tenant_id);
+CREATE INDEX idx_pairing_requests_tenant ON pairing_requests(tenant_id);
+CREATE INDEX idx_paired_devices_tenant ON paired_devices(tenant_id);
+CREATE INDEX idx_channel_pending_messages_tenant ON channel_pending_messages(tenant_id);
+CREATE INDEX idx_channel_contacts_tenant ON channel_contacts(tenant_id);
+CREATE INDEX idx_llm_providers_tenant ON llm_providers(tenant_id);
+CREATE INDEX idx_config_secrets_tenant ON config_secrets(tenant_id);
+CREATE INDEX idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id);
+CREATE INDEX idx_agent_context_files_tenant ON agent_context_files(tenant_id);
+CREATE INDEX idx_skill_agent_grants_tenant ON skill_agent_grants(tenant_id);
+CREATE INDEX idx_mcp_agent_grants_tenant ON mcp_agent_grants(tenant_id);
+CREATE INDEX idx_team_tasks_tenant ON team_tasks(tenant_id);
+CREATE INDEX idx_spans_tenant ON spans(tenant_id);
+CREATE INDEX idx_embedding_cache_tenant ON embedding_cache(tenant_id);
+CREATE INDEX idx_agent_team_members_tenant ON agent_team_members(tenant_id);
+CREATE INDEX idx_team_task_comments_tenant ON team_task_comments(tenant_id);
+CREATE INDEX idx_team_task_events_tenant ON team_task_events(tenant_id);
+CREATE INDEX idx_team_task_attachments_tenant ON team_task_attachments(tenant_id);
+
+-- Composite indexes for Plan 3 query performance
+CREATE INDEX idx_agents_tenant_active ON agents(tenant_id) WHERE deleted_at IS NULL;
+CREATE INDEX idx_sessions_tenant_user ON sessions(tenant_id, user_id);
+CREATE INDEX idx_traces_tenant_time ON traces(tenant_id, created_at DESC);
+
+-- ============================================================
+-- Phase E: Seed master tenant owner from existing agents
+-- ============================================================
+
+INSERT INTO tenant_users (tenant_id, user_id, role)
+SELECT DISTINCT '0193a5b0-7000-7000-8000-000000000001'::uuid, owner_id, 'owner'
+FROM agents
+WHERE owner_id IS NOT NULL AND owner_id != ''
+LIMIT 1
+ON CONFLICT (tenant_id, user_id) DO NOTHING;
+
+-- ============================================================
+-- Phase F: DROP custom_tools table (dead code — agent loop never wired)
+-- ============================================================
+
+DROP TABLE IF EXISTS custom_tools;
+
+-- ============================================================
+-- Phase G: Per-tenant builtin tool config overrides
+-- ============================================================
+
+CREATE TABLE builtin_tool_tenant_configs (
+ tool_name VARCHAR(100) NOT NULL REFERENCES builtin_tools(name) ON DELETE CASCADE,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ enabled BOOLEAN,
+ settings JSONB,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (tool_name, tenant_id)
+);
+
+CREATE INDEX idx_builtin_tool_tenant_configs_tenant ON builtin_tool_tenant_configs(tenant_id);
+
+-- ============================================================
+-- Phase H: Per-tenant skill config (disable system skills)
+-- ============================================================
+
+CREATE TABLE skill_tenant_configs (
+ skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (skill_id, tenant_id)
+);
+
+CREATE INDEX idx_skill_tenant_configs_tenant ON skill_tenant_configs(tenant_id);
+
+-- ============================================================
+-- Phase J: MCP per-user credentials
+-- ============================================================
+
+CREATE TABLE mcp_user_credentials (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
+ user_id VARCHAR(255) NOT NULL,
+ api_key TEXT,
+ headers BYTEA,
+ env BYTEA,
+ tenant_id UUID NOT NULL REFERENCES tenants(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(server_id, user_id, tenant_id)
+);
+
+CREATE INDEX idx_mcp_user_credentials_tenant ON mcp_user_credentials(tenant_id);
+CREATE INDEX idx_mcp_user_credentials_server ON mcp_user_credentials(server_id);
+
+-- ============================================================
+-- Phase I: Update UNIQUE constraints to include tenant_id
+-- Allows same name/key/slug across different tenants.
+-- ============================================================
+
+-- agents.agent_key: (agent_key) → (tenant_id, agent_key)
+-- Old constraint replaced by partial index in migration 23.
+DROP INDEX IF EXISTS idx_agents_agent_key_active;
+CREATE UNIQUE INDEX idx_agents_tenant_agent_key_active ON agents(tenant_id, agent_key) WHERE deleted_at IS NULL;
+
+-- sessions.session_key: globally unique → (tenant_id, session_key)
+ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_session_key_key;
+DROP INDEX IF EXISTS sessions_session_key_key;
+CREATE UNIQUE INDEX idx_sessions_tenant_session_key ON sessions(tenant_id, session_key);
+
+-- skills.slug: globally unique → (tenant_id, slug)
+ALTER TABLE skills DROP CONSTRAINT IF EXISTS skills_slug_key;
+DROP INDEX IF EXISTS skills_slug_key;
+CREATE UNIQUE INDEX idx_skills_tenant_slug ON skills(tenant_id, slug);
+
+-- mcp_servers.name: globally unique → (tenant_id, name)
+ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_name_key;
+DROP INDEX IF EXISTS mcp_servers_name_key;
+CREATE UNIQUE INDEX idx_mcp_servers_tenant_name ON mcp_servers(tenant_id, name);
+
+-- channel_contacts: (channel_type, sender_id) → (tenant_id, channel_type, sender_id)
+ALTER TABLE channel_contacts DROP CONSTRAINT IF EXISTS channel_contacts_channel_type_sender_id_key;
+DROP INDEX IF EXISTS channel_contacts_channel_type_sender_id_key;
+CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender ON channel_contacts(tenant_id, channel_type, sender_id);
+
+-- llm_providers.name: globally unique → (tenant_id, name)
+ALTER TABLE llm_providers DROP CONSTRAINT IF EXISTS llm_providers_name_key;
+DROP INDEX IF EXISTS llm_providers_name_key;
+CREATE UNIQUE INDEX idx_llm_providers_tenant_name ON llm_providers(tenant_id, name);
+
+-- config_secrets.key: PK (key) → PK (key, tenant_id)
+ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey;
+ALTER TABLE config_secrets ADD PRIMARY KEY (key, tenant_id);
+
+-- paired_devices: UNIQUE (sender_id, channel) → (tenant_id, sender_id, channel)
+ALTER TABLE paired_devices DROP CONSTRAINT IF EXISTS paired_devices_sender_id_channel_key;
+DROP INDEX IF EXISTS paired_devices_sender_id_channel_key;
+CREATE UNIQUE INDEX idx_paired_devices_tenant_sender_channel ON paired_devices(tenant_id, sender_id, channel);
+
+-- channel_instances.name: globally unique → (tenant_id, name)
+ALTER TABLE channel_instances DROP CONSTRAINT IF EXISTS channel_instances_name_key;
+DROP INDEX IF EXISTS channel_instances_name_key;
+CREATE UNIQUE INDEX idx_channel_instances_tenant_name ON channel_instances(tenant_id, name);
+
+-- usage_snapshots: add tenant_id to unique conflict index for per-tenant aggregation
+DROP INDEX IF EXISTS idx_usage_snapshots_unique;
+CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots (
+ bucket_hour,
+ COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ provider, model, channel,
+ tenant_id
+);
+
+-- Cleanup: strip leaked gateway tokens from session media URLs.
+-- Old code embedded ?token=GATEWAY_TOKEN in markdown image URLs stored in session messages.
+-- New code stores clean paths; frontend adds auth at render time.
+UPDATE sessions
+SET messages = regexp_replace(messages::text, '\?token=[a-f0-9]+', '', 'g')::jsonb
+WHERE messages::text LIKE '%?token=%';
+
+-- ============================================================
+-- Phase K: Migrate remaining UUID v4 defaults to v7
+-- ============================================================
+
+ALTER TABLE kg_entities ALTER COLUMN id SET DEFAULT uuid_generate_v7();
+ALTER TABLE kg_relations ALTER COLUMN id SET DEFAULT uuid_generate_v7();
+ALTER TABLE channel_contacts ALTER COLUMN id SET DEFAULT uuid_generate_v7();
+ALTER TABLE team_user_grants ALTER COLUMN id SET DEFAULT uuid_generate_v7();
+ALTER TABLE tenant_users ALTER COLUMN id SET DEFAULT uuid_generate_v7();
+ALTER TABLE mcp_user_credentials ALTER COLUMN id SET DEFAULT uuid_generate_v7();
diff --git a/pkg/browser/actions.go b/pkg/browser/actions.go
index 83c27c5b..fffe6996 100644
--- a/pkg/browser/actions.go
+++ b/pkg/browser/actions.go
@@ -12,7 +12,7 @@ import (
// Click clicks an element by ref.
func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpts) error {
- _, el, err := m.getPageAndResolve(targetID, ref)
+ _, el, err := m.getPageAndResolve(ctx, targetID, ref)
if err != nil {
return err
}
@@ -34,7 +34,7 @@ func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpt
// Type types text into an element by ref.
func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts TypeOpts) error {
- page, el, err := m.getPageAndResolve(targetID, ref)
+ page, el, err := m.getPageAndResolve(ctx, targetID, ref)
if err != nil {
return err
}
@@ -63,8 +63,9 @@ func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts Typ
// Press presses a keyboard key.
func (m *Manager) Press(ctx context.Context, targetID, key string) error {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return err
@@ -76,7 +77,7 @@ func (m *Manager) Press(ctx context.Context, targetID, key string) error {
// Hover hovers over an element by ref.
func (m *Manager) Hover(ctx context.Context, targetID, ref string) error {
- _, el, err := m.getPageAndResolve(targetID, ref)
+ _, el, err := m.getPageAndResolve(ctx, targetID, ref)
if err != nil {
return err
}
@@ -86,8 +87,9 @@ func (m *Manager) Hover(ctx context.Context, targetID, ref string) error {
// Wait waits for a condition on a page.
func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) error {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return err
@@ -148,8 +150,9 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro
// Evaluate runs JavaScript on a page.
func (m *Manager) Evaluate(ctx context.Context, targetID, js string) (string, error) {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return "", err
diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go
index bedcdaac..338c4ec4 100644
--- a/pkg/browser/browser.go
+++ b/pkg/browser/browser.go
@@ -12,14 +12,16 @@ import (
// Manager handles the Chrome browser lifecycle and page management.
type Manager struct {
- mu sync.Mutex
- browser *rod.Browser
- refs *RefStore
- pages map[string]*rod.Page // targetID → page
- console map[string][]ConsoleMessage // targetID → console messages
- headless bool
- remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
- logger *slog.Logger
+ mu sync.Mutex
+ browser *rod.Browser
+ refs *RefStore
+ pages map[string]*rod.Page // targetID → page
+ console map[string][]ConsoleMessage // targetID → console messages
+ tenantCtxs map[string]*rod.Browser // tenantID → incognito browser context
+ pageTenants map[string]string // targetID → tenantID (for filtering)
+ headless bool
+ remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
+ logger *slog.Logger
}
// Option configures a Manager.
@@ -44,10 +46,12 @@ func WithLogger(l *slog.Logger) Option {
// New creates a Manager with options.
func New(opts ...Option) *Manager {
m := &Manager{
- refs: NewRefStore(),
- pages: make(map[string]*rod.Page),
- console: make(map[string][]ConsoleMessage),
- logger: slog.Default(),
+ refs: NewRefStore(),
+ pages: make(map[string]*rod.Page),
+ console: make(map[string][]ConsoleMessage),
+ tenantCtxs: make(map[string]*rod.Browser),
+ pageTenants: make(map[string]string),
+ logger: slog.Default(),
}
for _, o := range opts {
o(m)
@@ -68,9 +72,11 @@ func (m *Manager) Start(ctx context.Context) error {
}
// Connection dead — clean up and reconnect
m.logger.Info("browser connection lost, reconnecting")
+ m.closeTenantContextsLocked()
m.browser = nil
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
+ m.pageTenants = make(map[string]string)
m.refs = NewRefStore()
}
@@ -118,6 +124,8 @@ func (m *Manager) Stop(ctx context.Context) error {
return nil
}
+ m.closeTenantContextsLocked()
+
var err error
if m.remoteURL == "" {
// Local Chrome — close the browser process
@@ -128,9 +136,49 @@ func (m *Manager) Stop(ctx context.Context) error {
m.browser = nil
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
+ m.pageTenants = make(map[string]string)
return err
}
+// closeTenantContextsLocked closes all incognito browser contexts. Must be called with mu held.
+func (m *Manager) closeTenantContextsLocked() {
+ for tid, ctx := range m.tenantCtxs {
+ if err := ctx.Close(); err != nil {
+ m.logger.Warn("failed to close tenant browser context", "tenant", tid, "error", err)
+ }
+ }
+ m.tenantCtxs = make(map[string]*rod.Browser)
+}
+
+// MasterTenantID is the well-known master tenant UUID string.
+// Pages opened without a tenant context or by the master tenant use the main browser directly.
+const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001"
+
+// tenantBrowserLocked returns an isolated incognito browser context for the given tenant.
+// Master tenant and empty string use the main browser (no isolation needed).
+// Must be called with mu held.
+func (m *Manager) tenantBrowserLocked(tenantID string) (*rod.Browser, error) {
+ if m.browser == nil {
+ return nil, fmt.Errorf("browser not running")
+ }
+ // Master tenant or no tenant: use main browser
+ if tenantID == "" || tenantID == MasterTenantID {
+ return m.browser, nil
+ }
+ // Return existing incognito context
+ if ctx, ok := m.tenantCtxs[tenantID]; ok {
+ return ctx, nil
+ }
+ // Create new incognito context for this tenant
+ incognito, err := m.browser.Incognito()
+ if err != nil {
+ return nil, fmt.Errorf("create incognito context for tenant %s: %w", tenantID, err)
+ }
+ m.tenantCtxs[tenantID] = incognito
+ m.logger.Info("created incognito browser context", "tenant", tenantID)
+ return incognito, nil
+}
+
// Status returns current browser status.
func (m *Manager) Status() *StatusInfo {
m.mu.Lock()
diff --git a/pkg/browser/browser_page.go b/pkg/browser/browser_page.go
index 2f5f429f..20cc56a4 100644
--- a/pkg/browser/browser_page.go
+++ b/pkg/browser/browser_page.go
@@ -10,8 +10,9 @@ import (
// Snapshot takes an accessibility snapshot of a page.
func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
@@ -39,8 +40,9 @@ func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOp
// Screenshot captures a page screenshot as PNG bytes.
func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
@@ -57,8 +59,9 @@ func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool
// Navigate navigates a page to a URL.
func (m *Manager) Navigate(ctx context.Context, targetID, url string) error {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go
index c636f3af..a2a339f9 100644
--- a/pkg/browser/browser_remote.go
+++ b/pkg/browser/browser_remote.go
@@ -1,6 +1,7 @@
package browser
import (
+ "context"
"encoding/json"
"fmt"
"net"
@@ -16,9 +17,11 @@ import (
// reconnectLocked re-establishes the CDP connection to a remote Chrome.
// Must be called with m.mu held. Only works when remoteURL is set.
func (m *Manager) reconnectLocked() error {
+ m.closeTenantContextsLocked()
m.browser = nil
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
+ m.pageTenants = make(map[string]string)
m.refs = NewRefStore()
controlURL, err := resolveRemoteCDP(m.remoteURL)
@@ -87,6 +90,29 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) {
return pages[0], nil
}
+// getPageForTenant wraps getPage with tenant ownership validation.
+// If tenantID is set and the page belongs to a different tenant, access is denied.
+// Must be called with m.mu held.
+func (m *Manager) getPageForTenant(targetID, tenantID string) (*rod.Page, error) {
+ page, err := m.getPage(targetID)
+ if err != nil {
+ return nil, err
+ }
+ // If no tenant context or master tenant, allow access to all pages
+ if tenantID == "" || tenantID == MasterTenantID {
+ return page, nil
+ }
+ // Check ownership: page must belong to this tenant
+ resolvedTID := targetID
+ if targetID == "" {
+ resolvedTID = string(page.TargetID)
+ }
+ if owner, ok := m.pageTenants[resolvedTID]; ok && owner != tenantID {
+ return nil, fmt.Errorf("tab not found: %s", targetID)
+ }
+ return page, nil
+}
+
// setupConsoleListener attaches a console message listener to a page via Rod's EachEvent.
func (m *Manager) setupConsoleListener(page *rod.Page, targetID string) {
go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) {
@@ -146,10 +172,11 @@ func (m *Manager) resolveElement(page *rod.Page, targetID, ref string) (*rod.Ele
return el, nil
}
-// getPageAndResolve is a helper that locks, gets page, and resolves an element.
-func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Element, error) {
+// getPageAndResolve is a helper that locks, gets page with tenant check, and resolves an element.
+func (m *Manager) getPageAndResolve(ctx context.Context, targetID, ref string) (*rod.Page, *rod.Element, error) {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return nil, nil, err
diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go
index bc067fc2..08c138e1 100644
--- a/pkg/browser/browser_tabs.go
+++ b/pkg/browser/browser_tabs.go
@@ -8,7 +8,7 @@ import (
"github.com/go-rod/rod/lib/proto"
)
-// ListTabs returns all open tabs.
+// ListTabs returns open tabs filtered by the caller's tenant context.
func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -17,14 +17,27 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) {
return nil, fmt.Errorf("browser not running")
}
- pages, err := m.browser.Pages()
+ tenantID := tenantIDFromCtx(ctx)
+
+ // Use tenant-scoped browser context for page listing
+ b, err := m.tenantBrowserLocked(tenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ pages, err := b.Pages()
if err != nil {
if m.remoteURL != "" {
if reconnErr := m.reconnectLocked(); reconnErr != nil {
return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr)
}
m.logger.Info("auto-reconnected to remote Chrome")
- pages, err = m.browser.Pages()
+ // Re-acquire tenant browser after reconnect (incognito contexts were reset)
+ b, err = m.tenantBrowserLocked(tenantID)
+ if err != nil {
+ return nil, err
+ }
+ pages, err = b.Pages()
if err != nil {
return nil, fmt.Errorf("list pages after reconnect: %w", err)
}
@@ -41,6 +54,9 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) {
}
tid := string(p.TargetID)
m.pages[tid] = p
+ if tenantID != "" {
+ m.pageTenants[tid] = tenantID
+ }
tabs = append(tabs, TabInfo{
TargetID: tid,
URL: info.URL,
@@ -51,15 +67,18 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) {
}
// OpenTab opens a new tab with the given URL.
+// Pages are created within the tenant's incognito browser context for isolation.
func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
- if m.browser == nil {
- return nil, fmt.Errorf("browser not running")
+ tenantID := tenantIDFromCtx(ctx)
+ b, err := m.tenantBrowserLocked(tenantID)
+ if err != nil {
+ return nil, err
}
- page, err := m.browser.Page(proto.TargetCreateTarget{URL: url})
+ page, err := b.Page(proto.TargetCreateTarget{URL: url})
if err != nil {
return nil, fmt.Errorf("open tab: %w", err)
}
@@ -70,6 +89,9 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) {
info, _ := page.Info()
tid := string(page.TargetID)
m.pages[tid] = page
+ if tenantID != "" {
+ m.pageTenants[tid] = tenantID
+ }
// Set up console listener
m.setupConsoleListener(page, tid)
@@ -84,10 +106,11 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) {
// FocusTab activates a tab.
func (m *Manager) FocusTab(ctx context.Context, targetID string) error {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
defer m.mu.Unlock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
if err != nil {
return err
}
@@ -98,24 +121,34 @@ func (m *Manager) FocusTab(ctx context.Context, targetID string) error {
// CloseTab closes a tab.
func (m *Manager) CloseTab(ctx context.Context, targetID string) error {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
defer m.mu.Unlock()
- page, err := m.getPage(targetID)
+ page, err := m.getPageForTenant(targetID, tenantID)
if err != nil {
return err
}
delete(m.pages, targetID)
delete(m.console, targetID)
+ delete(m.pageTenants, targetID)
return page.Close()
}
// ConsoleMessages returns captured console messages for a tab.
-func (m *Manager) ConsoleMessages(targetID string) []ConsoleMessage {
+func (m *Manager) ConsoleMessages(ctx context.Context, targetID string) []ConsoleMessage {
+ tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
defer m.mu.Unlock()
+ // Validate tenant ownership
+ if tenantID != "" && tenantID != MasterTenantID {
+ if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID {
+ return []ConsoleMessage{}
+ }
+ }
+
msgs := m.console[targetID]
if msgs == nil {
return []ConsoleMessage{}
diff --git a/pkg/browser/browser_tenant.go b/pkg/browser/browser_tenant.go
new file mode 100644
index 00000000..cc7a73bc
--- /dev/null
+++ b/pkg/browser/browser_tenant.go
@@ -0,0 +1,20 @@
+package browser
+
+import "context"
+
+// browserTenantKey is a context key for passing tenant ID to browser operations.
+type browserTenantKey struct{}
+
+// WithTenantID returns a context with the browser tenant ID set.
+// This is used to isolate browser pages per tenant via incognito contexts.
+func WithTenantID(ctx context.Context, tenantID string) context.Context {
+ return context.WithValue(ctx, browserTenantKey{}, tenantID)
+}
+
+// tenantIDFromCtx extracts the tenant ID from context.
+func tenantIDFromCtx(ctx context.Context) string {
+ if v, ok := ctx.Value(browserTenantKey{}).(string); ok {
+ return v
+ }
+ return ""
+}
diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go
index c26ee125..08b6efba 100644
--- a/pkg/browser/tool.go
+++ b/pkg/browser/tool.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"time"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -137,6 +138,11 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R
return tools.ErrorResult("action is required")
}
+ // Propagate tenant ID from store context to browser context for page isolation.
+ if tid := store.TenantIDFromContext(ctx); tid.String() != "00000000-0000-0000-0000-000000000000" {
+ ctx = WithTenantID(ctx, tid.String())
+ }
+
// Auto-start browser for actions that need it
switch action {
case "open", "snapshot", "screenshot", "navigate", "act", "tabs":
@@ -165,7 +171,7 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R
case "navigate":
return t.handleNavigate(ctx, args)
case "console":
- return t.handleConsole(args)
+ return t.handleConsole(ctx, args)
case "act":
return t.handleAct(ctx, args)
default:
@@ -279,9 +285,9 @@ func (t *BrowserTool) handleNavigate(ctx context.Context, args map[string]any) *
return tools.NewResult(fmt.Sprintf("Navigated to %s", url))
}
-func (t *BrowserTool) handleConsole(args map[string]any) *tools.Result {
+func (t *BrowserTool) handleConsole(ctx context.Context, args map[string]any) *tools.Result {
targetID, _ := args["targetId"].(string)
- msgs := t.manager.ConsoleMessages(targetID)
+ msgs := t.manager.ConsoleMessages(ctx, targetID)
return jsonResult(msgs)
}
diff --git a/pkg/protocol/errors.go b/pkg/protocol/errors.go
index bdff63ed..bfe6e4fa 100644
--- a/pkg/protocol/errors.go
+++ b/pkg/protocol/errors.go
@@ -13,6 +13,7 @@ const (
ErrNotFound = "NOT_FOUND"
ErrAlreadyExists = "ALREADY_EXISTS"
ErrResourceExhausted = "RESOURCE_EXHAUSTED"
- ErrFailedPrecondition = "FAILED_PRECONDITION"
- ErrInternal = "INTERNAL"
+ ErrFailedPrecondition = "FAILED_PRECONDITION"
+ ErrInternal = "INTERNAL"
+ ErrTenantAccessRevoked = "TENANT_ACCESS_REVOKED"
)
diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go
index 14ac156f..c295b278 100644
--- a/pkg/protocol/events.go
+++ b/pkg/protocol/events.go
@@ -97,6 +97,9 @@ const (
// Zalo Personal QR login events (client-scoped, not broadcast).
EventZaloPersonalQRCode = "zalo.personal.qr.code"
EventZaloPersonalQRDone = "zalo.personal.qr.done"
+
+ // Tenant access revocation — forces affected user's UI to logout.
+ EventTenantAccessRevoked = "tenant.access.revoked"
)
// Agent event subtypes (in payload.type)
diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go
index a26bb693..cd1eecbe 100644
--- a/pkg/protocol/methods.go
+++ b/pkg/protocol/methods.go
@@ -156,12 +156,6 @@ const (
MethodTeamsEventsList = "teams.events.list"
)
-// Delegation history
-const (
- MethodDelegationsList = "delegations.list"
- MethodDelegationsGet = "delegations.get"
-)
-
// API key management
const (
MethodAPIKeysList = "api_keys.list"
diff --git a/ui/web/index.html b/ui/web/index.html
index 8a30c15f..4c4c0245 100644
--- a/ui/web/index.html
+++ b/ui/web/index.html
@@ -4,6 +4,9 @@
+
+
+
GoClaw Dashboard
diff --git a/ui/web/public/favicon.svg b/ui/web/public/favicon.svg
new file mode 100644
index 00000000..5a23afe8
--- /dev/null
+++ b/ui/web/public/favicon.svg
@@ -0,0 +1,46 @@
+
diff --git a/ui/web/public/goclaw-icon.svg b/ui/web/public/goclaw-icon.svg
new file mode 100644
index 00000000..797941f5
--- /dev/null
+++ b/ui/web/public/goclaw-icon.svg
@@ -0,0 +1,74 @@
+
diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts
index a6018859..2b73dc7b 100644
--- a/ui/web/src/api/http-client.ts
+++ b/ui/web/src/api/http-client.ts
@@ -111,6 +111,9 @@ export class HttpClient {
if (userId) h["X-GoClaw-User-Id"] = userId;
const senderID = this.getSenderID();
if (senderID) h["X-GoClaw-Sender-Id"] = senderID;
+ // Tenant scope: narrow cross-tenant admin to a specific tenant
+ const tenantScope = localStorage.getItem("goclaw:tenant_id");
+ if (tenantScope) h["X-GoClaw-Tenant-Id"] = tenantScope;
return h;
}
@@ -130,10 +133,10 @@ export class HttpClient {
}
if (!res.ok) {
- if (res.status === 401) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ if (res.status === 401 || err.code === "TENANT_ACCESS_REVOKED") {
this.onAuthFailure?.();
}
- const err = await res.json().catch(() => ({ error: res.statusText }));
throw new ApiError(
err.code ?? "HTTP_ERROR",
err.error ?? err.message ?? res.statusText,
diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts
index e194dc03..6f0bb046 100644
--- a/ui/web/src/api/protocol.ts
+++ b/ui/web/src/api/protocol.ts
@@ -167,6 +167,16 @@ export const Methods = {
CONFIG_PERMISSIONS_GRANT: "config.permissions.grant",
CONFIG_PERMISSIONS_REVOKE: "config.permissions.revoke",
+ // Tenants (multi-tenant)
+ TENANTS_MINE: "tenants.mine",
+ TENANTS_LIST: "tenants.list",
+ TENANTS_GET: "tenants.get",
+ TENANTS_CREATE: "tenants.create",
+ TENANTS_UPDATE: "tenants.update",
+ TENANTS_USERS_LIST: "tenants.users.list",
+ TENANTS_USERS_ADD: "tenants.users.add",
+ TENANTS_USERS_REMOVE: "tenants.users.remove",
+
// Phase 3+ - NICE TO HAVE
LOGS_TAIL: "logs.tail",
} as const;
diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts
index 07a5832b..eb289b49 100644
--- a/ui/web/src/api/ws-client.ts
+++ b/ui/web/src/api/ws-client.ts
@@ -23,6 +23,15 @@ export class WsClient {
private intentionalClose = false;
private connectGeneration = 0;
+ /** Server-assigned role from connect response. */
+ role: "admin" | "operator" | "viewer" | "" = "";
+
+ /** Tenant fields from connect response. */
+ tenantId = "";
+ tenantName = "";
+ tenantSlug = "";
+ crossTenant = false;
+
private readonly maxReconnectDelay = 30_000;
private readonly baseReconnectDelay = 1_000;
private readonly defaultTimeout = 30_000;
@@ -193,11 +202,17 @@ export class WsClient {
status?: string;
pairing_code?: string;
sender_id?: string;
+ tenant_id?: string;
+ tenant_name?: string;
+ tenant_slug?: string;
+ cross_tenant?: boolean;
}>("connect", {
token: this.getToken(),
user_id: this.getUserId(),
sender_id: this.getSenderID(),
locale: localStorage.getItem("goclaw:language") || "en",
+ tenant_hint: localStorage.getItem("goclaw:tenant_hint") || "",
+ tenant_id: localStorage.getItem("goclaw:tenant_id") || "",
protocolVersion: PROTOCOL_VERSION,
});
if (this.connectGeneration !== generation) return;
@@ -218,9 +233,21 @@ export class WsClient {
}
this.authenticated = true;
+ this.role = (res?.role as "admin" | "operator" | "viewer") ?? "";
+ this.tenantId = res?.tenant_id ?? "";
+ this.tenantName = res?.tenant_name ?? "";
+ this.tenantSlug = res?.tenant_slug ?? "";
+ this.crossTenant = res?.cross_tenant ?? false;
this.onStateChange("connected");
- } catch {
+ } catch (e) {
if (this.connectGeneration === generation) {
+ // Tenant access revoked → force logout instead of reconnect
+ if (e instanceof ApiError && e.code === "TENANT_ACCESS_REVOKED") {
+ this.intentionalClose = true;
+ this.ws?.close();
+ this.onAuthFailure?.();
+ return;
+ }
this.ws?.close();
}
}
@@ -252,7 +279,7 @@ export class WsClient {
pending.resolve(frame.payload);
} else {
const err = frame.error as ErrorShape;
- if (err.code === "UNAUTHORIZED") {
+ if (err.code === "UNAUTHORIZED" || err.code === "TENANT_ACCESS_REVOKED") {
this.onAuthFailure?.();
}
pending.reject(
diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx
index ace4534f..cc9e66a2 100644
--- a/ui/web/src/components/chat/active-run-zone.tsx
+++ b/ui/web/src/components/chat/active-run-zone.tsx
@@ -1,3 +1,4 @@
+import { memo } from "react";
import { Bot } from "lucide-react";
import { ActivityIndicator } from "./activity-indicator";
import { BlockReplyBubble } from "./block-reply-bubble";
@@ -15,7 +16,7 @@ interface ActiveRunZoneProps {
blockReplies: ChatMessage[];
}
-export function ActiveRunZone({
+export const ActiveRunZone = memo(function ActiveRunZone({
isRunning,
activity,
thinkingText,
@@ -69,4 +70,4 @@ export function ActiveRunZone({
);
-}
+});
diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx
index abff3717..62aabfd9 100644
--- a/ui/web/src/components/chat/activity-indicator.tsx
+++ b/ui/web/src/components/chat/activity-indicator.tsx
@@ -34,7 +34,7 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp
function getPhaseConfig(activity: RunActivity) {
switch (activity.phase) {
case "thinking":
- return { icon: Brain, color: "text-purple-500", label: "Thinking..." };
+ return { icon: Brain, color: "text-orange-500", label: "Thinking..." };
case "tool_exec":
return {
icon: Wrench,
diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx
index 24fe6a27..e391b4ff 100644
--- a/ui/web/src/components/chat/message-bubble.tsx
+++ b/ui/web/src/components/chat/message-bubble.tsx
@@ -51,7 +51,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
/* Normal message bubble — assistant uses full width, user capped at 85% */
{hasThinking && (
@@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)}
{message.timestamp && (
-
+
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
)}
diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx
index 35cf9d10..3a70ec4e 100644
--- a/ui/web/src/components/chat/rich-content.tsx
+++ b/ui/web/src/components/chat/rich-content.tsx
@@ -264,7 +264,7 @@ export function RichContent({ content, role }: RichContentProps) {
// If no special blocks found, render as plain markdown (fast path)
const first = blocks[0];
if (blocks.length === 1 && first?.type === "markdown") {
- return
;
+ return
;
}
return (
@@ -278,7 +278,7 @@ export function RichContent({ content, role }: RichContentProps) {
case "video-notice":
return
;
case "markdown":
- return
;
+ return
;
case "file":
return
;
case "reply":
diff --git a/ui/web/src/components/chat/streaming-text.tsx b/ui/web/src/components/chat/streaming-text.tsx
index b85fef9b..45d9c6bc 100644
--- a/ui/web/src/components/chat/streaming-text.tsx
+++ b/ui/web/src/components/chat/streaming-text.tsx
@@ -1,10 +1,11 @@
+import { memo } from "react";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
interface StreamingTextProps {
text: string;
}
-export function StreamingText({ text }: StreamingTextProps) {
+export const StreamingText = memo(function StreamingText({ text }: StreamingTextProps) {
if (!text) return
;
return (
@@ -13,7 +14,7 @@ export function StreamingText({ text }: StreamingTextProps) {
);
-}
+});
function ThinkingIndicator() {
return (
diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx
index c7ae94c9..f8857409 100644
--- a/ui/web/src/components/layout/sidebar.tsx
+++ b/ui/web/src/components/layout/sidebar.tsx
@@ -26,6 +26,7 @@ import {
Contact,
KeyRound,
FileText,
+ Building2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { SidebarGroup } from "./sidebar-group";
@@ -34,6 +35,8 @@ import { ConnectionStatus } from "./connection-status";
import { ROUTES } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count";
+import { useAuthStore } from "@/stores/use-auth-store";
+import { useTenants } from "@/hooks/use-tenants";
interface SidebarProps {
collapsed: boolean;
@@ -43,6 +46,9 @@ interface SidebarProps {
export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
const { t } = useTranslation("sidebar");
const { pendingCount } = usePendingPairingsCount();
+ const role = useAuthStore((s) => s.role);
+ const { isCrossTenant } = useTenants();
+ const isAdmin = role === "admin";
return (