feat(channels): expose context capability matrix

This commit is contained in:
Goon committed 2026-05-29 19:17:18 +07:00
1 parent 29f3eb3874
commit f20d776ebb
4 files changed
+364

No files matched your search

+1
View File
@@ -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 {
@@ -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
}
@@ -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)
}
}
+10
View File
@@ -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 {