Files
goclaw/internal/http/channel_instance_capabilities.go
Duy /zuey/andGitHub 2cd750ac44 feat(channels): add context capability admin surface
Squash merge PR #115 after resolving changelog and SQLite migration-map conflicts with current dev. Renumbered channel-context PostgreSQL migration to 000075 and bumped PG required schema to 75 plus SQLite schema to 44 so it follows the run timeline migration. Local checks passed: go test ./..., go build ./..., go build -tags sqliteonly ./..., go vet ./..., and pnpm -C ui/web build. PR CI run 26705617311 passed release-versioning, go, and web.
2026-05-31 13:50:31 +07:00

301 lines
10 KiB
Go

package http
import (
"encoding/json"
"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())
} else if userID != store.CredentialUserIDFromContext(r.Context()) && !h.requireContextAdmin(w, r) {
return
}
scope := store.ChannelContextScope{ChannelInstanceID: inst.ID, ChannelInstanceName: inst.Name, ScopeType: scopeType, ScopeKey: scopeKey}
r = r.WithContext(store.WithChannelContextScope(r.Context(), scope))
mcpRows, err := h.listChannelMCPRows(r, inst, scope, 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, scope, 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, scope store.ChannelContextScope, 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
}
contextGrants := map[uuidString]store.MCPContextGrant{}
if h.mcpContextStore != nil {
for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) {
grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey)
if err != nil {
return nil, err
}
for _, grant := range grants {
contextGrants[uuidString(grant.ServerID.String())] = grant
}
}
}
contextCreds := map[uuidString]store.MCPContextCredentials{}
if h.mcpContextStore != nil {
for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) {
creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey)
if err != nil {
return nil, err
}
for _, cred := range creds {
contextCreds[uuidString(cred.ServerID.String())] = cred
}
}
}
rows := make([]channelCapabilityDTO, 0, len(accessible))
seen := map[uuidString]bool{}
for _, info := range accessible {
idKey := uuidString(info.Server.ID.String())
seen[idKey] = true
credentialSource := "global"
hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0
contextCredential := false
if cred, ok := contextCreds[idKey]; ok {
credentialSource = cred.ScopeType
hasCredential = cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0
contextCredential = true
}
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
}
}
}
source := "agent"
enabled := info.Server.Enabled
toolAllow := info.ToolAllow
toolDeny := info.ToolDeny
contextGrant := false
if grant, ok := contextGrants[idKey]; ok {
source = grant.ScopeType
enabled = grant.Enabled
toolAllow = decodeStringList(grant.ToolAllow)
toolDeny = decodeStringList(grant.ToolDeny)
contextGrant = true
}
rows = append(rows, channelCapabilityDTO{
Type: "mcp_server",
ID: info.Server.ID.String(),
Name: info.Server.Name,
DisplayName: info.Server.DisplayName,
Enabled: enabled,
Source: source,
ToolAllow: toolAllow,
ToolDeny: toolDeny,
CredentialSource: credentialSource,
HasCredential: hasCredential,
ContextGrant: contextGrant,
ContextCredentials: contextCredential,
})
}
for _, grant := range contextGrants {
idKey := uuidString(grant.ServerID.String())
if seen[idKey] {
continue
}
server, err := h.mcpStore.GetServer(r.Context(), grant.ServerID)
if err != nil || server == nil {
continue
}
cred, hasCredRow := contextCreds[idKey]
credentialSource := grant.ScopeType
if hasCredRow {
credentialSource = cred.ScopeType
}
rows = append(rows, channelCapabilityDTO{
Type: "mcp_server",
ID: server.ID.String(),
Name: server.Name,
DisplayName: server.DisplayName,
Enabled: server.Enabled && grant.Enabled,
Source: grant.ScopeType,
ToolAllow: decodeStringList(grant.ToolAllow),
ToolDeny: decodeStringList(grant.ToolDeny),
CredentialSource: credentialSource,
HasCredential: hasCredRow && (cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0),
ContextGrant: true,
ContextCredentials: hasCredRow,
})
}
return rows, nil
}
func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, scope store.ChannelContextScope, 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
}
contextGrants := map[uuidString]store.SecureCLIContextGrant{}
if h.cliContextStore != nil {
for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) {
grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey)
if err != nil {
return nil, err
}
for _, grant := range grants {
contextGrants[uuidString(grant.BinaryID.String())] = grant
}
}
}
contextCreds := map[uuidString]store.SecureCLIContextCredentials{}
if h.cliContextStore != nil {
for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) {
creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey)
if err != nil {
return nil, err
}
for _, cred := range creds {
contextCreds[uuidString(cred.BinaryID.String())] = cred
}
}
}
rows := make([]channelCapabilityDTO, 0, len(binaries))
seen := map[uuidString]bool{}
for _, b := range binaries {
idKey := uuidString(b.ID.String())
seen[idKey] = true
source := "agent"
if b.IsGlobal {
source = "global"
}
credentialSource := source
hasCredential := len(b.EncryptedEnv) > 0
contextGrant := false
if grant, ok := contextGrants[idKey]; ok {
source = grant.ScopeType
contextGrant = true
b.Enabled = b.Enabled && grant.Enabled
}
contextCredential := false
if cred, ok := contextCreds[idKey]; ok {
credentialSource = cred.ScopeType
hasCredential = len(cred.EncryptedEnv) > 0
contextCredential = true
}
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: contextGrant,
ContextCredentials: contextCredential,
})
}
for _, grant := range contextGrants {
idKey := uuidString(grant.BinaryID.String())
if seen[idKey] {
continue
}
binary, err := h.secureCLIStore.Get(r.Context(), grant.BinaryID)
if err != nil || binary == nil {
continue
}
_, hasCredRow := contextCreds[idKey]
credentialSource := grant.ScopeType
if cred, ok := contextCreds[idKey]; ok {
credentialSource = cred.ScopeType
}
rows = append(rows, channelCapabilityDTO{
Type: "secure_cli",
ID: binary.ID.String(),
Name: binary.BinaryName,
DisplayName: binary.Description,
Enabled: binary.Enabled && grant.Enabled,
Source: grant.ScopeType,
CredentialSource: credentialSource,
HasCredential: hasCredRow,
ContextGrant: true,
ContextCredentials: hasCredRow,
})
}
return rows, nil
}
type uuidString string
func decodeStringList(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var list []string
_ = json.Unmarshal(raw, &list)
return list
}