import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { mintMindSpaceMcpScopedToken, } from './mindspace-mcp-scoped-token.mjs'; import { resolveAgentGooseMode } from './policies.mjs'; const SCOPED_WORKSPACE_MCP_TOOLS = new Set([ 'create_dir', 'edit_file', 'generate_long_image', 'list_dir', 'publish_page', 'read_file', 'write_file', ]); function resolveBundledMcpServerPath(filename, overridePath, runtimeRoot) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; const normalizedRuntimeRoot = String( runtimeRoot ?? process.env.MEMIND_PORTAL_H5_ROOT ?? '', ).trim(); return path.join( normalizedRuntimeRoot || path.dirname(fileURLToPath(import.meta.url)), filename, ); } /** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */ export function resolveSandboxMcpServerPath(overridePath, runtimeRoot) { return resolveBundledMcpServerPath( 'mindspace-sandbox-mcp.mjs', overridePath, runtimeRoot, ); } 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']); function rewritePgUnixSocketUrlForContainer(sourceUrl, hostGateway) { const raw = String(sourceUrl ?? '').trim(); const authorityMatch = raw.match(/^(postgres(?:ql)?:\/\/[^@/]+@)\/(.*)$/i); if (!authorityMatch) return null; try { const parsed = new URL(`${authorityMatch[1]}localhost/${authorityMatch[2]}`); const socketHost = String(parsed.searchParams.get('host') ?? '').trim(); if (!socketHost.startsWith('/')) return null; const port = String(parsed.searchParams.get('port') ?? '5432').trim() || '5432'; parsed.hostname = String(hostGateway || 'host.docker.internal').trim(); parsed.port = port; parsed.searchParams.delete('host'); parsed.searchParams.delete('port'); return parsed.toString(); } catch { return null; } } 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 { const rewrittenSocketUrl = rewritePgUnixSocketUrlForContainer(sourceUrl, hostGateway); if (rewrittenSocketUrl) { const parsed = new URL(rewrittenSocketUrl); if (!parsed.password) { const error = new Error( 'Containerized private-data MCP cannot convert a passwordless PostgreSQL Unix-socket DSN to TCP. Configure MINDSPACE_USERDATA_MCP_PG_URL with a container-reachable authenticated DSN.', ); error.code = 'MINDSPACE_USERDATA_MCP_PG_URL_REQUIRED'; throw error; } return rewrittenSocketUrl; } // 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 resolveSandboxMcpControlDbEnv( sourceEnv = {}, { containerized = false, hostGateway = 'host.docker.internal', } = {}, ) { const resolved = {}; for (const key of [ 'DATABASE_URL', 'MYSQL_HOST', 'MYSQL_PORT', 'MYSQL_USER', 'MYSQL_PASSWORD', 'MYSQL_DATABASE', 'PRIVATE_DATA_MAX_BYTES', ]) { if (sourceEnv[key]) resolved[key] = String(sourceEnv[key]); } if (!containerized) return resolved; const gateway = String(hostGateway || 'host.docker.internal').trim(); if ( resolved.MYSQL_HOST && LOOPBACK_PG_HOSTS.has(resolved.MYSQL_HOST.trim()) ) { resolved.MYSQL_HOST = gateway; } if (resolved.DATABASE_URL) { try { const parsed = new URL(resolved.DATABASE_URL); if (LOOPBACK_PG_HOSTS.has(parsed.hostname)) { parsed.hostname = gateway; resolved.DATABASE_URL = parsed.toString(); } } catch { // Keep the configured value so the MCP reports the real parse error. } } return resolved; } export function resolveMindSearchMcpServerPath(overridePath, runtimeRoot) { return resolveBundledMcpServerPath( 'tkmind-search-mcp.mjs', overridePath, runtimeRoot, ); } export function resolveMindSearchMcpEndpoint( endpoint, { hostGateway = process.env.TKMIND_SEARCH_MCP_HOST_GATEWAY || 'host.docker.internal' } = {}, ) { const source = String(endpoint ?? '').trim(); if (!source) return source; try { const parsed = new URL(source); if (LOOPBACK_PG_HOSTS.has(parsed.hostname)) { parsed.hostname = String(hostGateway || 'host.docker.internal').trim(); } return parsed.toString(); } catch { return source; } } export function resolveExcelMcpServerPath(overridePath, runtimeRoot) { return resolveBundledMcpServerPath( 'tkmind-excel-mcp.mjs', overridePath, runtimeRoot, ); } function isGoosedMcpContainerized(sandboxMcp = null) { const containerFlag = String(process.env.GOOSED_MCP_CONTAINERIZED ?? '').trim(); if (containerFlag) return containerFlag === '1'; return Boolean(sandboxMcp?.containerized); } function resolveBundledMcpRuntimeRoot(sandboxMcp) { // Prefer the sandbox MCP's container-visible directory when present. if (sandboxMcp?.containerized && sandboxMcp?.serverPath) { return path.dirname(sandboxMcp.serverPath); } // tkmind-search / excel can still be enabled when sandbox-fs is absent // (e.g. sandbox setup failed). Keep their stdio paths on the same // container-canonical root as GOOSED_MCP_SERVER_PATH so reconcile does not // oscillate between host and /opt/portal paths. if (isGoosedMcpContainerized(sandboxMcp)) { const serverPath = String( sandboxMcp?.serverPath || process.env.GOOSED_MCP_SERVER_PATH || '', ).trim(); if (serverPath) return path.dirname(serverPath); } return undefined; } 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', 'publish_page', '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 ?? []; } /** * Visual inspection is optional for H5 Agent runs. When the active text model * rejects image content, remove read_image for the recovery session without * weakening any workspace or data capability boundary. */ export function withoutSessionImageRead(sessionPolicy) { if (!sessionPolicy || !Array.isArray(sessionPolicy.extensionOverrides)) { return sessionPolicy; } let changed = false; const extensionOverrides = sessionPolicy.extensionOverrides .map((extension) => { if (String(extension?.name ?? '') !== 'developer') return extension; const tools = Array.isArray(extension.available_tools) ? extension.available_tools : []; const availableTools = tools.filter((tool) => tool !== 'read_image'); if (availableTools.length === tools.length) return extension; changed = true; if (availableTools.length === 0) return null; return { ...extension, available_tools: availableTools, }; }) .filter(Boolean); if (!changed) return sessionPolicy; return { ...sessionPolicy, extensionOverrides, }; } 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; const scopedTools = [ ...new Set([ ...mcpTools.filter( (tool) => SCOPED_WORKSPACE_MCP_TOOLS.has( tool, ), ), ...(mcpTools.includes('generate_docx') ? ['write_binary_file'] : []), ]), ]; if ( sandboxMcp.mcpBaseUrl && sandboxMcp.mcpTokenSecret && sandboxMcp.workspaceRef && sandboxMcp.sessionId && sandboxMcp.packageId && scopedTools.length > 0 ) { const tokenBucketMs = 30 * 60 * 1000; const tokenNow = Math.floor(Date.now() / tokenBucketMs) * tokenBucketMs; envs.MINDSPACE_MCP_BASE_URL = String(sandboxMcp.mcpBaseUrl) .trim() .replace(/\/+$/, ''); envs.MINDSPACE_MCP_SCOPED_TOKEN = mintMindSpaceMcpScopedToken({ secret: sandboxMcp.mcpTokenSecret, userId: sandboxMcp.userId, sessionId: sandboxMcp.sessionId, packageId: sandboxMcp.packageId, workspaceRef: sandboxMcp.workspaceRef, tools: scopedTools, ttlSeconds: 60 * 60, now: tokenNow, tokenId: `${sandboxMcp.sessionId}:${tokenNow}`, }); envs.MINDSPACE_SESSION_ID = String(sandboxMcp.sessionId); envs.MINDSPACE_PACKAGE_ID = String(sandboxMcp.packageId); } if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; for (const [key, value] of [ ['H5_PUBLIC_BASE_URL', sandboxMcp.publicBaseUrl], ['H5_PORTAL_BASE_URL', sandboxMcp.portalBaseUrl], ['H5_PORT', sandboxMcp.portalPort], [ 'MEMIND_PAGE_DATA_DELIVERY_BASE_URL', sandboxMcp.pageDataDeliveryBaseUrl, ], ]) { if (value != null && String(value).trim()) { envs[key] = String(value).trim(); } } 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(); } } Object.assign( envs, resolveSandboxMcpControlDbEnv(process.env, { containerized: Boolean(sandboxMcp.containerized), hostGateway: sandboxMcp.controlDbHostGateway ?? process.env.MINDSPACE_CONTROL_DB_MCP_HOST ?? 'host.docker.internal', }), ); 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, userId = null, } = {}, ) { if (unrestricted) { return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' }; } const extensions = []; const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot(sandboxMcp); 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 scopedWorkspaceReady = Boolean( sandboxMcp.mcpBaseUrl && sandboxMcp.mcpTokenSecret && sandboxMcp.workspaceRef && sandboxMcp.sessionId && sandboxMcp.packageId, ); const mcpTools = sandboxMcpTools(capabilities).filter( (tool) => tool !== 'publish_page' || scopedWorkspaceReady, ); 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.some((ext) => ext.name === 'projectmemory') ) { 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')) { const mcpServices = (mindSearchConfig.services ?? []).map((service) => ({ ...service, endpoint: resolveMindSearchMcpEndpoint(service.endpoint), })); const researchService = mcpServices.find((service) => service.id === mindSearchConfig.routes?.research && service.adapter === 'research-http'); const deepSearchService = mcpServices.find((service) => service.id === 'deep-search' && service.adapter === 'research-http'); const hasResearchService = Boolean(researchService?.enabled); const githubGatewayUrl = resolveMindSearchMcpEndpoint( process.env.TKMIND_SEARCH_GITHUB_GATEWAY_URL || deepSearchService?.endpoint || researchService?.endpoint || '', ); 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, bundledMcpRuntimeRoot, ), ], 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: resolveMindSearchMcpEndpoint( 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), TKMIND_SEARCH_SERVICES_JSON: JSON.stringify(mcpServices), TKMIND_SEARCH_ROUTES_JSON: JSON.stringify(mindSearchConfig.routes ?? {}), TKMIND_SEARCH_USER_ID: userId ? String(userId) : '', TKMIND_DEEP_SEARCH_SECRET: process.env.TKMIND_DEEP_SEARCH_SECRET ?? '', TKMIND_SEARCH_GITHUB_GATEWAY_URL: githubGatewayUrl, TKMIND_SEARCH_GITHUB_GATEWAY_SECRET: process.env.TKMIND_SEARCH_GITHUB_GATEWAY_SECRET ?? process.env.TKMIND_DEEP_SEARCH_SECRET ?? '', }, available_tools: [ 'tkmind_search', 'tkmind_read', ...(hasResearchService ? ['tkmind_research', 'tkmind_research_status', 'tkmind_research_cancel'] : []), ], }); } 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, bundledMcpRuntimeRoot, ), 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; }