mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 22:15:02 +00:00
feat(channels): expose context capability matrix
This commit is contained in:
1 parent
29f3eb3874
commit
f20d776ebb
4 files changed
+364
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user