Merge remote-tracking branch 'origin/codex-issue-66-channel-details-admin-design' into codex/issue-64-passive-channel-memory-extraction

This commit is contained in:
Goon committed 2026-05-29 20:16:32 +07:00
commit 4c4d95ce49
45 files changed
+4689 -33

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 {
+15
View File
@@ -12,6 +12,7 @@ This matrix documents the effective authorization layers for agent actions acros
| Agent config permissions | Agent config mutations from chat | `agent_config_permissions` | Matches by `agent_id`, `scope`, `config_type`, `user_id`, including wildcard rows. |
| Workspace file boundary | Filesystem access | tool sandbox/boundary checks | Prevents path escape and unsupported writes. |
| Context file boundary | Agent identity/context files | `ContextFileInterceptor` | Routes protected files to store and requires group writer permission in group contexts. |
| Channel context capabilities | MCP + Secure CLI tool execution | `mcp_context_*`, `secure_cli_context_*` | Effective precedence: user credentials > context credentials/grants > agent grants > global defaults. |
## Agent Config Permission Rows
@@ -69,3 +70,17 @@ The Permissions tab should expose a full matrix editor:
- Permission-store errors fail closed for group mutation boundaries.
- Backend validation rejects unknown config types and permissions before writing rules.
- Platform send permissions are still separate from GoClaw permissions; a channel adapter may reject delivery even when GoClaw allows the agent action.
## Channel context capabilities
Channel instances expose stored contexts in the dashboard and API. The base
context is the channel instance itself; group contexts come from stored channel
contacts. Capability rows combine MCP and Secure CLI visibility for that
context, including source, enabled state, tool allow/deny lists, and credential
presence.
Context credential rows never return secret material. They only project
metadata such as `has_api_key`, `has_env`, `credential_source`, and key names
where available. Writes are tenant-admin gated, and runtime resolution carries
`ChannelContextScope` so grants and credentials are applied only to matching
channel/group scope.
+17
View File
@@ -4,6 +4,23 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-05-29
### Channel context admin surface (issue #66)
**New**
- Added channel context APIs under `/v1/channels/instances/{id}/contexts` for stored channel/group contexts, members, and effective MCP/Secure CLI capability matrix.
- Added tenant-scoped context grants and context credentials for MCP servers and Secure CLI binaries, with PostgreSQL migration `000074` and SQLite schema version `43`.
- Runtime tool execution now carries `ChannelContextScope`; MCP and Secure CLI resolution apply context grants/credentials before user credential overrides.
- Web dashboard channel detail page now has a `Contexts` tab showing contexts, stored members, effective granted tools, and scoped credential presence without exposing secret values.
**Security**
- Context capability write APIs use tenant-admin checks.
- Credential APIs return presence/metadata only; raw MCP API keys, headers/env values, and Secure CLI env values are never serialized.
- MCP grant-check cache key includes channel scope so scoped grants do not leak across channel contexts.
## 2026-05-28
### CLI credential adapter framework + git adapter (issue #82)
@@ -44,6 +44,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch
})
}
ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req))
// C2 fix: route through executeToolForActor so per-user MCP tools
// resolve to the calling user's BridgeTool (not the first user's
@@ -106,6 +107,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc
OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch
})
}
ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req))
// C2 fix (parallel path): route through executeToolForActor for per-user
// MCP tool isolation. Same rationale as makeExecuteToolCall above.
@@ -127,6 +129,22 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc
}
}
func channelContextScopeForRun(req *RunRequest) store.ChannelContextScope {
if req == nil || req.Channel == "" {
return store.ChannelContextScope{}
}
scope := store.ChannelContextScope{
ChannelInstanceName: req.Channel,
ScopeType: store.ChannelScopeTypeChannel,
ScopeKey: req.Channel,
}
if req.PeerKind == "group" && req.ChatID != "" {
scope.ScopeType = store.ChannelScopeTypeGroup
scope.ScopeKey = req.ChatID
}
return scope
}
// makeProcessToolResult wraps post-execution bookkeeping (sequential, mutates bridgeRS).
// rawData is *toolRawResult from ExecuteToolRaw — no re-execution.
func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message {
@@ -0,0 +1,300 @@
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
}
@@ -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: "tenant-user-1"},
})
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", 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)
}
}
@@ -0,0 +1,530 @@
package http
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
type channelContextPath struct {
Instance *store.ChannelInstanceData
ScopeType string
ScopeKey string
}
func (h *ChannelInstancesHandler) resolveContextPath(w http.ResponseWriter, r *http.Request) (channelContextPath, bool) {
inst, ok := h.resolveInstance(w, r)
if !ok {
return channelContextPath{}, false
}
scopeType := r.PathValue("scopeType")
scopeKey := r.PathValue("scopeKey")
if !validateHTTPChannelScope(scopeType, scopeKey) {
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope"))
return channelContextPath{}, false
}
return channelContextPath{Instance: inst, ScopeType: scopeType, ScopeKey: scopeKey}, true
}
func validateHTTPChannelScope(scopeType, scopeKey string) bool {
scope := store.ChannelContextScope{ChannelInstanceName: "placeholder", ScopeType: scopeType, ScopeKey: scopeKey}
return scope.Valid()
}
func (h *ChannelInstancesHandler) requireContextAdmin(w http.ResponseWriter, r *http.Request) bool {
return requireTenantAdmin(w, r, h.tenantStore)
}
type contextMCPGrantRequest struct {
Enabled *bool `json:"enabled,omitempty"`
ToolAllow json.RawMessage `json:"tool_allow,omitempty"`
ToolDeny json.RawMessage `json:"tool_deny,omitempty"`
ConfigOverrides json.RawMessage `json:"config_overrides,omitempty"`
}
func (h *ChannelInstancesHandler) handleListContextMCPGrants(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
grants, err := h.mcpContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey)
if err != nil {
slog.Error("channel_context.mcp_grants.list", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context grants"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
}
func (h *ChannelInstancesHandler) handleUpsertContextMCPGrant(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("serverID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server"))
return
}
var req contextMCPGrantRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
grant := &store.MCPContextGrant{
ChannelInstanceID: path.Instance.ID,
ScopeType: path.ScopeType,
ScopeKey: path.ScopeKey,
ServerID: serverID,
Enabled: enabled,
ToolAllow: req.ToolAllow,
ToolDeny: req.ToolDeny,
ConfigOverrides: req.ConfigOverrides,
GrantedBy: store.UserIDFromContext(r.Context()),
}
if err := h.mcpContextStore.UpsertContextGrant(r.Context(), grant); err != nil {
slog.Error("channel_context.mcp_grants.upsert", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.mcp_grant_upserted", "mcp_server", serverID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "updated"})
}
func (h *ChannelInstancesHandler) handleDeleteContextMCPGrant(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("serverID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server"))
return
}
if err := h.mcpContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil {
slog.Error("channel_context.mcp_grants.delete", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.mcp_grant_deleted", "mcp_server", serverID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"})
}
type contextMCPCredentialSummary struct {
ChannelInstanceID uuid.UUID `json:"channel_instance_id"`
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
ServerID uuid.UUID `json:"server_id"`
HasAPIKey bool `json:"has_api_key"`
HasHeaders bool `json:"has_headers"`
HasEnv bool `json:"has_env"`
CreatedBy string `json:"created_by"`
}
func summarizeMCPCredentials(creds store.MCPContextCredentials) contextMCPCredentialSummary {
return contextMCPCredentialSummary{
ChannelInstanceID: creds.ChannelInstanceID,
ScopeType: creds.ScopeType,
ScopeKey: creds.ScopeKey,
ServerID: creds.ServerID,
HasAPIKey: creds.APIKey != "",
HasHeaders: len(creds.Headers) > 0,
HasEnv: len(creds.Env) > 0,
CreatedBy: creds.CreatedBy,
}
}
func (h *ChannelInstancesHandler) handleListContextMCPCredentials(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey)
if err != nil {
slog.Error("channel_context.mcp_credentials.list", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context credentials"))
return
}
out := make([]contextMCPCredentialSummary, 0, len(creds))
for _, c := range creds {
out = append(out, summarizeMCPCredentials(c))
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": out})
}
func (h *ChannelInstancesHandler) handleSetContextMCPCredentials(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("serverID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server"))
return
}
var creds store.MCPContextCredentials
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&creds); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
creds.ChannelInstanceID = path.Instance.ID
creds.ScopeType = path.ScopeType
creds.ScopeKey = path.ScopeKey
creds.ServerID = serverID
creds.CreatedBy = store.UserIDFromContext(r.Context())
if err := h.mcpContextStore.SetContextCredentials(r.Context(), &creds); err != nil {
slog.Error("channel_context.mcp_credentials.set", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.mcp_credentials_set", "mcp_server", serverID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "updated"})
}
func (h *ChannelInstancesHandler) handleDeleteContextMCPCredentials(w http.ResponseWriter, r *http.Request) {
if h.mcpContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
serverID, err := uuid.Parse(r.PathValue("serverID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server"))
return
}
if err := h.mcpContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil {
slog.Error("channel_context.mcp_credentials.delete", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.mcp_credentials_deleted", "mcp_server", serverID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"})
}
type contextCLIGrantRequest struct {
Enabled *bool `json:"enabled,omitempty"`
EnvVars json.RawMessage `json:"env_vars,omitempty"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
Tips *string `json:"tips,omitempty"`
}
func (h *ChannelInstancesHandler) handleListContextCLIGrants(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
grants, err := h.cliContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey)
if err != nil {
slog.Error("channel_context.cli_grants.list", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context grants"))
return
}
for i := range grants {
populateContextGrantEnvFields(&grants[i])
}
writeJSON(w, http.StatusOK, map[string]any{"grants": grants})
}
func populateContextGrantEnvFields(g *store.SecureCLIContextGrant) {
if len(g.EncryptedEnv) == 0 {
g.EnvKeys = []string{}
g.Env = nil
g.EnvSet = false
return
}
g.EnvKeys = store.SecureCLIEnvKeys(g.EncryptedEnv)
g.Env = store.SanitizeSecureCLIEnvJSON(g.EncryptedEnv)
g.EnvSet = len(g.EnvKeys) > 0
}
func (h *ChannelInstancesHandler) handleUpsertContextCLIGrant(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
binaryID, err := uuid.Parse(r.PathValue("binaryID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential"))
return
}
var req contextCLIGrantRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
var envJSON []byte
if len(req.EnvVars) > 0 {
var ok bool
envJSON, ok = validateAndSerializeEnvVars(w, locale, req.EnvVars)
if !ok {
return
}
}
grant := &store.SecureCLIContextGrant{
ChannelInstanceID: path.Instance.ID,
ScopeType: path.ScopeType,
ScopeKey: path.ScopeKey,
BinaryID: binaryID,
DenyArgs: req.DenyArgs,
DenyVerbose: req.DenyVerbose,
TimeoutSeconds: req.TimeoutSeconds,
Tips: req.Tips,
EncryptedEnv: envJSON,
Enabled: enabled,
GrantedBy: store.UserIDFromContext(r.Context()),
}
if err := h.cliContextStore.UpsertContextGrant(r.Context(), grant); err != nil {
slog.Error("channel_context.cli_grants.upsert", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.cli_grant_upserted", "secure_cli", binaryID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "updated"})
}
func (h *ChannelInstancesHandler) handleDeleteContextCLIGrant(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
binaryID, err := uuid.Parse(r.PathValue("binaryID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential"))
return
}
if err := h.cliContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil {
slog.Error("channel_context.cli_grants.delete", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.cli_grant_deleted", "secure_cli", binaryID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"})
}
type contextCLICredentialRequest struct {
EnvVars json.RawMessage `json:"env_vars"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CredentialType *string `json:"credential_type,omitempty"`
HostScope *string `json:"host_scope,omitempty"`
}
type contextCLICredentialSummary struct {
ChannelInstanceID uuid.UUID `json:"channel_instance_id"`
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
BinaryID uuid.UUID `json:"binary_id"`
EnvSet bool `json:"env_set"`
EnvKeys []string `json:"env_keys"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CredentialType *string `json:"credential_type,omitempty"`
HostScope *string `json:"host_scope,omitempty"`
CreatedBy string `json:"created_by"`
}
func summarizeCLICredentials(creds store.SecureCLIContextCredentials) contextCLICredentialSummary {
keys := store.SecureCLIEnvKeys(creds.EncryptedEnv)
return contextCLICredentialSummary{
ChannelInstanceID: creds.ChannelInstanceID,
ScopeType: creds.ScopeType,
ScopeKey: creds.ScopeKey,
BinaryID: creds.BinaryID,
EnvSet: len(keys) > 0,
EnvKeys: keys,
Metadata: creds.Metadata,
CredentialType: creds.CredentialType,
HostScope: creds.HostScope,
CreatedBy: creds.CreatedBy,
}
}
func (h *ChannelInstancesHandler) handleListContextCLICredentials(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
creds, err := h.cliContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey)
if err != nil {
slog.Error("channel_context.cli_credentials.list", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context credentials"))
return
}
out := make([]contextCLICredentialSummary, 0, len(creds))
for _, c := range creds {
out = append(out, summarizeCLICredentials(c))
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": out})
}
func (h *ChannelInstancesHandler) handleSetContextCLICredentials(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
binaryID, err := uuid.Parse(r.PathValue("binaryID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential"))
return
}
var req contextCLICredentialRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
return
}
if len(req.EnvVars) == 0 {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "env_vars"))
return
}
envJSON, ok := validateAndSerializeEnvVars(w, locale, req.EnvVars)
if !ok {
return
}
creds := &store.SecureCLIContextCredentials{
ChannelInstanceID: path.Instance.ID,
ScopeType: path.ScopeType,
ScopeKey: path.ScopeKey,
BinaryID: binaryID,
EncryptedEnv: envJSON,
Metadata: req.Metadata,
CredentialType: req.CredentialType,
HostScope: req.HostScope,
CreatedBy: store.UserIDFromContext(r.Context()),
}
if err := h.cliContextStore.SetContextCredentials(r.Context(), creds); err != nil {
slog.Error("channel_context.cli_credentials.set", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.cli_credentials_set", "secure_cli", binaryID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "updated"})
}
func (h *ChannelInstancesHandler) handleDeleteContextCLICredentials(w http.ResponseWriter, r *http.Request) {
if h.cliContextStore == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"})
return
}
if !h.requireContextAdmin(w, r) {
return
}
path, ok := h.resolveContextPath(w, r)
if !ok {
return
}
locale := store.LocaleFromContext(r.Context())
binaryID, err := uuid.Parse(r.PathValue("binaryID"))
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential"))
return
}
if err := h.cliContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil {
slog.Error("channel_context.cli_credentials.delete", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error())
return
}
emitAudit(h.msgBus, r, "channel_context.cli_credentials_deleted", "secure_cli", binaryID.String())
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"})
}
+215
View File
@@ -0,0 +1,215 @@
package http
import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
type channelContextDTO struct {
ChannelInstanceID string `json:"channel_instance_id"`
ChannelType string `json:"channel_type"`
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
DisplayName string `json:"display_name"`
Source string `json:"source"`
PeerKind string `json:"peer_kind,omitempty"`
ContactType string `json:"contact_type,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
MemberCount int `json:"member_count,omitempty"`
LiveMembers bool `json:"live_members_supported"`
}
type channelContextMemberDTO struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name,omitempty"`
Username string `json:"username,omitempty"`
PlatformID string `json:"platform_id"`
Source string `json:"source"`
Status string `json:"membership_status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
func (h *ChannelInstancesHandler) handleListContexts(w http.ResponseWriter, r *http.Request) {
inst, ok := h.resolveInstance(w, r)
if !ok {
return
}
if h.contactStore == nil {
writeJSON(w, http.StatusOK, map[string]any{
"contexts": []channelContextDTO{baseChannelContextDTO(*inst)},
})
return
}
opts := store.ContactListOpts{
ChannelType: inst.ChannelType,
ChannelInstance: inst.Name,
ContactType: "group",
Limit: 200,
}
contacts, err := h.contactStore.ListContacts(r.Context(), opts)
if err != nil {
slog.Error("channel_instances.contexts", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel contexts"))
return
}
contexts := []channelContextDTO{baseChannelContextDTO(*inst)}
for _, c := range contacts {
ctxDTO := contextDTOFromContact(inst.ID, c)
contexts = append(contexts, ctxDTO)
}
writeJSON(w, http.StatusOK, map[string]any{"contexts": contexts})
}
func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter, r *http.Request) {
inst, ok := h.resolveInstance(w, r)
if !ok {
return
}
scopeType := strings.TrimSpace(r.PathValue("scopeType"))
scopeKey := strings.TrimSpace(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
}
var liveErr string
members := make([]channelContextMemberDTO, 0)
if h.channelMgr != nil && scopeType == store.ChannelScopeTypeGroup {
if live, err := h.channelMgr.ListGroupMembers(r.Context(), inst.Name, scopeKey); err == nil {
for _, m := range live {
members = append(members, channelContextMemberDTO{
UserID: m.MemberID,
DisplayName: m.Name,
PlatformID: m.MemberID,
Source: "live_provider",
Status: "visible",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"members": members,
"live_members_supported": true,
"source": "live_provider",
})
return
} else {
liveErr = err.Error()
}
}
if h.contactStore != nil && scopeType != store.ChannelScopeTypeGroup && scopeType != "topic" {
contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{
ChannelType: inst.ChannelType,
ChannelInstance: inst.Name,
ContactType: "user",
PeerKind: peerKindForScope(scopeType),
Limit: 200,
})
if err != nil {
slog.Error("channel_instances.context_members", "error", err)
locale := store.LocaleFromContext(r.Context())
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel members"))
return
}
for _, c := range contacts {
members = append(members, memberDTOFromContact(c))
}
}
writeJSON(w, http.StatusOK, map[string]any{
"members": members,
"live_members_supported": false,
"source": "stored_contacts",
"unsupported_reason": liveErr,
})
}
func baseChannelContextDTO(inst store.ChannelInstanceData) channelContextDTO {
name := inst.DisplayName
if name == "" {
name = inst.Name
}
return channelContextDTO{
ChannelInstanceID: inst.ID.String(),
ChannelType: inst.ChannelType,
ScopeType: store.ChannelScopeTypeChannel,
ScopeKey: inst.Name,
DisplayName: name,
Source: "channel_instance",
PeerKind: "direct",
ContactType: "channel",
LiveMembers: false,
}
}
func contextDTOFromContact(instanceID uuid.UUID, c store.ChannelContact) channelContextDTO {
scopeType := store.ChannelScopeTypeGroup
if c.ContactType == "topic" {
scopeType = "topic"
}
displayName := c.SenderID
if c.DisplayName != nil && *c.DisplayName != "" {
displayName = *c.DisplayName
}
peerKind := ""
if c.PeerKind != nil {
peerKind = *c.PeerKind
}
lastSeen := c.LastSeenAt
return channelContextDTO{
ChannelInstanceID: instanceID.String(),
ChannelType: c.ChannelType,
ScopeType: scopeType,
ScopeKey: c.SenderID,
DisplayName: displayName,
Source: "stored_contact",
PeerKind: peerKind,
ContactType: c.ContactType,
LastSeenAt: &lastSeen,
}
}
func memberDTOFromContact(c store.ChannelContact) channelContextMemberDTO {
displayName := ""
if c.DisplayName != nil {
displayName = *c.DisplayName
}
username := ""
if c.Username != nil {
username = *c.Username
}
userID := c.SenderID
if c.UserID != nil && *c.UserID != "" {
userID = *c.UserID
}
lastSeen := c.LastSeenAt
return channelContextMemberDTO{
UserID: userID,
DisplayName: displayName,
Username: username,
PlatformID: c.SenderID,
Source: "stored_contact",
Status: "visible",
LastSeenAt: &lastSeen,
}
}
func peerKindForScope(scopeType string) string {
if scopeType == store.ChannelScopeTypeGroup || scopeType == "topic" {
return "group"
}
return "direct"
}
@@ -0,0 +1,189 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type fakeChannelContextContactStore struct {
contacts []store.ChannelContact
seenOpts []store.ContactListOpts
}
func (s *fakeChannelContextContactStore) UpsertContact(context.Context, string, string, string, string, string, string, string, string, string, string) error {
return nil
}
func (s *fakeChannelContextContactStore) ListContacts(_ context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
s.seenOpts = append(s.seenOpts, opts)
var out []store.ChannelContact
for _, c := range s.contacts {
if opts.ChannelType != "" && c.ChannelType != opts.ChannelType {
continue
}
if opts.ChannelInstance != "" && (c.ChannelInstance == nil || *c.ChannelInstance != opts.ChannelInstance) {
continue
}
if opts.ContactType != "" && c.ContactType != opts.ContactType {
continue
}
if opts.PeerKind != "" && (c.PeerKind == nil || *c.PeerKind != opts.PeerKind) {
continue
}
out = append(out, c)
}
return out, nil
}
func (s *fakeChannelContextContactStore) CountContacts(context.Context, store.ContactListOpts) (int, error) {
return len(s.contacts), nil
}
func (s *fakeChannelContextContactStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) {
return map[string]store.ChannelContact{}, nil
}
func (s *fakeChannelContextContactStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) {
return nil, nil
}
func (s *fakeChannelContextContactStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) {
return nil, nil
}
func (s *fakeChannelContextContactStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error {
return nil
}
func (s *fakeChannelContextContactStore) UnmergeContacts(context.Context, []uuid.UUID) error {
return nil
}
func (s *fakeChannelContextContactStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) {
return nil, nil
}
func (s *fakeChannelContextContactStore) ResolveTenantUserID(context.Context, string, string) (string, error) {
return "", nil
}
func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) {
token := "channel-contexts-key"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"},
})
instID := uuid.New()
channelName := "discord-admin"
displayName := "Ops Discord"
peerKind := "group"
lastSeen := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{
ID: uuid.New(),
ChannelType: "discord",
ChannelInstance: &channelName,
SenderID: "guild-123/channel-456",
DisplayName: &displayName,
PeerKind: &peerKind,
ContactType: "group",
LastSeenAt: lastSeen,
}}}
handler := NewChannelInstancesHandler(
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, DisplayName: "Discord Admin", ChannelType: "discord", AgentID: uuid.New()}},
nil, nil, contacts, nil, nil,
)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts", 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())
}
var body struct {
Contexts []channelContextDTO `json:"contexts"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Contexts) != 2 {
t.Fatalf("contexts len = %d, want 2: %+v", len(body.Contexts), body.Contexts)
}
if body.Contexts[0].ScopeType != store.ChannelScopeTypeChannel || body.Contexts[0].ScopeKey != channelName {
t.Fatalf("base context = %+v", body.Contexts[0])
}
gotGroup := body.Contexts[1]
if gotGroup.ScopeType != store.ChannelScopeTypeGroup || gotGroup.ScopeKey != "guild-123/channel-456" || gotGroup.Source != "stored_contact" {
t.Fatalf("group context = %+v", gotGroup)
}
if len(contacts.seenOpts) == 0 || contacts.seenOpts[0].ChannelInstance != channelName {
t.Fatalf("expected channel_instance filter %q, got %+v", channelName, contacts.seenOpts)
}
}
func TestChannelContextMembersDoNotShowChannelWideContactsAsGroupMembers(t *testing.T) {
token := "channel-context-members-key"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"},
})
instID := uuid.New()
channelName := "telegram-main"
peerKind := "group"
displayName := "Duy"
username := "duy"
userID := "tenant-user-1"
contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{
ID: uuid.New(),
ChannelType: "telegram",
ChannelInstance: &channelName,
SenderID: "386246614",
UserID: &userID,
DisplayName: &displayName,
Username: &username,
PeerKind: &peerKind,
ContactType: "user",
LastSeenAt: time.Date(2026, 5, 29, 10, 5, 0, 0, time.UTC),
}}}
handler := NewChannelInstancesHandler(
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "telegram", AgentID: uuid.New()}},
nil, nil, contacts, nil, nil,
)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/group/group:telegram:-100123/members", 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())
}
var body struct {
Members []channelContextMemberDTO `json:"members"`
LiveMembersSupported bool `json:"live_members_supported"`
Source string `json:"source"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.LiveMembersSupported || body.Source != "stored_contacts" {
t.Fatalf("expected unsupported stored contact response, got %+v", body)
}
if len(body.Members) != 0 {
t.Fatalf("members = %+v", body.Members)
}
}
+38
View File
@@ -39,6 +39,10 @@ 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
mcpContextStore store.MCPContextAdminStore
cliContextStore store.SecureCLIContextAdminStore
// 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 +68,19 @@ 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
if contextStore, ok := mcpStore.(store.MCPContextAdminStore); ok {
h.mcpContextStore = contextStore
}
if contextStore, ok := secureCLIStore.(store.SecureCLIContextAdminStore); ok {
h.cliContextStore = contextStore
}
}
// 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
@@ -109,6 +126,24 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.adminAuth(h.handleAddWriter))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.adminAuth(h.handleRemoveWriter))
}
if h.contactStore != nil {
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))
mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants", h.adminAuth(h.handleListContextMCPGrants))
mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleUpsertContextMCPGrant))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleDeleteContextMCPGrant))
mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials", h.adminAuth(h.handleListContextMCPCredentials))
mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleSetContextMCPCredentials))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleDeleteContextMCPCredentials))
mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants", h.adminAuth(h.handleListContextCLIGrants))
mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleUpsertContextCLIGrant))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleDeleteContextCLIGrant))
mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials", h.adminAuth(h.handleListContextCLICredentials))
mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleSetContextCLICredentials))
mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleDeleteContextCLICredentials))
}
func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -631,6 +666,9 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h
if v := r.URL.Query().Get("channel_type"); v != "" {
opts.ChannelType = v
}
if v := r.URL.Query().Get("channel_instance"); v != "" {
opts.ChannelInstance = v
}
if v := r.URL.Query().Get("peer_kind"); v != "" {
opts.PeerKind = v
}
+10 -2
View File
@@ -26,8 +26,11 @@ type GrantChecker interface {
// cacheKey identifies a unique agent+user combination for caching.
type cacheKey struct {
agentID uuid.UUID
userID string
agentID uuid.UUID
userID string
channelInstanceName string
scopeType string
scopeKey string
}
// cacheEntry holds the resolved access info for an agent+user.
@@ -75,6 +78,11 @@ func (gc *storeGrantChecker) IsAllowed(ctx context.Context, agentID uuid.UUID, u
}
key := cacheKey{agentID: agentID, userID: userID}
if scope, ok := store.ChannelContextScopeFromContext(ctx); ok {
key.channelInstanceName = scope.ChannelInstanceName
key.scopeType = scope.ScopeType
key.scopeKey = scope.ScopeKey
}
// Try cache first
if cached, ok := gc.cache.Load(key); ok {
+43 -14
View File
@@ -62,7 +62,7 @@ type connParams struct {
type serverState struct {
name string
transport string
client *mcpclient.Client // direct ref for health checks (single-goroutine access)
client *mcpclient.Client // direct ref for health checks (single-goroutine access)
clientPtr atomic.Pointer[mcpclient.Client] // shared atomic ref for BridgeTools (multi-goroutine safe)
connected atomic.Bool
toolNames []string // registered tool names in the registry
@@ -70,10 +70,10 @@ type serverState struct {
cancel context.CancelFunc
conn connParams // connection params for reconnect
mu sync.Mutex
reconnAttempts int
healthFailures int // consecutive ping failures (resets on success)
lastErr string
mu sync.Mutex
reconnAttempts int
healthFailures int // consecutive ping failures (resets on success)
lastErr string
// reconnPending is set when a BridgeTool detects the server reset its
// session lifecycle (FastMCP-style "tools/call invalid during session
@@ -111,13 +111,13 @@ type Manager struct {
// Shared connection pool (nil = config-only mode)
pool *Pool
poolServers map[string]struct{} // server names acquired from pool (for cleanup)
poolToolNames map[string][]string // per-agent tool names for pool-backed servers
poolServers map[string]struct{} // server names acquired from pool (for cleanup)
poolToolNames map[string][]string // per-agent tool names for pool-backed servers
poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release
// Search mode: deferred tools not registered in registry
deferredTools map[string]*BridgeTool // registeredName → BridgeTool
activatedTools map[string]struct{} // tracks activated tool names for group:mcp
activatedTools map[string]struct{} // tracks activated tool names for group:mcp
searchMode bool
// User-credential servers: servers requiring per-user credentials, stored during
@@ -224,13 +224,23 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
return nil
}
// Skip server if it requires per-user credentials and user has none
var contextCreds *store.MCPContextCredentials
if contextStore, ok := m.store.(store.MCPContextAdminStore); ok {
for _, scope := range store.ChannelContextScopeChainFromContext(ctx) {
if creds, _ := contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID); creds != nil {
contextCreds = creds
}
}
}
// Skip server if it requires scoped/user credentials and none are present.
if requireUserCreds(srv.Settings) {
if userID == "" {
return nil
}
uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID)
if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) {
hasContextCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0)
if !hasContextCreds && (uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0)) {
slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID)
return nil
}
@@ -252,6 +262,27 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
headers["Authorization"] = "Bearer " + srv.APIKey
}
if contextCreds != nil {
if contextCreds.APIKey != "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + contextCreds.APIKey
}
for k, v := range contextCreds.Headers {
if headers == nil {
headers = make(map[string]string)
}
headers[k] = v
}
for k, v := range contextCreds.Env {
if env == nil {
env = make(map[string]string)
}
env[k] = v
}
}
// Merge per-user credentials (user overrides server defaults)
if userID != "" && m.store != nil {
if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil {
@@ -278,12 +309,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
// Per-user credentials change connection params → can't share pool connection.
// Fall back to per-agent mode when user has custom credentials.
hasUserCreds := userID != "" && m.store != nil
if hasUserCreds {
hasUserCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0)
if userID != "" && m.store != nil {
if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) {
hasUserCreds = true
} else {
hasUserCreds = false
}
}
+169
View File
@@ -0,0 +1,169 @@
package store
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
const (
ChannelScopeTypeChannel = "channel"
ChannelScopeTypeGroup = "group"
ChannelScopeTypeUser = "user"
ChannelScopeTypeRole = "role"
)
// ChannelContextScope identifies the channel-level context used for scoped
// grants and credentials. ChannelInstanceName lets runtime code resolve scope
// without a channel_instances lookup at the call site.
type ChannelContextScope struct {
ChannelInstanceID uuid.UUID `json:"channel_instance_id,omitempty"`
ChannelInstanceName string `json:"channel_instance_name,omitempty"`
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
}
func (s ChannelContextScope) Valid() bool {
if s.ScopeType == "" {
return false
}
if s.ChannelInstanceID == uuid.Nil && s.ChannelInstanceName == "" {
return false
}
return s.ScopeType == ChannelScopeTypeChannel || s.ScopeKey != ""
}
func WithChannelContextScope(ctx context.Context, scope ChannelContextScope) context.Context {
return context.WithValue(ctx, ChannelContextScopeKey, scope)
}
func ChannelContextScopeFromContext(ctx context.Context) (ChannelContextScope, bool) {
if v, ok := ctx.Value(ChannelContextScopeKey).(ChannelContextScope); ok && v.Valid() {
return v, true
}
if rc := RunContextFromCtx(ctx); rc != nil && rc.ChannelContextScope.Valid() {
return rc.ChannelContextScope, true
}
return ChannelContextScope{}, false
}
// ChannelContextScopeChainFromContext returns context scopes ordered from
// broadest to most specific. Applying the chain in order gives channel
// defaults first, then group/user/role overrides.
func ChannelContextScopeChainFromContext(ctx context.Context) []ChannelContextScope {
scope, ok := ChannelContextScopeFromContext(ctx)
if !ok {
return nil
}
if scope.ScopeType == ChannelScopeTypeChannel {
return []ChannelContextScope{scope}
}
channelKey := scope.ChannelInstanceName
if channelKey == "" && scope.ScopeType == ChannelScopeTypeChannel {
channelKey = scope.ScopeKey
}
if channelKey == "" {
return []ChannelContextScope{scope}
}
channelScope := ChannelContextScope{
ChannelInstanceID: scope.ChannelInstanceID,
ChannelInstanceName: scope.ChannelInstanceName,
ScopeType: ChannelScopeTypeChannel,
ScopeKey: channelKey,
}
return []ChannelContextScope{channelScope, scope}
}
type MCPContextGrant struct {
ID uuid.UUID `json:"id" db:"id"`
ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"`
ScopeType string `json:"scope_type" db:"scope_type"`
ScopeKey string `json:"scope_key" db:"scope_key"`
ServerID uuid.UUID `json:"server_id" db:"server_id"`
Enabled bool `json:"enabled" db:"enabled"`
ToolAllow json.RawMessage `json:"tool_allow,omitempty" db:"tool_allow"`
ToolDeny json.RawMessage `json:"tool_deny,omitempty" db:"tool_deny"`
ConfigOverrides json.RawMessage `json:"config_overrides,omitempty" db:"config_overrides"`
GrantedBy string `json:"granted_by" db:"granted_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type MCPContextCredentials struct {
ID uuid.UUID `json:"id" db:"id"`
ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"`
ScopeType string `json:"scope_type" db:"scope_type"`
ScopeKey string `json:"scope_key" db:"scope_key"`
ServerID uuid.UUID `json:"server_id" db:"server_id"`
APIKey string `json:"api_key,omitempty" db:"-"`
Headers map[string]string `json:"headers,omitempty" db:"-"`
Env map[string]string `json:"env,omitempty" db:"-"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type SecureCLIContextGrant struct {
ID uuid.UUID `json:"id" db:"id"`
ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"`
ScopeType string `json:"scope_type" db:"scope_type"`
ScopeKey string `json:"scope_key" db:"scope_key"`
BinaryID uuid.UUID `json:"binary_id" db:"binary_id"`
Enabled bool `json:"enabled" db:"enabled"`
DenyArgs *json.RawMessage `json:"deny_args,omitempty" db:"deny_args"`
DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty" db:"deny_verbose"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty" db:"timeout_seconds"`
Tips *string `json:"tips,omitempty" db:"tips"`
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
EnvKeys []string `json:"env_keys,omitempty" db:"-"`
Env map[string]SecureCLIEnvResponseEntry `json:"env,omitempty" db:"-"`
EnvSet bool `json:"env_set" db:"-"`
GrantedBy string `json:"granted_by" db:"granted_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type SecureCLIContextCredentials struct {
ID uuid.UUID `json:"id" db:"id"`
ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"`
ScopeType string `json:"scope_type" db:"scope_type"`
ScopeKey string `json:"scope_key" db:"scope_key"`
BinaryID uuid.UUID `json:"binary_id" db:"binary_id"`
EncryptedEnv []byte `json:"-" db:"encrypted_env"`
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
CredentialType *string `json:"credential_type,omitempty" db:"credential_type"`
HostScope *string `json:"host_scope,omitempty" db:"host_scope"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type MCPContextAdminStore interface {
UpsertContextGrant(ctx context.Context, grant *MCPContextGrant) error
DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error
ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextGrant, error)
ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextGrant, error)
SetContextCredentials(ctx context.Context, creds *MCPContextCredentials) error
GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*MCPContextCredentials, error)
GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, serverID uuid.UUID) (*MCPContextCredentials, error)
DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error
ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextCredentials, error)
ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextCredentials, error)
}
type SecureCLIContextAdminStore interface {
UpsertContextGrant(ctx context.Context, grant *SecureCLIContextGrant) error
DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error
ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextGrant, error)
ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextGrant, error)
SetContextCredentials(ctx context.Context, creds *SecureCLIContextCredentials) error
GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*SecureCLIContextCredentials, error)
GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, binaryID uuid.UUID) (*SecureCLIContextCredentials, error)
DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error
ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextCredentials, error)
ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextCredentials, error)
}
+7 -6
View File
@@ -29,12 +29,13 @@ type ChannelContact struct {
// ContactListOpts holds pagination and filter options for listing contacts.
type ContactListOpts struct {
Search string // ILIKE on display_name, username, sender_id
ChannelType string // filter by platform (telegram, discord, etc.)
PeerKind string // "direct" or "group"
ContactType string // "user" or "group"
Limit int
Offset int
Search string // ILIKE on display_name, username, sender_id
ChannelType string // filter by platform (telegram, discord, etc.)
ChannelInstance string // filter by channel instance name
PeerKind string // "direct" or "group"
ContactType string // "user" or "group"
Limit int
Offset int
}
// ContactStore manages channel contacts (auto-collected user info).
+2
View File
@@ -50,6 +50,8 @@ const (
CredentialUserIDKey contextKey = "goclaw_credential_user_id"
// SenderNameKey is the display name from channel metadata (for bootstrap auto-contact).
SenderNameKey contextKey = "goclaw_sender_name"
// ChannelContextScopeKey carries the channel/group/user scope for runtime grants and credentials.
ChannelContextScopeKey contextKey = "goclaw_channel_context_scope"
// AgentAudioKey carries the immutable agent audio snapshot for TTS tool dispatch.
AgentAudioKey contextKey = "goclaw_agent_audio"
)
+5 -1
View File
@@ -11,7 +11,6 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGContactStore implements store.ContactStore backed by Postgres.
type PGContactStore struct {
db *sql.DB
@@ -64,6 +63,11 @@ func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string
args = append(args, opts.ChannelType)
argIdx++
}
if opts.ChannelInstance != "" {
conditions = append(conditions, fmt.Sprintf("channel_instance = $%d", argIdx))
args = append(args, opts.ChannelInstance)
argIdx++
}
if opts.PeerKind != "" {
conditions = append(conditions, fmt.Sprintf("peer_kind = $%d", argIdx))
args = append(args, opts.PeerKind)
@@ -0,0 +1,516 @@
package pg
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) {
if scope.ChannelInstanceID != uuid.Nil {
return scope.ChannelInstanceID, nil
}
if scope.ChannelInstanceName == "" {
return uuid.Nil, sql.ErrNoRows
}
var id uuid.UUID
err := db.QueryRowContext(ctx,
`SELECT id FROM channel_instances WHERE tenant_id = $1 AND name = $2`,
tenantIDForInsert(ctx), scope.ChannelInstanceName,
).Scan(&id)
return id, err
}
func validateChannelScope(scopeType, scopeKey string) error {
switch scopeType {
case store.ChannelScopeTypeChannel:
return nil
case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole:
if scopeKey == "" {
return fmt.Errorf("scope_key required for %s scope", scopeType)
}
return nil
default:
return fmt.Errorf("invalid scope_type: %s", scopeType)
}
}
func rawPtrOrNull(raw *json.RawMessage) any {
if raw == nil || len(*raw) == 0 {
return nil
}
return *raw
}
func jsonOrEmptyObject(raw json.RawMessage) any {
if len(raw) == 0 {
return json.RawMessage(`{}`)
}
return raw
}
func (s *PGMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error {
if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
now := time.Now()
g.CreatedAt = now
g.UpdatedAt = now
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_context_grants
(id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled,
tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id)
DO UPDATE SET enabled = EXCLUDED.enabled,
tool_allow = EXCLUDED.tool_allow,
tool_deny = EXCLUDED.tool_deny,
config_overrides = EXCLUDED.config_overrides,
granted_by = EXCLUDED.granted_by,
updated_at = EXCLUDED.updated_at`,
g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
g.GrantedBy, now, now,
)
return err
}
func (s *PGMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_context_grants
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
return err
}
func (s *PGMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled,
COALESCE(tool_allow, 'null'::jsonb), COALESCE(tool_deny, 'null'::jsonb),
COALESCE(config_overrides, 'null'::jsonb), granted_by, created_at, updated_at
FROM mcp_context_grants
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPContextGrant, 0)
for rows.Next() {
var g store.MCPContextGrant
if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled,
&g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil {
return nil, err
}
result = append(result, g)
}
return result, rows.Err()
}
func (s *PGMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *PGMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error {
if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(creds.CreatedBy); err != nil {
return err
}
if creds.ID == uuid.Nil {
creds.ID = store.GenNewID()
}
var apiKey sql.NullString
if creds.APIKey != "" && s.encKey != "" {
enc, err := crypto.Encrypt(creds.APIKey, s.encKey)
if err != nil {
return fmt.Errorf("encrypt mcp context api_key: %w", err)
}
apiKey = sql.NullString{String: enc, Valid: true}
} else if creds.APIKey != "" {
apiKey = sql.NullString{String: creds.APIKey, Valid: true}
}
var headersEnc, envEnc []byte
if len(creds.Headers) > 0 {
raw, _ := json.Marshal(creds.Headers)
headersEnc = s.encryptJSONB(raw)
}
if len(creds.Env) > 0 {
raw, _ := json.Marshal(creds.Env)
envEnc = s.encryptJSONB(raw)
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_context_credentials
(id, tenant_id, channel_instance_id, scope_type, scope_key, server_id,
api_key, headers, env, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id)
DO UPDATE SET api_key = EXCLUDED.api_key,
headers = EXCLUDED.headers,
env = EXCLUDED.env,
created_by = EXCLUDED.created_by,
updated_at = EXCLUDED.updated_at`,
creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID,
apiKey, headersEnc, envEnc, creds.CreatedBy, now, now,
)
return err
}
func (s *PGMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at
FROM mcp_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
creds, err := s.scanContextCredentials(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return creds, err
}
func (s *PGMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID)
}
func (s *PGMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
return err
}
func (s *PGMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at
FROM mcp_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPContextCredentials, 0)
for rows.Next() {
creds, err := s.scanContextCredentials(rows)
if err != nil {
return nil, err
}
result = append(result, *creds)
}
return result, rows.Err()
}
func (s *PGMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey)
}
type contextCredentialScanner interface {
Scan(dest ...any) error
}
func (s *PGMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) {
var creds store.MCPContextCredentials
var apiKey sql.NullString
var headersEnc, envEnc []byte
if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID,
&apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil {
return nil, err
}
if apiKey.Valid && apiKey.String != "" && s.encKey != "" {
if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil {
creds.APIKey = dec
}
} else if apiKey.Valid {
creds.APIKey = apiKey.String
}
if len(headersEnc) > 0 {
_ = json.Unmarshal(s.decryptJSONB(headersEnc), &creds.Headers)
}
if len(envEnc) > 0 {
_ = json.Unmarshal(s.decryptJSONB(envEnc), &creds.Env)
}
return &creds, nil
}
func (s *PGSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error {
if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
var envBytes []byte
if len(g.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt context grant env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = g.EncryptedEnv
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_context_grants
(id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id,
deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled,
granted_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
DO UPDATE SET deny_args = EXCLUDED.deny_args,
deny_verbose = EXCLUDED.deny_verbose,
timeout_seconds = EXCLUDED.timeout_seconds,
tips = EXCLUDED.tips,
encrypted_env = EXCLUDED.encrypted_env,
enabled = EXCLUDED.enabled,
granted_by = EXCLUDED.granted_by,
updated_at = EXCLUDED.updated_at`,
g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID,
rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled,
g.GrantedBy, now, now,
)
return err
}
func (s *PGSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM secure_cli_context_grants
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
return err
}
func (s *PGSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id,
deny_args, deny_verbose, timeout_seconds, tips, encrypted_env,
enabled, granted_by, created_at, updated_at
FROM secure_cli_context_grants
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.SecureCLIContextGrant, 0)
for rows.Next() {
g, err := s.scanSecureCLIContextGrant(rows)
if err != nil {
return nil, err
}
result = append(result, *g)
}
return result, rows.Err()
}
func (s *PGSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *PGSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) {
var g store.SecureCLIContextGrant
var denyArgs, denyVerbose *[]byte
if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID,
&denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled,
&g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil {
return nil, err
}
if denyArgs != nil {
raw := json.RawMessage(*denyArgs)
g.DenyArgs = &raw
}
if denyVerbose != nil {
raw := json.RawMessage(*denyVerbose)
g.DenyVerbose = &raw
}
if len(g.EncryptedEnv) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil {
g.EncryptedEnv = []byte(decrypted)
}
}
g.EnvSet = len(g.EncryptedEnv) > 0
return &g, nil
}
func (s *PGSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error {
if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(creds.CreatedBy); err != nil {
return err
}
if creds.ID == uuid.Nil {
creds.ID = store.GenNewID()
}
var envBytes []byte
if len(creds.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt context credential env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = creds.EncryptedEnv
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_context_credentials
(id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id,
encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
DO UPDATE SET encrypted_env = EXCLUDED.encrypted_env,
metadata = EXCLUDED.metadata,
credential_type = EXCLUDED.credential_type,
host_scope = EXCLUDED.host_scope,
created_by = EXCLUDED.created_by,
updated_at = EXCLUDED.updated_at`,
creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID,
envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now,
)
return err
}
func (s *PGSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env,
COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at
FROM secure_cli_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
creds, err := s.scanSecureCLIContextCredentials(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return creds, err
}
func (s *PGSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID)
}
func (s *PGSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM secure_cli_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
return err
}
func (s *PGSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env,
COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at
FROM secure_cli_context_credentials
WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.SecureCLIContextCredentials, 0)
for rows.Next() {
creds, err := s.scanSecureCLIContextCredentials(rows)
if err != nil {
return nil, err
}
result = append(result, *creds)
}
return result, rows.Err()
}
func (s *PGSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *PGSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) {
var creds store.SecureCLIContextCredentials
if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID,
&creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope,
&creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil {
return nil, err
}
if len(creds.EncryptedEnv) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil {
creds.EncryptedEnv = []byte(decrypted)
}
}
return &creds, nil
}
+67 -2
View File
@@ -173,7 +173,11 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID
if err != nil {
return nil, err
}
return s.scanAccessibleRows(rows)
result, err := s.scanAccessibleRows(rows)
if err != nil {
return nil, err
}
return s.applyContextMCPAccess(ctx, result)
}
tClause, tArgs, _, err = scopeClauseAlias(ctx, 3, "ms")
if err != nil {
@@ -192,7 +196,68 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID
if err != nil {
return nil, err
}
return s.scanAccessibleRows(rows)
result, err := s.scanAccessibleRows(rows)
if err != nil {
return nil, err
}
return s.applyContextMCPAccess(ctx, result)
}
func (s *PGMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) {
scopes := store.ChannelContextScopeChainFromContext(ctx)
if len(scopes) == 0 {
return result, nil
}
for _, scope := range scopes {
grants, err := s.ListContextGrantsForScope(ctx, scope)
if err != nil {
return nil, err
}
if len(grants) == 0 {
continue
}
byServer := make(map[uuid.UUID]int, len(result))
for i := range result {
byServer[result[i].Server.ID] = i
}
for _, grant := range grants {
if idx, exists := byServer[grant.ServerID]; exists {
if !grant.Enabled {
result = append(result[:idx], result[idx+1:]...)
byServer = make(map[uuid.UUID]int, len(result))
for i := range result {
byServer[result[i].Server.ID] = i
}
continue
}
result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow)
result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny)
continue
}
if !grant.Enabled {
continue
}
server, err := s.GetServer(ctx, grant.ServerID)
if err != nil || server == nil || !server.Enabled {
continue
}
result = append(result, store.MCPAccessInfo{
Server: *server,
ToolAllow: decodeGrantStringList(grant.ToolAllow),
ToolDeny: decodeGrantStringList(grant.ToolDeny),
})
}
}
return result, nil
}
func decodeGrantStringList(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var list []string
_ = json.Unmarshal(raw, &list)
return list
}
// scanAccessibleRows decodes the shared SELECT projection used by both the
+59 -1
View File
@@ -410,7 +410,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string
query += ` LIMIT 1`
row := s.db.QueryRowContext(ctx, query, args...)
return s.scanRowWithGrantAndUserEnv(row)
b, err := s.scanRowWithGrantAndUserEnv(row)
if err != nil || b == nil {
return b, err
}
return s.applyContextSecureCLI(ctx, b)
}
// scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env.
@@ -498,6 +502,60 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu
return &b, nil
}
func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) {
scopes := store.ChannelContextScopeChainFromContext(ctx)
if len(scopes) == 0 || b == nil {
return b, nil
}
hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
disabledByContext := false
for _, scope := range scopes {
grants, err := s.ListContextGrantsForScope(ctx, scope)
if err != nil {
return nil, err
}
for _, contextGrant := range grants {
if contextGrant.BinaryID != b.ID {
continue
}
if !contextGrant.Enabled {
disabledByContext = true
break
}
grant := &store.SecureCLIAgentGrant{
DenyArgs: contextGrant.DenyArgs,
DenyVerbose: contextGrant.DenyVerbose,
TimeoutSeconds: contextGrant.TimeoutSeconds,
Tips: contextGrant.Tips,
EncryptedEnv: contextGrant.EncryptedEnv,
}
b.MergeGrantOverrides(grant)
disabledByContext = false
break
}
}
if disabledByContext {
return nil, nil
}
for _, scope := range scopes {
creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID)
if err != nil {
return nil, err
}
if creds == nil || len(creds.EncryptedEnv) == 0 {
continue
}
if !hasUserCredential {
b.UserEnv = creds.EncryptedEnv
b.UserCredentialType = creds.CredentialType
b.UserHostScope = creds.HostScope
} else {
b.EncryptedEnv = creds.EncryptedEnv
}
}
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
+1
View File
@@ -40,6 +40,7 @@ type RunContext struct {
// Tool configuration
BuiltinToolSettings map[string][]byte
ChannelType string
ChannelContextScope ChannelContextScope
SubagentsCfg *config.SubagentsConfig
ParentModel string
ParentProvider string
@@ -0,0 +1,528 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) {
if scope.ChannelInstanceID != uuid.Nil {
return scope.ChannelInstanceID, nil
}
if scope.ChannelInstanceName == "" {
return uuid.Nil, sql.ErrNoRows
}
var id uuid.UUID
err := db.QueryRowContext(ctx,
`SELECT id FROM channel_instances WHERE tenant_id = ? AND name = ?`,
tenantIDForInsert(ctx), scope.ChannelInstanceName,
).Scan(&id)
return id, err
}
func validateChannelScope(scopeType, scopeKey string) error {
switch scopeType {
case store.ChannelScopeTypeChannel:
return nil
case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole:
if scopeKey == "" {
return fmt.Errorf("scope_key required for %s scope", scopeType)
}
return nil
default:
return fmt.Errorf("invalid scope_type: %s", scopeType)
}
}
func rawPtrOrNull(raw *json.RawMessage) any {
if raw == nil || len(*raw) == 0 {
return nil
}
return string(*raw)
}
func jsonOrEmptyObject(raw json.RawMessage) any {
if len(raw) == 0 {
return "{}"
}
return string(raw)
}
func (s *SQLiteMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error {
if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_context_grants
(id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled,
tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id)
DO UPDATE SET enabled = excluded.enabled,
tool_allow = excluded.tool_allow,
tool_deny = excluded.tool_deny,
config_overrides = excluded.config_overrides,
granted_by = excluded.granted_by,
updated_at = excluded.updated_at`,
g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled,
jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides),
g.GrantedBy, now, now,
)
return err
}
func (s *SQLiteMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_context_grants
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
return err
}
func (s *SQLiteMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled,
COALESCE(tool_allow, 'null'), COALESCE(tool_deny, 'null'),
COALESCE(config_overrides, 'null'), granted_by, created_at, updated_at
FROM mcp_context_grants
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ?
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPContextGrant, 0)
for rows.Next() {
var g store.MCPContextGrant
var createdAt, updatedAt sqliteTime
if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled,
&g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &createdAt, &updatedAt); err != nil {
return nil, err
}
g.CreatedAt = createdAt.Time
g.UpdatedAt = updatedAt.Time
result = append(result, g)
}
return result, rows.Err()
}
func (s *SQLiteMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *SQLiteMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error {
if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(creds.CreatedBy); err != nil {
return err
}
if creds.ID == uuid.Nil {
creds.ID = store.GenNewID()
}
var apiKey sql.NullString
if creds.APIKey != "" && s.encKey != "" {
enc, err := crypto.Encrypt(creds.APIKey, s.encKey)
if err != nil {
return fmt.Errorf("encrypt mcp context api_key: %w", err)
}
apiKey = sql.NullString{String: enc, Valid: true}
} else if creds.APIKey != "" {
apiKey = sql.NullString{String: creds.APIKey, Valid: true}
}
var headersEnc, envEnc []byte
if len(creds.Headers) > 0 {
raw, _ := json.Marshal(creds.Headers)
headersEnc = s.encryptJSON(raw)
}
if len(creds.Env) > 0 {
raw, _ := json.Marshal(creds.Env)
envEnc = s.encryptJSON(raw)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_context_credentials
(id, tenant_id, channel_instance_id, scope_type, scope_key, server_id,
api_key, headers, env, created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id)
DO UPDATE SET api_key = excluded.api_key,
headers = excluded.headers,
env = excluded.env,
created_by = excluded.created_by,
updated_at = excluded.updated_at`,
creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID,
apiKey, headersEnc, envEnc, creds.CreatedBy, now, now,
)
return err
}
func (s *SQLiteMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at
FROM mcp_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
creds, err := s.scanContextCredentials(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return creds, err
}
func (s *SQLiteMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID)
}
func (s *SQLiteMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM mcp_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID,
)
return err
}
func (s *SQLiteMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at
FROM mcp_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ?
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.MCPContextCredentials, 0)
for rows.Next() {
creds, err := s.scanContextCredentials(rows)
if err != nil {
return nil, err
}
result = append(result, *creds)
}
return result, rows.Err()
}
func (s *SQLiteMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey)
}
type contextCredentialScanner interface {
Scan(dest ...any) error
}
func (s *SQLiteMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) {
var creds store.MCPContextCredentials
var apiKey sql.NullString
var headersEnc, envEnc []byte
var createdAt, updatedAt sqliteTime
if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID,
&apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &createdAt, &updatedAt); err != nil {
return nil, err
}
creds.CreatedAt = createdAt.Time
creds.UpdatedAt = updatedAt.Time
if apiKey.Valid && apiKey.String != "" && s.encKey != "" {
if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil {
creds.APIKey = dec
}
} else if apiKey.Valid {
creds.APIKey = apiKey.String
}
if len(headersEnc) > 0 {
_ = json.Unmarshal(s.decryptJSON(headersEnc), &creds.Headers)
}
if len(envEnc) > 0 {
_ = json.Unmarshal(s.decryptJSON(envEnc), &creds.Env)
}
return &creds, nil
}
func (s *SQLiteSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error {
if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(g.GrantedBy); err != nil {
return err
}
if g.ID == uuid.Nil {
g.ID = store.GenNewID()
}
var envBytes []byte
if len(g.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt context grant env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = g.EncryptedEnv
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_context_grants
(id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id,
deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled,
granted_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
DO UPDATE SET deny_args = excluded.deny_args,
deny_verbose = excluded.deny_verbose,
timeout_seconds = excluded.timeout_seconds,
tips = excluded.tips,
encrypted_env = excluded.encrypted_env,
enabled = excluded.enabled,
granted_by = excluded.granted_by,
updated_at = excluded.updated_at`,
g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID,
rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled,
g.GrantedBy, now, now,
)
return err
}
func (s *SQLiteSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM secure_cli_context_grants
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
return err
}
func (s *SQLiteSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id,
deny_args, deny_verbose, timeout_seconds, tips, encrypted_env,
enabled, granted_by, created_at, updated_at
FROM secure_cli_context_grants
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ?
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.SecureCLIContextGrant, 0)
for rows.Next() {
g, err := s.scanSecureCLIContextGrant(rows)
if err != nil {
return nil, err
}
result = append(result, *g)
}
return result, rows.Err()
}
func (s *SQLiteSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *SQLiteSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) {
var g store.SecureCLIContextGrant
var denyArgs, denyVerbose []byte
var createdAt, updatedAt sqliteTime
if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID,
&denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled,
&g.GrantedBy, &createdAt, &updatedAt); err != nil {
return nil, err
}
g.CreatedAt = createdAt.Time
g.UpdatedAt = updatedAt.Time
if len(denyArgs) > 0 {
raw := json.RawMessage(denyArgs)
g.DenyArgs = &raw
}
if len(denyVerbose) > 0 {
raw := json.RawMessage(denyVerbose)
g.DenyVerbose = &raw
}
if len(g.EncryptedEnv) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil {
g.EncryptedEnv = []byte(decrypted)
}
}
g.EnvSet = len(g.EncryptedEnv) > 0
return &g, nil
}
func (s *SQLiteSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error {
if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil {
return err
}
if err := store.ValidateUserID(creds.CreatedBy); err != nil {
return err
}
if creds.ID == uuid.Nil {
creds.ID = store.GenNewID()
}
var envBytes []byte
if len(creds.EncryptedEnv) > 0 && s.encKey != "" {
encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey)
if err != nil {
return fmt.Errorf("encrypt context credential env: %w", err)
}
envBytes = []byte(encrypted)
} else {
envBytes = creds.EncryptedEnv
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO secure_cli_context_credentials
(id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id,
encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
DO UPDATE SET encrypted_env = excluded.encrypted_env,
metadata = excluded.metadata,
credential_type = excluded.credential_type,
host_scope = excluded.host_scope,
created_by = excluded.created_by,
updated_at = excluded.updated_at`,
creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID,
envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now,
)
return err
}
func (s *SQLiteSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env,
COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at
FROM secure_cli_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
creds, err := s.scanSecureCLIContextCredentials(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return creds, err
}
func (s *SQLiteSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID)
}
func (s *SQLiteSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM secure_cli_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID,
)
return err
}
func (s *SQLiteSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env,
COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at
FROM secure_cli_context_credentials
WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ?
ORDER BY created_at`,
tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]store.SecureCLIContextCredentials, 0)
for rows.Next() {
creds, err := s.scanSecureCLIContextCredentials(rows)
if err != nil {
return nil, err
}
result = append(result, *creds)
}
return result, rows.Err()
}
func (s *SQLiteSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) {
id, err := resolveContextChannelInstanceID(ctx, s.db, scope)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey)
}
func (s *SQLiteSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) {
var creds store.SecureCLIContextCredentials
var createdAt, updatedAt sqliteTime
if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID,
&creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope,
&creds.CreatedBy, &createdAt, &updatedAt); err != nil {
return nil, err
}
creds.CreatedAt = createdAt.Time
creds.UpdatedAt = updatedAt.Time
if len(creds.EncryptedEnv) > 0 && s.encKey != "" {
if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil {
creds.EncryptedEnv = []byte(decrypted)
}
}
return &creds, nil
}
+4
View File
@@ -71,6 +71,10 @@ func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string
conditions = append(conditions, "channel_type = ?")
args = append(args, opts.ChannelType)
}
if opts.ChannelInstance != "" {
conditions = append(conditions, "channel_instance = ?")
args = append(args, opts.ChannelInstance)
}
if opts.PeerKind != "" {
conditions = append(conditions, "peer_kind = ?")
args = append(args, opts.PeerKind)
@@ -211,7 +211,11 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.
if err != nil {
return nil, err
}
return s.scanAccessibleRows(rows)
result, err := s.scanAccessibleRows(rows)
if err != nil {
return nil, err
}
return s.applyContextMCPAccess(ctx, result)
}
rows, err := s.db.QueryContext(ctx,
`SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env,
@@ -226,7 +230,68 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.
if err != nil {
return nil, err
}
return s.scanAccessibleRows(rows)
result, err := s.scanAccessibleRows(rows)
if err != nil {
return nil, err
}
return s.applyContextMCPAccess(ctx, result)
}
func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) {
scopes := store.ChannelContextScopeChainFromContext(ctx)
if len(scopes) == 0 {
return result, nil
}
for _, scope := range scopes {
grants, err := s.ListContextGrantsForScope(ctx, scope)
if err != nil {
return nil, err
}
if len(grants) == 0 {
continue
}
byServer := make(map[uuid.UUID]int, len(result))
for i := range result {
byServer[result[i].Server.ID] = i
}
for _, grant := range grants {
if idx, exists := byServer[grant.ServerID]; exists {
if !grant.Enabled {
result = append(result[:idx], result[idx+1:]...)
byServer = make(map[uuid.UUID]int, len(result))
for i := range result {
byServer[result[i].Server.ID] = i
}
continue
}
result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow)
result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny)
continue
}
if !grant.Enabled {
continue
}
server, err := s.GetServer(ctx, grant.ServerID)
if err != nil || server == nil || !server.Enabled {
continue
}
result = append(result, store.MCPAccessInfo{
Server: *server,
ToolAllow: decodeGrantStringList(grant.ToolAllow),
ToolDeny: decodeGrantStringList(grant.ToolDeny),
})
}
}
return result, nil
}
func decodeGrantStringList(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var list []string
_ = json.Unmarshal(raw, &list)
return list
}
// scanAccessibleRows decodes the shared SELECT projection used by both the
+81 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 42
const SchemaVersion = 43
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -772,8 +772,88 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at
40: `ALTER TABLE secure_cli_user_credentials ADD COLUMN host_scope TEXT;`,
// Version 41 → 42: credential adapter framework — adapter_name on binaries.
41: `ALTER TABLE secure_cli_binaries ADD COLUMN adapter_name TEXT;`,
// Version 42 → 43: channel-context MCP and Secure CLI grants/credentials.
42: addChannelContextCapabilityTables,
}
const addChannelContextCapabilityTables = `
CREATE TABLE IF NOT EXISTS mcp_context_grants (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT 1,
tool_allow TEXT,
tool_deny TEXT,
config_overrides TEXT,
granted_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id);
CREATE TABLE IF NOT EXISTS mcp_context_credentials (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
api_key TEXT,
headers BLOB,
env BLOB,
created_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id);
CREATE TABLE IF NOT EXISTS secure_cli_context_grants (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
deny_args TEXT,
deny_verbose TEXT,
timeout_seconds INTEGER,
tips TEXT,
encrypted_env BLOB,
enabled BOOLEAN NOT NULL DEFAULT 1,
granted_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id);
CREATE TABLE IF NOT EXISTS secure_cli_context_credentials (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
encrypted_env BLOB NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
credential_type TEXT,
host_scope TEXT,
created_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id);`
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
// Mirrors PG migrations 000052–000055 (consolidated — desktop never shipped
// with intermediate agent_hooks / agent_hook_agents names).
+85
View File
@@ -775,6 +775,91 @@ CREATE INDEX IF NOT EXISTS idx_channel_instances_type ON channel_instances(chann
CREATE INDEX IF NOT EXISTS idx_channel_instances_agent ON channel_instances(agent_id);
CREATE INDEX IF NOT EXISTS idx_channel_instances_tenant ON channel_instances(tenant_id);
-- ============================================================
-- Tables: channel-context MCP and Secure CLI capabilities
-- ============================================================
CREATE TABLE IF NOT EXISTS mcp_context_grants (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT 1,
tool_allow TEXT,
tool_deny TEXT,
config_overrides TEXT,
granted_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id);
CREATE TABLE IF NOT EXISTS mcp_context_credentials (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
api_key TEXT,
headers BLOB,
env BLOB,
created_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id);
CREATE TABLE IF NOT EXISTS secure_cli_context_grants (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
deny_args TEXT,
deny_verbose TEXT,
timeout_seconds INTEGER,
tips TEXT,
encrypted_env BLOB,
enabled BOOLEAN NOT NULL DEFAULT 1,
granted_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id);
CREATE TABLE IF NOT EXISTS secure_cli_context_credentials (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
encrypted_env BLOB NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
credential_type TEXT,
host_scope TEXT,
created_by VARCHAR(255) NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id);
-- ============================================================
-- Table: config_secrets
-- PK changed to (key, tenant_id) in migration 27 Phase I
+59 -1
View File
@@ -437,7 +437,11 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st
query += ` LIMIT 1`
row := s.db.QueryRowContext(ctx, query, args...)
return s.scanRowWithGrantAndUserEnv(row)
b, err := s.scanRowWithGrantAndUserEnv(row)
if err != nil || b == nil {
return b, err
}
return s.applyContextSecureCLI(ctx, b)
}
func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) {
@@ -524,6 +528,60 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.
return &b, nil
}
func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) {
scopes := store.ChannelContextScopeChainFromContext(ctx)
if len(scopes) == 0 || b == nil {
return b, nil
}
hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil
disabledByContext := false
for _, scope := range scopes {
grants, err := s.ListContextGrantsForScope(ctx, scope)
if err != nil {
return nil, err
}
for _, contextGrant := range grants {
if contextGrant.BinaryID != b.ID {
continue
}
if !contextGrant.Enabled {
disabledByContext = true
break
}
grant := &store.SecureCLIAgentGrant{
DenyArgs: contextGrant.DenyArgs,
DenyVerbose: contextGrant.DenyVerbose,
TimeoutSeconds: contextGrant.TimeoutSeconds,
Tips: contextGrant.Tips,
EncryptedEnv: contextGrant.EncryptedEnv,
}
b.MergeGrantOverrides(grant)
disabledByContext = false
break
}
}
if disabledByContext {
return nil, nil
}
for _, scope := range scopes {
creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID)
if err != nil {
return nil, err
}
if creds == nil || len(creds.EncryptedEnv) == 0 {
continue
}
if !hasUserCredential {
b.UserEnv = creds.EncryptedEnv
b.UserCredentialType = creds.CredentialType
b.UserHostScope = creds.HostScope
} else {
b.EncryptedEnv = creds.EncryptedEnv
}
}
return b, nil
}
// ListEnabled returns all enabled configs.
func (s *SQLiteSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) {
query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = 1`
+1 -1
View File
@@ -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 = 73
const RequiredSchemaVersion uint = 74
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS secure_cli_context_credentials;
DROP TABLE IF EXISTS secure_cli_context_grants;
DROP TABLE IF EXISTS mcp_context_credentials;
DROP TABLE IF EXISTS mcp_context_grants;
@@ -0,0 +1,80 @@
CREATE TABLE mcp_context_grants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
tool_allow JSONB,
tool_deny JSONB,
config_overrides JSONB,
granted_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX idx_mcp_context_grants_server ON mcp_context_grants(server_id);
CREATE TABLE mcp_context_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
api_key TEXT,
headers BYTEA,
env BYTEA,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id)
);
CREATE INDEX idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX idx_mcp_context_credentials_server ON mcp_context_credentials(server_id);
CREATE TABLE secure_cli_context_grants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
deny_args JSONB,
deny_verbose JSONB,
timeout_seconds INTEGER,
tips TEXT,
encrypted_env BYTEA,
enabled BOOLEAN NOT NULL DEFAULT true,
granted_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id);
CREATE TABLE secure_cli_context_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_key VARCHAR(255) NOT NULL DEFAULT '',
binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE,
encrypted_env BYTEA NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
credential_type TEXT,
host_scope TEXT,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id)
);
CREATE INDEX idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key);
CREATE INDEX idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id);
@@ -0,0 +1,99 @@
---
phase: 1
title: "Research and Contracts"
status: complete
effort: "M"
---
# Phase 1: Research and Contracts
## Context Links
- Issue: https://github.com/digitopvn/goclaw/issues/66
- Current page: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx`
- Current groups tab: `ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx`
- Current credentials tab: `ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx`
- Channel routes: `internal/http/channel_instances.go`
- MCP store/routes: `internal/store/mcp_store.go`, `internal/http/mcp_grants.go`, `internal/http/mcp_user_credentials.go`
- Secure CLI store/routes: `internal/store/secure_cli_store.go`, `internal/http/secure_cli_agent_grants.go`, `internal/http/secure_cli_user_credentials.go`
- Docs: `docs/05-channels-messaging.md`, `docs/23-ai-agent-permission-matrix.md`, `docs/09-security.md`
## Overview
Lock the API contracts and threat model before implementation. This phase should produce failing tests or contract snapshots for the expected DTOs, plus a concise scope note that separates channel login credentials from MCP/CLI scoped credentials.
## Key Insights
- Existing `groups` tab is Telegram config overrides, not a general group/member view.
- Existing channel credentials are encrypted channel-login data; they must not become MCP/CLI runtime credentials.
- MCP access currently resolves server defaults + per-user credentials only.
- Secure CLI access currently resolves global binary + agent grant + user credential only.
- `agent_config_permissions` is for config/file-writer grants and should not be stretched into MCP/CLI grant storage.
- Discord currently captures guild/channel metadata but does not expose full live members/roles without extra intent work.
## Requirements
- Define canonical context scope model for `channel`, `group`, and `member` rows.
- Define masked credential DTOs that never include raw secret values.
- Define source labels for effective capability and credential rows: global, agent, channel, group, user.
- Define permission requirements for read vs write endpoints.
- Document resolver precedence: user > group/member-role > channel > agent > global.
## Architecture
- Add HTTP DTOs near existing channel/admin route files; keep store structs separate from UI response structs.
- Keep MCP and Secure CLI resolver changes behind explicit scope inputs instead of reading mutable process-global state.
- Represent channel context with stable fields: `channelInstanceID`, `channelType`, `scopeType`, `scopeKey`, `displayName`, `source`, `lastSeenAt`.
- Treat Discord role/member support as a provider capability flag until verified.
## Related Code Files
- Modify later: `internal/http/channel_instances.go`
- Modify later: `internal/store/channel_contact_store.go`
- Modify later: `internal/store/mcp_store.go`
- Modify later: `internal/store/secure_cli_store.go`
- Modify later: `ui/web/src/types/channel.ts`
- Modify later: `ui/web/src/pages/channels/channel-detail/*`
- Create later only if needed: focused DTO/test files with kebab-case or existing Go naming convention.
## Implementation Steps
1. Write failing backend tests for channel context DTO masking and tenant-scoped route authorization.
2. Write failing resolver contract tests that encode credential precedence without creating new tables yet.
3. Add a short issue-scope note in this plan directory if implementation discovers a contract gap.
4. Verify all planned endpoint names against existing route structure before coding handlers.
5. Verify current UI route/i18n conventions before adding visible strings.
## Todo List
- [ ] Add backend contract tests for context list shape and permission checks.
- [ ] Add resolver precedence characterization tests for MCP and Secure CLI.
- [ ] Confirm UI i18n key placement for new channel detail tabs.
- [ ] Confirm Discord provider capability and permissions needed for live member/role sync.
## Success Criteria
- [ ] Tests fail for the intended missing behavior before implementation.
- [ ] Contract fields and precedence rules are explicit and cited in later phase code.
- [ ] No plan step relies on raw credential reveal.
- [ ] No plan step reuses `channel_instances.credentials` for MCP/CLI secrets.
## Risk Assessment
- Risk: resolver scope leaks across tenants. Mitigation: tenant ID on every new table/query and route-level tenant checks.
- Risk: UI implies Discord support that backend cannot provide. Mitigation: capability flags and documented fallback.
- Risk: scope strings become ad hoc. Mitigation: typed scope model plus tests.
## Security Considerations
- Never serialize raw secrets.
- Audit all write operations in later phases.
- Use existing encrypted storage patterns and parameterized SQL only.
## Next Steps
- Proceed to Phase 2 after route/DTO tests are committed or queued in the implementation branch.
## Unresolved Questions
- None for planning; Discord depth remains a phase gate.
@@ -0,0 +1,91 @@
---
phase: 2
title: "Read-only Context Surface"
status: complete
effort: "L"
---
# Phase 2: Read-only Context Surface
## Context Links
- UI shell: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx`
- Current hooks: `ui/web/src/pages/channels/hooks/use-channel-detail.ts`
- Channel contacts: `internal/store/channel_contact_store.go`, `internal/store/pg/channel_contacts.go`
- Live members abstraction: `internal/channels/channel.go`, `internal/channels/manager.go`
- Feishu member implementation: `internal/channels/feishu/feishu.go`
- Discord metadata source: `internal/channels/discord/handler.go`
## Overview
Add a read-only admin view for channel contexts and members/users. This should make the channel detail page useful before introducing new grant or credential mutations.
## Requirements
- Show channel contexts/groups discovered from contacts and supported provider APIs.
- Show members/users for a selected context when available.
- Show source and freshness: stored contact, live provider, config writer scope, or unsupported.
- Preserve existing Telegram override editing until it is intentionally replaced or renamed.
- Enforce tenant isolation and admin/operator read permissions.
## Architecture
- Add a channel context service that aggregates stored contacts plus provider-supported group/member APIs.
- Add HTTP endpoints under existing channel instance routes:
- `GET /v1/channels/instances/{id}/contexts`
- `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members`
- Extend channel contact store filters to support channel instance and context filters.
- UI adds a general `Contexts` tab for all channel types and keeps Telegram overrides as a separate advanced section if still needed.
## Related Code Files
- `internal/http/channel_instances.go`
- `internal/store/channel_contact_store.go`
- `internal/store/pg/channel_contacts.go`
- `internal/store/sqlitestore/*channel*`
- `internal/channels/manager.go`
- `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx`
- `ui/web/src/pages/channels/hooks/use-channel-detail.ts`
- `ui/web/src/types/channel.ts`
## Implementation Steps
1. TDD: add backend tests for context listing, member listing, tenant mismatch, and unsupported provider responses.
2. Add channel-instance filtering to contact store interfaces and PG/SQLite implementations.
3. Implement read-only context/member HTTP handlers using existing auth middleware and masked DTOs.
4. Add UI query hooks and type definitions for contexts and members.
5. Add a general channel detail tab with loading, empty, error, and unsupported states.
6. Add i18n keys to all web locale files before rendering new user-facing strings.
7. Keep Telegram override editing intact and avoid changing channel config behavior in this phase.
## Todo List
- [ ] Backend tests fail first for `/contexts` and `/members`.
- [ ] Store filters support `channel_instance` without full scans.
- [ ] UI tests cover tab visibility, empty state, and unsupported provider state.
- [ ] Existing channel detail behavior remains unchanged for general/credentials/managers tabs.
## Success Criteria
- [ ] Admin can see groups/contexts and members/users where data exists.
- [ ] Unsupported live member sync is explicit in the UI instead of silently blank.
- [ ] Tenant isolation tests pass for both PG and SQLite paths where applicable.
- [ ] No mutation endpoints are added in this phase.
## Risk Assessment
- Risk: `channel_contacts` has incomplete rows for some providers. Mitigation: label source/freshness and use provider capability flags.
- Risk: adding filters breaks contact search. Mitigation: preserve existing default list/search behavior with regression tests.
## Security Considerations
- Read endpoints must scope by authenticated tenant and selected channel instance.
- Do not include provider tokens or channel credentials in responses.
## Next Steps
- Phase 3 can consume the selected context model to display effective MCP/CLI capability rows.
## Unresolved Questions
- None.
@@ -0,0 +1,87 @@
---
phase: 3
title: "Effective Capability Matrix"
status: complete
effort: "L"
---
# Phase 3: Effective Capability Matrix
## Context Links
- MCP grants API: `internal/http/mcp_grants.go`
- MCP store: `internal/store/mcp_store.go`, `internal/store/pg/mcp_servers.go`, `internal/store/pg/mcp_servers_access.go`
- Secure CLI grants API: `internal/http/secure_cli_agent_grants.go`
- Secure CLI store: `internal/store/secure_cli_store.go`, `internal/store/pg/secure_cli.go`
- Channel detail UI: `ui/web/src/pages/channels/channel-detail/`
## Overview
Show the effective MCP servers/tools and Secure CLI packages for a channel context before allowing edits. This phase is read-only and should expose why access exists, not just whether access exists.
## Requirements
- Display MCP servers with tool allow/deny summaries and source labels.
- Display Secure CLI packages/binaries with source labels and masked env availability.
- Show future channel/group override slots as "not configured" until Phase 4/5 tables exist.
- Keep read endpoint tenant-scoped and usable by admins/operators with appropriate channel access.
## Architecture
- Add an effective capability aggregation service that composes existing MCP and Secure CLI store calls.
- Add endpoint under channel instance routes:
- `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities`
- Response groups rows by capability type: `mcp_server`, `mcp_tool`, `secure_cli`.
- Avoid resolver mutation in this phase; this is visibility over existing agent/user/global grants plus planned context placeholder metadata.
## Related Code Files
- `internal/http/channel_instances.go`
- `internal/http/mcp_grants.go`
- `internal/http/secure_cli_agent_grants.go`
- `internal/store/mcp_store.go`
- `internal/store/secure_cli_store.go`
- `ui/web/src/pages/channels/channel-detail/`
- `ui/web/src/types/channel.ts`
## Implementation Steps
1. TDD: add backend tests for effective capability response shape using existing agent/user grants.
2. TDD: add UI tests for capability matrix source labels and masked secret indicators.
3. Implement a read-only aggregator that calls existing MCP and Secure CLI stores.
4. Add handler under channel instance route group with tenant/channel authorization.
5. Add UI matrix with filters for MCP, CLI, and source.
6. Add i18n keys in all web locale files.
7. Document that edit controls remain disabled until Phase 4/5.
## Todo List
- [ ] Backend tests prove source labels for global, agent, and user-derived access.
- [ ] UI tests cover empty, partial, and mixed MCP/CLI rows.
- [ ] No new schema is introduced in this phase.
- [ ] No raw credential values appear in capability responses.
## Success Criteria
- [ ] Admin can see granted MCP servers/tools for a selected channel context.
- [ ] Admin can see granted Secure CLI packages/binaries for a selected channel context.
- [ ] Source labels are visible and consistent with resolver precedence.
- [ ] Read-only endpoint has tenant isolation tests.
## Risk Assessment
- Risk: read model diverges from actual runtime resolver. Mitigation: use existing store access methods where possible and add characterization tests before later resolver edits.
- Risk: UI becomes noisy. Mitigation: default grouped display and simple filters.
## Security Considerations
- Mask all environment and credential indicators.
- Return only metadata required to administer access.
## Next Steps
- Phase 4 can add mutation tables and update the matrix source labels to include channel/group grants.
## Unresolved Questions
- None.
@@ -0,0 +1,101 @@
---
phase: 4
title: "Context-scoped Grants"
status: complete
effort: "XL"
---
# Phase 4: Context-scoped Grants
## Context Links
- MCP grant schema: `migrations/000001_init_schema.up.sql`, later MCP migrations, `internal/store/pg/mcp_servers_access.go`
- Secure CLI grant schema: `migrations/000058_secure_cli_binaries.up.sql`, `migrations/000073_secure_cli_typed_credentials.up.sql`
- SQLite schema: `internal/store/sqlitestore/schema.sql`, `internal/store/sqlitestore/schema.go`
- Upgrade version: `internal/upgrade/version.go`
- Grant APIs: `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go`
## Overview
Add channel/group scoped grants for MCP and Secure CLI. This is the first write phase and requires dual database migrations, resolver changes, audit logs, and UI mutation controls.
## Requirements
- Add dedicated context grant tables; do not overload `agent_config_permissions`.
- Scope every row by tenant, channel instance, scope type, and scope key.
- Support grant sources: channel and group/member-role.
- Preserve existing agent/user grant behavior.
- Audit create/update/delete operations.
## Architecture
- Proposed tables:
- `mcp_context_grants`
- `secure_cli_context_grants`
- Common columns: `id`, `tenant_id`, `channel_instance_id`, `scope_type`, `scope_key`, resource ID, allow/deny or config overrides, `granted_by`, timestamps.
- Add PG migrations and SQLite full schema + incremental migrations in the same implementation PR.
- Extend resolver input with channel context. Prefer explicit params where call sites already carry channel metadata; use context values only when explicit threading would create broad churn.
- Runtime grant precedence: user > group/member-role > channel > agent > global.
## Related Code Files
- `migrations/*`
- `internal/upgrade/version.go`
- `internal/store/sqlitestore/schema.sql`
- `internal/store/sqlitestore/schema.go`
- `internal/store/mcp_store.go`
- `internal/store/secure_cli_store.go`
- `internal/store/pg/mcp_servers_access.go`
- `internal/store/pg/secure_cli.go`
- `internal/http/mcp_grants.go`
- `internal/http/secure_cli_agent_grants.go`
- `ui/web/src/pages/channels/channel-detail/`
## Implementation Steps
1. TDD: add migration tests or store tests that fail until context grant tables exist in PG and SQLite.
2. TDD: add resolver tests proving group grant overrides channel grant, and user grant overrides both.
3. Add PG up/down migrations and bump `RequiredSchemaVersion`.
4. Add SQLite schema and incremental migration map updates with `SchemaVersion` bump.
5. Add store interfaces and PG/SQLite implementations for context grants.
6. Extend MCP and Secure CLI resolvers to include context grants without breaking existing call sites.
7. Add admin-only HTTP endpoints for create/update/delete/list context grants.
8. Add UI controls to grant/revoke MCP/CLI rows for selected channel context.
9. Emit security/audit logs on every write.
10. Run dual DB compile and focused store/resolver tests.
## Todo List
- [ ] Add failing PG and SQLite tests for context grant persistence.
- [ ] Add failing resolver precedence tests.
- [ ] Add dual DB migrations and version bumps.
- [ ] Add audited admin endpoints.
- [ ] Add UI mutation controls with disabled/loading/error states.
## Success Criteria
- [ ] Channel/group scoped grants can be created, listed, updated, and revoked.
- [ ] Effective capability matrix shows channel/group grant sources.
- [ ] Existing agent/user MCP and CLI flows keep passing.
- [ ] PG and SQLite schema versions are both updated.
- [ ] Audit logs include actor, tenant, channel instance, scope, resource, and action.
## Risk Assessment
- Risk: resolver signature churn touches many runtime paths. Mitigation: enumerate all callers before editing and add compile-time failures as guide.
- Risk: partial migration coverage breaks desktop. Mitigation: SQLite schema update is mandatory in the same phase.
- Risk: broad grants accidentally cross tenant. Mitigation: tenant in primary queries and route authorization tests.
## Security Considerations
- Admin writes must use tenant admin or master-scope checks according to table ownership.
- SQL must be parameterized and indexed on tenant/channel/scope/resource columns.
- Never log secret material from overrides.
## Next Steps
- Phase 5 adds scoped credential storage after grant resolver behavior is stable.
## Unresolved Questions
- Decide exact DB constraint names during implementation after checking existing migration naming style.
@@ -0,0 +1,101 @@
---
phase: 5
title: "Context-scoped Credentials"
status: complete
effort: "XL"
---
# Phase 5: Context-scoped Credentials
## Context Links
- MCP credentials: `internal/http/mcp_user_credentials.go`, `internal/store/pg/mcp_user_credentials.go`, `internal/mcp/manager.go`
- Secure CLI credentials: `internal/http/secure_cli_user_credentials.go`, `internal/store/pg/secure_cli.go`, `internal/tools/secure_cli*`
- Encryption patterns: `internal/crypto/`, existing encrypted store columns
- Security docs: `docs/09-security.md`, `docs/20-api-keys-auth.md`
## Overview
Add channel/group scoped credential overrides for MCP and Secure CLI. This phase must be stricter than grants because it stores encrypted secrets and changes runtime credential resolution.
## Requirements
- Store MCP and CLI context credentials in dedicated encrypted tables.
- Never return raw secret values through list/detail endpoints.
- Support create/update/delete with audit logs.
- Apply precedence: user > group/member-role > channel > agent > global.
- Clearly label whether a capability is using global, agent, channel, group, or user credentials.
## Architecture
- Proposed tables:
- `mcp_context_credentials`
- `secure_cli_context_credentials`
- Scope columns match Phase 4 grant tables.
- MCP credential payload follows current per-user model: API key, headers, env.
- Secure CLI credential payload follows current typed credential model and encrypted env behavior.
- Runtime resolvers should merge defaults first, then progressively apply agent/channel/group/user overrides.
- UI uses masked fields, "configured" flags, and explicit reset/delete actions.
## Related Code Files
- `migrations/*`
- `internal/upgrade/version.go`
- `internal/store/sqlitestore/schema.sql`
- `internal/store/sqlitestore/schema.go`
- `internal/store/mcp_store.go`
- `internal/store/secure_cli_store.go`
- `internal/mcp/manager.go`
- `internal/http/mcp_user_credentials.go`
- `internal/http/secure_cli_user_credentials.go`
- `ui/web/src/pages/channels/channel-detail/`
## Implementation Steps
1. TDD: add credential masking tests for HTTP list/detail responses.
2. TDD: add resolver precedence tests for global, agent, channel, group, and user credentials.
3. TDD: add audit tests or log assertions for credential create/update/delete where existing test patterns allow.
4. Add PG and SQLite encrypted credential tables and version bumps.
5. Add store interfaces and implementations using existing encryption helpers.
6. Add admin endpoints for scoped MCP credentials and scoped Secure CLI credentials.
7. Update MCP manager credential resolution with context scope input.
8. Update Secure CLI lookup/credential resolution with context scope input.
9. Add UI credential forms with masked values, configured flags, and delete/reset flows.
10. Update docs to document precedence and masking behavior.
## Todo List
- [ ] Add failing tests proving no raw secrets are serialized.
- [ ] Add failing runtime resolver precedence tests.
- [ ] Add PG and SQLite schema changes together.
- [ ] Add audited credential write endpoints.
- [ ] Add UI forms with masked values only.
- [ ] Update security/API docs.
## Success Criteria
- [ ] Admin can configure channel/group scoped MCP credentials.
- [ ] Admin can configure channel/group scoped Secure CLI credentials.
- [ ] Runtime uses scoped credentials according to documented precedence.
- [ ] UI labels credential source and override state.
- [ ] All list/detail endpoints return masked metadata only.
## Risk Assessment
- Risk: raw secret leakage in UI/API/logs. Mitigation: masking tests and no reveal endpoint in this phase.
- Risk: agent/global config accidentally overwritten by channel edits. Mitigation: dedicated tables and explicit scope keys.
- Risk: resolver behavior differs between MCP and CLI. Mitigation: shared precedence test cases.
## Security Considerations
- Encrypt all stored secret payloads using existing crypto patterns.
- Audit create/update/delete, but never audit payload values.
- Rate-limit or omit reveal flows; default is no raw reveal.
## Next Steps
- Phase 6 verifies Discord-specific behavior and closes docs gaps.
## Unresolved Questions
- Whether to support one-time reveal for newly-created secrets is intentionally out of scope unless explicitly approved.
@@ -0,0 +1,91 @@
---
phase: 6
title: "Discord Enrichment and Verification"
status: complete
effort: "M"
---
# Phase 6: Discord Enrichment and Verification
## Context Links
- Discord channel: `internal/channels/discord/discord.go`, `internal/channels/discord/handler.go`
- Channel docs: `docs/05-channels-messaging.md`
- Permission matrix docs: `docs/23-ai-agent-permission-matrix.md`
- Web channel detail UI: `ui/web/src/pages/channels/channel-detail/`
## Overview
Validate Discord-specific group/member behavior after the generic channel context surface works. Ship either real Discord enrichment or a documented follow-up, depending on verified Discord intents and permission requirements.
## Requirements
- Support Discord guild/channel concepts in the channel detail page.
- If live member/role listing is available, implement it behind capability checks.
- If required intents are not available, show stored contacts and document the limitation.
- Run final backend, SQLite, and web validation.
## Architecture
- Reuse the Phase 2 context/member endpoints.
- Add Discord provider capability flags rather than hardcoding UI assumptions.
- Use stored contact metadata for baseline Discord user rows.
- Only add live role/member sync after verifying gateway intents, bot permissions, and API rate limits.
## Related Code Files
- `internal/channels/discord/discord.go`
- `internal/channels/discord/handler.go`
- `internal/channels/channel.go`
- `internal/channels/manager.go`
- `ui/web/src/pages/channels/channel-detail/`
- `docs/05-channels-messaging.md`
- `docs/23-ai-agent-permission-matrix.md`
## Implementation Steps
1. TDD: add Discord capability tests for stored-contact fallback and unsupported live members.
2. Verify required Discord intents and permissions against the current channel setup.
3. If safe, implement Discord `GroupMemberProvider` support for guild/channel/role views.
4. If not safe, keep fallback UI and add a follow-up issue/note with exact missing permissions.
5. Add docs for channel scoped grant and credential precedence.
6. Run focused tests:
- `go test ./internal/http ./internal/channels ./internal/store/pg`
- `go test -tags sqliteonly ./internal/store/sqlitestore`
- `go build ./...`
- `go build -tags sqliteonly ./...`
- relevant `pnpm` checks in `ui/web/`
7. Perform manual UI smoke for channel detail tabs.
## Todo List
- [ ] Add Discord fallback tests before behavior change.
- [ ] Verify intents/permissions before live sync.
- [ ] Implement or document Discord live member/role support.
- [ ] Update docs for final shipped behavior.
- [ ] Run backend, SQLite, and web validation.
## Success Criteria
- [ ] Discord channel details show guild/channel/user context without cross-tenant leakage.
- [ ] Live Discord member/role support is either implemented with verified permissions or documented as a precise follow-up.
- [ ] Final docs describe precedence: user > group/member-role > channel > agent > global.
- [ ] Final verification commands pass or failures are documented with root cause.
## Risk Assessment
- Risk: Discord member intent requires privileged configuration. Mitigation: capability gate and documented follow-up.
- Risk: Discord API rate limits from live member fetch. Mitigation: cache/stored contacts first; live fetch only where safe.
## Security Considerations
- Do not expose Discord tokens or raw provider credentials.
- Keep all context/member rows tenant and channel-instance scoped.
## Next Steps
- Open implementation PR after all phases pass and issue acceptance criteria are checked.
## Unresolved Questions
- Discord live role/member support depends on verified bot intents and permissions.
@@ -0,0 +1,50 @@
---
title: "Issue 66 Channel Context Admin Surface"
description: "Add channel details admin views for contexts, members, granted MCP/CLI capabilities, and scoped credential overrides."
status: complete
priority: P2
issue: 66
branch: "codex-issue-66-channel-details-admin-design"
tags: [channels, web-ui, security, tdd, issue-66]
blockedBy: []
blocks: []
created: "2026-05-28T11:49:55.757Z"
createdBy: "ck:plan"
source: skill
---
# Issue 66 Channel Context Admin Surface
## Overview
Implement issue #66 as an incremental channel-context admin surface, not a one-shot RBAC rewrite.
The first usable milestone is read-only: channel groups/contexts, members/users, and effective MCP/CLI capability visibility. Mutations come after contracts and tests prove tenant isolation, masking, audit logging, and resolver precedence.
Credential precedence is: user > group/member-role > channel > agent > global. This keeps user credentials most specific and avoids shared channel secrets overriding a user's explicit credential.
Out of scope for the first implementation pass: raw credential reveal, storing MCP/CLI secrets in `channel_instances.credentials`, and full Discord role/member sync without validating required Discord intents and permissions.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Research and Contracts](./phase-01-research-and-contracts.md) | Complete |
| 2 | [Read-only Context Surface](./phase-02-read-only-context-surface.md) | Complete |
| 3 | [Effective Capability Matrix](./phase-03-effective-capability-matrix.md) | Complete |
| 4 | [Context-scoped Grants](./phase-04-context-scoped-grants.md) | Complete |
| 5 | [Context-scoped Credentials](./phase-05-context-scoped-credentials.md) | Complete |
| 6 | [Discord Enrichment and Verification](./phase-06-discord-enrichment-and-verification.md) | Complete: live role/member enrichment remains gated behind Discord intent verification |
## Dependencies
- GitHub issue: https://github.com/digitopvn/goclaw/issues/66
- Existing web UI: `ui/web/src/pages/channels/channel-detail/`
- Existing HTTP routes: `internal/http/channel_instances.go`, `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go`
- Existing stores: `internal/store/pg/`, `internal/store/sqlitestore/`, `internal/store/*mcp*`, `internal/store/*secure_cli*`
- Migration rule: PostgreSQL migrations and SQLite schema/migration map must stay in sync.
- Verification baseline: focused Go tests first, then `go build ./...`, `go build -tags sqliteonly ./...`, and relevant web checks with `pnpm` in `ui/web/`.
## Unresolved Questions
None. Discord live role/member enrichment is intentionally not enabled until required intents and permissions are verified.
+41
View File
@@ -119,6 +119,7 @@
"config": "Config",
"credentials": "Credentials",
"groups": "Groups",
"contexts": "Contexts",
"managers": "Managers"
},
"general": {
@@ -157,6 +158,46 @@
"saveGroups": "Save Groups",
"saving": "Saving..."
},
"contexts": {
"description": "Review channel contexts, stored members, effective MCP tools, Secure CLI tools, and scoped credentials.",
"emptyTitle": "No contexts",
"emptyHint": "Contexts appear after this channel receives messages or group metadata.",
"loading": "Loading context details...",
"capabilities": "Capabilities",
"members": "Members",
"noCapabilities": "No capabilities available in this context.",
"noMembers": "No stored members for this context.",
"none": "None",
"allTools": "All tools",
"scopedCount": "{{count}} scoped override",
"scopedCount_plural": "{{count}} scoped overrides",
"live": "Live",
"grant": "Grant",
"revoke": "Revoke",
"manageCapability": "Manage capability",
"scopedCredential": "Scoped credential",
"apiKeyPlaceholder": "API key (MCP only)",
"envPlaceholder": "Environment JSON, for example {\"TOKEN\":\"...\"}",
"secretHint": "Values are encrypted and never returned after save.",
"saveCredential": "Save credential",
"deleteCredential": "Delete credential",
"grantSaved": "Context grant saved",
"grantDeleted": "Context grant deleted",
"credentialSaved": "Context credential saved",
"credentialDeleted": "Context credential deleted",
"invalidEnv": "Environment must be a JSON object with string values.",
"envRequired": "Secure CLI credentials require environment JSON.",
"credentialRequired": "Enter an API key or environment JSON.",
"actionFailed": "Action failed",
"columns": {
"name": "Name",
"type": "Type",
"source": "Source",
"credentials": "Credentials",
"tools": "Tools",
"actions": "Actions"
}
},
"timeline": {
"title": "Timeline",
"firstFailed": "First failed",
+41
View File
@@ -118,6 +118,7 @@
"config": "Cấu hình",
"credentials": "Thông tin xác thực",
"groups": "Nhóm",
"contexts": "Ngữ cảnh",
"managers": "Quản lý"
},
"general": {
@@ -156,6 +157,46 @@
"saveGroups": "Lưu nhóm",
"saving": "Đang lưu..."
},
"contexts": {
"description": "Xem ngữ cảnh channel, thành viên đã lưu, MCP tools, Secure CLI tools và thông tin xác thực theo scope.",
"emptyTitle": "Không có ngữ cảnh",
"emptyHint": "Ngữ cảnh xuất hiện sau khi channel nhận tin nhắn hoặc metadata nhóm.",
"loading": "Đang tải chi tiết ngữ cảnh...",
"capabilities": "Quyền năng",
"members": "Thành viên",
"noCapabilities": "Không có quyền năng trong ngữ cảnh này.",
"noMembers": "Không có thành viên đã lưu trong ngữ cảnh này.",
"none": "Không có",
"allTools": "Tất cả tools",
"scopedCount": "{{count}} override theo scope",
"scopedCount_plural": "{{count}} override theo scope",
"live": "Live",
"grant": "Grant",
"revoke": "Revoke",
"manageCapability": "Quản lý quyền năng",
"scopedCredential": "Xác thực theo scope",
"apiKeyPlaceholder": "API key (chỉ MCP)",
"envPlaceholder": "JSON môi trường, ví dụ {\"TOKEN\":\"...\"}",
"secretHint": "Giá trị được mã hóa và không bao giờ trả về sau khi lưu.",
"saveCredential": "Lưu xác thực",
"deleteCredential": "Xóa xác thực",
"grantSaved": "Đã lưu grant theo ngữ cảnh",
"grantDeleted": "Đã xóa grant theo ngữ cảnh",
"credentialSaved": "Đã lưu xác thực theo ngữ cảnh",
"credentialDeleted": "Đã xóa xác thực theo ngữ cảnh",
"invalidEnv": "Môi trường phải là JSON object với giá trị dạng chuỗi.",
"envRequired": "Secure CLI credential cần JSON môi trường.",
"credentialRequired": "Nhập API key hoặc JSON môi trường.",
"actionFailed": "Thao tác thất bại",
"columns": {
"name": "Tên",
"type": "Loại",
"source": "Nguồn",
"credentials": "Xác thực",
"tools": "Tools",
"actions": "Thao tác"
}
},
"timeline": {
"title": "Dòng thời gian",
"firstFailed": "Lỗi đầu tiên",
+41
View File
@@ -118,6 +118,7 @@
"config": "配置",
"credentials": "凭据",
"groups": "群组",
"contexts": "上下文",
"managers": "管理员"
},
"general": {
@@ -156,6 +157,46 @@
"saveGroups": "保存群组",
"saving": "保存中..."
},
"contexts": {
"description": "查看 Channel 上下文、已存成员、MCP 工具、Secure CLI 工具和作用域凭据。",
"emptyTitle": "暂无上下文",
"emptyHint": "Channel 收到消息或群组元数据后会显示上下文。",
"loading": "正在加载上下文详情...",
"capabilities": "能力",
"members": "成员",
"noCapabilities": "此上下文没有可用能力。",
"noMembers": "此上下文没有已存成员。",
"none": "无",
"allTools": "全部工具",
"scopedCount": "{{count}} 个作用域覆盖",
"scopedCount_plural": "{{count}} 个作用域覆盖",
"live": "实时",
"grant": "授权",
"revoke": "撤销",
"manageCapability": "管理能力",
"scopedCredential": "作用域凭据",
"apiKeyPlaceholder": "API key(仅 MCP)",
"envPlaceholder": "环境变量 JSON,例如 {\"TOKEN\":\"...\"}",
"secretHint": "值会加密保存,保存后不会返回。",
"saveCredential": "保存凭据",
"deleteCredential": "删除凭据",
"grantSaved": "上下文授权已保存",
"grantDeleted": "上下文授权已删除",
"credentialSaved": "上下文凭据已保存",
"credentialDeleted": "上下文凭据已删除",
"invalidEnv": "环境变量必须是字符串值的 JSON 对象。",
"envRequired": "Secure CLI 凭据需要环境变量 JSON。",
"credentialRequired": "请输入 API key 或环境变量 JSON。",
"actionFailed": "操作失败",
"columns": {
"name": "名称",
"type": "类型",
"source": "来源",
"credentials": "凭据",
"tools": "工具",
"actions": "操作"
}
},
"timeline": {
"title": "时间线",
"firstFailed": "首次失败",
+5
View File
@@ -38,6 +38,11 @@ export const queryKeys = {
all: ["channels"] as const,
list: (params: Record<string, unknown>) => ["channels", params] as const,
detail: (id: string) => ["channels", "detail", id] as const,
contexts: (id: string) => ["channels", "detail", id, "contexts"] as const,
contextMembers: (id: string, scopeType: string, scopeKey: string) =>
["channels", "detail", id, "contexts", scopeType, scopeKey, "members"] as const,
contextCapabilities: (id: string, scopeType: string, scopeKey: string) =>
["channels", "detail", id, "contexts", scopeType, scopeKey, "capabilities"] as const,
},
contacts: {
all: ["contacts"] as const,
@@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Loader2, ShieldCheck, ShieldX, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { ChannelCapability, ChannelContextData } from "@/types/channel";
import type { ChannelCredentialPayload } from "../hooks/use-channel-detail";
export interface ChannelContextCapabilityTarget {
context: ChannelContextData;
capability: ChannelCapability;
}
interface ChannelContextCapabilityAdminDialogProps {
target: ChannelContextCapabilityTarget | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onGrant: (target: ChannelContextCapabilityTarget) => Promise<void>;
onRevoke: (target: ChannelContextCapabilityTarget) => Promise<void>;
onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise<void>;
onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise<void>;
}
function parseEnvJSON(value: string): Record<string, string> | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const env: Record<string, string> = {};
for (const [key, raw] of Object.entries(parsed)) {
if (typeof raw === "string") {
env[key] = raw;
}
}
return env;
}
export function ChannelContextCapabilityAdminDialog({
target,
open,
onOpenChange,
onGrant,
onRevoke,
onSaveCredentials,
onDeleteCredentials,
}: ChannelContextCapabilityAdminDialogProps) {
const { t } = useTranslation("channels");
const [apiKey, setApiKey] = useState("");
const [envText, setEnvText] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState<"grant" | "revoke" | "credentials" | "deleteCredentials" | null>(null);
useEffect(() => {
if (open) {
setApiKey("");
setEnvText("");
setError("");
setSaving(null);
}
}, [open, target]);
if (!target) return null;
const { capability } = target;
const isMCP = capability.type === "mcp_server";
const run = async (
action: "grant" | "revoke" | "credentials" | "deleteCredentials",
task: () => Promise<void>,
) => {
setSaving(action);
setError("");
try {
await task();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("detail.contexts.actionFailed"));
} finally {
setSaving(null);
}
};
const saveCredentials = async () => {
let env: Record<string, string> | undefined;
try {
env = parseEnvJSON(envText);
} catch {
setError(t("detail.contexts.invalidEnv"));
return;
}
if (!isMCP && !env) {
setError(t("detail.contexts.envRequired"));
return;
}
if (isMCP && !env && !apiKey.trim()) {
setError(t("detail.contexts.credentialRequired"));
return;
}
await run("credentials", () => onSaveCredentials(target, { apiKey: apiKey.trim() || undefined, env }));
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!saving) onOpenChange(next); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("detail.contexts.manageCapability")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="text-sm font-medium">{capability.display_name || capability.name}</div>
<div className="font-mono text-xs text-muted-foreground">{capability.name}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={() => run("grant", () => onGrant(target))}
disabled={!!saving}
>
{saving === "grant" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
{t("detail.contexts.grant")}
</Button>
<Button
variant="outline"
onClick={() => run("revoke", () => onRevoke(target))}
disabled={!!saving || !capability.context_grant_configured}
>
{saving === "revoke" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldX className="h-4 w-4" />}
{t("detail.contexts.revoke")}
</Button>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t("detail.contexts.scopedCredential")}</div>
{isMCP && (
<Input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={t("detail.contexts.apiKeyPlaceholder")}
/>
)}
<Textarea
value={envText}
onChange={(event) => setEnvText(event.target.value)}
placeholder={t("detail.contexts.envPlaceholder")}
size="lg"
/>
<p className="text-xs text-muted-foreground">{t("detail.contexts.secretHint")}</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
variant="destructive"
onClick={() => run("deleteCredentials", () => onDeleteCredentials(target))}
disabled={!!saving || !capability.context_credentials_configured}
>
{saving === "deleteCredentials" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
{t("detail.contexts.deleteCredential")}
</Button>
<Button onClick={saveCredentials} disabled={!!saving}>
{saving === "credentials" ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{t("detail.contexts.saveCredential")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,266 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Settings2, ShieldCheck, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/shared/empty-state";
import type { ChannelCapability, ChannelContextData, ChannelContextMember } from "@/types/channel";
import {
ChannelContextCapabilityAdminDialog,
type ChannelContextCapabilityTarget,
} from "./channel-context-capability-admin-dialog";
import type { ChannelCredentialPayload } from "../hooks/use-channel-detail";
interface ChannelContextsTabProps {
listContexts: () => Promise<ChannelContextData[]>;
listContextMembers: (scopeType: string, scopeKey: string) => Promise<ChannelContextMember[]>;
listContextCapabilities: (scopeType: string, scopeKey: string) => Promise<ChannelCapability[]>;
onGrantCapability: (target: ChannelContextCapabilityTarget) => Promise<void>;
onRevokeCapability: (target: ChannelContextCapabilityTarget) => Promise<void>;
onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise<void>;
onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise<void>;
}
function contextKey(ctx: ChannelContextData) {
return `${ctx.scope_type}:${ctx.scope_key}`;
}
function capabilityLabel(cap: ChannelCapability) {
return cap.display_name || cap.name;
}
export function ChannelContextsTab({
listContexts,
listContextMembers,
listContextCapabilities,
onGrantCapability,
onRevokeCapability,
onSaveCredentials,
onDeleteCredentials,
}: ChannelContextsTabProps) {
const { t } = useTranslation("channels");
const [contexts, setContexts] = useState<ChannelContextData[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [members, setMembers] = useState<Record<string, ChannelContextMember[]>>({});
const [capabilities, setCapabilities] = useState<Record<string, ChannelCapability[]>>({});
const [loadingRows, setLoadingRows] = useState<Record<string, boolean>>({});
const [adminTarget, setAdminTarget] = useState<ChannelContextCapabilityTarget | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setContexts(await listContexts());
} finally {
setLoading(false);
}
}, [listContexts]);
useEffect(() => {
refresh();
}, [refresh]);
const loadContextDetail = useCallback(
async (ctx: ChannelContextData) => {
const key = contextKey(ctx);
setLoadingRows((prev) => ({ ...prev, [key]: true }));
try {
const [nextMembers, nextCapabilities] = await Promise.all([
listContextMembers(ctx.scope_type, ctx.scope_key),
listContextCapabilities(ctx.scope_type, ctx.scope_key),
]);
setMembers((prev) => ({ ...prev, [key]: nextMembers }));
setCapabilities((prev) => ({ ...prev, [key]: nextCapabilities }));
} finally {
setLoadingRows((prev) => ({ ...prev, [key]: false }));
}
},
[listContextCapabilities, listContextMembers],
);
const rows = useMemo(() => contexts, [contexts]);
const toggle = (ctx: ChannelContextData) => {
const key = contextKey(ctx);
const open = !expanded[key];
setExpanded((prev) => ({ ...prev, [key]: open }));
if (open && !capabilities[key]) {
loadContextDetail(ctx);
}
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{t("detail.contexts.description")}
</p>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={refresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{!loading && rows.length === 0 ? (
<EmptyState
icon={ShieldCheck}
title={t("detail.contexts.emptyTitle")}
description={t("detail.contexts.emptyHint")}
/>
) : (
<div className="rounded-md border divide-y">
{rows.map((ctx) => {
const key = contextKey(ctx);
const isOpen = !!expanded[key];
const rowMembers = members[key] ?? [];
const rowCapabilities = capabilities[key] ?? [];
const isLoading = !!loadingRows[key];
const scopedCapabilities = rowCapabilities.filter(
(cap) => cap.context_grant_configured || cap.context_credentials_configured || cap.source === ctx.scope_type,
);
return (
<div key={key}>
<button
type="button"
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30"
onClick={() => toggle(ctx)}
>
{isOpen
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{ctx.display_name || ctx.scope_key}</span>
<Badge variant="outline">{ctx.scope_type}</Badge>
{ctx.source && <Badge variant="secondary">{ctx.source}</Badge>}
{ctx.live_members_supported && <Badge variant="success">{t("detail.contexts.live")}</Badge>}
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{ctx.scope_key}</div>
</div>
{typeof ctx.member_count === "number" && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums">
{ctx.member_count}
</span>
)}
</button>
{isOpen && (
<div className="space-y-4 border-t bg-muted/10 px-4 py-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("detail.contexts.loading")}
</div>
) : (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("detail.contexts.capabilities")}</h3>
{rowCapabilities.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("detail.contexts.noCapabilities")}</p>
) : (
<div className="overflow-x-auto rounded-md border bg-background">
<table className="w-full min-w-[680px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.name")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.type")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.source")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.credentials")}</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.tools")}</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-muted-foreground">{t("detail.contexts.columns.actions")}</th>
</tr>
</thead>
<tbody>
{rowCapabilities.map((cap) => (
<tr key={`${cap.type}:${cap.id}`} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-3 py-2">
<div className="font-medium">{capabilityLabel(cap)}</div>
<div className="font-mono text-xs text-muted-foreground">{cap.name}</div>
</td>
<td className="px-3 py-2"><Badge variant="outline">{cap.type}</Badge></td>
<td className="px-3 py-2">{cap.source}</td>
<td className="px-3 py-2">
<Badge variant={cap.has_credential ? "success" : "secondary"}>
{cap.has_credential ? cap.credential_source : t("detail.contexts.none")}
</Badge>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{(cap.tool_allow?.length ?? 0) > 0 ? cap.tool_allow?.join(", ") : t("detail.contexts.allTools")}
</td>
<td className="px-3 py-2 text-right">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setAdminTarget({ context: ctx, capability: cap })}
aria-label={t("detail.contexts.manageCapability")}
>
<Settings2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{scopedCapabilities.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("detail.contexts.scopedCount", { count: scopedCapabilities.length })}
</p>
)}
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("detail.contexts.members")}</h3>
{rowMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("detail.contexts.noMembers")}</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{rowMembers.map((member) => (
<div key={member.platform_id} className="rounded-md border bg-background px-3 py-2">
<div className="flex items-center gap-2">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate text-sm font-medium">{member.display_name || member.username || member.platform_id}</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">{member.user_id || member.platform_id}</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
<ChannelContextCapabilityAdminDialog
target={adminTarget}
open={!!adminTarget}
onOpenChange={(open) => { if (!open) setAdminTarget(null); }}
onGrant={async (target) => {
await onGrantCapability(target);
await loadContextDetail(target.context);
}}
onRevoke={async (target) => {
await onRevokeCapability(target);
await loadContextDetail(target.context);
}}
onSaveCredentials={async (target, payload) => {
await onSaveCredentials(target, payload);
await loadContextDetail(target.context);
}}
onDeleteCredentials={async (target) => {
await onDeleteCredentials(target);
await loadContextDetail(target.context);
}}
/>
</div>
);
}
@@ -10,6 +10,7 @@ import { ChannelGeneralTab } from "./channel-general-tab";
import { ChannelCredentialsTab } from "./channel-credentials-tab";
import { ChannelGroupsTab } from "./channel-groups-tab";
import { ChannelManagersTab } from "./channel-managers-tab";
import { ChannelContextsTab } from "./channel-contexts-tab";
import { ChannelDiagnosticsCard } from "./channel-diagnostics-card";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { useChannels } from "../hooks/use-channels";
@@ -30,7 +31,7 @@ interface ChannelDetailPageProps {
}
const DEFAULT_CHANNEL_DETAIL_TAB = "general";
const baseChannelDetailTabs = new Set(["general", "credentials", "managers"]);
const baseChannelDetailTabs = new Set(["general", "credentials", "contexts", "managers"]);
export function resolveChannelDetailTab(
requestedTab: string | null,
@@ -60,6 +61,13 @@ export function ChannelDetailPage({
listManagers,
addManager,
removeManager,
listContexts,
listContextMembers,
listContextCapabilities,
upsertContextGrant,
deleteContextGrant,
setContextCredentials,
deleteContextCredentials,
} = useChannelDetail(instanceId);
const { agents } = useAgents();
const { channels } = useChannels();
@@ -198,6 +206,9 @@ export function ChannelDetailPage({
{t("detail.tabs.groups")}
</TabsTrigger>
)}
<TabsTrigger value="contexts">
{t("detail.tabs.contexts")}
</TabsTrigger>
<TabsTrigger value="managers">
{t("detail.tabs.managers")}
</TabsTrigger>
@@ -228,6 +239,34 @@ export function ChannelDetailPage({
</TabsContent>
)}
<TabsContent value="contexts" className="mt-4">
<ChannelContextsTab
listContexts={listContexts}
listContextMembers={listContextMembers}
listContextCapabilities={listContextCapabilities}
onGrantCapability={(target) => upsertContextGrant({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
onRevokeCapability={(target) => deleteContextGrant({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
onSaveCredentials={(target, payload) => setContextCredentials({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
}, payload)}
onDeleteCredentials={(target) => deleteContextCredentials({
scopeType: target.context.scope_type,
scopeKey: target.context.scope_key,
capability: target.capability,
})}
/>
</TabsContent>
<TabsContent value="managers" className="mt-4">
<ChannelManagersTab
listManagerGroups={listManagerGroups}
@@ -5,10 +5,25 @@ import { queryKeys } from "@/lib/query-keys";
import { toast } from "@/stores/use-toast-store";
import i18next from "i18next";
import { userFriendlyError } from "@/lib/error-utils";
import type { ChannelInstanceData } from "@/types/channel";
import type {
ChannelCapability,
ChannelContextData,
ChannelContextMember,
ChannelInstanceData,
} from "@/types/channel";
import type { ChannelContact } from "@/types/contact";
export type { ChannelContact };
export type ChannelCapabilityMutation = {
scopeType: string;
scopeKey: string;
capability: ChannelCapability;
};
export interface ChannelCredentialPayload {
apiKey?: string;
env?: Record<string, string>;
}
export interface GroupManagerGroupInfo {
group_id: string;
@@ -96,6 +111,83 @@ export function useChannelDetail(instanceId: string | undefined) {
[instanceId, http],
);
const listContexts = useCallback(async (): Promise<ChannelContextData[]> => {
if (!instanceId) return [];
const res = await http.get<{ contexts: ChannelContextData[] }>(
`/v1/channels/instances/${instanceId}/contexts`,
);
return res.contexts ?? [];
}, [instanceId, http]);
const listContextMembers = useCallback(
async (scopeType: string, scopeKey: string): Promise<ChannelContextMember[]> => {
if (!instanceId) return [];
const res = await http.get<{ members: ChannelContextMember[] }>(
`/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}/members`,
);
return res.members ?? [];
},
[instanceId, http],
);
const listContextCapabilities = useCallback(
async (scopeType: string, scopeKey: string): Promise<ChannelCapability[]> => {
if (!instanceId) return [];
const res = await http.get<{ capabilities: ChannelCapability[] }>(
`/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}/capabilities`,
);
return res.capabilities ?? [];
},
[instanceId, http],
);
const contextPath = useCallback((scopeType: string, scopeKey: string) => {
return `/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}`;
}, [instanceId]);
const upsertContextGrant = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-grants" : "cli-grants";
await http.put(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`, { enabled: true });
toast.success(i18next.t("channels:detail.contexts.grantSaved"));
},
[contextPath, http, instanceId],
);
const deleteContextGrant = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-grants" : "cli-grants";
await http.delete(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`);
toast.success(i18next.t("channels:detail.contexts.grantDeleted"));
},
[contextPath, http, instanceId],
);
const setContextCredentials = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation, payload: ChannelCredentialPayload) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-credentials" : "cli-credentials";
const body = capability.type === "mcp_server"
? { api_key: payload.apiKey, env: payload.env }
: { env_vars: payload.env };
await http.put(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`, body);
toast.success(i18next.t("channels:detail.contexts.credentialSaved"));
},
[contextPath, http, instanceId],
);
const deleteContextCredentials = useCallback(
async ({ scopeType, scopeKey, capability }: ChannelCapabilityMutation) => {
if (!instanceId) return;
const segment = capability.type === "mcp_server" ? "mcp-credentials" : "cli-credentials";
await http.delete(`${contextPath(scopeType, scopeKey)}/${segment}/${capability.id}`);
toast.success(i18next.t("channels:detail.contexts.credentialDeleted"));
},
[contextPath, http, instanceId],
);
const listContacts = useCallback(
async (search: string, channelType?: string): Promise<ChannelContact[]> => {
const params: Record<string, string> = {};
@@ -116,6 +208,13 @@ export function useChannelDetail(instanceId: string | undefined) {
listManagers,
addManager,
removeManager,
listContexts,
listContextMembers,
listContextCapabilities,
upsertContextGrant,
deleteContextGrant,
setContextCredentials,
deleteContextCredentials,
listContacts,
refresh: invalidate,
};
+34
View File
@@ -50,3 +50,37 @@ export interface ChannelInstanceInput {
config?: Record<string, unknown>;
enabled?: boolean;
}
export interface ChannelContextData {
scope_type: "channel" | "group" | "user" | "role";
scope_key: string;
display_name: string;
source: string;
live_members_supported: boolean;
member_count?: number;
last_seen_at?: string;
}
export interface ChannelContextMember {
platform_id: string;
user_id?: string;
display_name?: string;
username?: string;
source: string;
last_seen_at?: string;
}
export interface ChannelCapability {
type: "mcp_server" | "secure_cli";
id: string;
name: string;
display_name?: string;
enabled: boolean;
source: string;
tool_allow?: string[];
tool_deny?: string[];
credential_source: string;
has_credential: boolean;
context_grant_configured: boolean;
context_credentials_configured: boolean;
}