From f20d776ebb9693a7a3308ddbb8c37c8c4d85733b Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:17:18 +0700 Subject: [PATCH] feat(channels): expose context capability matrix --- cmd/gateway_http_handlers.go | 1 + .../http/channel_instance_capabilities.go | 143 ++++++++++++ .../channel_instance_capabilities_test.go | 210 ++++++++++++++++++ internal/http/channel_instances.go | 10 + 4 files changed, 364 insertions(+) create mode 100644 internal/http/channel_instance_capabilities.go create mode 100644 internal/http/channel_instance_capabilities_test.go diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 7d7cb532..c33a9f17 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -62,6 +62,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.ChannelInstances != nil { channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, msgBus) + channelInstancesH.SetCapabilityStores(stores.MCP, stores.SecureCLI) } if stores != nil && stores.Providers != nil { diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go new file mode 100644 index 00000000..9891a1ed --- /dev/null +++ b/internal/http/channel_instance_capabilities.go @@ -0,0 +1,143 @@ +package http + +import ( + "log/slog" + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelCapabilityDTO struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + ToolAllow []string `json:"tool_allow,omitempty"` + ToolDeny []string `json:"tool_deny,omitempty"` + CredentialSource string `json:"credential_source"` + HasCredential bool `json:"has_credential"` + ContextGrant bool `json:"context_grant_configured"` + ContextCredentials bool `json:"context_credentials_configured"` +} + +func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + scopeType := r.PathValue("scopeType") + scopeKey := r.PathValue("scopeKey") + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = store.CredentialUserIDFromContext(r.Context()) + } + + mcpRows, err := h.listChannelMCPRows(r, inst, userID) + if err != nil { + slog.Error("channel_instances.capabilities_mcp", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP capabilities")) + return + } + cliRows, err := h.listChannelCLIRows(r, inst, userID) + if err != nil { + slog.Error("channel_instances.capabilities_cli", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI capabilities")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "scope_type": scopeType, + "scope_key": scopeKey, + "capabilities": append(mcpRows, cliRows...), + "mcp": mcpRows, + "secure_cli": cliRows, + }) +} + +func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { + if h.mcpStore == nil { + return []channelCapabilityDTO{}, nil + } + accessible, err := h.mcpStore.ListAccessible(r.Context(), inst.AgentID, userID) + if err != nil { + return nil, err + } + rows := make([]channelCapabilityDTO, 0, len(accessible)) + for _, info := range accessible { + credentialSource := "global" + hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 + if userID != "" { + if creds, err := h.mcpStore.GetUserCredentials(r.Context(), info.Server.ID, userID); err == nil && creds != nil { + if creds.APIKey != "" || len(creds.Headers) > 0 || len(creds.Env) > 0 { + credentialSource = "user" + hasCredential = true + } + } + } + rows = append(rows, channelCapabilityDTO{ + Type: "mcp_server", + ID: info.Server.ID.String(), + Name: info.Server.Name, + DisplayName: info.Server.DisplayName, + Enabled: info.Server.Enabled, + Source: "agent", + ToolAllow: info.ToolAllow, + ToolDeny: info.ToolDeny, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: false, + ContextCredentials: false, + }) + } + return rows, nil +} + +func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { + if h.secureCLIStore == nil { + return []channelCapabilityDTO{}, nil + } + binaries, err := h.secureCLIStore.ListForAgent(r.Context(), inst.AgentID) + if err != nil { + return nil, err + } + rows := make([]channelCapabilityDTO, 0, len(binaries)) + for _, b := range binaries { + source := "agent" + if b.IsGlobal { + source = "global" + } + credentialSource := source + hasCredential := len(b.EncryptedEnv) > 0 + if userID != "" { + if creds, err := h.secureCLIStore.GetUserCredentials(r.Context(), b.ID, userID); err == nil && creds != nil && len(creds.EncryptedEnv) > 0 { + credentialSource = "user" + hasCredential = true + } + } + rows = append(rows, channelCapabilityDTO{ + Type: "secure_cli", + ID: b.ID.String(), + Name: b.BinaryName, + DisplayName: b.Description, + Enabled: b.Enabled, + Source: source, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: false, + ContextCredentials: false, + }) + } + return rows, nil +} diff --git a/internal/http/channel_instance_capabilities_test.go b/internal/http/channel_instance_capabilities_test.go new file mode 100644 index 00000000..c7ba31cf --- /dev/null +++ b/internal/http/channel_instance_capabilities_test.go @@ -0,0 +1,210 @@ +package http + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelCapabilityMCPStore struct { + accessible []store.MCPAccessInfo + userCreds map[uuid.UUID]*store.MCPUserCredentials +} + +func (s *fakeChannelCapabilityMCPStore) CreateServer(context.Context, *store.MCPServerData) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetServer(context.Context, uuid.UUID) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) GetServerByName(context.Context, string) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) ListServers(context.Context) ([]store.MCPServerData, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) UpdateServer(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteServer(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToAgent(context.Context, *store.MCPAgentGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListAgentGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ListServerGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToUser(context.Context, *store.MCPUserGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromUser(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) CountAgentGrantsByServer(context.Context) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil +} +func (s *fakeChannelCapabilityMCPStore) ListAccessible(context.Context, uuid.UUID, string) ([]store.MCPAccessInfo, error) { + return s.accessible, nil +} +func (s *fakeChannelCapabilityMCPStore) CreateRequest(context.Context, *store.MCPAccessRequest) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListPendingRequests(context.Context) ([]store.MCPAccessRequest, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ReviewRequest(context.Context, uuid.UUID, bool, string, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetUserCredentials(_ context.Context, serverID uuid.UUID, _ string) (*store.MCPUserCredentials, error) { + if creds := s.userCreds[serverID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) SetUserCredentials(context.Context, uuid.UUID, string, store.MCPUserCredentials) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} + +type fakeChannelCapabilityCLIStore struct { + binaries []store.SecureCLIBinary + userCreds map[uuid.UUID]*store.SecureCLIUserCredential +} + +func (s *fakeChannelCapabilityCLIStore) Create(context.Context, *store.SecureCLIBinary) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) Update(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Delete(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) { + return s.binaries, nil +} +func (s *fakeChannelCapabilityCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) { + return false, nil +} +func (s *fakeChannelCapabilityCLIStore) GetUserCredentials(_ context.Context, binaryID uuid.UUID, _ string) (*store.SecureCLIUserCredential, error) { + if creds := s.userCreds[binaryID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) { + return nil, nil +} + +func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { + token := "channel-capabilities-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + agentID := uuid.New() + channelName := "discord-admin" + mcpID := uuid.New() + cliID := uuid.New() + mcpStore := &fakeChannelCapabilityMCPStore{ + accessible: []store.MCPAccessInfo{{ + Server: store.MCPServerData{ + BaseModel: store.BaseModel{ID: mcpID}, + Name: "github", + DisplayName: "GitHub", + APIKey: "global-value", + Enabled: true, + }, + ToolAllow: []string{"issues.create"}, + }}, + userCreds: map[uuid.UUID]*store.MCPUserCredentials{ + mcpID: {APIKey: "user-value"}, + }, + } + cliStore := &fakeChannelCapabilityCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: cliID}, + BinaryName: "gh", + Description: "GitHub CLI", + EncryptedEnv: []byte(`{"VALUE":"global-value"}`), + IsGlobal: true, + Enabled: true, + }}, + userCreds: map[uuid.UUID]*store.SecureCLIUserCredential{ + cliID: {BinaryID: cliID, UserID: "tenant-user-1", EncryptedEnv: []byte(`{"VALUE":"user-value"}`)}, + }, + } + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "discord", AgentID: agentID}}, + nil, nil, nil, nil, nil, + ) + handler.SetCapabilityStores(mcpStore, cliStore) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities?user_id=tenant-user-1", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + bodyText := rec.Body.String() + if strings.Contains(bodyText, "global-value") || strings.Contains(bodyText, "user-value") { + t.Fatalf("capability response leaked credential material: %s", bodyText) + } + var body struct { + MCP []channelCapabilityDTO `json:"mcp"` + SecureCLI []channelCapabilityDTO `json:"secure_cli"` + } + if err := json.Unmarshal([]byte(bodyText), &body); err != nil { + t.Fatal(err) + } + if len(body.MCP) != 1 || body.MCP[0].CredentialSource != "user" || !body.MCP[0].HasCredential { + t.Fatalf("mcp capability = %+v", body.MCP) + } + if len(body.SecureCLI) != 1 || body.SecureCLI[0].Source != "global" || body.SecureCLI[0].CredentialSource != "user" || !body.SecureCLI[0].HasCredential { + t.Fatalf("secure cli capability = %+v", body.SecureCLI) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index f16b29d8..607c24a1 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -39,6 +39,8 @@ type ChannelInstancesHandler struct { msgBus *bus.MessageBus memberResolver channels.MemberResolver // optional — enriches file_writer metadata on addwriter channelMgr *channels.Manager // optional — enables ChannelDestroyer hook on delete + mcpStore store.MCPServerStore + secureCLIStore store.SecureCLIStore // orphanCleaners is keyed by channel_type; called when channelMgr.GetChannel // returns false. Keeps handler agnostic of per-channel packages. orphanCleaners map[string]OrphanChannelCleaner @@ -64,6 +66,13 @@ func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) { h.channelMgr = mgr } +// SetCapabilityStores wires MCP and Secure CLI stores for channel-context +// capability visibility. Kept as a setter to preserve startup ordering. +func (h *ChannelInstancesHandler) SetCapabilityStores(mcpStore store.MCPServerStore, secureCLIStore store.SecureCLIStore) { + h.mcpStore = mcpStore + h.secureCLIStore = secureCLIStore +} + // RegisterOrphanCleaner registers a per-channel-type cleanup function that // fires during handleDelete when the channel is no longer loaded in the // Manager (typically because admin disabled it). Without this, deleting a @@ -114,6 +123,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/channels/instances/{id}/contexts", h.auth(h.handleListContexts)) mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) } + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities", h.auth(h.handleListContextCapabilities)) } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {