diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index b9ced614..69d2513f 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -109,7 +109,8 @@ func processNormalMessage( } // Auto-collect channel contacts for the contact selector. - if deps.ContactCollector != nil && msg.SenderID != "" { + // Skip internal senders (system:*, notification:*, teammate:*, ticker:*, session_send_tool). + if deps.ContactCollector != nil && msg.SenderID != "" && !bus.IsInternalSender(msg.SenderID) { senderNumericID := msg.SenderID if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 { senderNumericID = senderNumericID[:idx] @@ -120,7 +121,33 @@ func processNormalMessage( } displayName := sessionMeta["display_name"] username := sessionMeta["username"] - deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind) + deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind, "user") + + // Also collect group chat as a contact (for group permission management / merge). + // Group IDs (e.g., Telegram "-100456") differ from user IDs — no UNIQUE conflict. + if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" { + groupTitle := msg.Metadata["chat_title"] // Telegram: message.Chat.Title + deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, msg.ChatID, "", groupTitle, "", "group", "group") + } + } + + // --- Resolve merged tenant user identity --- + // If the sender has been merged to a tenant_user, use the tenant user's ID + // for DM sessions. This enables per-user features (MCP creds, SecureCLI creds). + // Group sessions keep the group-scoped userID; sender resolution happens via SenderID. + if deps.ContactCollector != nil && peerKind == string(sessions.PeerDirect) && msg.SenderID != "" && !bus.IsInternalSender(msg.SenderID) { + senderNumeric := msg.SenderID + if idx := strings.IndexByte(senderNumeric, '|'); idx > 0 { + senderNumeric = senderNumeric[:idx] + } + chType := deps.ChannelMgr.ChannelTypeForName(msg.Channel) + if chType == "" { + chType = msg.Channel + } + if resolved, err := deps.ContactCollector.ResolveTenantUserID(ctx, chType, senderNumeric); err == nil && resolved != "" { + slog.Debug("contact.resolved_tenant_user", "sender", senderNumeric, "tenant_user", resolved) + userID = resolved + } } // --- Quota check --- diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 204f343d..32037b6d 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -203,6 +203,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // channel type, and final-iteration stripping. var toolDefs []providers.ToolDefinition var allowedTools map[string]bool + // Resolve per-user MCP tools (servers requiring user credentials). + // Must run before buildFilteredTools so tools are in the Registry for policy filtering. + if req.UserID != "" { + l.getUserMCPTools(iterCtx, req.UserID) + } toolDefs, allowedTools, messages = l.buildFilteredTools(&req, hadBootstrap, rs.iteration, maxIter, messages) // Use per-request overrides if set (e.g. heartbeat uses cheaper provider/model). @@ -321,10 +326,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Calibrate overhead on first LLM response with usage data. if !rs.overheadCalibrated && resp.Usage != nil && resp.Usage.PromptTokens > 0 { historyEst := EstimateHistoryTokens(messages) - rs.overheadTokens = resp.Usage.PromptTokens - historyEst - if rs.overheadTokens < 0 { - rs.overheadTokens = 0 - } + rs.overheadTokens = max(resp.Usage.PromptTokens-historyEst, 0) rs.overheadCalibrated = true } diff --git a/internal/agent/loop_finalize.go b/internal/agent/loop_finalize.go index ef6d59a3..dcf2b6b2 100644 --- a/internal/agent/loop_finalize.go +++ b/internal/agent/loop_finalize.go @@ -22,7 +22,7 @@ func isUserFilePopulated(content string) bool { return false } // Template markers: "**Name:**" followed by newline (no value) or just whitespace - for _, line := range strings.Split(content, "\n") { + for line := range strings.SplitSeq(content, "\n") { line = strings.TrimSpace(line) if line == "- **Name:**" || line == "**Name:**" { return false // name field still empty diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 59585d5a..76391540 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -547,10 +547,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // lastPromptTokens includes everything (system prompt, tools, context files, history). // We subtract estimated overhead so the threshold comparison is history-only. lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey) - adjustedLastPT := lastPT - l.estimateOverhead(history, lastPT, lastMC) - if adjustedLastPT < 0 { - adjustedLastPT = 0 - } + adjustedLastPT := max(lastPT-l.estimateOverhead(history, lastPT, lastMC), 0) tokenEstimate := EstimateTokensWithCalibration(history, adjustedLastPT, lastMC) // Resolve compaction threshold from config: token-only (no message count guard). @@ -666,23 +663,14 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { func (l *Loop) estimateOverhead(history []providers.Message, lastPromptTokens, lastMsgCount int) int { if lastPromptTokens <= 0 || lastMsgCount <= 0 { // No calibration data — use conservative default (20% of context, capped at 40k). - fallback := int(float64(l.contextWindow) * 0.2) - if fallback > 40000 { - fallback = 40000 - } + fallback := min(int(float64(l.contextWindow)*0.2), 40000) return fallback } // Overhead = total prompt tokens - estimated history tokens at calibration time. - count := lastMsgCount - if count > len(history) { - count = len(history) - } + count := min(lastMsgCount, len(history)) historyEstAtCalibration := EstimateHistoryTokens(history[:count]) - overhead := lastPromptTokens - historyEstAtCalibration - if overhead < 0 { - overhead = 0 - } + overhead := max(lastPromptTokens-historyEstAtCalibration, 0) // Clamp: overhead shouldn't exceed 40% of context window. maxOverhead := int(float64(l.contextWindow) * 0.4) if overhead > maxOverhead { diff --git a/internal/agent/loop_history_edge_test.go b/internal/agent/loop_history_edge_test.go index 95faf606..7eb1d089 100644 --- a/internal/agent/loop_history_edge_test.go +++ b/internal/agent/loop_history_edge_test.go @@ -217,7 +217,7 @@ func TestSanitizeHistory_DedupAcrossTwoTurns(t *testing.T) { func TestSanitizeHistory_LargeHistory_Performance(t *testing.T) { // Build 1000-message history with proper tool pairing msgs := make([]providers.Message, 0, 1000) - for i := 0; i < 250; i++ { + for i := range 250 { tcID := "tc_" + strings.Repeat("x", 5) + "_" + string(rune('a'+i%26)) + string(rune('0'+i%10)) msgs = append(msgs, providers.Message{Role: "user", Content: "question " + tcID}, diff --git a/internal/agent/loop_mcp_user.go b/internal/agent/loop_mcp_user.go new file mode 100644 index 00000000..ce6ba393 --- /dev/null +++ b/internal/agent/loop_mcp_user.go @@ -0,0 +1,106 @@ +package agent + +import ( + "context" + "log/slog" + "maps" + + mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// getUserMCPTools returns per-user MCP tools for servers requiring user credentials. +// Tools are cached per-user in mcpUserTools sync.Map and registered in the shared +// tool registry so ExecuteWithContext can resolve them. On first call for a user, +// connections are established via pool.AcquireUser() and BridgeTools created. +func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool { + if len(l.mcpUserCredSrvs) == 0 || l.mcpPool == nil || l.mcpStore == nil || userID == "" { + return nil + } + + if cached, ok := l.mcpUserTools.Load(userID); ok { + cachedTools := cached.([]tools.Tool) + // Check if any cached tool's connection was evicted by pool. + // If so, clear cache and re-acquire connections. + allConnected := true + for _, t := range cachedTools { + if bt, ok := t.(interface{ IsConnected() bool }); ok && !bt.IsConnected() { + allConnected = false + break + } + } + if allConnected { + return cachedTools + } + l.mcpUserTools.Delete(userID) + slog.Debug("mcp.user_tools_stale", "user", userID, "reason", "pool_evicted") + } + + var userTools []tools.Tool + for _, info := range l.mcpUserCredSrvs { + srv := info.Server + + // Check if user has credentials for this server + uc, err := l.mcpStore.GetUserCredentials(ctx, srv.ID, userID) + if err != nil || uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + continue + } + + // Resolve connection params: server defaults merged with user overrides + args := mcpbridge.ParseJSONBytesToStringSlice(srv.Args) + env := mcpbridge.ParseJSONBytesToStringMap(srv.Env) + if env == nil { + env = make(map[string]string) + } + headers := mcpbridge.ParseJSONBytesToStringMap(srv.Headers) + if headers == nil { + headers = make(map[string]string) + } + + // Inject server-level API key into headers if present + if srv.APIKey != "" && headers["Authorization"] == "" { + headers["Authorization"] = "Bearer " + srv.APIKey + } + + // Merge user credentials (user overrides server defaults) + if uc.APIKey != "" { + headers["Authorization"] = "Bearer " + uc.APIKey + } + maps.Copy(headers, uc.Headers) + maps.Copy(env, uc.Env) + + // Acquire user-keyed pool connection + entry, err := l.mcpPool.AcquireUser(ctx, l.tenantID, srv.Name, userID, + srv.Transport, srv.Command, args, env, srv.URL, headers, srv.TimeoutSec) + if err != nil { + slog.Warn("mcp.user_pool_acquire_failed", "server", srv.Name, "user", userID, "error", err) + continue + } + + // Release immediately — BridgeTools hold client pointer directly. + // This allows pool idle eviction to work (refCount=0 + lastUsed for TTL). + // When pool evicts the connection, BridgeTool.Execute detects connected=false. + l.mcpPool.ReleaseUser(mcpbridge.UserPoolKey(l.tenantID, srv.Name, userID)) + + // Create BridgeTools pointing to user's connection and register in the + // shared tool registry so ExecuteWithContext can resolve them by name. + reg, _ := l.tools.(*tools.Registry) + for _, mcpTool := range entry.MCPTools() { + bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.Client(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected()) + // Register in registry so ExecuteWithContext can find them. + // Skip if already registered (another user loaded this server with same tool names). + if reg != nil { + if _, exists := reg.Get(bt.Name()); !exists { + reg.Register(bt) + } + } + userTools = append(userTools, bt) + } + } + + if len(userTools) > 0 { + l.mcpUserTools.Store(userID, userTools) + slog.Info("mcp.user_tools_loaded", "user", userID, "tools", len(userTools)) + } + return userTools +} diff --git a/internal/agent/loop_tool_filter.go b/internal/agent/loop_tool_filter.go index 8304fb4b..cd0ac76c 100644 --- a/internal/agent/loop_tool_filter.go +++ b/internal/agent/loop_tool_filter.go @@ -10,6 +10,8 @@ import ( // buildFilteredTools resolves the per-iteration tool definitions based on policy, // disabled tools, bootstrap mode, skill visibility, channel type, and iteration budget. +// Per-user MCP tools must be registered in the Registry before calling this function +// (via getUserMCPTools) so they are included in policy filtering and execution. // Returns tool definitions for the provider, an allowed-tools map for execution validation, // and the (potentially modified) messages slice when final-iteration stripping appends a hint. func (l *Loop) buildFilteredTools(req *RunRequest, hadBootstrap bool, iteration, maxIter int, messages []providers.Message) ([]providers.ToolDefinition, map[string]bool, []providers.Message) { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 117a15a2..7fcfe49d 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" + mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" @@ -110,6 +111,12 @@ type Loop struct { cacheInvalidate CacheInvalidateFunc // invalidate context file cache after seeding userSetups sync.Map // userID → *userSetup (workspace + seeding state, per Loop instance) + // Per-user MCP tools: servers requiring user credentials get connected per-request. + mcpStore store.MCPServerStore // for credential lookup + mcpPool *mcpbridge.Pool // user-keyed connection pool + mcpUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds + mcpUserTools sync.Map // userID → []tools.Tool (cached per-user tools) + // Compaction config (memory flush settings) compactionCfg *config.CompactionConfig @@ -304,6 +311,11 @@ type LoopConfig struct { // Memory store for extractive memory fallback (writes directly when LLM flush fails) MemoryStore store.MemoryStore + + // Per-user MCP tools (servers requiring per-user credentials) + MCPStore store.MCPServerStore // for credential lookup + MCPPool *mcpbridge.Pool // user-keyed connection pool + MCPUserCredSrvs []store.MCPAccessInfo // servers needing per-user creds } const defaultMaxTokens = config.DefaultMaxTokens @@ -398,6 +410,9 @@ func NewLoop(cfg LoopConfig) *Loop { budgetMonthlyCents: cfg.BudgetMonthlyCents, tracingStore: cfg.TracingStore, memStore: cfg.MemoryStore, + mcpStore: cfg.MCPStore, + mcpPool: cfg.MCPPool, + mcpUserCredSrvs: cfg.MCPUserCredSrvs, } } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 9051342c..91e02562 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -256,6 +256,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { // one agent pollute the shared deps. Tools and become visible to ALL agents // (even those without MCP grants), because FilterTools reads from registry.List(). hasMCPTools := false + var mcpUserCredSrvs []store.MCPAccessInfo if deps.MCPStore != nil { if toolsReg == deps.Tools { toolsReg = deps.Tools.Clone() @@ -268,20 +269,23 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { mcpMgr := mcpbridge.NewManager(toolsReg, mcpOpts...) if err := mcpMgr.LoadForAgent(ctx, ag.ID, ""); err != nil { slog.Warn("failed to load MCP servers for agent", "agent", agentKey, "error", err) - } else if mcpMgr.IsSearchMode() { - // Search mode: too many tools — register mcp_tool_search meta-tool. - // Also wire lazy activator so deferred tools can be called by name directly. - toolsReg.SetDeferredActivator(mcpMgr.ActivateToolIfDeferred) - searchTool := mcpbridge.NewMCPToolSearchTool(mcpMgr) - toolsReg.Register(searchTool) - hasMCPTools = true - slog.Info("mcp.agent.search_mode", "agent", agentKey, - "deferred_tools", len(mcpMgr.DeferredToolInfos())) } else { - toolNames := mcpMgr.ToolNames() - if len(toolNames) > 0 { + mcpUserCredSrvs = mcpMgr.UserCredServers() + if mcpMgr.IsSearchMode() { + // Search mode: too many tools — register mcp_tool_search meta-tool. + // Also wire lazy activator so deferred tools can be called by name directly. + toolsReg.SetDeferredActivator(mcpMgr.ActivateToolIfDeferred) + searchTool := mcpbridge.NewMCPToolSearchTool(mcpMgr) + toolsReg.Register(searchTool) hasMCPTools = true - slog.Info("mcp.agent.tools_loaded", "agent", agentKey, "tools", len(toolNames)) + slog.Info("mcp.agent.search_mode", "agent", agentKey, + "deferred_tools", len(mcpMgr.DeferredToolInfos())) + } else { + toolNames := mcpMgr.ToolNames() + if len(toolNames) > 0 { + hasMCPTools = true + slog.Info("mcp.agent.tools_loaded", "agent", agentKey, "tools", len(toolNames)) + } } } } @@ -398,6 +402,9 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents), TracingStore: deps.TracingStore, MemoryStore: deps.MemoryStore, + MCPStore: deps.MCPStore, + MCPPool: deps.MCPPool, + MCPUserCredSrvs: mcpUserCredSrvs, }) slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider) diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index b5001588..78eeea0a 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -15,7 +15,7 @@ import ( // retryOnBusy retries fn up to 3 times on SQLITE_BUSY errors with 500ms delay. func retryOnBusy(fn func() error) error { var lastErr error - for attempt := 0; attempt < 3; attempt++ { + for attempt := range 3 { lastErr = fn() if lastErr == nil { return nil diff --git a/internal/bus/bus_test.go b/internal/bus/bus_test.go index dce2b81c..1635a811 100644 --- a/internal/bus/bus_test.go +++ b/internal/bus/bus_test.go @@ -221,9 +221,7 @@ func TestBroadcast_ConcurrentSubscribeUnsubscribe(t *testing.T) { done := make(chan struct{}) // Broadcast in a goroutine - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { for { select { case <-done: @@ -232,10 +230,10 @@ func TestBroadcast_ConcurrentSubscribeUnsubscribe(t *testing.T) { mb.Broadcast(Event{Name: "concurrent"}) } } - }() + }) // Subscribe/unsubscribe rapidly - for i := 0; i < 100; i++ { + for range 100 { mb.Subscribe("rapid", func(e Event) {}) mb.Unsubscribe("rapid") } @@ -252,7 +250,7 @@ func TestPublishInbound_ConcurrentProducers(t *testing.T) { const n = 100 var wg sync.WaitGroup wg.Add(n) - for i := 0; i < n; i++ { + for range n { go func() { defer wg.Done() mb.TryPublishInbound(InboundMessage{Content: "msg"}) diff --git a/internal/bus/types.go b/internal/bus/types.go index f73e556d..9619fcbb 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -3,6 +3,7 @@ package bus import ( "context" "encoding/json" + "strings" "github.com/google/uuid" ) @@ -160,3 +161,13 @@ type MessageRouter interface { PublishOutbound(msg OutboundMessage) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) } + +// IsInternalSender returns true if the senderID belongs to an internal system +// component (not a real channel user). These should not be stored as contacts. +func IsInternalSender(senderID string) bool { + return strings.HasPrefix(senderID, "system:") || + strings.HasPrefix(senderID, "notification:") || + strings.HasPrefix(senderID, "teammate:") || + strings.HasPrefix(senderID, "ticker:") || + senderID == "session_send_tool" +} diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index 77c74d72..3dee33be 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -195,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(ctx, 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", "user") } slog.Debug("discord group message recorded (no mention)", @@ -287,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(ctx, 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, "user") } // Publish directly to bus (to preserve MediaFile MIME types) diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index f78e404b..0f97687c 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -106,7 +106,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), mc.SenderID, mc.SenderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), mc.SenderID, mc.SenderID, senderName, "", "group", "user") } slog.Debug("feishu group message recorded (no mention)", @@ -162,7 +162,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), mc.SenderID, mc.SenderID, senderName, "", peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), mc.SenderID, mc.SenderID, senderName, "", peerKind, "user") } metadata := map[string]string{ diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index fd67abff..6c9879bb 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -575,7 +575,7 @@ func (c *Channel) ListGroupMembers(ctx context.Context, chatID string) ([]channe } // Auto-sync member into contact store if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, channels.TypeFeishu, c.Name(), m.MemberID, m.MemberID, m.Name, "", "group") + cc.EnsureContact(ctx, channels.TypeFeishu, c.Name(), m.MemberID, m.MemberID, m.Name, "", "group", "user") } } return result, nil diff --git a/internal/channels/slack/handlers.go b/internal/channels/slack/handlers.go index 35452eff..7ccc812b 100644 --- a/internal/channels/slack/handlers.go +++ b/internal/channels/slack/handlers.go @@ -200,7 +200,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(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group", "user") } slog.Debug("slack group message recorded (no mention)", diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go index da639a61..f357e80d 100644 --- a/internal/channels/slack/utils.go +++ b/internal/channels/slack/utils.go @@ -34,7 +34,7 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { ctx := store.WithTenantID(context.Background(), c.TenantID()) - cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind, "user") } c.Bus().PublishInbound(bus.InboundMessage{ diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index d1ba7a9c..72bba31a 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -331,7 +331,9 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { contactName := strings.TrimSpace(user.FirstName + " " + user.LastName) - cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, contactName, user.Username, "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, contactName, user.Username, "group", "user") + // Also collect group chat itself as a contact (for group permission / merge). + cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", message.Chat.Title, "", "group", "group") } slog.Debug("telegram group message recorded (no mention)", @@ -583,7 +585,11 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, userID, user.FirstName, user.Username, peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, userID, user.FirstName, user.Username, peerKind, "user") + // Also collect group chat itself as a contact (for group permission / merge). + if isGroup { + cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", message.Chat.Title, "", "group", "group") + } } c.Bus().PublishInbound(bus.InboundMessage{ diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 582bb5d2..7429c8b8 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -271,7 +271,7 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { // Collect contact for processed messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind, "user") } c.HandleMessage(senderID, chatID, content, media, metadata, peerKind) diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go index e1369e5c..4602e60b 100644 --- a/internal/channels/zalo/personal/handlers.go +++ b/internal/channels/zalo/personal/handlers.go @@ -64,7 +64,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { // Collect contact for DM messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct", "user") } metadata := map[string]string{ @@ -111,7 +111,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(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group", "user") } slog.Debug("zalo_personal group message recorded (no mention)", @@ -144,7 +144,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { // Collect contact for group-mentioned messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group", "user") } metadata := map[string]string{ diff --git a/internal/channels/zalo/personal/protocol/contacts.go b/internal/channels/zalo/personal/protocol/contacts.go index 82cc9a51..04c1b444 100644 --- a/internal/channels/zalo/personal/protocol/contacts.go +++ b/internal/channels/zalo/personal/protocol/contacts.go @@ -105,10 +105,7 @@ func FetchGroups(ctx context.Context, sess *Session) ([]GroupListInfo, error) { var allGroups []GroupListInfo for i := 0; i < len(ids); i += batchSize { - end := i + batchSize - if end > len(ids) { - end = len(ids) - } + end := min(i+batchSize, len(ids)) batch := make(map[string]string, end-i) for _, id := range ids[i:end] { batch[id] = gridVerMap[id] diff --git a/internal/cron/service_test.go b/internal/cron/service_test.go index ed08d03f..d5797503 100644 --- a/internal/cron/service_test.go +++ b/internal/cron/service_test.go @@ -19,11 +19,11 @@ func TestValidateSchedule(t *testing.T) { sched Schedule wantErr bool }{ - {"at_valid", Schedule{Kind: "at", AtMS: ptrInt64(time.Now().Add(time.Hour).UnixMilli())}, false}, + {"at_valid", Schedule{Kind: "at", AtMS: new(time.Now().Add(time.Hour).UnixMilli())}, false}, {"at_missing_timestamp", Schedule{Kind: "at"}, true}, - {"every_valid", Schedule{Kind: "every", EveryMS: ptrInt64(5000)}, false}, - {"every_zero_interval", Schedule{Kind: "every", EveryMS: ptrInt64(0)}, true}, - {"every_negative_interval", Schedule{Kind: "every", EveryMS: ptrInt64(-1)}, true}, + {"every_valid", Schedule{Kind: "every", EveryMS: new(int64(5000))}, false}, + {"every_zero_interval", Schedule{Kind: "every", EveryMS: new(int64(0))}, true}, + {"every_negative_interval", Schedule{Kind: "every", EveryMS: new(int64(-1))}, true}, {"every_nil_interval", Schedule{Kind: "every"}, true}, {"cron_valid", Schedule{Kind: "cron", Expr: "*/5 * * * *"}, false}, {"cron_empty_expr", Schedule{Kind: "cron", Expr: ""}, true}, @@ -268,7 +268,7 @@ func TestService_NilHandler_NoPanic(t *testing.T) { cs.Start() time.Sleep(1500 * time.Millisecond) // wait for at least 1 tick - cs.Stop() // should not panic + cs.Stop() // should not panic } // --- Job failure with retry --- @@ -354,4 +354,5 @@ func TestService_RunLog_PopulatedByAutoExecution(t *testing.T) { // --- helpers --- -func ptrInt64(v int64) *int64 { return &v } +//go:fix inline +func ptrInt64(v int64) *int64 { return new(v) } diff --git a/internal/http/agents_export.go b/internal/http/agents_export.go index 11faffea..29fc61f3 100644 --- a/internal/http/agents_export.go +++ b/internal/http/agents_export.go @@ -82,13 +82,13 @@ func sweepExportTokens() { // ExportManifest describes the archive contents. type ExportManifest struct { - Version int `json:"version"` - Format string `json:"format"` - ExportedAt string `json:"exported_at"` - ExportedBy string `json:"exported_by"` - AgentKey string `json:"agent_key"` - AgentID string `json:"agent_id"` - Sections map[string]interface{} `json:"sections"` + Version int `json:"version"` + Format string `json:"format"` + ExportedAt string `json:"exported_at"` + ExportedBy string `json:"exported_by"` + AgentKey string `json:"agent_key"` + AgentID string `json:"agent_id"` + Sections map[string]any `json:"sections"` } // KGEntityExport is a portable KG entity (no internal UUID). @@ -293,7 +293,7 @@ func (h *AgentsHandler) writeExportArchive(ctx context.Context, w io.Writer, ag ExportedBy: store.UserIDFromContext(ctx), AgentKey: ag.AgentKey, AgentID: ag.ID.String(), - Sections: make(map[string]interface{}), + Sections: make(map[string]any), } // Section: config (always included) @@ -693,7 +693,7 @@ func parseExportSections(raw string) map[string]bool { return map[string]bool{"config": true, "context_files": true} } out := make(map[string]bool) - for _, s := range strings.Split(raw, ",") { + for s := range strings.SplitSeq(raw, ",") { if s = strings.TrimSpace(s); s != "" { out[s] = true } diff --git a/internal/http/agents_import.go b/internal/http/agents_import.go index b7d858c0..7b41e8eb 100644 --- a/internal/http/agents_import.go +++ b/internal/http/agents_import.go @@ -96,7 +96,7 @@ func parseImportSections(raw string) map[string]bool { return all } out := make(map[string]bool) - for _, s := range strings.Split(raw, ",") { + for s := range strings.SplitSeq(raw, ",") { if s = strings.TrimSpace(s); s != "" { out[s] = true } @@ -619,4 +619,3 @@ func (h *AgentsHandler) dedupAgentKey(ctx context.Context, base string) string { } return key } - diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ee0f9c29..c5e868f3 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -52,6 +52,11 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/tenant-users", h.auth(h.handleListTenantUsers)) } + // Unified user search (contacts + tenant_users) + if h.contactStore != nil { + mux.HandleFunc("GET /v1/users/search", h.auth(h.handleSearchUsers)) + } + // Group file writers (nested under channel instances) if h.configPermStore != nil { mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups)) diff --git a/internal/http/secure_cli.go b/internal/http/secure_cli.go index 05651bb1..d8ede321 100644 --- a/internal/http/secure_cli.go +++ b/internal/http/secure_cli.go @@ -37,6 +37,12 @@ func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /v1/cli-credentials/{id}", h.auth(h.handleUpdate)) mux.HandleFunc("DELETE /v1/cli-credentials/{id}", h.auth(h.handleDelete)) mux.HandleFunc("POST /v1/cli-credentials/{id}/test", h.auth(h.handleDryRun)) + + // Per-user credential management + mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials", h.auth(h.handleListUserCredentials)) + mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleGetUserCredentials)) + mux.HandleFunc("PUT /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleSetUserCredentials)) + mux.HandleFunc("DELETE /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleDeleteUserCredentials)) } func (h *SecureCLIHandler) auth(next http.HandlerFunc) http.HandlerFunc { diff --git a/internal/http/secure_cli_user_credentials.go b/internal/http/secure_cli_user_credentials.go new file mode 100644 index 00000000..48e56b99 --- /dev/null +++ b/internal/http/secure_cli_user_credentials.go @@ -0,0 +1,147 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (h *SecureCLIHandler) handleListUserCredentials(w http.ResponseWriter, r *http.Request) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)}) + return + } + creds, err := h.store.ListUserCredentials(r.Context(), binaryID) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + // Return without encrypted env for listing (only user_id + timestamps) + type entry struct { + ID uuid.UUID `json:"id"` + BinaryID uuid.UUID `json:"binary_id"` + UserID string `json:"user_id"` + HasEnv bool `json:"has_env"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + entries := make([]entry, 0, len(creds)) + for _, c := range creds { + entries = append(entries, entry{ + ID: c.ID, + BinaryID: c.BinaryID, + UserID: c.UserID, + HasEnv: len(c.EncryptedEnv) > 0, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"user_credentials": entries}) +} + +func (h *SecureCLIHandler) handleGetUserCredentials(w http.ResponseWriter, r *http.Request) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)}) + return + } + userID := r.PathValue("userId") + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"}) + return + } + + cred, err := h.store.GetUserCredentials(r.Context(), binaryID, userID) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + if cred == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + + // Return decrypted env as JSON object (admin-only endpoint) + var envObj any + if len(cred.EncryptedEnv) > 0 { + _ = json.Unmarshal(cred.EncryptedEnv, &envObj) + } + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": cred.UserID, + "env": envObj, + }) +} + +func (h *SecureCLIHandler) handleSetUserCredentials(w http.ResponseWriter, r *http.Request) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)}) + return + } + userID := r.PathValue("userId") + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"}) + return + } + + var body struct { + Env json.RawMessage `json:"env"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) + return + } + if len(body.Env) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "env is required"}) + return + } + + // Validate env is a JSON object + var envCheck map[string]string + if err := json.Unmarshal(body.Env, &envCheck); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "env must be a JSON object with string values"}) + return + } + + if err := h.store.SetUserCredentials(r.Context(), binaryID, userID, body.Env); err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SecureCLIHandler) handleDeleteUserCredentials(w http.ResponseWriter, r *http.Request) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID)}) + return + } + userID := r.PathValue("userId") + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id required"}) + return + } + + if err := h.store.DeleteUserCredentials(r.Context(), binaryID, userID); err != nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/http/skills_import.go b/internal/http/skills_import.go index be80f568..b4de734c 100644 --- a/internal/http/skills_import.go +++ b/internal/http/skills_import.go @@ -94,12 +94,12 @@ func (h *SkillsHandler) doSkillsImport(ctx context.Context, r io.Reader, userID continue } rest := strings.TrimPrefix(name, "skills/") - slashIdx := strings.Index(rest, "/") - if slashIdx < 0 { + before, after, ok := strings.Cut(rest, "/") + if !ok { continue } - slug := rest[:slashIdx] - file := rest[slashIdx+1:] + slug := before + file := after if slug == "" || file == "" { continue } @@ -240,4 +240,3 @@ func tenantIDForSkillImport(ctx context.Context) uuid.UUID { } return tid } - diff --git a/internal/http/sse.go b/internal/http/sse.go index 0e47feaf..7a3e95ff 100644 --- a/internal/http/sse.go +++ b/internal/http/sse.go @@ -18,7 +18,7 @@ type ProgressEvent struct { // sendSSE writes a named SSE event with JSON payload and flushes. // The event format follows the standard SSE spec: "event: \ndata: \n\n". -func sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) { +func sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data any) { jsonData, err := json.Marshal(data) if err != nil { return diff --git a/internal/http/teams_export.go b/internal/http/teams_export.go index 942c4f28..ccdccd22 100644 --- a/internal/http/teams_export.go +++ b/internal/http/teams_export.go @@ -24,14 +24,14 @@ import ( // TeamExportManifest describes the contents of a team export archive. type TeamExportManifest struct { - Version int `json:"version"` - Format string `json:"format"` - ExportedAt string `json:"exported_at"` - ExportedBy string `json:"exported_by"` - TeamName string `json:"team_name"` - TeamID string `json:"team_id"` - AgentKeys []string `json:"agent_keys"` - Sections map[string]interface{} `json:"sections"` + Version int `json:"version"` + Format string `json:"format"` + ExportedAt string `json:"exported_at"` + ExportedBy string `json:"exported_by"` + TeamName string `json:"team_name"` + TeamID string `json:"team_id"` + AgentKeys []string `json:"agent_keys"` + Sections map[string]any `json:"sections"` } // handleTeamExportPreview returns team export counts without building the archive. @@ -60,12 +60,12 @@ func (h *AgentsHandler) handleTeamExportPreview(w http.ResponseWriter, r *http.R } writeJSON(w, http.StatusOK, map[string]any{ - "team_name": teamMeta.Name, - "team_id": teamIDStr, - "tasks": tasks, - "members": members, - "agent_links": links, - "agent_count": len(agentMembers), + "team_name": teamMeta.Name, + "team_id": teamIDStr, + "tasks": tasks, + "members": members, + "agent_links": links, + "agent_count": len(agentMembers), }) } @@ -148,7 +148,7 @@ func (h *AgentsHandler) writeTeamExportArchive(ctx context.Context, w io.Writer, TeamName: teamMeta.Name, TeamID: teamID.String(), AgentKeys: []string{}, - Sections: make(map[string]interface{}), + Sections: make(map[string]any), } // team/team.json @@ -273,13 +273,13 @@ func (h *AgentsHandler) writeTeamExportArchive(ctx context.Context, w io.Writer, } sections := map[string]bool{ - "context_files": true, - "memory": true, + "context_files": true, + "memory": true, "knowledge_graph": true, - "cron": true, - "user_profiles": true, - "user_overrides": true, - "workspace": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, } for _, member := range agentMembers { @@ -467,7 +467,7 @@ func (h *AgentsHandler) writeAgentSectionsToTar(ctx context.Context, tw *tar.Wri exportRelations = append(exportRelations, KGRelationExport{ SourceExternalID: idToExternal[rel.SourceEntityID], TargetExternalID: idToExternal[rel.TargetEntityID], - UserID: rel.UserID, RelationType: rel.RelationType, + UserID: rel.UserID, RelationType: rel.RelationType, Confidence: rel.Confidence, Properties: rel.Properties, }) } diff --git a/internal/http/teams_import.go b/internal/http/teams_import.go index 1fb9e530..142ce1cc 100644 --- a/internal/http/teams_import.go +++ b/internal/http/teams_import.go @@ -174,12 +174,12 @@ func readTeamImportArchive(r io.Reader) (*teamImportArchive, error) { continue } rest := strings.TrimPrefix(name, "agents/") - slashIdx := strings.Index(rest, "/") - if slashIdx < 0 { + before, after, ok := strings.Cut(rest, "/") + if !ok { continue } - agentKey := rest[:slashIdx] - relPath := rest[slashIdx+1:] + agentKey := before + relPath := after if agentKey == "" || relPath == "" { continue } @@ -358,13 +358,13 @@ func (h *AgentsHandler) doTeamImport(ctx context.Context, r *http.Request, teamA } sections := map[string]bool{ - "context_files": true, - "memory": true, + "context_files": true, + "memory": true, "knowledge_graph": true, - "cron": true, - "user_profiles": true, - "user_overrides": true, - "workspace": true, + "cron": true, + "user_profiles": true, + "user_overrides": true, + "workspace": true, } if _, err := h.doMergeImport(ctx, ag, agArc, sections, progressFn); err != nil { slog.Warn("team.import: merge agent data failed", "key", dedupedKey, "error", err) diff --git a/internal/http/user_search.go b/internal/http/user_search.go new file mode 100644 index 00000000..1ae09cdf --- /dev/null +++ b/internal/http/user_search.go @@ -0,0 +1,116 @@ +package http + +import ( + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// UserSearchResult is a unified result from contacts + tenant_users. +type UserSearchResult struct { + ID string `json:"id"` + DisplayName *string `json:"display_name,omitempty"` + Username *string `json:"username,omitempty"` + Source string `json:"source"` // "contact" or "tenant_user" + ChannelType *string `json:"channel_type,omitempty"` + PeerKind *string `json:"peer_kind,omitempty"` + MergedTenantUserID *string `json:"merged_tenant_user_id,omitempty"` + Role *string `json:"role,omitempty"` +} + +// handleSearchUsers returns unified results from channel_contacts + tenant_users. +// GET /v1/users/search?q=&limit=30&peer_kind= +// Empty q → return most recent. With q → ILIKE search across both tables. +func (h *ChannelInstancesHandler) handleSearchUsers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + peerKind := r.URL.Query().Get("peer_kind") + source := r.URL.Query().Get("source") // "contact", "tenant_user", or "" (both) + limit := 30 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + ctx := r.Context() + tid := store.TenantIDFromContext(ctx) + var results []UserSearchResult + mergedUserIDs := make(map[string]bool) // for deduplication between contacts and tenant_users + + // 1. Search channel_contacts (skip if source=tenant_user) + if h.contactStore != nil && source != "tenant_user" { + opts := store.ContactListOpts{ + Search: q, + PeerKind: peerKind, + Limit: limit, + } + contacts, err := h.contactStore.ListContacts(ctx, opts) + if err != nil { + slog.Warn("user_search.contacts", "error", err) + } + for _, c := range contacts { + r := UserSearchResult{ + ID: c.SenderID, + DisplayName: c.DisplayName, + Username: c.Username, + Source: "contact", + ChannelType: &c.ChannelType, + PeerKind: c.PeerKind, + } + if c.MergedID != nil { + if resolved, err := h.contactStore.ResolveTenantUserID(ctx, c.ChannelType, c.SenderID); err == nil && resolved != "" { + r.MergedTenantUserID = &resolved + mergedUserIDs[resolved] = true + } + } + results = append(results, r) + } + } + + // 2. Search tenant_users (skip if source=contact) + if h.tenantStore != nil && tid != uuid.Nil && source != "contact" { + users, err := h.tenantStore.ListUsers(ctx, tid) + if err != nil { + slog.Warn("user_search.tenant_users", "error", err) + } + for _, u := range users { + if mergedUserIDs[u.UserID] { + continue + } + if q != "" && !containsInsensitive(u.UserID, q) && !containsInsensitive(ptrStr(u.DisplayName), q) { + continue + } + if len(results) >= limit { + break + } + role := u.Role + results = append(results, UserSearchResult{ + ID: u.UserID, + DisplayName: u.DisplayName, + Source: "tenant_user", + Role: &role, + }) + } + } + + if results == nil { + results = []UserSearchResult{} + } + writeJSON(w, http.StatusOK, map[string]any{"results": results}) +} + +func ptrStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +func containsInsensitive(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} diff --git a/internal/knowledgegraph/similarity.go b/internal/knowledgegraph/similarity.go index caef4631..2f6c1b0e 100644 --- a/internal/knowledgegraph/similarity.go +++ b/internal/knowledgegraph/similarity.go @@ -16,10 +16,7 @@ func JaroWinkler(a, b string) float64 { } // Jaro similarity - matchDist := max(len(a), len(b))/2 - 1 - if matchDist < 0 { - matchDist = 0 - } + matchDist := max(max(len(a), len(b))/2-1, 0) aMatched := make([]bool, len(a)) bMatched := make([]bool, len(b)) diff --git a/internal/mcp/bridge_tool.go b/internal/mcp/bridge_tool.go index 00ab22f0..2315b30a 100644 --- a/internal/mcp/bridge_tool.go +++ b/internal/mcp/bridge_tool.go @@ -90,6 +90,9 @@ func (t *BridgeTool) ServerName() string { return t.serverName } // OriginalName returns the original MCP tool name (without prefix). func (t *BridgeTool) OriginalName() string { return t.toolName } +// IsConnected returns whether the underlying MCP server connection is healthy. +func (t *BridgeTool) IsConnected() bool { return t.connected.Load() } + func (t *BridgeTool) Execute(ctx context.Context, args map[string]any) *tools.Result { if !t.connected.Load() { return tools.ErrorResult(fmt.Sprintf("MCP server %q is disconnected", t.serverName)) diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 03a658f5..e1211e34 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -83,6 +83,11 @@ type Manager struct { deferredTools map[string]*BridgeTool // registeredName → BridgeTool activatedTools map[string]struct{} // tracks activated tool names for group:mcp searchMode bool + + // User-credential servers: servers requiring per-user credentials, stored during + // LoadForAgent("") for later per-request tool resolution. These servers are NOT + // connected at startup — connections are created per-user via pool.AcquireUser(). + userCredServers []store.MCPAccessInfo } // ManagerOption configures the Manager. @@ -276,8 +281,17 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st // Unregister all existing MCP tools first m.unregisterAllTools() + m.userCredServers = nil for _, info := range accessible { + // When loading at startup (userID=""), store servers requiring per-user + // credentials for later per-request resolution instead of skipping them. + if userID == "" && requireUserCreds(info.Server.Settings) && info.Server.Enabled { + m.userCredServers = append(m.userCredServers, info) + slog.Debug("mcp.server.deferred_user_creds", "server", info.Server.Name) + continue + } + rs := m.resolveServerCredentials(ctx, info, userID) if rs == nil { continue diff --git a/internal/mcp/manager_tools.go b/internal/mcp/manager_tools.go index 5ba94c1b..e4f7561b 100644 --- a/internal/mcp/manager_tools.go +++ b/internal/mcp/manager_tools.go @@ -7,9 +7,17 @@ import ( "time" mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) +// UserCredServers returns servers requiring per-user credentials. +// These are stored during LoadForAgent("") and used by the agent loop +// for per-request tool resolution via pool.AcquireUser(). +func (m *Manager) UserCredServers() []store.MCPAccessInfo { + return m.userCredServers +} + // ToolNames returns all registered MCP tool names. func (m *Manager) ToolNames() []string { m.mu.RLock() diff --git a/internal/mcp/manager_util.go b/internal/mcp/manager_util.go index a26930ae..a0f750fd 100644 --- a/internal/mcp/manager_util.go +++ b/internal/mcp/manager_util.go @@ -35,6 +35,12 @@ func joinErrors(errs []string) string { return result.String() } +// ParseJSONBytesToStringSlice converts JSONB []byte to []string (exported for loop_mcp_user). +func ParseJSONBytesToStringSlice(data []byte) []string { return jsonBytesToStringSlice(data) } + +// ParseJSONBytesToStringMap converts JSONB []byte to map[string]string (exported for loop_mcp_user). +func ParseJSONBytesToStringMap(data []byte) map[string]string { return jsonBytesToStringMap(data) } + // jsonBytesToStringSlice converts JSONB []byte to []string. Returns nil on error. func jsonBytesToStringSlice(data []byte) []string { if len(data) == 0 { diff --git a/internal/mcp/pool.go b/internal/mcp/pool.go index 3dc81149..f20bf0c5 100644 --- a/internal/mcp/pool.go +++ b/internal/mcp/pool.go @@ -4,28 +4,37 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" + mcpclient "github.com/mark3labs/mcp-go/client" 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) + MaxSize int // global max connections (default 200) + 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) + MaxUserConns int // max per-user connections per MCP server (default 30) + UserIdleTTL time.Duration // close idle user connections after this (default 15m) + UserAcquireTimeout time.Duration // wait for user pool slot before error (default 10s) } // DefaultPoolConfig returns the default pool configuration. func DefaultPoolConfig() PoolConfig { return PoolConfig{ - MaxSize: 100, - MaxIdle: 20, - IdleTTL: 20 * time.Minute, - AcquireTimeout: 60 * time.Second, + MaxSize: 200, + MaxIdle: 20, + IdleTTL: 20 * time.Minute, + AcquireTimeout: 60 * time.Second, + MaxUserConns: 30, + UserIdleTTL: 15 * time.Minute, + UserAcquireTimeout: 10 * time.Second, } } @@ -39,18 +48,21 @@ type poolEntry struct { // Pool manages shared MCP server connections across agents. // Connections are keyed by tenantID/serverName for tenant isolation. +// Per-user connections are keyed by tenantID/serverName/user:userID. type Pool struct { - mu sync.Mutex - servers map[string]*poolEntry - cfg PoolConfig - slot chan struct{} // semaphore for MaxSize - stopCh chan struct{} + mu sync.Mutex + servers map[string]*poolEntry // shared connections: tenantID/serverName + userServers map[string]*poolEntry // user connections: tenantID/serverName/user:userID + userSlots map[string]chan struct{} // per-server semaphores: tenantID/serverName → capacity MaxUserConns + cfg PoolConfig + slot chan struct{} // semaphore for MaxSize + stopCh chan struct{} } // NewPool creates a shared MCP connection pool with idle eviction. func NewPool(cfg PoolConfig) *Pool { if cfg.MaxSize <= 0 { - cfg.MaxSize = 100 + cfg.MaxSize = 200 } if cfg.MaxIdle <= 0 { cfg.MaxIdle = 20 @@ -61,12 +73,23 @@ func NewPool(cfg PoolConfig) *Pool { if cfg.AcquireTimeout <= 0 { cfg.AcquireTimeout = 60 * time.Second } + if cfg.MaxUserConns <= 0 { + cfg.MaxUserConns = 30 + } + if cfg.UserIdleTTL <= 0 { + cfg.UserIdleTTL = 15 * time.Minute + } + if cfg.UserAcquireTimeout <= 0 { + cfg.UserAcquireTimeout = 10 * time.Second + } p := &Pool{ - servers: make(map[string]*poolEntry), - cfg: cfg, - slot: make(chan struct{}, cfg.MaxSize), - stopCh: make(chan struct{}), + servers: make(map[string]*poolEntry), + userServers: make(map[string]*poolEntry), + userSlots: make(map[string]chan struct{}), + cfg: cfg, + slot: make(chan struct{}, cfg.MaxSize), + stopCh: make(chan struct{}), } go p.evictLoop() return p @@ -77,6 +100,17 @@ func poolKey(tenantID uuid.UUID, name string) string { return tenantID.String() + "/" + name } +// UserPoolKey builds a tenant+user-scoped key for user pool lookups. +// Exported for callers that need to construct release keys. +func UserPoolKey(tenantID uuid.UUID, serverName, userID string) string { + return tenantID.String() + "/" + serverName + "/user:" + userID +} + +// userSlotKey returns the per-server semaphore key (tenantID/serverName). +func userSlotKey(tenantID uuid.UUID, serverName string) string { + return tenantID.String() + "/" + serverName +} + // 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. @@ -161,6 +195,99 @@ func (p *Pool) Acquire(ctx context.Context, tenantID uuid.UUID, name, transportT return entry, nil } +// AcquireUser returns a per-user connection for the named server scoped to a tenant+user. +// If no connection exists, it connects using the provided config. +// Blocks up to UserAcquireTimeout if per-server user slot limit is reached. +func (p *Pool) AcquireUser(ctx context.Context, tenantID uuid.UUID, name, userID, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) { + key := UserPoolKey(tenantID, name, userID) + slotKey := userSlotKey(tenantID, name) + + p.mu.Lock() + if entry, ok := p.userServers[key]; ok && entry.state.connected.Load() { + entry.refCount++ + entry.lastUsed = time.Now() + p.mu.Unlock() + slog.Debug("mcp.pool.user.reuse", "key", key, "refCount", entry.refCount) + return entry, nil + } + + // If entry exists but disconnected, close old and reclaim slot + if old, ok := p.userServers[key]; ok { + if old.state.cancel != nil { + old.state.cancel() + } + if old.state.client != nil { + _ = old.state.client.Close() + } + delete(p.userServers, key) + // Return slot to per-server semaphore + if sem, ok := p.userSlots[slotKey]; ok { + select { + case <-sem: + default: + } + } + } + + // Ensure per-server semaphore exists (lazy init under lock) + if _, ok := p.userSlots[slotKey]; !ok { + p.userSlots[slotKey] = make(chan struct{}, p.cfg.MaxUserConns) + } + sem := p.userSlots[slotKey] + p.mu.Unlock() + + // Acquire a user slot for this server (blocks up to UserAcquireTimeout) + if err := p.acquireUserSlot(ctx, sem, slotKey); err != nil { + return nil, fmt.Errorf("mcp user pool exhausted for server %s: %w", name, 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 <-sem: + default: + } + return nil, err + } + + // Start health loop + hctx, hcancel := context.WithCancel(context.Background()) + ss.cancel = hcancel + go poolHealthLoop(hctx, ss) + + entry := &poolEntry{ + 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.userServers[key]; ok && existing.state.connected.Load() { + p.mu.Unlock() + hcancel() + _ = ss.client.Close() + // Return our extra slot + select { + case <-sem: + default: + } + p.mu.Lock() + existing.refCount++ + existing.lastUsed = time.Now() + p.mu.Unlock() + return existing, nil + } + p.userServers[key] = entry + p.mu.Unlock() + + slog.Info("mcp.pool.user.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 @@ -197,6 +324,29 @@ func (p *Pool) acquireSlot(ctx context.Context) error { } } +// acquireUserSlot tries to acquire a per-server user slot. +func (p *Pool) acquireUserSlot(ctx context.Context, sem chan struct{}, slotKey string) error { + // Fast path: slot available + select { + case sem <- struct{}{}: + return nil + default: + } + + // Wait up to UserAcquireTimeout + timer := time.NewTimer(p.cfg.UserAcquireTimeout) + defer timer.Stop() + + select { + case sem <- struct{}{}: + return nil + case <-timer.C: + return fmt.Errorf("timeout after %s waiting for user slot (max %d, server %s)", p.cfg.UserAcquireTimeout, p.cfg.MaxUserConns, slotKey) + case <-ctx.Done(): + return ctx.Err() + } +} + // Release decrements the reference count for a server. // Accepts the same key format as Acquire (tenantID + name). func (p *Pool) Release(key string) { @@ -213,6 +363,22 @@ func (p *Pool) Release(key string) { } } +// ReleaseUser decrements the reference count for a user-scoped connection. +// Accepts the same key format as AcquireUser (tenantID + serverName + userID). +func (p *Pool) ReleaseUser(key string) { + p.mu.Lock() + defer p.mu.Unlock() + + if entry, ok := p.userServers[key]; ok { + entry.refCount-- + if entry.refCount < 0 { + entry.refCount = 0 + } + entry.lastUsed = time.Now() + slog.Debug("mcp.pool.user.release", "key", key, "refCount", entry.refCount) + } +} + // Stop closes all pooled connections and stops eviction. Called on gateway shutdown. func (p *Pool) Stop() { close(p.stopCh) @@ -230,6 +396,17 @@ func (p *Pool) Stop() { slog.Debug("mcp.pool.stopped", "key", key) } p.servers = make(map[string]*poolEntry) + + for key, entry := range p.userServers { + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + slog.Debug("mcp.pool.user.stopped", "key", key) + } + p.userServers = make(map[string]*poolEntry) } // Evict closes a specific pooled connection by tenant + server name. @@ -273,11 +450,14 @@ func (p *Pool) evictLoop() { } // evictIdle closes connections idle > IdleTTL when total idle exceeds MaxIdle. +// Also evicts user connections idle > UserIdleTTL. func (p *Pool) evictIdle() { p.mu.Lock() defer p.mu.Unlock() now := time.Now() + + // Evict shared connections var idleKeys []string for key, entry := range p.servers { if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.IdleTTL { @@ -295,39 +475,74 @@ func (p *Pool) evictIdle() { // Only evict if over MaxIdle toEvict := totalIdle - p.cfg.MaxIdle - if toEvict <= 0 && len(idleKeys) == 0 { - return + if toEvict > 0 || len(idleKeys) > 0 { + 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) + select { + case <-p.slot: + default: + } + slog.Debug("mcp.pool.evicted", "key", key, "reason", "idle_ttl") + } } - // 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() + // Evict user connections idle > UserIdleTTL + for key, entry := range p.userServers { + if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.UserIdleTTL { + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.userServers, key) + // Return slot to per-server semaphore + // Extract slotKey from user key: "tenantID/serverName/user:userID" → "tenantID/serverName" + // We search userSlots by iterating — key format guarantees prefix match + for slotKey, sem := range p.userSlots { + if strings.HasPrefix(key, slotKey+"/") { + select { + case <-sem: + default: + } + break + } + } + slog.Debug("mcp.pool.user.evicted", "key", key, "reason", "idle_ttl") } - 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. +// evictOldestIdleLocked evicts one idle entry (oldest lastUsed) from shared or user pools. +// Caller must hold mu. func (p *Pool) evictOldestIdleLocked() bool { var oldestKey string var oldestTime time.Time + isUser := false for key, entry := range p.servers { if entry.refCount == 0 { if oldestKey == "" || entry.lastUsed.Before(oldestTime) { oldestKey = key oldestTime = entry.lastUsed + isUser = false + } + } + } + + for key, entry := range p.userServers { + if entry.refCount == 0 { + if oldestKey == "" || entry.lastUsed.Before(oldestTime) { + oldestKey = key + oldestTime = entry.lastUsed + isUser = true } } } @@ -336,23 +551,52 @@ func (p *Pool) evictOldestIdleLocked() bool { return false } - entry := p.servers[oldestKey] - if entry.state.cancel != nil { - entry.state.cancel() + if isUser { + entry := p.userServers[oldestKey] + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.userServers, oldestKey) + for slotKey, sem := range p.userSlots { + if strings.HasPrefix(oldestKey, slotKey+"/") { + select { + case <-sem: + default: + } + break + } + } + } else { + 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) + select { + case <-p.slot: + default: + } } - 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") + + slog.Debug("mcp.pool.evicted", "key", oldestKey, "reason", "make_room", "user", isUser) return true } +// Client returns the MCP client for this pool entry. +func (e *poolEntry) Client() *mcpclient.Client { return e.state.client } + +// Connected returns a pointer to the connected flag for this pool entry. +func (e *poolEntry) Connected() *atomic.Bool { return &e.state.connected } + +// MCPTools returns the discovered MCP tool definitions for this pool entry. +func (e *poolEntry) MCPTools() []mcpgo.Tool { return e.tools } + // poolHealthLoop is a standalone health loop for pool-managed connections. func poolHealthLoop(ctx context.Context, ss *serverState) { ticker := newHealthTicker() diff --git a/internal/providers/retry_test.go b/internal/providers/retry_test.go index bd95a410..bd361d29 100644 --- a/internal/providers/retry_test.go +++ b/internal/providers/retry_test.go @@ -107,7 +107,7 @@ func TestComputeDelay_JitterRange(t *testing.T) { min := 750 * time.Millisecond max := 1250 * time.Millisecond - for i := 0; i < 100; i++ { + for range 100 { d := computeDelay(cfg, 1, err) if d < min || d > max { t.Fatalf("jitter out of range: got %v, want [%v, %v]", d, min, max) @@ -123,7 +123,7 @@ func TestComputeDelay_NeverNegative(t *testing.T) { } err := &HTTPError{Status: 500} - for i := 0; i < 200; i++ { + for range 200 { d := computeDelay(cfg, 1, err) if d < 0 { t.Fatalf("negative delay: %v", d) @@ -158,8 +158,8 @@ func TestParseRetryAfter(t *testing.T) { {"integer_seconds", "30", 30 * time.Second}, {"zero", "0", 0}, {"negative_int", "-5", -5 * time.Second}, // strconv.Atoi succeeds → returns negative duration (caller should clamp) - {"non_numeric", "abc", 0}, // neither int nor date - {"float", "1.5", 0}, // not a valid int + {"non_numeric", "abc", 0}, // neither int nor date + {"float", "1.5", 0}, // not a valid int } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/scheduler/scheduler_queue_test.go b/internal/scheduler/scheduler_queue_test.go index 22814e38..9c6880fd 100644 --- a/internal/scheduler/scheduler_queue_test.go +++ b/internal/scheduler/scheduler_queue_test.go @@ -270,7 +270,7 @@ func TestLane_ConcurrencyEnforcement(t *testing.T) { var current atomic.Int32 var wg sync.WaitGroup - for i := 0; i < 10; i++ { + for range 10 { wg.Add(1) err := lane.Submit(context.Background(), func() { defer wg.Done() @@ -400,7 +400,7 @@ func TestSessionQueue_Debounce_CollapsesRapidMessages(t *testing.T) { // Send 5 rapid messages within debounce window var channels []<-chan RunOutcome - for i := 0; i < 5; i++ { + for i := range 5 { ch := sq.Enqueue(ctx, agent.RunRequest{ RunID: "r" + string(rune('0'+i)), SessionKey: "test", diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index e392b836..4d84559f 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -86,7 +86,7 @@ func TestAddMessage_ConcurrentSafety(t *testing.T) { const n = 100 var wg sync.WaitGroup wg.Add(n) - for i := 0; i < n; i++ { + for i := range n { go func(i int) { defer wg.Done() m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"}) @@ -209,7 +209,7 @@ func TestTruncateHistory(t *testing.T) { ctx := context.Background() key := "agent:a1:s1" - for i := 0; i < 10; i++ { + for range 10 { m.AddMessage(ctx, key, providers.Message{Role: "user", Content: "msg"}) } diff --git a/internal/store/contact_collector.go b/internal/store/contact_collector.go index b7cba9e4..928f4462 100644 --- a/internal/store/contact_collector.go +++ b/internal/store/contact_collector.go @@ -23,14 +23,23 @@ func NewContactCollector(s ContactStore, c cache.Cache[bool]) *ContactCollector } // EnsureContact creates or refreshes a contact entry, skipping DB if recently seen. -func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) { +// contactType: "user" (individual sender) or "group" (group chat entity). +func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType string) { key := channelType + ":" + senderID if _, ok := c.seen.Get(ctx, key); ok { return } - if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind); err != nil { + if contactType == "" { + contactType = "user" + } + if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType); err != nil { slog.Warn("contact_collector.upsert_failed", "error", err, "channel", channelType, "sender", senderID) return } c.seen.Set(ctx, key, true, contactSeenTTL) } + +// ResolveTenantUserID delegates to the underlying ContactStore. +func (c *ContactCollector) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) { + return c.store.ResolveTenantUserID(ctx, channelType, senderID) +} diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go index 4715b7b8..fb9bcbe4 100644 --- a/internal/store/contact_store.go +++ b/internal/store/contact_store.go @@ -19,6 +19,7 @@ type ChannelContact struct { Username *string `json:"username,omitempty"` AvatarURL *string `json:"avatar_url,omitempty"` PeerKind *string `json:"peer_kind,omitempty"` + ContactType string `json:"contact_type"` // "user" or "group" MergedID *uuid.UUID `json:"merged_id,omitempty"` FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` @@ -37,7 +38,7 @@ type ContactListOpts struct { type ContactStore interface { // UpsertContact creates or updates a contact. On conflict (channel_type, sender_id), // updates display_name, username, user_id, channel_instance, and last_seen_at. - UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error + UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType string) error // ListContacts searches contacts with pagination and filters. ListContacts(ctx context.Context, opts ContactListOpts) ([]ChannelContact, error) @@ -63,4 +64,9 @@ type ContactStore interface { // GetContactsByMergedID returns all contacts linked to a given merged_id. // Tenant-scoped via context. GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]ChannelContact, error) + + // ResolveTenantUserID looks up a contact by (channelType, senderID) and, if + // the contact has been merged, returns the linked tenant_user's user_id. + // Returns ("", nil) when the contact is not found or not merged. + ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) } diff --git a/internal/store/cron_store_test.go b/internal/store/cron_store_test.go index 421d1749..ea21cfd6 100644 --- a/internal/store/cron_store_test.go +++ b/internal/store/cron_store_test.go @@ -10,10 +10,10 @@ func TestNextRunForToggle_DisableClearsNextRun(t *testing.T) { now := time.Date(2026, time.March, 28, 12, 0, 0, 0, time.UTC) schedule := &CronSchedule{ Kind: "every", - EveryMS: int64Ptr(60_000), + EveryMS: new(int64(60_000)), } - next, err := NextRunForToggle(schedule, false, true, timePtr(now.Add(time.Minute)), now, "") + next, err := NextRunForToggle(schedule, false, true, new(now.Add(time.Minute)), now, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -26,7 +26,7 @@ func TestNextRunForToggle_EnableRecomputesEverySchedule(t *testing.T) { now := time.Date(2026, time.March, 28, 12, 0, 0, 0, time.UTC) schedule := &CronSchedule{ Kind: "every", - EveryMS: int64Ptr(60_000), + EveryMS: new(int64(60_000)), } next, err := NextRunForToggle(schedule, true, false, nil, now, "") @@ -69,7 +69,7 @@ func TestNextRunForToggle_AlreadyEnabledPreservesCurrentNextRun(t *testing.T) { currentNextRun := now.Add(5 * time.Minute) schedule := &CronSchedule{ Kind: "every", - EveryMS: int64Ptr(60_000), + EveryMS: new(int64(60_000)), } next, err := NextRunForToggle(schedule, true, true, ¤tNextRun, now.Add(time.Minute), "") @@ -104,10 +104,12 @@ func TestNextRunForToggle_ExpiredAtReturnsError(t *testing.T) { } } +//go:fix inline func int64Ptr(v int64) *int64 { - return &v + return new(v) } +//go:fix inline func timePtr(v time.Time) *time.Time { - return &v + return new(v) } diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index 84517b68..cc49ee2c 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -14,30 +14,32 @@ import ( // PGContactStore implements store.ContactStore backed by Postgres. type PGContactStore struct { - db *sql.DB + db *sql.DB + resolveCache *contactResolveCache // tenant-user resolution cache (60s TTL) } // NewPGContactStore creates a new PGContactStore. func NewPGContactStore(db *sql.DB) *PGContactStore { - return &PGContactStore{db: db} + return &PGContactStore{db: db, resolveCache: newContactResolveCache()} } -func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { +func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType 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, tenant_id) - VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8) + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, tenant_id) + VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9) 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), + contact_type = $8, last_seen_at = NOW()`, - channelType, channelInstance, senderID, userID, displayName, username, peerKind, tenantID, + channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType, tenantID, ) return err } @@ -88,7 +90,7 @@ func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactLis 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, + display_name, username, avatar_url, peer_kind, contact_type, merged_id, first_seen_at, last_seen_at FROM channel_contacts` + where + " ORDER BY last_seen_at DESC" @@ -116,7 +118,7 @@ func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactLis 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.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.ContactType, &c.MergedID, &c.FirstSeenAt, &c.LastSeenAt, ); err != nil { return nil, err @@ -147,7 +149,7 @@ func (s *PGContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs [ query := fmt.Sprintf(`SELECT DISTINCT ON (sender_id) id, channel_type, channel_instance, sender_id, user_id, - display_name, username, avatar_url, peer_kind, merged_id, + display_name, username, avatar_url, peer_kind, contact_type, merged_id, first_seen_at, last_seen_at FROM channel_contacts WHERE sender_id IN (%s) @@ -164,7 +166,7 @@ func (s *PGContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs [ 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.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.ContactType, &c.MergedID, &c.FirstSeenAt, &c.LastSeenAt, ); err != nil { return nil, err @@ -213,6 +215,9 @@ func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UU len(args)-1, inClause, len(args), ) _, err := s.db.ExecContext(ctx, q, args...) + if err == nil { + s.InvalidateContactResolveCache() + } return err } @@ -236,6 +241,9 @@ func (s *PGContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid. inClause, len(args), ) _, err := s.db.ExecContext(ctx, q, args...) + if err == nil { + s.InvalidateContactResolveCache() + } return err } @@ -243,7 +251,7 @@ func (s *PGContactStore) GetContactsByMergedID(ctx context.Context, mergedID uui tid := store.TenantIDFromContext(ctx) q := `SELECT id, channel_type, channel_instance, sender_id, user_id, - display_name, username, avatar_url, peer_kind, merged_id, + display_name, username, avatar_url, peer_kind, contact_type, 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` @@ -259,7 +267,7 @@ func (s *PGContactStore) GetContactsByMergedID(ctx context.Context, mergedID uui 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.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.ContactType, &c.MergedID, &c.FirstSeenAt, &c.LastSeenAt, ); err != nil { return nil, err diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go index 9e709e82..4c1f24f8 100644 --- a/internal/store/pg/config_permissions.go +++ b/internal/store/pg/config_permissions.go @@ -27,6 +27,7 @@ type permRow struct { Scope string ConfigType string Permission string + UserID string // individual user ID or "*" (group wildcard) } // fwCacheEntry holds cached file_writer ConfigPermission rows for a scope. @@ -76,7 +77,7 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u s.mu.RLock() if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { s.mu.RUnlock() - return evalPermRows(entry.rows, scope, configType), nil + return evalPermRows(entry.rows, scope, configType, userID), nil } s.mu.RUnlock() @@ -86,8 +87,8 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u 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`+tClause, + `SELECT scope, config_type, permission, user_id FROM agent_config_permissions + WHERE agent_id = $1 AND (user_id = $2 OR user_id = '*')`+tClause, append([]any{agentID, userID}, tArgs...)..., ) if err != nil { @@ -98,7 +99,7 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u var permRows []permRow for rows.Next() { var r permRow - if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission); err != nil { + if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission, &r.UserID); err != nil { return false, err } permRows = append(permRows, r) @@ -112,27 +113,53 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()} s.mu.Unlock() - return evalPermRows(permRows, scope, configType), nil + return evalPermRows(permRows, scope, configType, userID), nil } // evalPermRows evaluates cached permission rows against scope and configType. -func evalPermRows(rows []permRow, scope, configType string) bool { - var hasDeny, hasAllow bool +// Priority-based evaluation: individual permissions override group wildcards (user_id="*"). +// +// 1. Individual DENY → REJECT (highest priority) +// 2. Individual ALLOW → ACCEPT +// 3. Group (*) DENY → REJECT +// 4. Group (*) ALLOW → ACCEPT +// 5. No match → REJECT (default) +func evalPermRows(rows []permRow, scope, configType, targetUserID string) bool { + var individualDeny, individualAllow bool + var groupDeny, groupAllow bool + for _, r := range rows { if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) { continue } - switch r.Permission { - case "deny": - hasDeny = true - case "allow": - hasAllow = true + if r.UserID == targetUserID { + switch r.Permission { + case "deny": + individualDeny = true + case "allow": + individualAllow = true + } + } else if r.UserID == "*" { + switch r.Permission { + case "deny": + groupDeny = true + case "allow": + groupAllow = true + } } } - if hasDeny { + + // Individual takes priority over group + if individualDeny { return false } - return hasAllow + if individualAllow { + return true + } + if groupDeny { + return false + } + return groupAllow } // matchWildcard performs simple wildcard matching for scope/config_type. diff --git a/internal/store/pg/contact_resolve.go b/internal/store/pg/contact_resolve.go new file mode 100644 index 00000000..d2586cc0 --- /dev/null +++ b/internal/store/pg/contact_resolve.go @@ -0,0 +1,102 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const contactResolveCacheTTL = 60 * time.Second + +// contactResolveEntry holds a cached tenant-user resolution result. +type contactResolveEntry struct { + tenantUserID string // empty = not merged + fetched time.Time +} + +// contactResolveCache is a TTL cache for contact→tenant-user resolution. +// Mirrors the pattern in config_permissions.go (permCacheTTL). +type contactResolveCache struct { + mu sync.RWMutex + items map[string]contactResolveEntry // key: "tenantID:channelType:senderID" +} + +func newContactResolveCache() *contactResolveCache { + return &contactResolveCache{items: make(map[string]contactResolveEntry)} +} + +func (c *contactResolveCache) get(key string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if entry, ok := c.items[key]; ok && time.Since(entry.fetched) < contactResolveCacheTTL { + return entry.tenantUserID, true + } + return "", false +} + +func (c *contactResolveCache) set(key, tenantUserID string) { + c.mu.Lock() + c.items[key] = contactResolveEntry{tenantUserID: tenantUserID, fetched: time.Now()} + c.mu.Unlock() +} + +// InvalidateContactResolveCache clears all cached contact→tenant-user resolutions. +// Call after merge/unmerge operations. +func (s *PGContactStore) InvalidateContactResolveCache() { + if s.resolveCache == nil { + return + } + s.resolveCache.mu.Lock() + s.resolveCache.items = make(map[string]contactResolveEntry) + s.resolveCache.mu.Unlock() +} + +// ResolveTenantUserID looks up a contact's merged tenant-user identity. +// Uses an in-memory cache with 60s TTL to avoid per-message DB queries. +func (s *PGContactStore) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "", nil + } + cacheKey := tid.String() + ":" + channelType + ":" + senderID + + // Check cache. + if s.resolveCache != nil { + if resolved, ok := s.resolveCache.get(cacheKey); ok { + return resolved, nil + } + } + + // Query DB: join channel_contacts → tenant_users via merged_id. + var tenantUserID string + err := s.db.QueryRowContext(ctx, + `SELECT tu.user_id FROM channel_contacts cc + JOIN tenant_users tu ON cc.merged_id = tu.id + WHERE cc.tenant_id = $1 AND cc.channel_type = $2 AND cc.sender_id = $3 + AND cc.merged_id IS NOT NULL`, + tid, channelType, senderID, + ).Scan(&tenantUserID) + + if errors.Is(err, sql.ErrNoRows) { + // Not merged — cache the negative result too. + if s.resolveCache != nil { + s.resolveCache.set(cacheKey, "") + } + return "", nil + } + if err != nil { + return "", err + } + + // Cache positive result. + if s.resolveCache != nil { + s.resolveCache.set(cacheKey, tenantUserID) + } + return tenantUserID, nil +} diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 462d173b..1d88d4b2 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -262,46 +262,137 @@ func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ( // LookupByBinary finds the best credential config for a binary name. // Agent-specific config takes priority over global (agent_id IS NULL). -func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) { - var row *sql.Row +// If userID is non-empty, also fetches per-user env overrides via LEFT JOIN (zero extra queries). +func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*store.SecureCLIBinary, error) { + tid := store.TenantIDFromContext(ctx) + isCross := store.IsCrossTenant(ctx) + if !isCross && tid == uuid.Nil { + return nil, nil + } + + // Build query with optional LEFT JOIN for per-user credentials. + selectCols := secureCLISelectCols + joinClause := "" + if userID != "" { + selectCols += ", uc.encrypted_env AS user_env" + joinClause = " LEFT JOIN secure_cli_user_credentials uc ON uc.binary_id = b.id AND uc.user_id = $%d AND uc.tenant_id = $%d" + } else { + selectCols += ", NULL AS user_env" + } + + var args []any + var query string + if agentID != nil { - 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) + if userID != "" { + if isCross { + args = []any{binaryName, *agentID, userID, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + + fmt.Sprintf(joinClause, 3, 4) + + ` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true + ORDER BY b.agent_id NULLS LAST LIMIT 1` + } else { + args = []any{binaryName, *agentID, tid, userID, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + + fmt.Sprintf(joinClause, 4, 5) + + ` WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3 + ORDER BY b.agent_id NULLS LAST LIMIT 1` + } } else { - tid := store.TenantIDFromContext(ctx) - if tid == uuid.Nil { - return nil, nil + if isCross { + args = []any{binaryName, *agentID} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b + WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true + ORDER BY b.agent_id NULLS LAST LIMIT 1` + } else { + args = []any{binaryName, *agentID, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b + WHERE b.binary_name = $1 AND (b.agent_id = $2 OR b.agent_id IS NULL) AND b.enabled = true AND b.tenant_id = $3 + ORDER BY b.agent_id NULLS LAST LIMIT 1` } - 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 { - 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) + if userID != "" { + if isCross { + args = []any{binaryName, userID, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + + fmt.Sprintf(joinClause, 2, 3) + + ` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1` + } else { + args = []any{binaryName, tid, userID, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b` + + fmt.Sprintf(joinClause, 3, 4) + + ` WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1` + } } else { - tid := store.TenantIDFromContext(ctx) - if tid == uuid.Nil { - return nil, nil + if isCross { + args = []any{binaryName} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b + WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true LIMIT 1` + } else { + args = []any{binaryName, tid} + query = `SELECT ` + selectCols + ` FROM secure_cli_binaries b + WHERE b.binary_name = $1 AND b.agent_id IS NULL AND b.enabled = true AND b.tenant_id = $2 LIMIT 1` } - 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) + + row := s.db.QueryRowContext(ctx, query, args...) + b, err := s.scanRowWithUserEnv(row) if err == sql.ErrNoRows { return nil, nil } return b, err } +// scanRowWithUserEnv scans a row that includes the extra user_env column from LEFT JOIN. +func (s *PGSecureCLIStore) scanRowWithUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { + var b store.SecureCLIBinary + var binaryPath *string + var agentID *uuid.UUID + var denyArgs, denyVerbose *[]byte + var env []byte + var userEnv []byte + + err := row.Scan( + &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, + &denyArgs, &denyVerbose, + &b.TimeoutSeconds, &b.Tips, &agentID, + &b.Enabled, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt, + &userEnv, + ) + if err != nil { + return nil, err + } + + b.BinaryPath = binaryPath + b.AgentID = agentID + if denyArgs != nil { + b.DenyArgs = *denyArgs + } + if denyVerbose != nil { + b.DenyVerbose = *denyVerbose + } + + // Decrypt base env + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + b.EncryptedEnv = []byte(decrypted) + } + } else { + b.EncryptedEnv = env + } + + // Decrypt per-user env + if len(userEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { + b.UserEnv = []byte(decrypted) + } + } + + return &b, nil +} + func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` var qArgs []any diff --git a/internal/store/pg/secure_cli_user_credentials.go b/internal/store/pg/secure_cli_user_credentials.go new file mode 100644 index 00000000..91ada577 --- /dev/null +++ b/internal/store/pg/secure_cli_user_credentials.go @@ -0,0 +1,119 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGSecureCLIStore) GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*store.SecureCLIUserCredential, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + var uc store.SecureCLIUserCredential + var env []byte + err := s.db.QueryRowContext(ctx, + `SELECT id, binary_id, user_id, encrypted_env, metadata, created_at, updated_at + FROM secure_cli_user_credentials + WHERE binary_id = $1 AND user_id = $2 AND tenant_id = $3`, + binaryID, userID, tid, + ).Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &uc.CreatedAt, &uc.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + // Decrypt env + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + uc.EncryptedEnv = []byte(decrypted) + } + } else { + uc.EncryptedEnv = env + } + return &uc, nil +} + +func (s *PGSecureCLIStore) SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + // Encrypt env + var envBytes []byte + if len(encryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(encryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = encryptedEnv + } + + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_user_credentials (binary_id, user_id, encrypted_env, metadata, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, '{}', $4, $5, $5) + ON CONFLICT (binary_id, user_id, tenant_id) DO UPDATE SET + encrypted_env = EXCLUDED.encrypted_env, + updated_at = EXCLUDED.updated_at`, + binaryID, userID, envBytes, tid, now, + ) + return err +} + +func (s *PGSecureCLIStore) DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_user_credentials WHERE binary_id = $1 AND user_id = $2 AND tenant_id = $3`, + binaryID, userID, tid, + ) + return err +} + +func (s *PGSecureCLIStore) ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIUserCredential, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, binary_id, user_id, encrypted_env, metadata, created_at, updated_at + FROM secure_cli_user_credentials + WHERE binary_id = $1 AND tenant_id = $2 + ORDER BY created_at`, binaryID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIUserCredential + for rows.Next() { + var uc store.SecureCLIUserCredential + var env []byte + if err := rows.Scan(&uc.ID, &uc.BinaryID, &uc.UserID, &env, &uc.Metadata, &uc.CreatedAt, &uc.UpdatedAt); err != nil { + return nil, err + } + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + uc.EncryptedEnv = []byte(decrypted) + } + } else { + uc.EncryptedEnv = env + } + result = append(result, uc) + } + return result, rows.Err() +} diff --git a/internal/store/secure_cli_store.go b/internal/store/secure_cli_store.go index b92867e2..80ef45de 100644 --- a/internal/store/secure_cli_store.go +++ b/internal/store/secure_cli_store.go @@ -22,6 +22,19 @@ type SecureCLIBinary struct { AgentID *uuid.UUID `json:"agent_id,omitempty"` Enabled bool `json:"enabled"` CreatedBy string `json:"created_by"` + UserEnv []byte `json:"-"` // per-user encrypted env (populated by LookupByBinary LEFT JOIN) +} + +// SecureCLIUserCredential holds per-user encrypted env overrides for a binary. +type SecureCLIUserCredential struct { + ID uuid.UUID `json:"id"` + BinaryID uuid.UUID `json:"binary_id"` + UserID string `json:"user_id"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + // EncryptedEnv is decrypted JSON — never serialized to API. + EncryptedEnv []byte `json:"-"` } // SecureCLIStore manages secure CLI binary credential configurations. @@ -35,8 +48,17 @@ type SecureCLIStore interface { // LookupByBinary finds the best-matching credential config for a binary name. // Priority: agent-specific > global (agent_id IS NULL). Returns nil if not found. - LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*SecureCLIBinary, error) + // If userID is non-empty, also fetches per-user env overrides via LEFT JOIN + // and populates SecureCLIBinary.UserEnv (zero extra queries). + LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID, userID string) (*SecureCLIBinary, error) // ListEnabled returns all enabled configs (for TOOLS.md context generation). ListEnabled(ctx context.Context) ([]SecureCLIBinary, error) + + // --- Per-user credential management --- + + GetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) (*SecureCLIUserCredential, error) + SetUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string, encryptedEnv []byte) error + DeleteUserCredentials(ctx context.Context, binaryID uuid.UUID, userID string) error + ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error) } diff --git a/internal/store/sqlitestore/config_permissions.go b/internal/store/sqlitestore/config_permissions.go index 3841f982..2dfe9b2d 100644 --- a/internal/store/sqlitestore/config_permissions.go +++ b/internal/store/sqlitestore/config_permissions.go @@ -21,6 +21,7 @@ type permRow struct { Scope string ConfigType string Permission string + UserID string } type permCacheEntry struct { @@ -72,7 +73,7 @@ func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agent s.mu.RLock() if entry, ok := s.cache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { s.mu.RUnlock() - return evalPermRows(entry.rows, scope, configType), nil + return evalPermRows(entry.rows, scope, configType, userID), nil } s.mu.RUnlock() @@ -81,8 +82,8 @@ func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agent return false, err } rows, err := s.db.QueryContext(ctx, - `SELECT scope, config_type, permission FROM agent_config_permissions - WHERE agent_id = ? AND user_id = ?`+tClause, + `SELECT scope, config_type, permission, user_id FROM agent_config_permissions + WHERE agent_id = ? AND (user_id = ? OR user_id = '*')`+tClause, append([]any{agentID, userID}, tArgs...)..., ) if err != nil { @@ -93,7 +94,7 @@ func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agent var permRows []permRow for rows.Next() { var r permRow - if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission); err != nil { + if err := rows.Scan(&r.Scope, &r.ConfigType, &r.Permission, &r.UserID); err != nil { return false, err } permRows = append(permRows, r) @@ -106,7 +107,7 @@ func (s *SQLiteConfigPermissionStore) CheckPermission(ctx context.Context, agent s.cache[cacheKey] = permCacheEntry{rows: permRows, fetched: time.Now()} s.mu.Unlock() - return evalPermRows(permRows, scope, configType), nil + return evalPermRows(permRows, scope, configType, userID), nil } func (s *SQLiteConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigPermission) error { @@ -243,24 +244,43 @@ func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) { return perms, nil } -// evalPermRows evaluates cached permission rows against scope and configType (deny-first). -func evalPermRows(rows []permRow, scope, configType string) bool { - var hasDeny, hasAllow bool +// evalPermRows evaluates cached permission rows with priority-based evaluation. +// Individual permissions (matching targetUserID) override group wildcards (user_id="*"). +func evalPermRows(rows []permRow, scope, configType, targetUserID string) bool { + var individualDeny, individualAllow bool + var groupDeny, groupAllow bool + for _, r := range rows { if !matchWildcard(r.Scope, scope) || !matchWildcard(r.ConfigType, configType) { continue } - switch r.Permission { - case "deny": - hasDeny = true - case "allow": - hasAllow = true + if r.UserID == targetUserID { + switch r.Permission { + case "deny": + individualDeny = true + case "allow": + individualAllow = true + } + } else if r.UserID == "*" { + switch r.Permission { + case "deny": + groupDeny = true + case "allow": + groupAllow = true + } } } - if hasDeny { + + if individualDeny { return false } - return hasAllow + if individualAllow { + return true + } + if groupDeny { + return false + } + return groupAllow } // matchWildcard performs simple wildcard matching for scope/config_type. diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go index 9d872d86..40d0e7d2 100644 --- a/internal/store/sqlitestore/contacts.go +++ b/internal/store/sqlitestore/contacts.go @@ -5,6 +5,7 @@ package sqlitestore import ( "context" "database/sql" + "errors" "fmt" "strings" @@ -22,22 +23,21 @@ func NewSQLiteContactStore(db *sql.DB) *SQLiteContactStore { return &SQLiteContactStore{db: db} } -func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { +func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType string) error { tenantID := store.TenantIDFromContext(ctx) if tenantID == uuid.Nil { tenantID = store.MasterTenantID } - // SQLite UPSERT: ON CONFLICT ... DO UPDATE SET - // NULLIF emulated with CASE WHEN _, err := s.db.ExecContext(ctx, ` - INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id) - VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?) + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, tenant_id) + VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?, ?) ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name), username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username), user_id = COALESCE(NULLIF(excluded.user_id,''), channel_contacts.user_id), channel_instance = COALESCE(NULLIF(excluded.channel_instance,''), channel_contacts.channel_instance), peer_kind = COALESCE(NULLIF(excluded.peer_kind,''), channel_contacts.peer_kind), + contact_type = excluded.contact_type, last_seen_at = CURRENT_TIMESTAMP`, channelType, channelInstance, "", @@ -46,6 +46,7 @@ func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, cha displayName, "", username, "", peerKind, "", + contactType, tenantID, ) return err @@ -86,14 +87,14 @@ func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string } const contactSelectCols = `id, channel_type, channel_instance, sender_id, user_id, - display_name, username, avatar_url, peer_kind, merged_id, + display_name, username, avatar_url, peer_kind, contact_type, merged_id, first_seen_at, last_seen_at` func scanContact(rows *sql.Rows) (store.ChannelContact, error) { var c store.ChannelContact err := rows.Scan( &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, - &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.ContactType, &c.MergedID, &c.FirstSeenAt, &c.LastSeenAt, ) return c, err @@ -150,7 +151,7 @@ func (s *SQLiteContactStore) GetContactsBySenderIDs(ctx context.Context, senderI // SQLite has no DISTINCT ON; emulate with GROUP BY + MAX rowid trick via subquery query := `SELECT id, channel_type, channel_instance, sender_id, user_id, - display_name, username, avatar_url, peer_kind, merged_id, + display_name, username, avatar_url, peer_kind, contact_type, merged_id, first_seen_at, last_seen_at FROM channel_contacts WHERE sender_id IN (` + strings.Join(placeholders, ",") + `) @@ -182,7 +183,7 @@ func (s *SQLiteContactStore) GetContactByID(ctx context.Context, id uuid.UUID) ( 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.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.ContactType, &c.MergedID, &c.FirstSeenAt, &c.LastSeenAt, ); err != nil { return nil, err @@ -253,3 +254,19 @@ func (s *SQLiteContactStore) GetContactsByMergedID(ctx context.Context, mergedID } return contacts, rows.Err() } + +func (s *SQLiteContactStore) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) { + tid := store.TenantIDFromContext(ctx) + var tenantUserID string + err := s.db.QueryRowContext(ctx, + `SELECT tu.user_id FROM channel_contacts cc + JOIN tenant_users tu ON cc.merged_id = tu.id + WHERE cc.tenant_id = ? AND cc.channel_type = ? AND cc.sender_id = ? + AND cc.merged_id IS NOT NULL`, + tid, channelType, senderID, + ).Scan(&tenantUserID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return tenantUserID, err +} diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index f9e321ae..528ecb00 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -14,7 +14,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 1 +const SchemaVersion = 2 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -28,7 +28,8 @@ const SchemaVersion = 1 // // Then bump SchemaVersion to 2. var migrations = map[int]string{ - // Version 1 is the initial schema — no patch needed (schema.sql covers it). + // Version 1 → 2: add contact_type column to channel_contacts. + 1: `ALTER TABLE channel_contacts ADD COLUMN contact_type VARCHAR(20) NOT NULL DEFAULT 'user';`, } // EnsureSchema creates tables if they don't exist and applies incremental migrations. diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 420443aa..ef164d14 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1036,6 +1036,7 @@ CREATE TABLE IF NOT EXISTS channel_contacts ( username VARCHAR(255), avatar_url TEXT, peer_kind VARCHAR(20), + contact_type VARCHAR(20) NOT NULL DEFAULT 'user', metadata TEXT DEFAULT '{}', merged_id TEXT, tenant_id TEXT NOT NULL REFERENCES tenants(id), diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index 3a6dbf26..7bed0f73 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log/slog" + "maps" "os" "os/exec" "regexp" @@ -136,6 +137,14 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL } } + // Step 4b: Merge per-user env overrides (user takes priority over base) + if len(cred.UserEnv) > 0 { + var userEnvMap map[string]string + if err := json.Unmarshal(cred.UserEnv, &userEnvMap); err == nil { + maps.Copy(envMap, userEnvMap) + } + } + // Step 5: Register credential values for output scrubbing for _, v := range envMap { AddCredentialScrubValues(v) @@ -275,7 +284,9 @@ func (t *ExecTool) lookupCredentialedBinary(ctx context.Context, command string) if agentID != uuid.Nil { agentIDPtr = &agentID } - cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr) + // Pass userID for per-user credential resolution (LEFT JOIN, zero extra queries). + userID := store.UserIDFromContext(ctx) + cred, err := t.secureCLIStore.LookupByBinary(ctx, binary, agentIDPtr, userID) if err != nil || cred == nil { return nil, "", nil } diff --git a/internal/tools/heartbeat.go b/internal/tools/heartbeat.go index 5852e5bf..d515b40a 100644 --- a/internal/tools/heartbeat.go +++ b/internal/tools/heartbeat.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -268,10 +269,13 @@ func (t *HeartbeatTool) checkPermission(ctx context.Context, agentID uuid.UUID) if t.permStore == nil { return nil // no permission store = allow all (backward compat) } - userID := store.UserIDFromContext(ctx) - if userID == "" { + // Use individual sender ID for permission check (not group-scoped UserID). + // This matches the pattern used by file_writer and /addwriter commands. + senderID := store.SenderIDFromContext(ctx) + if senderID == "" { return nil // system context (cron, subagent) = allow } + numericID := strings.SplitN(senderID, "|", 2)[0] // Determine scope from context: "agent" for DM, "group:{channel}:{chatId}" for groups. scope := "agent" @@ -281,7 +285,7 @@ func (t *HeartbeatTool) checkPermission(ctx context.Context, agentID uuid.UUID) } } - allowed, err := t.permStore.CheckPermission(ctx, agentID, scope, "heartbeat", userID) + allowed, err := t.permStore.CheckPermission(ctx, agentID, scope, "heartbeat", numericID) if err != nil { return fmt.Errorf("permission check failed: %w", err) } @@ -292,14 +296,8 @@ func (t *HeartbeatTool) checkPermission(ctx context.Context, agentID uuid.UUID) // Fallback: check if user is agent owner (via agent store). if t.agentStore != nil { ag, agErr := t.agentStore.GetByID(ctx, agentID) - if agErr == nil { - senderID := store.SenderIDFromContext(ctx) - if senderID == "" { - senderID = userID - } - if ag.OwnerID != "" && ag.OwnerID == senderID { - return nil // agent owner = allow - } + if agErr == nil && ag.OwnerID != "" && ag.OwnerID == senderID { + return nil // agent owner = allow } } diff --git a/internal/tools/knowledge_graph_test.go b/internal/tools/knowledge_graph_test.go index 6414a0c1..ba2f7561 100644 --- a/internal/tools/knowledge_graph_test.go +++ b/internal/tools/knowledge_graph_test.go @@ -13,8 +13,8 @@ import ( // ── mock KG store ────────────────────────────────────────────────── type mockKGStore struct { - entities map[string]store.Entity // keyed by entity ID - relations []store.Relation // all relations + entities map[string]store.Entity // keyed by entity ID + relations []store.Relation // all relations traversal map[string][]store.TraversalResult // keyed by start entity ID } @@ -122,7 +122,7 @@ func (m *mockKGStore) Stats(context.Context, string, string) (*store.GraphStats, } func (m *mockKGStore) SetEmbeddingProvider(store.EmbeddingProvider) {} -func (m *mockKGStore) Close() error { return nil } +func (m *mockKGStore) Close() error { return nil } // ── test helpers ─────────────────────────────────────────────────── @@ -174,7 +174,7 @@ func setupBaseGraph() (*mockKGStore, map[string]string) { // Pre-compute traversal results (mock outgoing-only behavior) // A → B, C (outgoing from A) ms.traversal[ids["A"]] = []store.TraversalResult{ - {Entity: entities[1], Depth: 1, Via: "owns"}, // B=GoClaw + {Entity: entities[1], Depth: 1, Via: "owns"}, // B=GoClaw {Entity: entities[2], Depth: 1, Via: "manages"}, // C=Dầu thô } // B → C (outgoing from B) @@ -273,7 +273,7 @@ func TestKGTraversal_Tier2_CappedAt10(t *testing.T) { ms.entities[entityX] = store.Entity{ID: entityX, AgentID: testAgentID.String(), UserID: testUserID, Name: "HubNode", EntityType: "concept"} // Create 15 incoming relations to X - for i := 0; i < 15; i++ { + for i := range 15 { srcID := uuid.NewString() srcName := fmt.Sprintf("Source_%02d", i) ms.entities[srcID] = store.Entity{ID: srcID, AgentID: testAgentID.String(), UserID: testUserID, Name: srcName, EntityType: "concept"} @@ -359,7 +359,7 @@ func TestKGTraversal_Tier1_CappedAt20(t *testing.T) { // Create 25 traversal results var results []store.TraversalResult - for i := 0; i < 25; i++ { + for i := range 25 { eid := uuid.NewString() name := fmt.Sprintf("Node_%02d", i) ms.entities[eid] = store.Entity{ID: eid, AgentID: testAgentID.String(), UserID: testUserID, Name: name, EntityType: "concept"} @@ -389,7 +389,7 @@ func TestKGSearch_RelationsCappedAt5(t *testing.T) { ms.entities[entityID] = store.Entity{ID: entityID, AgentID: testAgentID.String(), UserID: testUserID, Name: "HubEntity", EntityType: "concept"} // Create 8 outgoing relations from entity - for i := 0; i < 8; i++ { + for i := range 8 { tgtID := uuid.NewString() tgtName := fmt.Sprintf("Target_%02d", i) ms.entities[tgtID] = store.Entity{ID: tgtID, AgentID: testAgentID.String(), UserID: testUserID, Name: tgtName, EntityType: "concept"} diff --git a/internal/tools/memory_interceptor_test.go b/internal/tools/memory_interceptor_test.go index e39de310..6c7ff95d 100644 --- a/internal/tools/memory_interceptor_test.go +++ b/internal/tools/memory_interceptor_test.go @@ -46,8 +46,8 @@ func (m *mockMemoryStore) ListDocuments(_ context.Context, agentID, userID strin var out []store.DocumentInfo prefix := agentID + "|" + userID + "|" for k := range m.docs { - if strings.HasPrefix(k, prefix) { - path := strings.TrimPrefix(k, prefix) + if after, ok := strings.CutPrefix(k, prefix); ok { + path := after out = append(out, store.DocumentInfo{Path: path}) } } @@ -72,7 +72,7 @@ func (m *mockMemoryStore) Search(_ context.Context, _ string, _, _ string, _ sto } func (m *mockMemoryStore) IndexDocument(_ context.Context, _, _, _ string) error { return nil } func (m *mockMemoryStore) IndexAll(_ context.Context, _, _ string) error { return nil } -func (m *mockMemoryStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {} +func (m *mockMemoryStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {} func (m *mockMemoryStore) Close() error { return nil } // --- Test helpers --- diff --git a/internal/tools/sessions_test.go b/internal/tools/sessions_test.go index b1127075..3298cd76 100644 --- a/internal/tools/sessions_test.go +++ b/internal/tools/sessions_test.go @@ -111,25 +111,25 @@ func (m *mockSessionStore) SetLabel(_ context.Context, key, label string) { } func (m *mockSessionStore) SetAgentInfo(context.Context, string, uuid.UUID, string) {} -func (m *mockSessionStore) TruncateHistory(context.Context, string, int) {} -func (m *mockSessionStore) SetHistory(context.Context, string, []providers.Message) {} -func (m *mockSessionStore) Reset(context.Context, string) {} -func (m *mockSessionStore) Delete(context.Context, string) error { return nil } -func (m *mockSessionStore) Save(context.Context, string) error { return nil } +func (m *mockSessionStore) TruncateHistory(context.Context, string, int) {} +func (m *mockSessionStore) SetHistory(context.Context, string, []providers.Message) {} +func (m *mockSessionStore) Reset(context.Context, string) {} +func (m *mockSessionStore) Delete(context.Context, string) error { return nil } +func (m *mockSessionStore) Save(context.Context, string) error { return nil } func (m *mockSessionStore) UpdateMetadata(context.Context, string, string, string, string) {} func (m *mockSessionStore) AccumulateTokens(context.Context, string, int64, int64) {} -func (m *mockSessionStore) IncrementCompaction(context.Context, string) {} -func (m *mockSessionStore) GetCompactionCount(context.Context, string) int { return 0 } -func (m *mockSessionStore) GetMemoryFlushCompactionCount(context.Context, string) int { return 0 } -func (m *mockSessionStore) SetMemoryFlushDone(context.Context, string) {} -func (m *mockSessionStore) GetSessionMetadata(context.Context, string) map[string]string { return nil } -func (m *mockSessionStore) SetSessionMetadata(context.Context, string, map[string]string) {} -func (m *mockSessionStore) SetSpawnInfo(context.Context, string, string, int) {} -func (m *mockSessionStore) SetContextWindow(context.Context, string, int) {} -func (m *mockSessionStore) GetContextWindow(context.Context, string) int { return 0 } -func (m *mockSessionStore) SetLastPromptTokens(context.Context, string, int, int) {} -func (m *mockSessionStore) GetLastPromptTokens(context.Context, string) (int, int) { return 0, 0 } +func (m *mockSessionStore) IncrementCompaction(context.Context, string) {} +func (m *mockSessionStore) GetCompactionCount(context.Context, string) int { return 0 } +func (m *mockSessionStore) GetMemoryFlushCompactionCount(context.Context, string) int { return 0 } +func (m *mockSessionStore) SetMemoryFlushDone(context.Context, string) {} +func (m *mockSessionStore) GetSessionMetadata(context.Context, string) map[string]string { return nil } +func (m *mockSessionStore) SetSessionMetadata(context.Context, string, map[string]string) {} +func (m *mockSessionStore) SetSpawnInfo(context.Context, string, string, int) {} +func (m *mockSessionStore) SetContextWindow(context.Context, string, int) {} +func (m *mockSessionStore) GetContextWindow(context.Context, string) int { return 0 } +func (m *mockSessionStore) SetLastPromptTokens(context.Context, string, int, int) {} +func (m *mockSessionStore) GetLastPromptTokens(context.Context, string) (int, int) { return 0, 0 } func (m *mockSessionStore) List(_ context.Context, agentID string) []store.SessionInfo { m.mu.RLock() @@ -230,7 +230,7 @@ func TestSessionsList_ActiveMinutesFilter(t *testing.T) { func TestSessionsList_LimitCapsResults(t *testing.T) { ms := newMockSessionStore() - for i := 0; i < 5; i++ { + for i := range 5 { ms.seed("agent:"+sessTestAgentID+":ws:direct:"+string(rune('a'+i)), nil, "") } @@ -408,7 +408,7 @@ func TestSessionsHistory_LimitFromEnd(t *testing.T) { ms := newMockSessionStore() key := "agent:" + sessTestAgentID + ":ws:direct:1" var msgs []providers.Message - for i := 0; i < 10; i++ { + for range 10 { msgs = append(msgs, providers.Message{Role: "user", Content: "msg"}) } ms.seed(key, msgs, "") diff --git a/internal/tracing/truncate.go b/internal/tracing/truncate.go index b86c5a34..1ac69c8b 100644 --- a/internal/tracing/truncate.go +++ b/internal/tracing/truncate.go @@ -100,7 +100,7 @@ func estimateArraySize(arr []json.RawMessage, elemLens []int, keep int) int { placeholderLen := 22 + digitCount(dropped) + 17 // key + number + " elements omitted"} total := 2 // [ and ] - for i := 0; i < keepHead; i++ { + for i := range keepHead { total += elemLens[i] } total += placeholderLen @@ -131,7 +131,7 @@ func buildTruncatedArray(arr []json.RawMessage, keepHead, keepTail int) string { placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped) parts := make([]string, 0, keepHead+1+keepTail) - for i := 0; i < keepHead; i++ { + for i := range keepHead { parts = append(parts, string(arr[i])) } parts = append(parts, placeholder) @@ -147,10 +147,7 @@ func truncateArrayElements(arr []json.RawMessage, maxLen int) string { dropped := len(arr) - 2 placeholder := fmt.Sprintf(`{"__truncated__":"%d elements omitted"}`, dropped) overhead := len("[,,]") + len(placeholder) - perElem := (maxLen - overhead) / 2 - if perElem < 50 { - perElem = 50 - } + perElem := max((maxLen-overhead)/2, 50) first := TruncateMid(string(arr[0]), perElem) last := TruncateMid(string(arr[len(arr)-1]), perElem) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 026ca9bb..38b678ed 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -341,7 +341,7 @@ func removeQuarantine(appPath string) { func isNewer(a, b string) bool { pa := parseSemver(a) pb := parseSemver(b) - for i := 0; i < 3; i++ { + for i := range 3 { if pa[i] > pb[i] { return true } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index c07b7e9a..a075e8dd 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 = 31 +const RequiredSchemaVersion uint = 32 diff --git a/migrations/000032_secure_cli_user_credentials.down.sql b/migrations/000032_secure_cli_user_credentials.down.sql new file mode 100644 index 00000000..f1ce4ee9 --- /dev/null +++ b/migrations/000032_secure_cli_user_credentials.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_contacts DROP COLUMN IF EXISTS contact_type; +DROP TABLE IF EXISTS secure_cli_user_credentials; diff --git a/migrations/000032_secure_cli_user_credentials.up.sql b/migrations/000032_secure_cli_user_credentials.up.sql new file mode 100644 index 00000000..c76f365b --- /dev/null +++ b/migrations/000032_secure_cli_user_credentials.up.sql @@ -0,0 +1,20 @@ +-- Per-user credentials for secure CLI binaries. +-- Mirrors mcp_user_credentials pattern: user-specific env vars override binary defaults. +CREATE TABLE secure_cli_user_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + encrypted_env BYTEA NOT NULL, -- AES-256-GCM encrypted JSON: {"GH_TOKEN":"xxx"} + metadata JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL REFERENCES tenants(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(binary_id, user_id, tenant_id) +); + +CREATE INDEX idx_scuc_tenant ON secure_cli_user_credentials(tenant_id); +CREATE INDEX idx_scuc_binary ON secure_cli_user_credentials(binary_id); + +-- Add contact_type column to channel_contacts to distinguish user vs group contacts. +-- Default "user" for backward compatibility with existing records. +ALTER TABLE channel_contacts ADD COLUMN IF NOT EXISTS contact_type VARCHAR(20) NOT NULL DEFAULT 'user'; diff --git a/ui/web/src/components/shared/user-picker-combobox.tsx b/ui/web/src/components/shared/user-picker-combobox.tsx new file mode 100644 index 00000000..ea4fbbd4 --- /dev/null +++ b/ui/web/src/components/shared/user-picker-combobox.tsx @@ -0,0 +1,54 @@ +import { Combobox } from "@/components/ui/combobox"; +import { useUserPicker } from "@/hooks/use-user-picker"; + +interface UserPickerComboboxProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + /** Filter contacts by peer_kind: "direct" | "group" | undefined (all). */ + peerKind?: "direct" | "group"; + /** Filter by source: "contact" | "tenant_user" | undefined (both). + * Use "tenant_user" for merge dialogs and tenant user pickers. */ + source?: "contact" | "tenant_user"; + /** Allow typing custom values not in the list. Default true. */ + allowCustom?: boolean; + /** Render dropdown into a portal container (useful inside dialogs). */ + portalContainer?: React.RefObject; +} + +/** + * Unified user picker that searches both channel_contacts and tenant_users. + * Drop-in replacement for Combobox + useContactSearch/useContactPicker. + * + * - Shows 30 most recent results when opened (no typing needed) + * - Debounced server-side search as user types + * - Source badges: [telegram], [discord], [tenant], merged status + * - Deduplicates merged contacts + * + * Uses `value` prop as search term (same pattern as useContactSearch(userId)). + */ +export function UserPickerCombobox({ + value, + onChange, + placeholder, + className, + peerKind, + source, + allowCustom = true, + portalContainer, +}: UserPickerComboboxProps) { + const { options } = useUserPicker(value, peerKind, source); + + return ( + + ); +} diff --git a/ui/web/src/hooks/use-user-picker.ts b/ui/web/src/hooks/use-user-picker.ts new file mode 100644 index 00000000..3d3b0603 --- /dev/null +++ b/ui/web/src/hooks/use-user-picker.ts @@ -0,0 +1,70 @@ +import { useState, useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ComboboxOption } from "@/components/ui/combobox"; + +/** Unified search result from contacts + tenant_users. */ +export interface UserPickerItem { + id: string; + display_name?: string; + username?: string; + source: "contact" | "tenant_user"; + channel_type?: string; + peer_kind?: string; + merged_tenant_user_id?: string; + role?: string; +} + +/** + * Unified user picker hook — searches both channel_contacts and tenant_users. + * - Empty search: returns 30 most recent + * - With search: debounced server-side ILIKE search + * - Deduplicates merged contacts (shows tenant_user badge instead) + */ +/** + * @param source - Filter by source: "contact" | "tenant_user" | undefined (both). + * Use "tenant_user" for merge dialog / add tenant user (contacts excluded). + */ +export function useUserPicker(search: string, peerKind?: string, source?: "contact" | "tenant_user") { + const http = useHttp(); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 150); + return () => clearTimeout(timer); + }, [search]); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.users.search({ search: debouncedSearch, peerKind, source, limit: 30 }), + queryFn: async () => { + const params: Record = { limit: "30" }; + if (debouncedSearch) params.q = debouncedSearch; + if (peerKind) params.peer_kind = peerKind; + if (source) params.source = source; + const res = await http.get<{ results: UserPickerItem[] }>("/v1/users/search", params); + return res.results ?? []; + }, + // Always enabled — empty search returns recent contacts + staleTime: 30_000, + }); + + const results = data ?? []; + + /** Format results as ComboboxOptions with source badges. */ + const options: ComboboxOption[] = useMemo(() => + results.map((r) => { + const parts: string[] = []; + if (r.display_name) parts.push(r.display_name); + if (r.username) parts.push(`@${r.username}`); + parts.push(`(${r.id})`); + if (r.source === "contact" && r.channel_type) parts.push(`[${r.channel_type}]`); + if (r.source === "tenant_user") parts.push("[tenant]"); + if (r.merged_tenant_user_id) parts.push(`→ ${r.merged_tenant_user_id}`); + return { value: r.id, label: parts.join(" ") }; + }), + [results], + ); + + return { results, options, loading: isLoading }; +} diff --git a/ui/web/src/i18n/locales/en/cli-credentials.json b/ui/web/src/i18n/locales/en/cli-credentials.json index e57af89c..4496fef2 100644 --- a/ui/web/src/i18n/locales/en/cli-credentials.json +++ b/ui/web/src/i18n/locales/en/cli-credentials.json @@ -56,5 +56,25 @@ "updateFailed": "Failed to update credential", "deleted": "CLI credential deleted", "deleteFailed": "Failed to delete credential" + }, + "userCredentials": { + "title": "User Credentials", + "description": "Per-user environment variable overrides for {{name}}", + "userId": "User ID", + "userIdPlaceholder": "user-id or email", + "env": "Environment Variables", + "addEnv": "Add Variable", + "add": "Add", + "save": "Save", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "close": "Close", + "empty": "No user credentials configured", + "saved": "User credentials saved", + "saveFailed": "Failed to save user credentials", + "deleted": "User credentials deleted", + "deleteFailed": "Failed to delete user credentials", + "envRequired": "At least one environment variable is required" } } diff --git a/ui/web/src/i18n/locales/vi/cli-credentials.json b/ui/web/src/i18n/locales/vi/cli-credentials.json index d95c1103..49250dcc 100644 --- a/ui/web/src/i18n/locales/vi/cli-credentials.json +++ b/ui/web/src/i18n/locales/vi/cli-credentials.json @@ -56,5 +56,25 @@ "updateFailed": "Không thể cập nhật thông tin", "deleted": "Đã xóa thông tin CLI", "deleteFailed": "Không thể xóa thông tin" + }, + "userCredentials": { + "title": "Thông tin người dùng", + "description": "Ghi đè biến môi trường cho từng người dùng của {{name}}", + "userId": "ID người dùng", + "userIdPlaceholder": "user-id hoặc email", + "env": "Biến môi trường", + "addEnv": "Thêm biến", + "add": "Thêm", + "save": "Lưu", + "edit": "Sửa", + "delete": "Xóa", + "back": "Quay lại", + "close": "Đóng", + "empty": "Chưa có thông tin người dùng nào", + "saved": "Đã lưu thông tin người dùng", + "saveFailed": "Không thể lưu thông tin người dùng", + "deleted": "Đã xóa thông tin người dùng", + "deleteFailed": "Không thể xóa thông tin người dùng", + "envRequired": "Cần ít nhất một biến môi trường" } } diff --git a/ui/web/src/i18n/locales/zh/cli-credentials.json b/ui/web/src/i18n/locales/zh/cli-credentials.json index 45983926..5ef5d977 100644 --- a/ui/web/src/i18n/locales/zh/cli-credentials.json +++ b/ui/web/src/i18n/locales/zh/cli-credentials.json @@ -56,5 +56,25 @@ "updateFailed": "更新凭证失败", "deleted": "CLI 凭证已删除", "deleteFailed": "删除凭证失败" + }, + "userCredentials": { + "title": "用户凭证", + "description": "{{name}} 的每用户环境变量覆盖", + "userId": "用户 ID", + "userIdPlaceholder": "用户 ID 或邮箱", + "env": "环境变量", + "addEnv": "添加变量", + "add": "添加", + "save": "保存", + "edit": "编辑", + "delete": "删除", + "back": "返回", + "close": "关闭", + "empty": "未配置用户凭证", + "saved": "用户凭证已保存", + "saveFailed": "保存用户凭证失败", + "deleted": "用户凭证已删除", + "deleteFailed": "删除用户凭证失败", + "envRequired": "至少需要一个环境变量" } } diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 8da844d4..4d37ad0f 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -78,6 +78,10 @@ export const queryKeys = { tenantUsers: { all: ["tenantUsers"] as const, }, + users: { + all: ["users"] as const, + search: (params: Record) => ["users", "search", params] as const, + }, tenants: { all: ["tenants"] as const, detail: (tenantId: string) => ["tenants", tenantId] as const, diff --git a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx index 50b2d8a9..34ff4e87 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx @@ -1,6 +1,5 @@ -import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react"; -import { createPortal } from "react-dom"; -import { Save, Loader2, Users, FileText, Search, UserPlus } from "lucide-react"; +import { useState, useEffect, useMemo } from "react"; +import { Save, Loader2, Users, FileText } from "lucide-react"; import { toast } from "@/stores/use-toast-store"; import { userFriendlyError } from "@/lib/error-utils"; import { useTranslation } from "react-i18next"; @@ -9,7 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { useContactResolver } from "@/hooks/use-contact-resolver"; import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances"; -import { useContactSearch } from "../hooks/use-contact-search"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; interface AgentInstancesTabProps { agentId: string; @@ -60,8 +59,13 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) { // Existing instance user_ids for deduplication const existingIDs = useMemo(() => new Set(instances.map((i) => i.user_id)), [instances]); - const handleContactSelect = (senderID: string) => { - setSelected(senderID); + const [addUserId, setAddUserId] = useState(""); + + const handleAddUser = (val: string) => { + setAddUserId(val); + if (val && !existingIDs.has(val)) { + setSelected(val); + } }; if (loading) { @@ -72,10 +76,14 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
{/* Instance list */}
- +
+ +
{instances.length > 0 && (
{instances.length} instance{instances.length !== 1 ? "s" : ""} @@ -135,98 +143,6 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) { ); } -/** Inline contact search dropdown for adding new instances. */ -function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set; onSelect: (id: string) => void }) { - const { t } = useTranslation("agents"); - const [search, setSearch] = useState(""); - const [open, setOpen] = useState(false); - const { contacts } = useContactSearch(search); - const containerRef = useRef(null); - const dropdownRef = useRef(null); - const [dropdownStyle, setDropdownStyle] = useState({}); - - // Filter out contacts already in instances - const filtered = contacts.filter((c) => !existingIDs.has(c.sender_id)); - - // Compute dropdown position for portal rendering - useLayoutEffect(() => { - if (!open || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setDropdownStyle({ - position: "fixed", - top: rect.bottom + 4, - left: rect.left, - width: rect.width, - zIndex: 9999, - }); - }, [open, search]); - - // Close on outside click - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - const target = e.target as Node; - if ( - containerRef.current && !containerRef.current.contains(target) && - (!dropdownRef.current || !dropdownRef.current.contains(target)) - ) { - setOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); - - return ( -
-
- - { setSearch(e.target.value); setOpen(true); }} - onFocus={() => search.length >= 2 && setOpen(true)} - placeholder={t("instances.searchContacts")} - className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> -
- {open && search.length >= 2 && filtered.length > 0 && createPortal( -
- {filtered.map((c) => ( - - ))} -
, - document.body, - )} - {open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && createPortal( -
- {t("instances.noContactsFound")} -
, - document.body, - )} -
- ); -} function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: UserInstance; isSelected: boolean; onClick: () => void; resolve: (id: string) => import("@/types/contact").ChannelContact | null }) { const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null; diff --git a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx index 10b96003..3e03b355 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx @@ -8,8 +8,7 @@ import { } from "@/components/ui/select"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { useConfigPermissions, type ConfigPermission } from "../hooks/use-config-permissions"; -import { useContactSearch } from "../hooks/use-contact-search"; -import type { ChannelContact } from "@/types/contact"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; const CONFIG_TYPES = [ { value: "file_writer", label: "File Writer", descKey: "permissions.types.file_writer_desc" }, @@ -48,19 +47,6 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { const [scope, setScope] = useState("group:*"); const [permission, setPermission] = useState("allow"); const [adding, setAdding] = useState(false); - const [selectedContact, setSelectedContact] = useState(null); - - const { contacts } = useContactSearch(userId); - - const contactOptions: ComboboxOption[] = useMemo(() => - contacts.map((c) => { - const name = c.display_name || c.sender_id; - const username = c.username ? ` @${c.username}` : ""; - const channel = c.channel_type ? ` [${c.channel_type}]` : ""; - return { value: c.sender_id, label: `${name}${username} (${c.sender_id})${channel}` }; - }), - [contacts], - ); // Collect existing file_writer scopes for dynamic scope options const existingFileWriterScopes = useMemo(() => @@ -88,25 +74,11 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { useEffect(() => { load(); }, [load]); - const handleUserChange = (val: string) => { - setUserId(val); - const contact = contacts.find((c) => c.sender_id === val); - setSelectedContact(contact ?? null); - }; - const handleAdd = async () => { if (!userId.trim()) return; setAdding(true); - const meta = - configType === "file_writer" && selectedContact - ? { - displayName: selectedContact.display_name ?? "", - username: selectedContact.username ?? "", - } - : undefined; - await grant(scope, configType, userId.trim(), permission, meta); + await grant(scope, configType, userId.trim(), permission); setUserId(""); - setSelectedContact(null); setAdding(false); }; @@ -158,10 +130,9 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { {/* Add Rule form */}
- diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx index d916cf0c..0caed084 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/workspace-sharing-section.tsx @@ -1,13 +1,12 @@ -import { useState, useMemo } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Combobox } from "@/components/ui/combobox"; import { Shield, X, AlertTriangle, Plus, Brain } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { WorkspaceSharingConfig } from "@/types/agent"; -import { useContacts } from "@/pages/contacts/hooks/use-contacts"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; import { InfoLabel } from "./config-section"; const MAX_SHARED_USERS = 100; @@ -22,22 +21,7 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec const s = "configSections.workspaceSharing"; const [contactSearch, setContactSearch] = useState(""); - // Fetch contacts for the combobox - const { contacts } = useContacts({ search: contactSearch, limit: 20 }); - - // Build combobox options from contacts, excluding already-added users const existingUsers = value.shared_users ?? []; - const contactOptions = useMemo(() => { - const existing = new Set(existingUsers); - return contacts - .filter((c) => c.user_id && !existing.has(c.user_id)) - .map((c) => ({ - value: c.user_id!, - label: c.display_name - ? `${c.display_name} (${c.user_id})` - : c.user_id!, - })); - }, [contacts, existingUsers]); const addUser = (userId: string) => { const trimmed = userId.trim(); @@ -139,10 +123,9 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
)}
- setContactSearch(val)} - options={contactOptions} placeholder={t(`${s}.userIdPlaceholder`)} className="flex-1" /> diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx index ed40f754..814fd031 100644 --- a/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx +++ b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx @@ -1,17 +1,16 @@ -import { useState, useEffect, useRef, useLayoutEffect } from "react"; -import { createPortal } from "react-dom"; -import { Search, UserPlus } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Badge } from "@/components/ui/badge"; -import { useContactSearch } from "../../hooks/use-contact-search"; -import type { ChannelContact } from "@/types/contact"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; +import { useUserPicker } from "@/hooks/use-user-picker"; +import type { UserPickerItem } from "@/hooks/use-user-picker"; -/** Format a contact into a human-readable snippet for insertion into context files. */ -function formatContactSnippet(c: ChannelContact): string { +/** Format a UserPickerItem into a human-readable snippet for insertion into context files. */ +function formatSnippet(item: UserPickerItem): string { const parts: string[] = []; - if (c.display_name) parts.push(c.display_name); - if (c.username) parts.push(`@${c.username}`); - parts.push(`${c.channel_type}:${c.sender_id}`); + if (item.display_name) parts.push(item.display_name); + if (item.username) parts.push(`@${item.username}`); + if (item.channel_type) parts.push(`${item.channel_type}:${item.id}`); + else parts.push(item.id); return `- ${parts.join(" — ")}`; } @@ -22,90 +21,24 @@ interface ContactInsertSearchProps { export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) { const { t } = useTranslation("agents"); const [search, setSearch] = useState(""); - const [open, setOpen] = useState(false); - const { contacts } = useContactSearch(search); - const containerRef = useRef(null); - const dropdownRef = useRef(null); - const [dropdownStyle, setDropdownStyle] = useState({}); + const { results } = useUserPicker(search); - // Compute dropdown position for portal rendering - useLayoutEffect(() => { - if (!open || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setDropdownStyle({ - position: "fixed", - top: rect.bottom + 4, - left: rect.left, - width: Math.min(rect.width, 384), // max-w-sm = 24rem = 384px - zIndex: 9999, - }); - }, [open, search]); - - // Close on outside click - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - const target = e.target as Node; - if ( - containerRef.current && !containerRef.current.contains(target) && - (!dropdownRef.current || !dropdownRef.current.contains(target)) - ) { - setOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); - - const handleSelect = (c: ChannelContact) => { - onInsert(formatContactSnippet(c)); - setSearch(""); - setOpen(false); + const handleChange = (val: string) => { + const item = results.find((r) => r.id === val); + if (item) { + onInsert(formatSnippet(item)); + setSearch(""); + } else { + setSearch(val); + } }; return ( -
-
- - { setSearch(e.target.value); setOpen(true); }} - onFocus={() => search.length >= 2 && setOpen(true)} - placeholder={t("files.insertContact")} - className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-base md:text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" - /> -
- {open && search.length >= 2 && contacts.length > 0 && createPortal( -
- {contacts.map((c) => ( - - ))} -
, - document.body, - )} - {open && search.length >= 2 && contacts.length === 0 && createPortal( -
- {t("instances.noContactsFound")} -
, - document.body, - )} -
+ ); } diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index dc5d29c2..d26c3ce0 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -29,7 +29,6 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai listManagers, addManager, removeManager, - listContacts, } = useChannelDetail(instanceId); const { agents } = useAgents(); const { channels } = useChannels(); @@ -107,7 +106,6 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai listManagers={listManagers} addManager={addManager} removeManager={removeManager} - listContacts={listContacts} /> diff --git a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx index 06069af1..89e75cf9 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx @@ -4,18 +4,15 @@ import { Plus, Trash2, Loader2, RefreshCw, Users, ChevronDown, ChevronRight } fr import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Combobox } from "@/components/ui/combobox"; import { EmptyState } from "@/components/shared/empty-state"; -import { useContactPicker } from "@/hooks/use-contact-picker"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; import type { GroupManagerGroupInfo, GroupManagerData } from "../hooks/use-channel-detail"; -import type { ChannelContact } from "@/types/contact"; interface ChannelManagersTabProps { listManagerGroups: () => Promise; listManagers: (groupId: string) => Promise; addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise; removeManager: (groupId: string, userId: string) => Promise; - listContacts: (search: string, channelType?: string) => Promise; } /** Strips the "group::" prefix for display, e.g. "group:telegram:-100123" → "-100123" */ @@ -29,22 +26,15 @@ function shortGroupId(id: string): string { interface InlineAddFormProps { groupId?: string; showGroupField?: boolean; - listContacts: (search: string) => Promise; onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise; } -function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineAddFormProps) { +function InlineAddForm({ groupId, showGroupField, onAdd }: InlineAddFormProps) { const { t } = useTranslation("channels"); const [formGroupId, setFormGroupId] = useState(""); const [userId, setUserId] = useState(""); const [adding, setAdding] = useState(false); const [error, setError] = useState(""); - const { options: contactOptions, searchContacts, getContact, clearOptions } = useContactPicker(listContacts); - - const handleUserIdChange = (val: string) => { - setUserId(val); - searchContacts(val); - }; const handleSubmit = async () => { const gid = groupId || formGroupId.trim(); @@ -56,13 +46,8 @@ function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineA setAdding(true); setError(""); try { - // Auto-fill display name and username from selected contact - const contact = getContact(uid); - const displayName = contact?.display_name ?? ""; - const username = contact?.username ?? ""; - await onAdd(gid, uid, displayName, username); + await onAdd(gid, uid, "", ""); setUserId(""); - clearOptions(); if (!groupId) setFormGroupId(""); } catch (err) { setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd")); @@ -88,10 +73,9 @@ function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineA
-
@@ -114,10 +98,9 @@ function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineA
- @@ -143,7 +126,6 @@ export function ChannelManagersTab({ listManagers, addManager, removeManager, - listContacts, }: ChannelManagersTabProps) { const { t } = useTranslation("channels"); const [groups, setGroups] = useState([]); @@ -318,7 +300,6 @@ export function ChannelManagersTab({
@@ -333,7 +314,6 @@ export function ChannelManagersTab({ {/* Add to new group */}
diff --git a/ui/web/src/pages/cli-credentials/cli-credentials-page.tsx b/ui/web/src/pages/cli-credentials/cli-credentials-page.tsx index 656933df..db3b028e 100644 --- a/ui/web/src/pages/cli-credentials/cli-credentials-page.tsx +++ b/ui/web/src/pages/cli-credentials/cli-credentials-page.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { KeyRound, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react"; +import { KeyRound, Plus, RefreshCw, Pencil, Trash2, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { PageHeader } from "@/components/shared/page-header"; @@ -11,6 +11,7 @@ import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; import { useCliCredentials, useCliCredentialPresets } from "./hooks/use-cli-credentials"; import { CliCredentialFormDialog } from "./cli-credential-form-dialog"; +import { CLIUserCredentialsDialog } from "./cli-user-credentials-dialog"; import type { SecureCLIBinary, CLICredentialInput } from "./hooks/use-cli-credentials"; export function CliCredentialsPage() { @@ -21,6 +22,7 @@ export function CliCredentialsPage() { const [editItem, setEditItem] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); + const [userCredsTarget, setUserCredsTarget] = useState(null); const { items, loading, refresh, createCredential, updateCredential, deleteCredential } = useCliCredentials(); @@ -128,6 +130,9 @@ export function CliCredentialsPage() { {item.timeout_seconds}s
+
); } diff --git a/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx new file mode 100644 index 00000000..e1915c92 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { KeyRound, Loader2, Plus, Trash2, Pencil } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { KeyValueEditor } from "@/components/shared/key-value-editor"; +import { toast } from "@/stores/use-toast-store"; +import { useHttp } from "@/hooks/use-ws"; +import i18next from "i18next"; +import type { SecureCLIBinary } from "./hooks/use-cli-credentials"; + +interface UserCredEntry { + id: string; + binary_id: string; + user_id: string; + has_env: boolean; + created_at: string; + updated_at: string; +} + +interface CLIUserCredentialsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + binary: SecureCLIBinary; +} + +const SENSITIVE_ENV_RE = /^.*(key|secret|token|password|credential).*$/i; +const isSensitiveEnv = (key: string) => SENSITIVE_ENV_RE.test(key.trim()); + +type ViewState = "list" | "form"; + +export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUserCredentialsDialogProps) { + const { t } = useTranslation("cli-credentials"); + const http = useHttp(); + + const [view, setView] = useState("list"); + const [entries, setEntries] = useState([]); + const [loadingList, setLoadingList] = useState(false); + + // Form state + const [editEntry, setEditEntry] = useState(null); + const [userId, setUserId] = useState(""); + const [env, setEnv] = useState>({}); + const [saving, setSaving] = useState(false); + const [deleting, setDeletingId] = useState(null); + + const loadList = useCallback(async () => { + setLoadingList(true); + try { + const res = await http.get<{ user_credentials: UserCredEntry[] }>( + `/v1/cli-credentials/${binary.id}/user-credentials`, + ); + setEntries(res.user_credentials ?? []); + } catch { + // silently ignore + } finally { + setLoadingList(false); + } + }, [http, binary.id]); + + useEffect(() => { + if (!open) return; + setView("list"); + setEditEntry(null); + setUserId(""); + setEnv({}); + loadList(); + }, [open, loadList]); + + const openAdd = () => { + setEditEntry(null); + setUserId(""); + setEnv({}); + setView("form"); + }; + + const openEdit = async (entry: UserCredEntry) => { + setEditEntry(entry); + setUserId(entry.user_id); + setEnv({}); + setView("form"); + // Load existing env for edit + try { + const res = await http.get<{ user_id: string; env: Record | null }>( + `/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`, + ); + setEnv(res.env ?? {}); + } catch { + // leave env empty — user can re-enter + } + }; + + const handleSave = async () => { + const uid = userId.trim(); + if (!uid) return; + if (Object.keys(env).length === 0) { + toast.error(i18next.t("cli-credentials:userCredentials.envRequired")); + return; + } + setSaving(true); + try { + await http.put(`/v1/cli-credentials/${binary.id}/user-credentials/${uid}`, { env }); + toast.success(i18next.t("cli-credentials:userCredentials.saved")); + await loadList(); + setView("list"); + } catch (err) { + toast.error( + i18next.t("cli-credentials:userCredentials.saveFailed"), + err instanceof Error ? err.message : "", + ); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (entry: UserCredEntry) => { + setDeletingId(entry.id); + try { + await http.delete(`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`); + toast.success(i18next.t("cli-credentials:userCredentials.deleted")); + await loadList(); + } catch (err) { + toast.error( + i18next.t("cli-credentials:userCredentials.deleteFailed"), + err instanceof Error ? err.message : "", + ); + } finally { + setDeletingId(null); + } + }; + + return ( + + + + + + {t("userCredentials.title")} + + + {t("userCredentials.description", { name: binary.binary_name })} + + + + {view === "list" ? ( + <> + {loadingList ? ( +
+ +
+ ) : entries.length === 0 ? ( +

+ {t("userCredentials.empty")} +

+ ) : ( +
+ {entries.map((entry) => ( +
+
+ {entry.user_id} + {entry.has_env && ( + + env + + )} +
+
+ + +
+
+ ))} +
+ )} + + + + + + ) : ( + <> +
+
+ + setUserId(e.target.value)} + placeholder={t("userCredentials.userIdPlaceholder")} + disabled={!!editEntry} + className="text-base md:text-sm font-mono" + /> +
+ +
+ + +
+
+ + + + + + + )} +
+
+ ); +} diff --git a/ui/web/src/pages/contacts/merge-contacts-dialog.tsx b/ui/web/src/pages/contacts/merge-contacts-dialog.tsx index c4a29f06..97ed550b 100644 --- a/ui/web/src/pages/contacts/merge-contacts-dialog.tsx +++ b/ui/web/src/pages/contacts/merge-contacts-dialog.tsx @@ -1,10 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Construction, Merge } from "lucide-react"; +import { Merge } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Combobox } from "@/components/ui/combobox"; import { Dialog, DialogContent, @@ -16,7 +15,7 @@ import { import { toast } from "@/stores/use-toast-store"; import type { ChannelContact } from "@/types/contact"; import { useContactMerge } from "./hooks/use-contact-merge"; -import { useTenantUsersList } from "./hooks/use-tenant-users-list"; +import { UserPickerCombobox } from "@/components/shared/user-picker-combobox"; interface MergeContactsDialogProps { open: boolean; @@ -35,15 +34,12 @@ export function MergeContactsDialog({ }: MergeContactsDialogProps) { const { t } = useTranslation("contacts"); const { merge } = useContactMerge(); - const { users } = useTenantUsersList(); const [mode, setMode] = useState("existing"); const [selectedUserId, setSelectedUserId] = useState(""); const [newDisplayName, setNewDisplayName] = useState(""); const [newUserId, setNewUserId] = useState(""); - // TODO: re-enable when merge feature is ready - // const [submitting, setSubmitting] = useState(false); - const setSubmitting = (_v: boolean) => {}; + const [submitting, setSubmitting] = useState(false); // Reset form state when dialog opens useEffect(() => { @@ -59,11 +55,6 @@ export function MergeContactsDialog({ const defaultUserId = selectedContacts[0]?.username || selectedContacts[0]?.sender_id || ""; - const userOptions = users.map((u) => ({ - value: u.id, - label: u.display_name || u.user_id, - })); - const handleSubmit = async () => { const contactIds = selectedContacts.map((c) => c.id); setSubmitting(true); @@ -92,8 +83,7 @@ export function MergeContactsDialog({ } }; - // TODO: re-enable when merge feature is ready - // const canSubmit = mode === "existing" ? !!selectedUserId : !!(newUserId || defaultUserId); + const canSubmit = mode === "existing" ? !!selectedUserId : !!(newUserId || defaultUserId); return ( @@ -106,13 +96,7 @@ export function MergeContactsDialog({ {t("merge.dialogDescription")} - {/* Coming soon banner */} -
- - {t("merge.comingSoon")} -
- -
+
{/* Mode selection — simple radio buttons */}