mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 20:13:25 +00:00
* fix(prompt): remove duplicate Team Members section from system prompt The TEAM.md context file already provides the Members section with better formatting. The system prompt section was redundant and inconsistently formatted. - Remove buildTeamMembersSection() call from system prompt building - Remove unused buildTeamMembersSection() function * fix(mcp): wire store to manager for prompt preview tool visibility MCP manager needs database access to query configured servers for tool visibility in prompt preview. - Add SetStore() method to MCP manager - Wire pgStores.MCP to manager after initialization - Add debug logging for MCP initialization flow * fix(systemprompt): hide Tooling section when agent has no tools The Tooling section header and boilerplate were displayed even when the agent had no tools available. Add early return in buildToolingSection() to skip the section entirely when toolNames is empty. This reduces prompt noise for agents with no tool access. * feat(mcp): cache tool descriptions for prompt preview visibility MCP tools now show descriptions in prompt preview without requiring a live server connection. - Add CacheToolDescriptions() method to MCPServerStore (PG + SQLite) - Cache tool descriptions in settings['tool_cache'] when server connects - Use cached descriptions in ListToolsForAgent (fallback: hints → cache → global) - Descriptions are auto-populated from live server manifest on connection - Admins can still override via tool_hints in server settings * test(mcp): add CacheToolDescriptions to MCPServerStore test fakes Commit 5ce410b6 added CacheToolDescriptions() to the MCPServerStore interface but missed updating test mock implementations, breaking go vet across internal/mcp, internal/agent, internal/http, and internal/channels/bitrix24. Add no-op implementations matching each fake's existing style. * fix(providers): enforce per-agent tool policy for Claude CLI provider The Claude CLI provider (stdio+MCP bridge) was not enforcing per-agent tool policy, unlike other providers where the policy-filtered tool list already drives the system prompt's Tooling section. Two gaps closed: 1. --disallowedTools was previously skipped entirely when no MCP config path was resolved, letting the CLI subprocess run with its full native toolset (Bash, Edit, Read, Write, Glob, Grep, WebFetch, WebSearch) regardless of agent policy. It's now unconditional and derived from the agent's actual allowed-tools list (state.Tool.AllowedTools), mapped to Claude CLI's native tool names. 2. The MCP bridge server executed any tool call without checking the calling agent's policy. It now resolves the agent's policy from context (via the existing HMAC-verified agent lookup) and denies calls to tools outside that agent's allowed set, logging security.mcp_bridge_denied on denial. Both gaps were closed using existing plumbing (PolicyEngine.WouldAllow, AgentData.ParseToolsConfig, the bridge context middleware) — no new cross-cutting mechanism was introduced. * feat(web): show MCP/tool schemas in system prompt preview dialog The prompt-preview API response includes a separate `tools` field (the actual JSON schemas sent to the LLM as the tools API parameter) alongside `prompt` (the system prompt text), but the web UI only rendered `prompt`, silently dropping the tools list. Add a collapsible Tools section to both the full-screen System Prompt dialog and the inline agent-detail preview, showing tool count, name, description, and expandable parameter schema per tool. i18n keys added to en/vi/zh locales. * fix(prompt): render pinned skills on bootstrap turns Pinned skills are documented (web UI copy) as "always inlined in the system prompt", but the entire Skills section was gated behind !cfg.IsBootstrap, so pinned skill XML never appeared on bootstrap turns (first message of a session) despite the promise. Separate pinned-skill rendering from bootstrap-suppressed guidance: - Bootstrap + pinned skills present: render pinned XML only, no search/manage guidance (which stays suppressed as before) - Non-bootstrap: unchanged behavior - Minimal/none modes: pinned skills always render regardless of bootstrap state Add regression tests covering all four prompt modes on bootstrap turns, plus a non-bootstrap guard confirming existing behavior is preserved. * fix(skills): resolve managed skills directory per-tenant, not master-only skills.Loader was wired at startup to scan a single fixed directory (the master tenant's managed-skills dir), making any skill belonging to a non-master tenant invisible to both pinned-skills prompt resolution and skill_search/use_skill, regardless of DB visibility settings. - Loader now resolves the calling tenant's managed-skills directory per-call via context (store.TenantIDFromContext), never enumerating other tenants' directories - Skill cache is now tenant-keyed to prevent slug collisions and cross-tenant cache leaks across tenants using the same skill slug - gateway_setup.go passes the root data dir instead of a pre-resolved master-tenant path Write-side tooling (skill_manage, publish_skill) was already correctly tenant-scoped per-operation — no changes needed there. Added TestLoader_ManagedSkills_TenantIsolation proving two tenants with same-slug/different-content skills never see each other's content, including after cache population from a different tenant's lookup. Known follow-up (not in this commit): skill_search's BM25 index is still a single process-global index shared across tenants, which is a related but separate cross-tenant search-result leak requiring its own scoped fix (per-tenant index maps + threading tenant context through ensureIndex/rebuildIndex). * fix(tools): scope skill_search BM25 index per-tenant SkillSearchTool held a single process-global BM25 index built once from whichever tenant's context first triggered ensureIndex, then reused for all subsequent Execute() calls regardless of caller — leaking one tenant's skill search results into another's, the search-path counterpart to the managed-directory bug fixed in 7b4668ad. - index/lastVersion are now keyed per-tenant (map[uuid.UUID]*tenantIndexState) - ensureIndex resolves the calling tenant from context and only builds/reads that tenant's index entry, never touching another tenant's cached state - Builtin/bundled skills remain visible in every tenant's index (Loader already merges those tiers correctly per 7b4668ad) Loader.Version() remains a single global counter — a version bump in one tenant causes unnecessary rebuilds in others but does not cause cross-tenant leakage, an acceptable tradeoff to avoid scope creep. Added TestSkillSearchTool_TenantIsolation proving two tenants with same-slug/different-content skills never see each other's search results, including after cache population from a different tenant. * fix(tools): fix group-spec expansion in tool policy engine PolicyEngine.registry was only ever set via SetRegistry(), which was never called in production (only in one test) — so pe.registry was permanently nil in production. Every group-expansion helper (applyProfile, intersectWithSpec, unionWithSpec, subtractSpec, expandSpec, matchDenySpec, filterByCapability) silently dropped any "group:*" spec entry instead of expanding it when registry was nil. Concretely: "group:mcp" (auto-injected into agentToolPolicy.AlsoAllow for any agent with MCP tools) never resolved to real tool names, so MCP tools connected successfully and appeared in prompt text (which reads the registry directly, bypassing PolicyEngine) but were never included in the actual ChatRequest.Tools payload sent to the LLM — confirmed live via mcp.agent.tools_loaded tools=6 immediately followed by mcp.filtered_tools mcp_defs_count=0 in the same request. This affects any agent relying on group-based grants, not just MCP. PolicyEngine is a shared/global singleton used concurrently across all agents (constructed once at gateway startup), so mutating a registry field per-call would be a data race. Fix instead threads the registry as an explicit parameter from FilterTools down through all internal group-expansion helpers, and adds IsDenied/WouldAllow registry parameters, removing the dead SetRegistry() mechanism entirely. Also fixes group expansion for the per-user-MCP-tools path: FilterTools is sometimes called with a userToolOverlay wrapping a *Registry rather than a *Registry directly; added Unwrap() to userToolOverlay so the new registry-resolution logic works for both cases. Added 4 tests proving group expansion works via the threaded parameter alone (no SetRegistry): plain registry allow, userToolOverlay allow, deny-side group expansion, and the WouldAllow bridge-server path. Blast radius note: this restores intended access for every agent configured with group:* specs (group:mcp, group:vault, group:goclaw, group:coding, etc.) that were silently inert before. Existing agent configs relying on group grants will gain the tool access they were nominally already configured for. * fix(mcp): cache tool descriptions from pool-connected servers too 5ce410b6 added tool-description caching (for prompt-preview visibility) only inside connectServer. connectViaPool — the separate connect path used when MCP connections go through the shared pool — never got the same caching hook, even though it shares the same underlying connectAndDiscover wire handshake. Confirmed live: cloudflare/docker connected via connectViaPool and received real descriptions over the wire, but prompt preview still showed blank descriptions because this path never wrote to the cache. Also removes the temporary mcp.connect.raw_tool debug log added earlier this session for diagnosing the same issue — no longer needed now that the root cause is fixed. * chore(skills): remove temporary pinned-skills diagnostic logging Confirmed live: pinned skills (caveman, infra-ansible-knowledge) now resolve correctly end-to-end for tenant-scoped agents. Debug logging added to trace the resolution chain is no longer needed. * fix(tools): deny always wins over AlsoAllow group grants AlsoAllow's unionWithSpec could reintroduce a tool explicitly listed in Deny, since it added tools back from allTools without re-checking deny specs. Previously masked because AlsoAllow's group-expansion was also broken (fixed in f7af95de this session) — group specs silently expanded to nothing, so this ordering bug never manifested. Now that group expansion works, an admin-denied tool that's also reachable via a group:* AlsoAllow entry (e.g. group:mcp) would silently reappear. Re-apply deny-spec subtraction as a final step after AlsoAllow union, for both global and per-agent policy, so deny always wins regardless of which allow mechanism tries to add a tool back. Added tests proving global and per-agent Deny correctly override an overlapping AlsoAllow group grant, while sibling non-denied tools in the same group remain allowed. * fix(mcp): enumerate cached tools instead of wildcard placeholder in prompt preview ListToolsForAgent (the prompt-preview path) collapsed any server with an empty ToolAllow (unrestricted grant — the common case) into a single "server__*" placeholder entry, even when tool_cache already had every real tool name and description from connect time (5ce410b6, 8ffd67b9). This meant agents with unrestricted MCP server access never saw individual tool names or descriptions in prompt preview, forcing trial-and-error tool usage. When ToolAllow is empty and tool_cache is populated, enumerate every cached tool (skipping any explicitly denied) and emit one MCPToolPreviewInfo per tool, matching the construction logic already used for the ToolAllow-non-empty case. Falls back to the single placeholder only when tool_cache is also empty (server never connected). The live (non-preview) conversation path, buildMCPToolDescs, does not have this bug — it resolves tool identity from the live connected registry, never from ToolAllow, so no placeholder shortcut exists there. Added tests covering both the cache-populated enumeration case and the no-cache placeholder fallback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompt): filter alias re-injection and add MCP schemas to preview ToolDefs Prompt-preview's tools: schema array (PreviewResult.ToolDefs) had two bugs, both preview-only — confirmed live conversations already use correctly-filtered tool payloads via buildToolsPayload/PolicyEngine.FilterTools, unaffected by either bug: 1. Alias re-injection iterated ALL registry aliases globally with no check against the deny-filtered toolNames list, letting denied tools reappear via their alias name (e.g. a denied canonical tool still showing up as its Claude-Code-compat alias like Bash/Edit/Write/Read). Now skips any alias whose canonical tool isn't in the filtered set. 2. MCP tool descriptions (from the store-based, connection-free ListToolsForAgent path) only ever fed the prompt TEXT section, never got converted into ToolDefinition schema objects — so the tools: array never showed MCP tools at all, even when the MCP section of the prompt text correctly listed them. Now appends a ToolDefinition per MCP tool with name+description populated and a placeholder {"type":"object"} Parameters schema, documented as preview-only (real parameter schemas require a live MCP connection, only available during an actual conversation turn). Added tests proving denied-tool-alias exclusion and MCP tool inclusion in preview ToolDefs. * feat(skills): inline full content for pinned skills instead of pointer-only Web UI documented "Pinned skills are always inlined in the system prompt", but BuildPinnedSummary was just BuildSummary with an allowlist filter — same as the general searchable skill list: name/description(truncated)/location pointer only, requiring use_skill+read_file round trips to get actual content. No code path inlined real SKILL.md content for pinned skills specifically. BuildPinnedSummary now reads and inlines full SKILL.md content (frontmatter stripped) per pinned skill inside <skill_instructions> tags. Per-skill (10000 bytes) and total (30000 bytes) size caps fall back to the original pointer-only format with a note when a skill is too large to inline, so oversized skills degrade gracefully instead of blowing the prompt budget. Added tests: full-content inlining, size-cap fallback, and tenant isolation for the new inline path (mirroring the existing managed- skills tenant isolation test). * fix(mcp): real parameter schemas and full policy enforcement in prompt preview Three interconnected fixes to prompt preview, none affecting the live conversation path (which was already correct): 1. Real MCP parameter schemas instead of a useless empty placeholder. tool_cache previously stored only name+description; extended to also capture the real JSON Schema from the MCP server's tools/list response (CachedToolInfo{Description, Parameters}) at connect time, for both direct-connect and pool-connect paths. Preview now shows complete, real input schemas instead of {"type":"object"} with no properties — verified against actual wire data from a live cloudflare MCP server showing genuinely rich schemas (zone_id, type, name, content, ttl, proxied, all typed with descriptions and correct required arrays) that were previously being discarded. Backward-compatible: old-shape cache entries degrade gracefully to description-only rather than crashing, self-healing on next connect. 2. Global tool deny now enforced in preview. BuildPreviewPrompt previously hand-rolled a partial policy reimplementation (per-agent deny only, explicitly skipping the full PolicyEngine "because runtime state isn't available in preview") — but PolicyEngine.WouldAllow already handles this per-tool-name without needing channel context. A tool denied via the global config (not per-agent) would appear in preview despite being correctly denied in every real conversation. Preview now calls WouldAllow per candidate tool, with a graceful per-agent-deny-only fallback when no PolicyEngine is wired (e.g. in tests). 3. MCP tools now also subject to the same policy check — previously the MCP tool supplement (store-based, connection-free tool listing) added MCP tool names unconditionally, bypassing WouldAllow entirely, so a denied MCP tool could still appear in preview. Added tests for all three: real-schema presence, global-deny exclusion for both core and MCP tools, and backward-compat cache handling. * fix(prompt): remove redundant per-tool MCP enumeration from prompt text Now that MCP tool schemas in the tools: API parameter are real and complete (1290d4f1), the ## MCP Tools prompt-text section's per-tool "- mcp_x__y: description" enumeration is pure duplication with zero added value — the model already gets each tool's real schema (including description) via tools:. buildMCPToolsInlineSection now keeps only the behavioral instructions that aren't expressible via JSON schema and thus aren't duplicated: prefer-MCP-over-core-tools guidance, and the optional-parameter guidance (don't guess/fill optional fields). The per-tool name+ description enumeration loop is removed. Section still only appears when the agent has MCP tools (len(cfg.MCPToolDescs) > 0, unchanged gate). Updated tests to assert the enumeration is gone while the behavioral instructions remain; ToolDefs assertions are now the authoritative check for MCP tool allow/deny filtering behavior (prompt text no longer enumerates names at all). * test(agent): update TeamContextInjection test for removed Team Members section TestBuildSystemPrompt_TeamContextInjection asserted the presence of a 'Team Members' prompt-text section that was intentionally removed in 71d33180 (duplicate of the canonical TEAM.md-context-file Members section, which has better formatting). The test was never updated to match, causing it to fail on every run since. Moved the assertion from wantIn to wantNotIn for the 3 affected subtests -- BuildSystemPrompt correctly no longer renders team-member roster info directly; that info now comes exclusively from the TEAM.md context-file mechanism, outside this test's isolated scope. * fix(prompt): resolve real registry for WouldAllow calls in preview BuildPreviewPrompt's two WouldAllow calls hardcoded reg=nil, silently breaking group:* expansion (e.g. group:mcp) needed to resolve the AlsoAllow grant production actually uses to grant MCP tool access (resolver_helpers.go's agentToolPolicyWithMCP injects AlsoAllow: ["group:mcp"]). With reg=nil, WouldAllow could match literal tool names fine (the 18 core/static tools) but could never resolve group-based grants, so every MCP tool silently failed WouldAllow and was excluded from preview -- confirmed live via curl: 18 tools returned, zero mcp_* ones, for an agent with genuinely working MCP access in real conversations. Live conversations were never affected -- internal/mcp/bridge_server.go's WouldAllow call already correctly passes a real registry. Fix resolves a real *tools.Registry from deps.ToolLister via tools.ResolveConcreteRegistry (the same helper used at the live call site), passing it to both WouldAllow calls instead of nil. Falls back to nil gracefully for test mocks that don't implement the full ToolExecutor interface, preserving existing test behavior. Added a test proving an MCP tool granted via the exact production AlsoAllow: ["group:mcp"] pattern is now correctly included in preview ToolDefs, where the old reg=nil bug would have silently excluded it. * fix(prompt): use literal deny check for MCP tools in preview, not group expansion The MCP-tools policy gate added in 1290d4f1 called WouldAllow with a real registry (per 9b5fd2eb), which requires group:mcp expansion against that registry to grant access via the production AlsoAllow: ["group:mcp"] pattern. But MCP tools are only ever registered into ephemeral per-agent registry clones at live connection time (manager_connect.go) -- never into the shared/global registry preview uses. group:mcp always resolved empty in preview's connection-free context, so WouldAllow denied every MCP tool -- confirmed live via tool-name diff: live conversations correctly included all 6 MCP tools, preview included zero. MCP access-granting is already correctly handled by ListToolsForAgent's own per-server tool_allow/tool_deny grant logic (confirmed working correctly earlier this session). The preview gate only needs to catch the narrower case of a literally-denied tool name via global/per-agent policy config -- it never needed group expansion. Replaced WouldAllow with IsDenied(nil, name, agentPolicy), which forces a pure literal-name match with zero registry dependency, matching the existing usage pattern already established elsewhere in policy.go. This class of bug cannot recur: there's no registry-passing code path left in this check to silently reintroduce group-expansion dependence. Added a test proving MCP tool inclusion in preview is independent of group-expansion outcome (no AlsoAllow: group:mcp needed for a non-denied tool to appear). Also reverts the temporary loop.filtered_tool_names/ preview_prompt.filtered_tool_names diagnostic logging used to capture the live-vs-preview tool-name comparison that diagnosed this bug. * fix(http): preserve real MCP parameter schemas through HTTP preview adapter mcpPreviewAdapter.ListToolsForAgent (the HTTP-layer glue converting mcp.MCPToolPreviewInfo to agent.MCPToolPreviewInfo for BuildPreviewPrompt) only copied RegisteredName and Description, silently dropping Parameters -- a bug present since this adapter was introduced (2499d0be/7e250244), unrelated to today's other MCP preview fixes. This was masked until d5fc6344 fixed MCP tools being excluded from preview entirely (a separate bug) -- once MCP tools started appearing again, this pre-existing adapter gap became visible: tools showed up correctly, but always with the bare {"type":"object"} placeholder instead of their real cached schema (confirmed live: update_dns_record missing its 7 real properties). One-line fix: copy Parameters through in the adapter's struct literal. Added a regression test constructing a real *mcp.Manager with populated tool_cache, asserting the adapter's output preserves specific real schema properties (not just non-nil Parameters) -- verified this test fails without the fix and passes with it. --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
690 lines
26 KiB
Go
690 lines
26 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"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/store/pg"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tts"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/browser"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// setupToolRegistry creates the tool registry and registers all tools.
|
|
// Returns the registry, exec approval manager, MCP manager, sandbox manager,
|
|
// browser manager (caller must defer Close), web fetch tool, TTS tool,
|
|
// permission policy engine, tool policy engine, data directory, and resolved agent defaults.
|
|
func setupToolRegistry(
|
|
cfg *config.Config,
|
|
workspace string,
|
|
providerRegistry *providers.Registry,
|
|
) (
|
|
toolsReg *tools.Registry,
|
|
execApprovalMgr *tools.ExecApprovalManager,
|
|
mcpMgr *mcpbridge.Manager,
|
|
sandboxMgr sandbox.Manager,
|
|
browserMgr *browser.Manager,
|
|
webFetchTool *tools.WebFetchTool,
|
|
ttsTool *tools.TtsTool,
|
|
audioMgr *audio.Manager,
|
|
permPE *permissions.PolicyEngine,
|
|
toolPE *tools.PolicyEngine,
|
|
dataDir string,
|
|
agentCfg config.AgentDefaults,
|
|
) {
|
|
// Create tool registry with all tools
|
|
toolsReg = tools.NewRegistry()
|
|
agentCfg = cfg.ResolveAgent("default")
|
|
|
|
// Sandbox manager (optional — routes tools through Docker containers)
|
|
if sbCfg := cfg.Agents.Defaults.Sandbox; sbCfg != nil && sbCfg.Mode != "" && sbCfg.Mode != "off" {
|
|
if err := sandbox.CheckDockerAvailable(context.Background()); err != nil {
|
|
slog.Warn("sandbox disabled: Docker not available",
|
|
"configured_mode", sbCfg.Mode,
|
|
"error", err,
|
|
)
|
|
} else {
|
|
resolved := sbCfg.ToSandboxConfig()
|
|
sandboxMgr = sandbox.NewDockerManager(resolved)
|
|
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
|
|
}
|
|
}
|
|
|
|
// Register file tools + exec tool (with sandbox routing via FsBridge if enabled)
|
|
if sandboxMgr != nil {
|
|
toolsReg.Register(tools.NewSandboxedReadFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
|
toolsReg.Register(tools.NewSandboxedWriteFileTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
|
toolsReg.Register(tools.NewSandboxedListFilesTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
|
toolsReg.Register(tools.NewSandboxedEditTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
|
toolsReg.Register(tools.NewSandboxedExecTool(workspace, agentCfg.RestrictToWorkspace, sandboxMgr))
|
|
} else {
|
|
toolsReg.Register(tools.NewReadFileTool(workspace, agentCfg.RestrictToWorkspace))
|
|
toolsReg.Register(tools.NewWriteFileTool(workspace, agentCfg.RestrictToWorkspace))
|
|
toolsReg.Register(tools.NewListFilesTool(workspace, agentCfg.RestrictToWorkspace))
|
|
toolsReg.Register(tools.NewEditTool(workspace, agentCfg.RestrictToWorkspace))
|
|
toolsReg.Register(tools.NewExecTool(workspace, agentCfg.RestrictToWorkspace))
|
|
}
|
|
|
|
// Memory tools — PG-backed; always registered (PG memory is always available)
|
|
toolsReg.Register(tools.NewMemorySearchTool())
|
|
toolsReg.Register(tools.NewMemoryGetTool())
|
|
toolsReg.Register(tools.NewMemoryExpandTool())
|
|
toolsReg.Register(tools.NewKnowledgeGraphSearchTool())
|
|
slog.Info("memory + knowledge graph tools registered (PG-backed)")
|
|
|
|
// Browser automation tool
|
|
if cfg.Tools.Browser.Enabled {
|
|
var opts []browser.Option
|
|
if cfg.Tools.Browser.RemoteURL != "" {
|
|
opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL))
|
|
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL)
|
|
} else {
|
|
opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless))
|
|
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
|
|
}
|
|
if cfg.Tools.Browser.ActionTimeoutMs > 0 {
|
|
opts = append(opts, browser.WithActionTimeout(time.Duration(cfg.Tools.Browser.ActionTimeoutMs)*time.Millisecond))
|
|
}
|
|
if cfg.Tools.Browser.IdleTimeoutMs > 0 {
|
|
opts = append(opts, browser.WithIdleTimeout(time.Duration(cfg.Tools.Browser.IdleTimeoutMs)*time.Millisecond))
|
|
} else if cfg.Tools.Browser.IdleTimeoutMs < 0 {
|
|
// Explicitly disable idle reaper with negative value
|
|
opts = append(opts, browser.WithIdleTimeout(0))
|
|
}
|
|
if cfg.Tools.Browser.MaxPages > 0 {
|
|
opts = append(opts, browser.WithMaxPages(cfg.Tools.Browser.MaxPages))
|
|
}
|
|
browserMgr = browser.New(opts...)
|
|
toolsReg.Register(browser.NewBrowserTool(browserMgr))
|
|
}
|
|
|
|
// Web tools (web_fetch; web_search is registered in wireExtraTools after stores are ready)
|
|
webFetchTool = tools.NewWebFetchTool(tools.WebFetchConfig{
|
|
Policy: cfg.Tools.WebFetch.Policy,
|
|
AllowedDomains: cfg.Tools.WebFetch.AllowedDomains,
|
|
BlockedDomains: cfg.Tools.WebFetch.BlockedDomains,
|
|
})
|
|
toolsReg.Register(webFetchTool)
|
|
slog.Info("web_fetch tool enabled", "policy", cfg.Tools.WebFetch.Policy, "blocked", len(cfg.Tools.WebFetch.BlockedDomains))
|
|
|
|
// Vision fallback tool (for non-vision providers like MiniMax)
|
|
toolsReg.Register(tools.NewReadImageTool(providerRegistry))
|
|
toolsReg.Register(tools.NewCreateImageTool(providerRegistry))
|
|
|
|
// Audio system: build Manager first so Music/SFX providers are registered
|
|
// before the create_audio tool is constructed.
|
|
ttsMgr := setupTTS(cfg)
|
|
if ttsMgr == nil {
|
|
ttsMgr = tts.NewManager(tts.ManagerConfig{})
|
|
}
|
|
setupAudioExtras(cfg, ttsMgr) // Phase 3: registers Music + SFX providers.
|
|
audio.BridgeLegacySTT(ttsMgr, cfg) // Phase 4: bridge per-channel STTProxyURL → channel-scoped providers.
|
|
audioMgr = ttsMgr // expose to caller for channel STT wiring (Phase 5)
|
|
|
|
// Audio generation tool — backed by audio.Manager (Music + SFX).
|
|
toolsReg.Register(tools.NewCreateAudioTool(ttsMgr))
|
|
|
|
ttsTool = tools.NewTtsTool(ttsMgr)
|
|
toolsReg.Register(ttsTool)
|
|
if ttsMgr.HasProviders() {
|
|
slog.Info("tts enabled", "provider", ttsMgr.PrimaryProvider(), "auto", string(ttsMgr.AutoMode()))
|
|
}
|
|
|
|
// Tool rate limiting (per session, sliding window)
|
|
if cfg.Tools.RateLimitPerHour > 0 {
|
|
toolsReg.SetRateLimiter(tools.NewToolRateLimiter(cfg.Tools.RateLimitPerHour))
|
|
slog.Info("tool rate limiting enabled", "per_hour", cfg.Tools.RateLimitPerHour)
|
|
}
|
|
|
|
// Credential scrubbing (enabled by default, can be disabled via config)
|
|
if cfg.Tools.ScrubCredentials != nil && !*cfg.Tools.ScrubCredentials {
|
|
toolsReg.SetScrubbing(false)
|
|
slog.Info("credential scrubbing disabled")
|
|
}
|
|
|
|
// MCP servers are loaded from the database in gateway.go after the store is
|
|
// initialised. The manager is created here so that the return value is always
|
|
// non-nil and downstream wiring (pool, grant-checker, etc.) can be applied
|
|
// unconditionally in gateway.go.
|
|
mcpMgr = mcpbridge.NewManager(toolsReg)
|
|
|
|
// Exec approval system — always active (deny patterns + safe bins + configurable ask mode)
|
|
{
|
|
approvalCfg := tools.DefaultExecApprovalConfig()
|
|
// Override from user config (backward compat: explicit values take precedence)
|
|
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Security != "" {
|
|
approvalCfg.Security = tools.ExecSecurity(eaCfg.Security)
|
|
}
|
|
if eaCfg := cfg.Tools.ExecApproval; eaCfg.Ask != "" {
|
|
approvalCfg.Ask = tools.ExecAskMode(eaCfg.Ask)
|
|
}
|
|
if len(cfg.Tools.ExecApproval.Allowlist) > 0 {
|
|
approvalCfg.Allowlist = cfg.Tools.ExecApproval.Allowlist
|
|
}
|
|
execApprovalMgr = tools.NewExecApprovalManager(approvalCfg)
|
|
|
|
// Wire approval to exec tools in the registry
|
|
if execTool, ok := toolsReg.Get("exec"); ok {
|
|
if aa, ok := execTool.(tools.ApprovalAware); ok {
|
|
aa.SetApprovalManager(execApprovalMgr, "default")
|
|
}
|
|
}
|
|
slog.Info("exec approval enabled", "security", string(approvalCfg.Security), "ask", string(approvalCfg.Ask))
|
|
}
|
|
|
|
// --- Enforcement: Policy engines ---
|
|
|
|
// Permission policy engine (role-based RPC access control)
|
|
permPE = permissions.NewPolicyEngine(cfg.Gateway.OwnerIDs)
|
|
|
|
// Tool policy engine (7-step tool filtering pipeline)
|
|
toolPE = tools.NewPolicyEngine(&cfg.Tools)
|
|
|
|
// Data directory for Phase 2 services
|
|
dataDir = cfg.ResolvedDataDir()
|
|
os.MkdirAll(dataDir, 0755)
|
|
|
|
// Block exec from accessing sensitive directories (data dir, .goclaw, config file).
|
|
// Prevents `cp /app/data/config.json workspace/` and similar exfiltration.
|
|
// Exception: .goclaw/skills-store/ is allowed (skills may contain executable scripts).
|
|
if execTool, ok := toolsReg.Get("exec"); ok {
|
|
if et, ok := execTool.(*tools.ExecTool); ok {
|
|
// Apply global shell deny-group toggles before any request can arrive.
|
|
// Per-agent overrides via store.WithShellDenyGroups still win per-key.
|
|
et.SetGlobalShellDenyGroups(cfg.Tools.ShellDenyGroups)
|
|
et.SetCommandKeywordAllowlist(cfg.Tools.CommandKeywordAllowlist)
|
|
et.DenyPaths(dataDir, ".goclaw/")
|
|
// Allow skills execution: master-tenant skills-store + all tenant-scoped skills-store dirs.
|
|
et.AllowPathExemptions(
|
|
".goclaw/skills-store/",
|
|
filepath.Join(dataDir, "skills-store")+"/",
|
|
filepath.Join(dataDir, "tenants")+"/",
|
|
)
|
|
// Harden: block access to internal workspace files via shell commands.
|
|
// Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces.
|
|
et.DenyPaths(
|
|
filepath.Join(workspace, "memory.db"),
|
|
filepath.Join(workspace, "memory.db-wal"),
|
|
filepath.Join(workspace, "memory.db-shm"),
|
|
filepath.Join(workspace, "config.json"),
|
|
filepath.Join(workspace, "delegate"),
|
|
filepath.Join(dataDir, "goclaw.db"),
|
|
filepath.Join(dataDir, "goclaw.db-wal"),
|
|
filepath.Join(dataDir, "goclaw.db-shm"),
|
|
)
|
|
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
|
|
et.DenyPaths(cfgPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Block filesystem tools from accessing internal system files within the workspace.
|
|
// Shared-workspace agents have workspace = dataDir root, exposing config.json,
|
|
// memory.db, .media/, delegate/ etc. via list_files/read_file.
|
|
// Non-shared agents are already isolated by resolvePath boundary check, but
|
|
// deny paths add defense-in-depth.
|
|
internalDenyPaths := []string{
|
|
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
|
|
"goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
|
|
"memory/", ".media/", ".uploads/", "delegate/",
|
|
}
|
|
// read_file: allow .media/ access (uploaded documents accessed via AllowPaths
|
|
// for backward compat; new uploads go to per-user .uploads/ within workspace).
|
|
readFileDenyPaths := []string{
|
|
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
|
|
"goclaw.db", "goclaw.db-wal", "goclaw.db-shm",
|
|
"memory/", "delegate/",
|
|
}
|
|
if rf, ok := toolsReg.Get("read_file"); ok {
|
|
if t, ok := rf.(*tools.ReadFileTool); ok {
|
|
t.DenyPaths(readFileDenyPaths...)
|
|
}
|
|
}
|
|
if wf, ok := toolsReg.Get("write_file"); ok {
|
|
if t, ok := wf.(*tools.WriteFileTool); ok {
|
|
t.DenyPaths(internalDenyPaths...)
|
|
}
|
|
}
|
|
if lf, ok := toolsReg.Get("list_files"); ok {
|
|
if t, ok := lf.(*tools.ListFilesTool); ok {
|
|
t.DenyPaths(internalDenyPaths...)
|
|
}
|
|
}
|
|
if ed, ok := toolsReg.Get("edit"); ok {
|
|
if t, ok := ed.(*tools.EditTool); ok {
|
|
t.DenyPaths(internalDenyPaths...)
|
|
}
|
|
}
|
|
if sf, ok := toolsReg.Get("send_file"); ok {
|
|
if t, ok := sf.(*tools.SendFileTool); ok {
|
|
t.DenyPaths(internalDenyPaths...)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// wireTracingAndCron sets up tracing collector, snapshot worker, and cron config
|
|
// on an already-created store set. Shared between PG and SQLite build variants.
|
|
func wireTracingAndCron(
|
|
cfg *config.Config,
|
|
stores *store.Stores,
|
|
msgBus *bus.MessageBus,
|
|
dataDir string,
|
|
) (*tracing.Collector, *tracing.SnapshotWorker) {
|
|
var traceCollector *tracing.Collector
|
|
if stores.Tracing != nil {
|
|
traceCollector = tracing.NewCollector(stores.Tracing)
|
|
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
|
|
ids := make([]string, len(traceIDs))
|
|
for i, id := range traceIDs {
|
|
ids[i] = id.String()
|
|
}
|
|
msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventTraceUpdated,
|
|
Payload: map[string]any{"trace_ids": ids},
|
|
})
|
|
}
|
|
// Immediate status broadcast on every successful status write (bypasses 5s flush).
|
|
traceCollector.SetStatusBroadcaster(func(p tracing.TraceStatusPayload, tid uuid.UUID) {
|
|
msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventTraceStatusChanged,
|
|
Payload: p,
|
|
TenantID: tid,
|
|
})
|
|
})
|
|
traceCollector.Start()
|
|
slog.Info("LLM tracing enabled")
|
|
}
|
|
|
|
// Start snapshot worker for hourly usage aggregation
|
|
var snapshotWorker *tracing.SnapshotWorker
|
|
if stores.Snapshots != nil {
|
|
snapshotWorker = tracing.NewSnapshotWorker(stores.DB, stores.Snapshots, stores.UsageEvents)
|
|
snapshotWorker.Start()
|
|
|
|
// Backfill historical data in background
|
|
go func() {
|
|
count, err := snapshotWorker.Backfill(context.Background())
|
|
if err != nil {
|
|
slog.Warn("snapshot backfill failed", "error", err)
|
|
} else if count > 0 {
|
|
slog.Info("snapshot backfill complete", "hours", count)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wire cron config from config.json
|
|
cronRetryCfg := cfg.Cron.ToRetryConfig()
|
|
if stores.Cron != nil {
|
|
stores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
|
|
_ = cronRetryCfg // config available; cron store reads it internally
|
|
if cfg.Cron.DefaultTimezone != "" {
|
|
stores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
|
|
}
|
|
}
|
|
|
|
// Load secrets from config_secrets table before env overrides.
|
|
// Precedence: config.json → DB secrets → env vars (highest).
|
|
if stores.ConfigSecrets != nil {
|
|
if secrets, err := stores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 {
|
|
cfg.ApplyDBSecrets(secrets)
|
|
cfg.ApplyEnvOverrides()
|
|
slog.Info("config secrets loaded from DB", "count", len(secrets))
|
|
}
|
|
}
|
|
|
|
return traceCollector, snapshotWorker
|
|
}
|
|
|
|
// setupMemoryEmbeddings wires embedding provider to PGMemoryStore and triggers backfill.
|
|
// Resolves embedding provider from DB providers with settings.embedding.enabled.
|
|
func setupMemoryEmbeddings(
|
|
pgStores *store.Stores,
|
|
providerRegistry *providers.Registry,
|
|
) {
|
|
if pgStores.Memory != nil {
|
|
if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil {
|
|
pgStores.Memory.SetEmbeddingProvider(embProvider)
|
|
slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model())
|
|
|
|
// Backfill embeddings for existing chunks that were stored without vectors.
|
|
type backfiller interface {
|
|
BackfillEmbeddings(ctx context.Context) (int, error)
|
|
}
|
|
if bf, ok := pgStores.Memory.(backfiller); ok {
|
|
go func() {
|
|
bgCtx := context.Background()
|
|
count, err := bf.BackfillEmbeddings(bgCtx)
|
|
if err != nil {
|
|
slog.Warn("memory embeddings backfill failed", "error", err)
|
|
} else if count > 0 {
|
|
slog.Info("memory embeddings backfill complete", "chunks_updated", count)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wire embedding provider into team store for semantic task search.
|
|
if pgTeamStore, ok := pgStores.Teams.(*pg.PGTeamStore); ok {
|
|
pgTeamStore.SetEmbeddingProvider(embProvider)
|
|
go func() {
|
|
if count, err := pgTeamStore.BackfillTaskEmbeddings(context.Background()); err != nil {
|
|
slog.Warn("task embeddings backfill failed", "error", err)
|
|
} else if count > 0 {
|
|
slog.Info("task embeddings backfill complete", "tasks_updated", count)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wire embedding provider into KG store for entity semantic search.
|
|
if pgKG, ok := pgStores.KnowledgeGraph.(*pg.PGKnowledgeGraphStore); ok {
|
|
pgKG.SetEmbeddingProvider(embProvider)
|
|
go func() {
|
|
if count, err := pgKG.BackfillKGEmbeddings(context.Background()); err != nil {
|
|
slog.Warn("KG embeddings backfill failed", "error", err)
|
|
} else if count > 0 {
|
|
slog.Info("KG embeddings backfill complete", "entities_updated", count)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Wire embedding provider into vault store for semantic document search.
|
|
if pgStores.Vault != nil {
|
|
pgStores.Vault.SetEmbeddingProvider(embProvider)
|
|
slog.Info("vault embeddings enabled", "provider", embProvider.Name())
|
|
}
|
|
|
|
// V3: Wire embedding provider into episodic store for semantic search.
|
|
if pgStores.Episodic != nil {
|
|
pgStores.Episodic.SetEmbeddingProvider(embProvider)
|
|
slog.Info("episodic embeddings enabled", "provider", embProvider.Name())
|
|
}
|
|
} else {
|
|
slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors")
|
|
}
|
|
}
|
|
}
|
|
|
|
// seedSystemConfigs ensures system_configs has all expected keys for all tenants.
|
|
// Inserts missing keys from config.json without overwriting existing values.
|
|
func seedSystemConfigs(sc store.SystemConfigStore, ts store.TenantStore, cfg *config.Config) {
|
|
syncSystemConfigs(sc, ts, cfg, true) // onlyMissing=true
|
|
}
|
|
|
|
// loadBootstrapFiles loads bootstrap files for the default agent's system prompt from DB.
|
|
// Seeds if empty; falls back to filesystem as last resort.
|
|
func loadBootstrapFiles(
|
|
pgStores *store.Stores,
|
|
workspace string,
|
|
agentCfg config.AgentDefaults,
|
|
) []bootstrap.ContextFile {
|
|
// Load bootstrap files for default agent's system prompt from DB.
|
|
// Seeds if empty; falls back to filesystem as last resort.
|
|
var contextFiles []bootstrap.ContextFile
|
|
|
|
if pgStores.Agents != nil {
|
|
bgCtx := context.Background()
|
|
defaultAgent, agErr := pgStores.Agents.GetByKey(bgCtx, "default")
|
|
if agErr == nil {
|
|
dbFiles := bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
|
|
if len(dbFiles) > 0 {
|
|
contextFiles = dbFiles
|
|
slog.Info("bootstrap loaded from store", "count", len(dbFiles))
|
|
} else {
|
|
// DB empty → seed templates, then load
|
|
if _, seedErr := bootstrap.SeedToStore(bgCtx, pgStores.Agents, defaultAgent.ID, defaultAgent.AgentType); seedErr != nil {
|
|
slog.Warn("failed to seed bootstrap to store", "error", seedErr)
|
|
} else {
|
|
contextFiles = bootstrap.LoadFromStore(bgCtx, pgStores.Agents, defaultAgent.ID)
|
|
slog.Info("bootstrap seeded and loaded from store", "count", len(contextFiles))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(contextFiles) == 0 {
|
|
// DB fallback: load from workspace filesystem
|
|
rawFiles := bootstrap.LoadWorkspaceFiles(workspace)
|
|
truncCfg := bootstrap.TruncateConfig{
|
|
MaxCharsPerFile: agentCfg.BootstrapMaxChars,
|
|
TotalMaxChars: agentCfg.BootstrapTotalMaxChars,
|
|
}
|
|
if truncCfg.MaxCharsPerFile <= 0 {
|
|
truncCfg.MaxCharsPerFile = bootstrap.DefaultMaxCharsPerFile
|
|
}
|
|
if truncCfg.TotalMaxChars <= 0 {
|
|
truncCfg.TotalMaxChars = bootstrap.DefaultTotalMaxChars
|
|
}
|
|
contextFiles = bootstrap.BuildContextFiles(rawFiles, truncCfg)
|
|
slog.Info("bootstrap loaded from filesystem", "count", len(contextFiles))
|
|
}
|
|
|
|
// Debug: log bootstrap file loading results
|
|
{
|
|
var loadedNames []string
|
|
for _, cf := range contextFiles {
|
|
loadedNames = append(loadedNames, fmt.Sprintf("%s(%d)", cf.Path, len(cf.Content)))
|
|
}
|
|
slog.Info("bootstrap context files", "count", len(contextFiles), "files", loadedNames)
|
|
}
|
|
|
|
return contextFiles
|
|
}
|
|
|
|
// setupSkillsSystem creates the skills loader, registers skill tools, wires skills-store,
|
|
// seeds bundled skills, and enables embedding-based skill search.
|
|
func setupSkillsSystem(
|
|
cfg *config.Config,
|
|
workspace string,
|
|
dataDir string,
|
|
pgStores *store.Stores,
|
|
toolsReg *tools.Registry,
|
|
providerRegistry *providers.Registry,
|
|
msgBus *bus.MessageBus,
|
|
) (*skills.Loader, *tools.SkillSearchTool, string, string, string) {
|
|
var bundledSkillsDir string // resolved later; returned for HTTP handler fallback
|
|
|
|
// Skills loader + search tool
|
|
// Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/.
|
|
globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR")
|
|
if globalSkillsDir == "" {
|
|
globalSkillsDir = filepath.Join(dataDir, "skills")
|
|
}
|
|
// Bundled skills: shipped with the Docker image at /app/bundled-skills/.
|
|
// Lowest priority — managed (skills-store) and user-uploaded skills override these.
|
|
builtinSkillsDir := os.Getenv("GOCLAW_BUILTIN_SKILLS_DIR")
|
|
if builtinSkillsDir == "" {
|
|
builtinSkillsDir = "/app/bundled-skills"
|
|
}
|
|
skillsLoader := skills.NewLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
|
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
|
|
toolsReg.Register(skillSearchTool)
|
|
toolsReg.Register(tools.NewUseSkillTool())
|
|
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(context.Background())))
|
|
|
|
// Wire skills-store directory into filesystem loader so agents
|
|
// can discover uploaded skills in their system prompt and BM25 search index.
|
|
if pgStores.Skills != nil {
|
|
storeDirs := pgStores.Skills.Dirs()
|
|
if len(storeDirs) > 0 {
|
|
// Pass the root data dir, not storeDirs[0] (which is the master
|
|
// tenant's pre-resolved skills-store path) — the loader resolves
|
|
// each tenant's own skills-store directory per request from this root.
|
|
skillsLoader.SetManagedDir(dataDir)
|
|
slog.Info("skills-store directory wired into loader", "dataDir", dataDir)
|
|
|
|
// Seed system/bundled skills into DB
|
|
bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
|
|
if bundledSkillsDir == "" {
|
|
// Check common locations: Docker default, then local dev
|
|
for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} {
|
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
|
bundledSkillsDir = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if bundledSkillsDir != "" {
|
|
if seederStore, ok := pgStores.Skills.(skills.SystemSkillStore); ok {
|
|
seeder := skills.NewSeeder(bundledSkillsDir, storeDirs[0], seederStore)
|
|
seeded, skipped, seededSkills, err := seeder.Seed(context.Background())
|
|
if err != nil {
|
|
slog.Warn("system skills seed failed", "error", err)
|
|
} else {
|
|
if seeded > 0 {
|
|
slog.Info("system skills seeded", "seeded", seeded, "skipped", skipped)
|
|
}
|
|
// Check dependencies asynchronously — does not block startup.
|
|
// Emits WS events per-skill so UI updates in realtime.
|
|
if len(seededSkills) > 0 {
|
|
seeder.CheckDepsAsync(seededSkills, msgBus)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish skill tool — lets agents register created skills in the database.
|
|
// Disabled in lite edition: agents should not self-manage skills on desktop.
|
|
if pgStores.Skills != nil && edition.Current().TeamFullMode {
|
|
if manageStore, ok := pgStores.Skills.(store.SkillManageStore); ok {
|
|
storeDirs := pgStores.Skills.Dirs()
|
|
if len(storeDirs) > 0 {
|
|
toolsReg.Register(tools.NewPublishSkillTool(manageStore, storeDirs[0], dataDir, skillsLoader))
|
|
slog.Info("publish_skill tool registered")
|
|
toolsReg.Register(tools.NewSkillManageTool(manageStore, storeDirs[0], dataDir, skillsLoader))
|
|
slog.Info("skill_manage tool registered")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wire embedding-based skill search + per-agent access filtering
|
|
if pgStores.Skills != nil {
|
|
if sas, ok := pgStores.Skills.(store.SkillAccessStore); ok {
|
|
skillSearchTool.SetSkillAccessStore(sas)
|
|
}
|
|
if pgSkills, ok := pgStores.Skills.(*pg.PGSkillStore); ok {
|
|
if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil {
|
|
pgSkills.SetEmbeddingProvider(embProvider)
|
|
skillSearchTool.SetEmbeddingSearcher(pgSkills, embProvider)
|
|
slog.Info("skill embeddings enabled", "provider", embProvider.Name())
|
|
|
|
// Backfill embeddings for existing skills
|
|
go func() {
|
|
count, err := pgSkills.BackfillSkillEmbeddings(context.Background())
|
|
if err != nil {
|
|
slog.Warn("skill embeddings backfill failed", "error", err)
|
|
} else if count > 0 {
|
|
slog.Info("skill embeddings backfill complete", "skills_updated", count)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir, builtinSkillsDir
|
|
}
|
|
|
|
// initMCPFromDB loads all enabled MCP servers from the database and connects them
|
|
// into the shared manager. This replaces the former config-file-based initialisation.
|
|
// Non-fatal: individual server connection failures are logged as warnings.
|
|
func initMCPFromDB(ctx context.Context, mgr *mcpbridge.Manager, mcpStore store.MCPServerStore) error {
|
|
slog.Debug("initMCPFromDB starting")
|
|
slog.Debug("querying mcp_servers from database")
|
|
servers, err := mcpStore.ListServers(ctx)
|
|
if err != nil {
|
|
slog.Error("initMCPFromDB: failed to query mcp_servers", "error", err)
|
|
return fmt.Errorf("list mcp servers from db: %w", err)
|
|
}
|
|
slog.Debug("found mcp_servers from database", "count", len(servers))
|
|
|
|
cfgs := make(map[string]*config.MCPServerConfig, len(servers))
|
|
for i := range servers {
|
|
srv := &servers[i]
|
|
slog.Debug("initMCPFromDB: processing server", "name", srv.Name, "transport", srv.Transport, "enabled", srv.Enabled)
|
|
if !srv.Enabled {
|
|
slog.Debug("initMCPFromDB: skipping disabled server", "name", srv.Name)
|
|
continue
|
|
}
|
|
|
|
var args []string
|
|
if len(srv.Args) > 0 {
|
|
if jsonErr := json.Unmarshal(srv.Args, &args); jsonErr != nil {
|
|
slog.Warn("mcp.db.invalid_args", "server", srv.Name, "error", jsonErr)
|
|
}
|
|
}
|
|
|
|
var headers map[string]string
|
|
if len(srv.Headers) > 0 {
|
|
if jsonErr := json.Unmarshal(srv.Headers, &headers); jsonErr != nil {
|
|
slog.Warn("mcp.db.invalid_headers", "server", srv.Name, "error", jsonErr)
|
|
}
|
|
}
|
|
|
|
var env map[string]string
|
|
if len(srv.Env) > 0 {
|
|
if jsonErr := json.Unmarshal(srv.Env, &env); jsonErr != nil {
|
|
slog.Warn("mcp.db.invalid_env", "server", srv.Name, "error", jsonErr)
|
|
}
|
|
}
|
|
|
|
// Inject decrypted APIKey as Authorization header when not already set.
|
|
if srv.APIKey != "" && headers["Authorization"] == "" {
|
|
if headers == nil {
|
|
headers = make(map[string]string)
|
|
}
|
|
headers["Authorization"] = "Bearer " + srv.APIKey
|
|
}
|
|
|
|
enabled := true
|
|
cfgs[srv.Name] = &config.MCPServerConfig{
|
|
Transport: srv.Transport,
|
|
Command: srv.Command,
|
|
Args: args,
|
|
Env: env,
|
|
URL: srv.URL,
|
|
Headers: headers,
|
|
Enabled: &enabled,
|
|
ToolPrefix: srv.ToolPrefix,
|
|
TimeoutSec: srv.TimeoutSec,
|
|
}
|
|
}
|
|
|
|
if len(cfgs) == 0 {
|
|
slog.Debug("mcp.db: no enabled servers found")
|
|
return nil
|
|
}
|
|
|
|
slog.Debug("initMCPFromDB: building config map", "servers", len(cfgs))
|
|
slog.Debug("initMCPFromDB: calling mgr.SetConfigs()")
|
|
mgr.SetConfigs(cfgs)
|
|
slog.Debug("initMCPFromDB: calling mgr.Start()")
|
|
if startErr := mgr.Start(ctx); startErr != nil {
|
|
slog.Warn("mcp.db.startup_errors", "error", startErr)
|
|
}
|
|
toolCount := len(mgr.ToolNames())
|
|
slog.Debug("initMCPFromDB: MCP init complete", "tools_registered", toolCount)
|
|
return nil
|
|
}
|