Files
goclaw/internal/agent/resolver.go
T
thotam 0ae55991bb feat(mcp): MCP OAuth 2.1 client for tool servers (#1196)
* feat(mcp): MCP OAuth 2.1 client — full implementation with tests

Implements a complete MCP OAuth 2.1 authorization flow for tool servers that
require user-delegated access, covering all layers from DB to UI.

- discovery.go: RFC 9728 protected-resource → RFC 8414 AS metadata → OIDC
  fallback chain with 5-min in-memory cache and InvalidateCache()
- dcr.go: RFC 7591 Dynamic Client Registration with response size guard
- flow.go: PKCE (S256) authorization code flow — StartFlow(), ExchangeCode(),
  ClientCredentials(), auto-cleanup of expired flows; carries AS issuer through
  PendingFlow for status display
- refresher.go: OAuthTokenProvider with in-memory token cache, automatic refresh
  on expiry, per-user vs global slot isolation, InvalidateCache/InvalidateServer

- migrations/000074 + SQLite schema: mcp_oauth_tokens with AES-256-GCM encrypted
  access/refresh tokens, partial unique index for global vs per-user rows,
  ON DELETE CASCADE from mcp_servers

- store.MCPOAuthTokenStore: Upsert, Get/GetUser, Delete/DeleteUser, and
  DeleteServerOAuthTokens (purge all rows for a server)
- PostgreSQL + SQLite implementations

- POST   /v1/mcp/oauth/start      — discovery + optional DCR + PKCE redirect URL;
  client_credentials completes server-side (no redirect) and returns completed=true
- GET    /v1/mcp/oauth/callback   — exchange code, persist token, publish WS event;
  payload built via json.Marshal (no reflected XSS via error_description)
- GET    /v1/mcp/oauth/status/{id}, DELETE /v1/mcp/oauth/token/{id} — admin-gated
- POST   /v1/mcp/oauth/discover/{id} — on-demand discovery probe
- All outbound calls go through the SSRF-safe client with pinned IPs

- pkg/protocol/mcp_events.go: EventMCPOAuthComplete routed only to the initiating
  user (admins in-tenant included); fail-closed across tenants

- getUserMCPTools() injects Authorization: Bearer from OAuthTokenProvider; on a
  401 for OAuth servers it purges the cached token so the next turn re-resolves

- handleUpdateServer purges all OAuth tokens (global + per-user), drops the
  refresher cache, and evicts the pool when a server's URL or OAuth config
  (client_id / endpoints / grant_type / scope / auth_type) changes — so the
  status UI and agent never use a token minted for the old resource/AS

- MCPOAuthDialog (WS-driven), unified user-credentials dialog, OAuth settings
  fields; handles the no-redirect client_credentials completion

- internal/mcp/oauth/*_test.go: discovery cache, PKCE, DCR, refresher
- internal/http/mcp_oauth_test.go + mcp_update_oauth_purge_test.go: routes, auth
  gating, WS event, purge-on-URL/OAuth-config-change
- tests/integration: store + encryption + tenant isolation, E2E start→callback,
  DeleteServerOAuthTokens
- internal/gateway/event_filter_test.go, internal/agent/loop_mcp_user_test.go

* fix(mcp): return 400 on OAuth callback with code but missing state

The callback handler rendered a 200 HTML page whenever code or state was
absent. An auth code WITH a missing state is a malformed / CSRF-risk
callback (state is the CSRF token), so reject that case with HTTP 400.
A bare hit with neither code nor state (user opening the URL directly),
provider errors, and exchange failures keep their 200 HTML popup page.

Adds a status code parameter to writeCallbackHTML. Fixes the
TestOAuthCallbackMissingState integration regression while keeping
TestHandleCallbackMissingCodeAndState (no params -> 200) green.

* fix(mcp): scope-based OAuth auth + honor manual OAuth endpoints

Addresses the two MCP/OAuth security-review findings.

Finding 1 — authorization. mcp_oauth_tokens is tenant-scoped, but
start/status/revoke were gated only by requireAuth(RoleAdmin), an RBAC
role check, not tenant membership, so a RoleAdmin caller could act on a
tenant they don't administer. A blanket requireTenantAdmin would have
broken per-user self-service, which the UI exposes (the per-user
MCPUserCredentialsDialog shows an "Authorize" button to regular users for
their own credentials). Instead mirror the existing per-user MCP
credentials model (resolveTargetUserID in mcp_user_credentials.go):
- start/status/revoke accept any authenticated user; each handler calls
  authorizeOAuthScope.
- a caller may manage their OWN per-user token (self-service); the
  global/server token (user_id="") and other users' tokens require
  tenant-admin (owner bypass), so a RoleAdmin that is not a tenant admin
  is rejected.
- discover stays admin-only (it only previews AS metadata for a server).
Add a TenantStore dependency. Tests cover self-service, on-behalf-of-
another (403), and global-by-non-tenant-admin (403).

Finding 2 — honor manual OAuth config end-to-end. The UI sent use_dcr /
auth_endpoint / token_endpoint and the update path fingerprinted them for
purge, but handleStart always discovered + DCR'd and ignored them. Now:
- use_dcr=false (a *bool, so legacy/absent stays discover+DCR) skips
  discovery/registration and uses the operator endpoints, SSRF-validated.
- token_endpoint is always required; auth_endpoint only for auth-code
  grants — client_credentials needs no authorization URL, matching the UI
  which hides that field for that grant.
- the refresher already refreshes against the stored token_endpoint and
  the callback persists it, so manual-mode tokens refresh correctly.
- oauthFingerprint includes use_dcr (nil normalized to true) so toggling
  DCR mode purges stale tokens.
- the web form only serializes manual endpoints when use_dcr is off.

Audited all MCP dialogs (form, global OAuth, per-user credentials, grants,
tools): OAuth dialogs handle completed/auth_url identically and read
config from stored server settings; runtime connect uses the stored token
via the refresher (no re-discovery).

Tests: manual auth-code + client_credentials endpoints, missing/SSRF
endpoints, and the full self/global/on-behalf authorization matrix.
2026-06-21 22:27:44 +07:00

631 lines
24 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
// ResolverDeps holds shared dependencies for the agent resolver.
type ResolverDeps struct {
AgentStore store.AgentStore
ProviderStore store.ProviderStore
ProviderReg *providers.Registry
ModelRegistry providers.ModelRegistry // per-model context window + capabilities lookup
Bus bus.EventPublisher
Sessions store.SessionStore
Tools *tools.Registry
ToolPolicy *tools.PolicyEngine
Skills *skills.Loader
HasMemory bool
OnEvent func(AgentEvent)
TraceCollector *tracing.Collector
// Per-user profile + file seeding + dynamic context loading
EnsureUserProfile EnsureUserProfileFunc
SeedUserFiles SeedUserFilesFunc
ContextFileLoader ContextFileLoaderFunc
BootstrapCleanup BootstrapCleanupFunc
CacheInvalidate CacheInvalidateFunc
DefaultTimezone string // system default timezone for bootstrap pre-fill
// Security
InjectionAction string // "log", "warn", "block", "off"
MaxMessageChars int
// Global defaults (from config.json) — per-agent DB overrides take priority
CompactionCfg *config.CompactionConfig
ContextPruningCfg *config.ContextPruningConfig
SandboxEnabled bool
SandboxContainerDir string
SandboxWorkspaceAccess string
// Inter-agent delegation
AgentLinkStore store.AgentLinkStore
// Agent teams
TeamStore store.TeamStore
DataDir string // global workspace root for team workspace resolution
// Secure CLI credential store for credentialed exec
SecureCLIStore store.SecureCLIStore
// Builtin tool settings
BuiltinToolStore store.BuiltinToolStore
// MCP server store — for per-agent MCP tool loading
MCPStore store.MCPServerStore
// Shared MCP connection pool — eliminates duplicate connections across agents
MCPPool *mcpbridge.Pool
// MCP grant checker — for runtime grant verification at BridgeTool.Execute
MCPGrantChecker mcpbridge.GrantChecker
// MCP OAuth token provider — injects Bearer tokens for OAuth-enabled MCP servers
MCPOAuthTokenProvider mcpbridge.OAuthTokenProvider
// Skill access store — for per-agent skill visibility filtering
SkillAccessStore store.SkillAccessStore
SkillStore store.SkillStore
SkillEvolutionStore store.SkillEvolutionStore
SkillSlashCommands config.SkillSlashCommandConfig
// Config permission store for group file writer checks
ConfigPermStore store.ConfigPermissionStore
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
// Model pricing for cost tracking
ModelPricing map[string]*config.ModelPricing
// Tracing store for budget enforcement queries
TracingStore store.TracingStore
UsageCaps *usagecaps.Service
UsageEvents store.UsageEventStore
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
// V3 evolution metrics store
EvolutionMetricsStore store.EvolutionMetricsStore
// Contact store for user identity resolution (channel contacts → tenant users)
ContactStore store.ContactStore
// Tenant store for workspace path resolution
TenantStore store.TenantStore
// Per-tenant tool/skill config overrides
BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore
SkillTenantCfgs store.SkillTenantConfigStore
// System config store for tenant-scoped settings (allowed_paths, etc.)
SystemConfigs store.SystemConfigStore
// Global workspace root (GOCLAW_WORKSPACE)
Workspace string
// TTS auto mode from config: "off", "always", "inbound", "tagged"
TTSAutoMode string
// V3 auto-inject: episodic memory injection into system prompt (nil = disabled)
AutoInjector memory.AutoInjector
// V3 domain event bus for consolidation pipeline (nil = disabled)
DomainBus eventbus.DomainEventBus
// HookDispatcher fires lifecycle hook events (Issue #875). Nil = noop.
HookDispatcher hooks.Dispatcher
// Vault hook: called when a text file is uploaded by user (nil = no vault registration)
OnTextUploaded func(ctx context.Context, path, content string)
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
// Agents are defined in Postgres, not config.json.
func NewManagedResolver(deps ResolverDeps) ResolverFunc {
return func(ctx context.Context, agentKey string) (Agent, error) {
// Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID)
var ag *store.AgentData
var err error
if id, parseErr := uuid.Parse(agentKey); parseErr == nil {
ag, err = deps.AgentStore.GetByID(ctx, id)
} else {
ag, err = deps.AgentStore.GetByKey(ctx, agentKey)
}
if err != nil {
return nil, fmt.Errorf("agent not found: %s", agentKey)
}
if ag.Status != store.AgentStatusActive {
return nil, fmt.Errorf("agent %s is inactive", agentKey)
}
// Resolve provider (tenant-aware: tries tenant-specific first, falls back to master)
provider, err := providerresolve.ResolveAgentProvider(deps.ProviderReg, ag)
if err != nil {
// Fallback to any available provider for this tenant
names := deps.ProviderReg.ListForTenant(ag.TenantID)
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured for agent %s", agentKey)
}
provider, _ = deps.ProviderReg.GetForTenant(ag.TenantID, names[0])
slog.Warn("agent provider not found, using fallback",
"agent", agentKey, "wanted", ag.Provider, "using", names[0])
if rc := ag.ParseReasoningConfig(); rc.Effort != "" && rc.Effort != "off" {
slog.Warn("agent thinking may not be supported by fallback provider",
"agent", agentKey, "thinking_level", rc.Effort,
"wanted_provider", ag.Provider, "fallback_provider", names[0])
}
}
if provider == nil {
return nil, fmt.Errorf("no provider available for agent %s", agentKey)
}
providerReasoningDefaults := (*store.ProviderReasoningConfig)(nil)
if deps.ProviderStore != nil {
if providerData, err := deps.ProviderStore.GetProviderByName(ctx, provider.Name()); err == nil && providerData != nil {
providerReasoningDefaults = store.ParseProviderReasoningConfig(providerData.Settings)
}
}
// Load bootstrap files from DB
contextFiles := bootstrap.LoadFromStore(ctx, deps.AgentStore, ag.ID)
// Inject TEAM.md for all team members (lead + members) so every agent
// knows the team workflow: create/claim/complete tasks via team_tasks tool.
hasTeam := false
isTeamLead := false
if deps.TeamStore != nil {
hasTeamMD := false
for _, cf := range contextFiles {
if cf.Path == bootstrap.TeamFile {
hasTeamMD = true
break
}
}
if !hasTeamMD {
if team, err := deps.TeamStore.GetTeamForAgent(ctx, ag.ID); err == nil && team != nil {
if members, err := deps.TeamStore.ListMembers(ctx, team.ID); err == nil {
hasTeam = true
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.TeamFile,
Content: buildTeamMD(team, members, ag.ID),
})
// Detect lead role for tool policy
for _, m := range members {
if m.AgentID == ag.ID && m.Role == store.TeamRoleLead {
isTeamLead = true
break
}
}
}
}
} else {
hasTeam = true
}
}
// Inject negative context so the model doesn't waste iterations probing
// unavailable capabilities (team_tasks, etc.).
if !hasTeam {
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.AvailabilityFile,
Content: "You are NOT part of any team. Do not use team_tasks tool.",
})
}
contextWindow := ag.ContextWindow
if contextWindow <= 0 {
contextWindow = config.DefaultContextWindow
}
maxIter := ag.MaxToolIterations
if maxIter <= 0 {
maxIter = config.DefaultMaxIterations
}
// Per-agent config overrides (fallback to global defaults from config.json)
compactionCfg := deps.CompactionCfg
if c := ag.ParseCompactionConfig(); c != nil {
compactionCfg = c
}
contextPruningCfg := deps.ContextPruningCfg
if c := ag.ParseContextPruning(); c != nil {
contextPruningCfg = c
}
sandboxEnabled := deps.SandboxEnabled
sandboxContainerDir := deps.SandboxContainerDir
sandboxWorkspaceAccess := deps.SandboxWorkspaceAccess
var sandboxCfgOverride *sandbox.Config
if c := ag.ParseSandboxConfig(); c != nil {
resolved := c.ToSandboxConfig()
sandboxContainerDir = resolved.ContainerWorkdir()
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
sandboxCfgOverride = &resolved
}
// Resolve tenant slug once for workspace + dataDir scoping.
var tenantSlug string
if ag.TenantID != store.MasterTenantID && ag.TenantID != uuid.Nil {
tenantSlug = resolveTenantSlug(deps.TenantStore, ag.TenantID)
}
// Expand ~ in workspace path and ensure directory exists.
// For non-master tenants, prefix workspace with tenant slug directory.
workspace := ag.Workspace
if workspace != "" {
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
}
if tenantSlug != "" {
if deps.Workspace != "" {
workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug)
}
}
// Fallback to global workspace if per-agent workspace is empty
if workspace == "" && deps.Workspace != "" {
workspace = deps.Workspace
}
if workspace != "" {
if err := os.MkdirAll(workspace, 0755); err != nil {
slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err)
}
}
toolsReg := deps.Tools
// Per-agent MCP servers: connect to granted MCP servers and register their tools.
// Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers.
//
// IMPORTANT: Always clone the registry before MCP registration to prevent
// cross-agent tool leaks. Without cloning, MCP BridgeTools registered for
// one agent pollute the shared deps. Tools and become visible to ALL agents
// (even those without MCP grants), because FilterTools reads from registry.List().
hasMCPTools := false
var mcpUserCredSrvs []store.MCPAccessInfo
if deps.MCPStore != nil {
if toolsReg == deps.Tools {
toolsReg = deps.Tools.Clone()
}
var mcpOpts []mcpbridge.ManagerOption
mcpOpts = append(mcpOpts, mcpbridge.WithStore(deps.MCPStore))
if deps.MCPPool != nil {
mcpOpts = append(mcpOpts, mcpbridge.WithPool(deps.MCPPool))
}
if deps.MCPGrantChecker != nil {
mcpOpts = append(mcpOpts, mcpbridge.WithGrantChecker(deps.MCPGrantChecker))
}
if deps.MCPOAuthTokenProvider != nil {
mcpOpts = append(mcpOpts, mcpbridge.WithOAuthTokenProvider(deps.MCPOAuthTokenProvider))
}
mcpMgr := mcpbridge.NewManager(toolsReg, mcpOpts...)
if err := mcpMgr.LoadForAgent(ctx, ag.ID, ""); err != nil {
slog.Warn("failed to load MCP servers for agent", "agent", agentKey, "error", err)
} else {
mcpUserCredSrvs = mcpMgr.UserCredServers()
// User-credential servers (Notion, etc.) are deferred at startup
// but will produce tools per-request via getUserMCPTools.
// Set flag so agentToolPolicyWithMCP injects "group:mcp" into alsoAllow.
if len(mcpUserCredSrvs) > 0 {
hasMCPTools = true
}
if mcpMgr.IsSearchMode() {
// Search mode: too many tools — register mcp_tool_search meta-tool.
// Also wire lazy activator so deferred tools can be called by name directly.
toolsReg.SetDeferredActivator(mcpMgr.ActivateToolIfDeferred)
searchTool := mcpbridge.NewMCPToolSearchTool(mcpMgr)
toolsReg.Register(searchTool)
hasMCPTools = true
slog.Info("mcp.agent.search_mode", "agent", agentKey,
"deferred_tools", len(mcpMgr.DeferredToolInfos()))
} else {
toolNames := mcpMgr.ToolNames()
if len(toolNames) > 0 {
hasMCPTools = true
slog.Info("mcp.agent.tools_loaded", "agent", agentKey, "tools", len(toolNames))
}
}
}
}
// Per-agent memory: enabled if global memory manager exists AND
// per-agent config doesn't explicitly disable it.
hasMemory := deps.HasMemory
if mc := ag.ParseMemoryConfig(); mc != nil && mc.Enabled != nil {
if !*mc.Enabled {
hasMemory = false
}
}
// Load global builtin tool settings from DB (for settings cascade)
var builtinSettings tools.BuiltinToolSettings
if deps.BuiltinToolStore != nil {
if allTools, err := deps.BuiltinToolStore.List(ctx); err == nil {
builtinSettings = make(tools.BuiltinToolSettings, len(allTools))
for _, t := range allTools {
if len(t.Settings) > 0 && string(t.Settings) != "{}" {
builtinSettings[t.Name] = []byte(t.Settings)
}
}
}
}
// Load per-tenant tool exclusions (disabled tools for this agent's tenant)
// AND per-tenant tool settings overlay (tier 2 in the 4-tier cascade).
var (
disabledTools map[string]bool
tenantToolSettings tools.BuiltinToolSettings
)
if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil {
if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 {
disabledTools = make(map[string]bool, len(disabled))
for _, name := range disabled {
disabledTools[name] = true
}
slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled))
}
if settings, err := deps.BuiltinToolTenantCfgs.ListAllSettings(ctx, ag.TenantID); err != nil {
// Log but don't fail agent creation — fall back to global/hardcoded defaults.
slog.Warn("failed to load tenant tool settings", "agent", agentKey, "tenant", ag.TenantID, "error", err)
} else if len(settings) > 0 {
tenantToolSettings = make(tools.BuiltinToolSettings, len(settings))
for name, raw := range settings {
tenantToolSettings[name] = []byte(raw)
}
slog.Debug("tenant tool settings loaded", "agent", agentKey, "tenant", ag.TenantID, "tools", len(tenantToolSettings))
}
}
// Load tenant-specific allowed paths (from system_configs['allowed_paths']).
// These extend filesystem tool access beyond the agent's workspace.
var tenantAllowedPaths []string
if deps.SystemConfigs != nil && ag.TenantID != uuid.Nil {
tenantCtx := store.WithTenantID(ctx, ag.TenantID)
if raw, err := deps.SystemConfigs.Get(tenantCtx, "allowed_paths"); err == nil && raw != "" {
if json.Unmarshal([]byte(raw), &tenantAllowedPaths) == nil && len(tenantAllowedPaths) > 0 {
// Expand home directory in paths
for i, p := range tenantAllowedPaths {
tenantAllowedPaths[i] = config.ExpandHome(p)
}
slog.Debug("tenant allowed paths loaded", "agent", agentKey, "tenant", ag.TenantID, "paths", len(tenantAllowedPaths))
}
}
}
// Filter skills by visibility + agent grants.
// Only public skills and explicitly granted internal skills appear in the system prompt.
var skillAllowList []string
if deps.SkillAccessStore != nil {
if accessible, err := deps.SkillAccessStore.ListAccessible(ctx, ag.ID, ""); err == nil {
skillAllowList = make([]string, 0, len(accessible))
for _, sk := range accessible {
skillAllowList = append(skillAllowList, sk.Slug)
}
slog.Debug("skill visibility filter", "agent", agentKey, "accessible", len(skillAllowList))
} else {
slog.Warn("failed to load accessible skills, falling back to all", "agent", agentKey, "error", err)
// nil = fallback to all (better than blocking all skills)
}
}
// Resolve tenant-scoped DataDir for team workspace resolution.
dataDir := deps.DataDir
if tenantSlug != "" {
dataDir = config.TenantDataDir(deps.DataDir, ag.TenantID, tenantSlug)
}
// v3 feature flags (from other_config JSONB).
// NOTE: flags are immutable per-Loop — changes via admin API take effect on next session only.
// In-flight loops continue with the flags set at creation. This is by design:
// CacheKindAgent invalidation destroys the old Loop, and the next request creates a new one.
v3f := ag.ParseV3Flags()
// v3 orchestration mode: resolve from team membership + agent links
orchMode := ResolveOrchestrationMode(ctx, ag.ID, deps.TeamStore, deps.AgentLinkStore)
// Populate delegation targets for prompt injection (only when mode >= delegate).
var delegateTargets []DelegateTargetEntry
if orchMode != ModeSpawn && deps.AgentLinkStore != nil {
if links, err := deps.AgentLinkStore.DelegateTargets(ctx, ag.ID); err == nil {
for _, link := range links {
delegateTargets = append(delegateTargets, DelegateTargetEntry{
AgentKey: link.TargetAgentKey,
DisplayName: link.TargetDisplayName,
Description: link.Description,
})
}
}
}
// v3 evolution metrics: only wire store when feature flag enabled
var evoMetricsStore store.EvolutionMetricsStore
if v3f.EvolutionMetrics && deps.EvolutionMetricsStore != nil {
evoMetricsStore = deps.EvolutionMetricsStore
}
restrictVal := true // always restrict agents to their workspace
loop := NewLoop(LoopConfig{
ID: ag.AgentKey,
DisplayName: ag.DisplayName,
AgentUUID: ag.ID,
TenantID: ag.TenantID,
AgentOtherConfig: ag.OtherConfig,
AgentType: ag.AgentType,
IsTeamLead: isTeamLead,
AutoInjector: deps.AutoInjector,
Provider: provider,
Model: ag.Model,
ModelRegistry: deps.ModelRegistry,
ContextWindow: contextWindow,
MaxTokens: ag.ParseMaxTokens(),
MaxIterations: maxIter,
Workspace: workspace,
DataDir: dataDir,
RestrictToWs: &restrictVal,
SubagentsCfg: ag.ParseSubagentsConfig(),
MemoryCfg: ag.ParseMemoryConfig(),
SandboxCfg: sandboxCfgOverride,
Bus: deps.Bus,
DomainBus: deps.DomainBus,
HookDispatcher: deps.HookDispatcher,
Sessions: deps.Sessions,
Tools: toolsReg,
ToolPolicy: deps.ToolPolicy,
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithWorkspace(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), hasTeam), isTeamLead),
SkillsLoader: deps.Skills,
SkillAllowList: skillAllowList,
SkillSlashCommands: deps.SkillSlashCommands,
HasMemory: hasMemory,
ContextFiles: contextFiles,
EnsureUserProfile: deps.EnsureUserProfile,
SeedUserFiles: deps.SeedUserFiles,
ContextFileLoader: deps.ContextFileLoader,
BootstrapCleanup: deps.BootstrapCleanup,
CacheInvalidate: deps.CacheInvalidate,
DefaultTimezone: deps.DefaultTimezone,
OnEvent: deps.OnEvent,
TraceCollector: deps.TraceCollector,
InjectionAction: deps.InjectionAction,
MaxMessageChars: deps.MaxMessageChars,
CompactionCfg: compactionCfg,
ContextPruningCfg: contextPruningCfg,
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
TenantToolSettings: tenantToolSettings,
TenantAllowedPaths: tenantAllowedPaths,
SystemConfigs: deps.SystemConfigs,
DisabledTools: disabledTools,
ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()),
PromptMode: PromptMode(ag.ParsePromptMode()),
PinnedSkills: ag.ParsePinnedSkills(),
SelfEvolve: ag.ParseSelfEvolve(),
AllowImageGeneration: ag.ParseAllowImageGeneration(),
TTSAutoMode: deps.TTSAutoMode,
SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(),
SkillNudgeInterval: ag.ParseSkillNudgeInterval(),
WorkspaceSharing: ag.ParseWorkspaceSharing(),
ShellDenyGroups: ag.ParseShellDenyGroups(),
ConfigPermStore: deps.ConfigPermStore,
TeamStore: deps.TeamStore,
SecureCLIStore: deps.SecureCLIStore,
OnTextUploaded: deps.OnTextUploaded,
MediaStore: deps.MediaStore,
ModelPricing: deps.ModelPricing,
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
TracingStore: deps.TracingStore,
UsageCaps: deps.UsageCaps,
UsageEvents: deps.UsageEvents,
MemoryStore: deps.MemoryStore,
MCPStore: deps.MCPStore,
MCPPool: deps.MCPPool,
MCPUserCredSrvs: mcpUserCredSrvs,
MCPGrantChecker: deps.MCPGrantChecker,
MCPOAuthTokenProvider: deps.MCPOAuthTokenProvider,
OrchMode: orchMode,
DelegateTargets: delegateTargets,
EvolutionMetricsStore: evoMetricsStore,
SkillEvolutionStore: deps.SkillEvolutionStore,
SkillStore: deps.SkillStore,
UserResolver: newContactResolver(deps.ContactStore),
})
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
return loop, nil
}
}
// InvalidateAgent removes an agent from the router cache, forcing re-resolution.
// Used when agent config is updated via API.
// Matches both plain key ("agentKey") and tenant-scoped key ("tenantID:agentKey")
// via exact-segment match (prevents substring collisions like "sub-foo" matching
// when invalidating "foo"). Empty agentKey is rejected to prevent wildcard wipes.
func (r *Router) InvalidateAgent(agentKey string) {
r.mu.Lock()
defer r.mu.Unlock()
for key := range r.agents {
if matchAgentCacheKey(key, agentKey) {
delete(r.agents, key)
}
}
slog.Debug("invalidated agent cache", "agent", agentKey)
}
// InvalidateAll clears the entire agent cache, forcing all agents to re-resolve.
// Used when global tools change (custom tools reload).
func (r *Router) InvalidateAll() {
r.mu.Lock()
defer r.mu.Unlock()
r.agents = make(map[string]*agentEntry)
slog.Debug("invalidated all agent caches")
}
// InvalidateTenant clears all cached agents for a single tenant.
// Cache keys are "tenantID:agentKey" when tenant-scoped (see agentCacheKey).
// Non-tenant entries (bare "agentKey") are untouched. uuid.Nil is a no-op —
// callers use InvalidateAll for global invalidation.
func (r *Router) InvalidateTenant(tenantID uuid.UUID) {
if tenantID == uuid.Nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
prefix := tenantID.String() + ":"
var deleted int
for key := range r.agents {
if strings.HasPrefix(key, prefix) {
delete(r.agents, key)
deleted++
}
}
slog.Debug("invalidated tenant agent cache", "tenant", tenantID, "count", deleted)
}
// resolveTenantSlug looks up the tenant slug for workspace path resolution.
// Returns the tenant ID string as fallback if lookup fails.
func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string {
if ts == nil {
return tenantID.String()
}
tenant, err := ts.GetTenant(context.Background(), tenantID)
if err != nil || tenant == nil {
return tenantID.String()
}
return tenant.Slug
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}