import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveAgentGooseMode } from './policies.mjs'; /** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */ export function resolveSandboxMcpServerPath(overridePath) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; return path.join(path.dirname(fileURLToPath(import.meta.url)), 'mindspace-sandbox-mcp.mjs'); } export function resolveSandboxMcpNodeExecPath(overridePath) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; return process.execPath; } const LOOPBACK_PG_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); export function resolveSandboxMcpUserDataPgUrl({ portalUrl, mcpUrl, containerized = false, hostGateway = 'host.docker.internal', } = {}) { const explicitMcpUrl = String(mcpUrl ?? '').trim(); if (explicitMcpUrl) return explicitMcpUrl; const sourceUrl = String(portalUrl ?? '').trim(); if (!sourceUrl || !containerized) return sourceUrl; try { const parsed = new URL(sourceUrl); if (LOOPBACK_PG_HOSTS.has(parsed.hostname)) { parsed.hostname = String(hostGateway || 'host.docker.internal').trim(); } return parsed.toString(); } catch { // Preserve the configured value so the MCP reports the real configuration // error instead of silently falling back to its local Unix socket default. return sourceUrl; } } export function resolveMindSearchMcpServerPath(overridePath) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-search-mcp.mjs'); } export function resolveExcelMcpServerPath(overridePath) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-excel-mcp.mjs'); } export const CAPABILITY_CATALOG = [ { key: 'shell', label: 'Shell 命令', description: '执行 bash/shell 命令(developer.shell)', risk: 'high', category: 'developer', }, { key: 'static_publish', label: '用户沙箱目录', description: '在 MindSpace/<用户ID>/ 内可使用 sandbox-fs 的 write_file/edit_file/read_file/create_dir(以及按权限开放的 list_dir;不可越出该目录)', risk: 'medium', category: 'publisher', }, { key: 'private_data_space', label: '用户私有数据空间', description: '为用户提供唯一的私有 SQLite 数据空间,供 Agent 创建问卷、表单、清单等私有结构化数据表', risk: 'medium', category: 'publisher', }, { key: 'filesystem', label: '文件读写(全目录)', description: '在工作区内任意读写文件(developer.write / edit),仅高级用户', risk: 'high', category: 'developer', }, { key: 'code_browse', label: '代码浏览', description: '目录树、代码结构分析(developer.tree、analyze)', risk: 'low', category: 'developer', }, { key: 'image_read', label: '图片读取', description: '读取本地或网络图片(developer.read_image)', risk: 'low', category: 'developer', }, { key: 'skills', label: '技能加载', description: '加载技能与知识(skills / summon.load)', risk: 'low', category: 'knowledge', }, { key: 'subagent', label: '子任务派发', description: 'delegate 子 Agent 执行复杂任务(summon)', risk: 'high', category: 'agent', }, { key: 'code_sandbox', label: '沙箱脚本', description: 'execute_typescript 在沙箱中批量调用工具(code_execution)', risk: 'high', category: 'agent', }, { key: 'chat_recall', label: '对话回溯', description: '检索历史会话(chatrecall)', risk: 'low', category: 'memory', }, { key: 'context_memory', label: '项目记忆', description: '会话级项目记忆注入(projectmemory,H5 引导用)', risk: 'low', category: 'memory', }, { key: 'memory_store', label: '长期记忆', description: '记住/检索用户偏好(memory 扩展)', risk: 'medium', category: 'memory', }, { key: 'extension_admin', label: '扩展管理', description: '搜索、启用、禁用扩展(extensionmanager)', risk: 'high', category: 'admin', }, { key: 'apps', label: '应用管理', description: '创建与管理 TKMind 应用(apps)', risk: 'medium', category: 'agent', }, { key: 'todo', label: '待办事项', description: '任务列表跟踪(todo)', risk: 'low', category: 'productivity', }, { key: 'web', label: '网页搜索', description: '联网搜索与抓取(platform/web:web_search、fetch_url)', risk: 'medium', category: 'knowledge', }, { key: 'search_external', label: 'MindSearch 外部搜索增强', description: '可插拔的外部搜索补充能力;不替代现有网页搜索,默认关闭', risk: 'medium', category: 'knowledge', }, { key: 'computer', label: '电脑控制', description: '自动化脚本、网页抓取、文档处理(computercontroller)', risk: 'high', category: 'automation', }, { key: 'charts', label: '数据可视化', description: '图表与可视化(autovisualiser)', risk: 'low', category: 'automation', }, { key: 'aider', label: 'Aider 编码', description: '多文件编码委托(aider)', risk: 'high', category: 'developer', }, { key: 'openhands', label: 'OpenHands 编码', description: '复杂多文件编码与仓库级任务委托(openhands)', risk: 'high', category: 'developer', }, ]; /** Capabilities that must never be enabled for regular users, even via DB overrides. */ export const USER_NON_GRANTABLE_CAPABILITIES = new Set(['extension_admin', 'apps']); export function clampUserCapabilities(capabilities) { const clamped = { ...capabilities }; for (const key of USER_NON_GRANTABLE_CAPABILITIES) { clamped[key] = false; } return clamped; } export const DEFAULT_USER_CAPABILITIES = Object.fromEntries( CAPABILITY_CATALOG.map(({ key }) => { const defaults = { shell: false, static_publish: false, private_data_space: true, filesystem: false, code_browse: false, image_read: true, skills: true, subagent: false, code_sandbox: false, chat_recall: true, context_memory: true, memory_store: true, extension_admin: false, apps: false, todo: false, web: true, search_external: false, computer: false, charts: false, aider: false, openhands: false, }; return [key, defaults[key] ?? false]; }), ); const CATALOG_KEYS = new Set(CAPABILITY_CATALOG.map((item) => item.key)); export function catalogKeys() { return [...CATALOG_KEYS]; } export function isValidCapabilityKey(key) { return CATALOG_KEYS.has(key); } function makeExtension(type, name, tools = []) { return { type, name, description: '', display_name: name, bundled: true, available_tools: tools, }; } export const SANDBOX_DEVELOPER_TOOLS = ['write', 'edit', 'shell', 'tree', 'read_image']; /** static_publish sandbox: only file writes inside MindSpace// — no tree/shell by default */ export function sandboxDeveloperTools(capabilities) { const tools = ['write', 'edit']; if (capabilities.shell) { tools.push('shell', 'tree'); } else if (capabilities.code_browse) { tools.push('tree'); } if (capabilities.image_read) tools.push('read_image'); return tools; } /** * Tools exposed by the sandbox MCP server (mindspace-sandbox-mcp.mjs) for static_publish users. * read_file is always included because edit_file requires reading first. */ export function sandboxMcpTools(capabilities) { const tools = []; if (capabilities.static_publish) { tools.push( 'read_file', 'write_file', 'edit_file', 'create_dir', 'generate_image', 'generate_docx', 'generate_long_image', ); if (capabilities.shell || capabilities.code_browse) tools.push('list_dir'); } if (capabilities.private_data_space) { tools.push( 'private_data_info', 'private_data_schema', 'private_data_query', 'private_data_execute', 'private_data_register_dataset', 'private_data_set_page_policy', 'private_data_close_page_dataset', 'private_data_bind_workspace_page', 'schedule_create_item', 'schedule_create_reminder', 'schedule_list_items', ); } return tools; } export function developerToolsFromPolicy(sessionPolicy) { const developer = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === 'developer'); const sandboxFs = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === 'sandbox-fs'); return (sandboxFs ?? developer)?.available_tools ?? []; } function mergeDeveloperTools(capabilities) { const tools = []; if (capabilities.shell) tools.push('shell'); if (capabilities.filesystem) tools.push('write', 'edit'); if (capabilities.code_browse) tools.push('tree'); if (capabilities.image_read) tools.push('read_image'); return tools; } function resolveSandboxMcpLocalRoot(sandboxMcp) { const localRoot = sandboxMcp?.workspaceRoot || sandboxMcp?.sandboxRoot || ''; return localRoot ? String(localRoot) : ''; } function resolveSandboxMcpCompatRoot(sandboxMcp) { const compatRoot = sandboxMcp?.sandboxRoot || sandboxMcp?.workspaceRoot || ''; return compatRoot ? String(compatRoot) : ''; } function sandboxMcpEnvs(sandboxMcp, mcpTools) { const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); const envs = { ALLOWED_TOOLS: mcpTools.join(','), }; if (compatRoot) envs.SANDBOX_ROOT = compatRoot; if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; if (mcpTools.includes('generate_image')) { if (sandboxMcp.agentApiBaseUrl) { envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl; } if (sandboxMcp.internalAgentSecret) { envs.MINDSPACE_INTERNAL_AGENT_SECRET = sandboxMcp.internalAgentSecret; } } if (mcpTools.includes('private_data_info')) { const userDataPgUrl = resolveSandboxMcpUserDataPgUrl({ portalUrl: sandboxMcp.userDataPgUrl ?? process.env.MINDSPACE_USERDATA_PG_URL, mcpUrl: sandboxMcp.userDataMcpPgUrl ?? process.env.MINDSPACE_USERDATA_MCP_PG_URL, containerized: Boolean(sandboxMcp.containerized), hostGateway: sandboxMcp.userDataPgHostGateway ?? process.env.MINDSPACE_USERDATA_MCP_PG_HOST ?? 'host.docker.internal', }); envs.MINDSPACE_USERDATA_BACKEND = sandboxMcp.userDataBackend ?? process.env.MINDSPACE_USERDATA_BACKEND ?? 'postgres'; if (userDataPgUrl) envs.MINDSPACE_USERDATA_PG_URL = userDataPgUrl; const autoProvision = sandboxMcp.userDataAutoProvision ?? process.env.MINDSPACE_USERDATA_AUTO_PROVISION; if (autoProvision != null && String(autoProvision).trim()) { envs.MINDSPACE_USERDATA_AUTO_PROVISION = String(autoProvision).trim(); } } for (const key of [ 'DATABASE_URL', 'MYSQL_HOST', 'MYSQL_PORT', 'MYSQL_USER', 'MYSQL_PASSWORD', 'MYSQL_DATABASE', 'PRIVATE_DATA_MAX_BYTES', ]) { if (process.env[key]) envs[key] = process.env[key]; } return envs; } /** * Build goose agent/start extension_overrides from resolved capability flags. * Returns null when the caller should use server defaults (admin / unrestricted). * * sandboxMcp: { serverPath, sandboxRoot?, workspaceRoot?, workspaceRef?, nodeExecPath? } * `sandboxRoot` remains the legacy local-path compatibility field. * `workspaceRoot` is the preferred local adapter field for future MindSpace runtime extraction. * When a local root is available for a static_publish user, the built-in developer extension * is replaced by a sandboxed stdio MCP that enforces filesystem boundaries at the OS level. */ export function buildAgentExtensionPolicy( capabilities, { unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat', mindSearchConfig = null } = {}, ) { if (unrestricted) { return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' }; } const extensions = []; if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) { const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); if (sandboxMcp?.serverPath && localRoot) { // Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level. // Replaces the built-in developer extension so path traversal is impossible. const mcpTools = sandboxMcpTools(capabilities); if (mcpTools.length > 0) { extensions.push({ type: 'stdio', name: 'sandbox-fs', description: '工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户隔离的 PostgreSQL schema,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。', display_name: 'sandbox-fs', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), // Legacy MCP still accepts a local filesystem root as argv[2] even when the source // capability is named workspaceRoot instead of sandboxRoot. args: [sandboxMcp.serverPath, compatRoot], // envs (goosed field name) as belt-and-suspenders backup envs: sandboxMcpEnvs(sandboxMcp, mcpTools), available_tools: mcpTools, }); } // Keep developer extension only for image reading when applicable. if (capabilities.image_read) { extensions.push(makeExtension('platform', 'developer', ['read_image'])); } } else if (capabilities.static_publish) { // Fallback when sandbox MCP is not configured: use built-in developer extension. // This is the legacy path — file operations are NOT boundary-enforced. const sandboxTools = sandboxDeveloperTools(capabilities); if (sandboxTools.length > 0) { extensions.push(makeExtension('platform', 'developer', sandboxTools)); } } if (capabilities.static_publish) { extensions.push(makeExtension('platform', 'skills', [])); extensions.push(makeExtension('platform', 'summon', ['load_skill'])); extensions.push(makeExtension('platform', 'projectmemory', [])); } } else { const developerTools = mergeDeveloperTools(capabilities); if (developerTools.length > 0) { extensions.push(makeExtension('platform', 'developer', developerTools)); } if (capabilities.code_browse) { extensions.push(makeExtension('platform', 'analyze', [])); } if (capabilities.skills) { extensions.push(makeExtension('platform', 'skills', [])); } if (capabilities.skills || capabilities.subagent) { const summonTools = []; if (capabilities.skills) summonTools.push('load', 'load_skill'); if (capabilities.subagent) summonTools.push('delegate'); extensions.push(makeExtension('platform', 'summon', summonTools)); } } const enableCodeExecutionExtension = /^(1|true|yes)$/i.test( process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? '', ); if (capabilities.code_sandbox && enableCodeExecutionExtension) { extensions.push(makeExtension('platform', 'code_execution', [])); } if (capabilities.chat_recall) { extensions.push(makeExtension('platform', 'chatrecall', [])); } if (capabilities.context_memory) { extensions.push(makeExtension('platform', 'projectmemory', [])); } if (capabilities.memory_store) { extensions.push(makeExtension('builtin', 'memory', [])); } if (capabilities.extension_admin) { extensions.push(makeExtension('platform', 'extensionmanager', [])); } if (capabilities.apps) { extensions.push(makeExtension('platform', 'apps', [])); } if (capabilities.todo) { extensions.push(makeExtension('platform', 'todo', [])); } if (capabilities.web) { extensions.push(makeExtension('platform', 'web', ['web_search', 'fetch_url'])); } const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off'; if (searchEnabled && (!policies || policies.network_egress !== 'deny')) { extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] }); } if (capabilities.excel_analysis) { const excelWorkspaceRoot = resolveSandboxMcpLocalRoot(sandboxMcp); if (excelWorkspaceRoot) { extensions.push({ type: 'stdio', name: 'tkmind-excel', description: 'Excel Analyst:当前用户工作区内 .xlsx 的只读检查、统计分析、图表与一致性校验报告。', display_name: 'Excel Analyst', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp?.nodeExecPath), args: [resolveExcelMcpServerPath(process.env.GOOSED_EXCEL_MCP_SERVER_PATH), excelWorkspaceRoot], envs: { EXCEL_ANALYST_ENABLED: '1', MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot, ...(sandboxMcp?.workspaceRef ? { MINDSPACE_WORKSPACE_REF: sandboxMcp.workspaceRef } : {}), }, available_tools: ['excel_inspect', 'excel_analyze', 'excel_chart', 'excel_report'], }); } } if (capabilities.computer) { extensions.push(makeExtension('builtin', 'computercontroller', [])); } if (capabilities.charts) { extensions.push(makeExtension('builtin', 'autovisualiser', [])); } const codeToolMode = toolMode === 'code' || toolMode === 'code-task'; if (codeToolMode && capabilities.aider) { extensions.push({ ...makeExtension('platform', 'aider', []), timeout_ms: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 600_000), metadata: { runtime_scope: 'code_tool_task' }, }); } if (codeToolMode && capabilities.openhands) { extensions.push({ ...makeExtension('platform', 'openhands', []), timeout_ms: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 900_000), metadata: { runtime_scope: 'code_tool_task' }, }); } return { extensionOverrides: extensions, enableContextMemory: Boolean( capabilities.context_memory || capabilities.chat_recall || capabilities.static_publish, ), gooseMode: resolveAgentGooseMode(capabilities, policies), }; } /** * Narrow policy for in-preview page edit sub-sessions: patch API via shell when allowed, * otherwise reply-only patches via mindspace-page-update blocks. */ export function buildPageEditAgentPolicy(basePolicy) { if (basePolicy?.unrestricted) { return { ...basePolicy, enableContextMemory: false, gooseMode: 'auto', }; } const baseDeveloper = basePolicy?.extensionOverrides?.find((ext) => ext.name === 'developer'); const canShell = baseDeveloper?.available_tools?.includes('shell') || basePolicy?.capabilities?.shell === true; const extensions = canShell ? [makeExtension('platform', 'developer', ['shell'])] : []; return { ...basePolicy, extensionOverrides: extensions, enableContextMemory: false, gooseMode: 'auto', }; } export function normalizeCapabilityPatch(patch) { const normalized = {}; for (const [key, value] of Object.entries(patch ?? {})) { if (!isValidCapabilityKey(key)) continue; normalized[key] = Boolean(value); } return normalized; }