From fc5b50c936401c53e8e4de5ee82da237486ce6d6 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:31:02 +0800 Subject: [PATCH] fix: make goosed mcp paths container safe --- capabilities.mjs | 17 ++++++-- capabilities.test.mjs | 28 +++++++++++++ llm-providers.mjs | 33 ++++++++++----- llm-providers.test.mjs | 72 +++++++++++++++++++++++++++++--- scripts/build-portal-runtime.mjs | 14 ++++--- server.mjs | 20 ++++++--- user-auth.mjs | 3 +- 7 files changed, 157 insertions(+), 30 deletions(-) diff --git a/capabilities.mjs b/capabilities.mjs index fee7b5d..b5c86e2 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -3,10 +3,18 @@ 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() { +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; +} + export const CAPABILITY_CATALOG = [ { key: 'shell', @@ -307,7 +315,7 @@ export function buildAgentExtensionPolicy( '工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。', display_name: 'sandbox-fs', bundled: false, - cmd: sandboxMcp.nodeExecPath ?? process.execPath, + cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], // envs (goosed field name) as belt-and-suspenders backup @@ -350,7 +358,10 @@ export function buildAgentExtensionPolicy( extensions.push(makeExtension('platform', 'summon', summonTools)); } } - if (capabilities.code_sandbox) { + 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) { diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 34ec989..e05fef6 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -8,6 +8,7 @@ import { clampUserCapabilities, DEFAULT_USER_CAPABILITIES, normalizeCapabilityPatch, + resolveSandboxMcpNodeExecPath, resolveSandboxMcpServerPath, sandboxDeveloperTools, sandboxMcpTools, @@ -20,6 +21,18 @@ test('resolveSandboxMcpServerPath resolves to an existing MCP entry file', () => assert.ok(fs.existsSync(serverPath)); }); +test('resolveSandboxMcpServerPath honors container-path override without host fs checks', () => { + assert.equal( + resolveSandboxMcpServerPath('/opt/portal/mindspace-sandbox-mcp.mjs'), + '/opt/portal/mindspace-sandbox-mcp.mjs', + ); +}); + +test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => { + assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node'); + assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath); +}); + test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.shell, false); assert.equal(DEFAULT_USER_CAPABILITIES.filesystem, false); @@ -222,6 +235,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); assert.ok(sandboxExt, 'sandbox-fs extension should be present'); assert.equal(sandboxExt.type, 'stdio'); + assert.equal(sandboxExt.cmd, process.execPath); assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123'); assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2] assert.ok(sandboxExt.available_tools.includes('write_file')); @@ -237,3 +251,17 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of assert.ok(!allTools.includes('write'), 'built-in write should not be exposed'); assert.ok(!allTools.includes('shell'), 'shell should not be exposed'); }); + +test('sandboxMcp honors container node executable override', () => { + const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; + const policy = buildAgentExtensionPolicy(caps, { + sandboxMcp: { + serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/opt/h5/MindSpace/abc123', + nodeExecPath: '/usr/local/bin/node', + }, + }); + + const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); + assert.equal(sandboxExt?.cmd, '/usr/local/bin/node'); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 719928c..6461a37 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -861,9 +861,12 @@ function validateCustomPayload(payload) { export function createLlmProviderService( pool, - { apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}, + { apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}, ) { const launchStates = new Map(); + const syncTargets = [ + ...new Set([...(Array.isArray(apiTargets) ? apiTargets : []), apiTarget].filter(Boolean)), + ]; function catalogItem(providerId) { return catalogById[providerId] ?? null; @@ -1742,19 +1745,29 @@ export function createLlmProviderService( }, async syncSelectedToGoosed() { - let resolved = await resolveExecutorProvider('goose'); - if (!resolved.ok) { - resolved = await resolveSelectedProvider(); - } - if (!resolved.ok) { - return { ok: false, synced: false, message: resolved.message }; + const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean); + let lastResolved = null; + for (const target of targets) { + const targetFetch = (url, init) => { + const pathname = `${url.pathname}${url.search}`; + return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl); + }; + let resolved = await resolveExecutorProvider('goose', 'default', targetFetch); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(targetFetch); + } + if (!resolved.ok) { + return { ok: false, synced: false, target, message: resolved.message }; + } + lastResolved = resolved; } return { ok: true, synced: true, - source: resolved.source, - providerId: resolved.providerId, - model: resolved.model, + targets, + source: lastResolved.source, + providerId: lastResolved.providerId, + model: lastResolved.model, }; }, diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index b3c9671..e840977 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -487,14 +487,15 @@ test('launchExecutor rejects openhands headless without instruction', async () = }); test('launchExecutor returns friendly error when command is missing', async () => { + const encryptedKey = encryptSecret('sk-x', 'unit-test-secret'); const keyRow = { id: 'key-1', provider_id: 'custom_deepseek', provider_kind: 'builtin', name: 'DeepSeek', - api_key_ciphertext: encryptSecret('sk-x', 'unit-test-secret').ciphertext, - api_key_iv: encryptSecret('sk-x', 'unit-test-secret').iv, - api_key_tag: encryptSecret('sk-x', 'unit-test-secret').tag, + api_key_ciphertext: encryptedKey.ciphertext, + api_key_iv: encryptedKey.iv, + api_key_tag: encryptedKey.tag, default_model: 'deepseek-chat', status: 'active', is_selected: 1, @@ -513,13 +514,13 @@ test('launchExecutor returns friendly error when command is missing', async () = }; const pool = { async query(sql) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) { + return [[bindingRow]]; + } if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]]; if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) { return [[keyRow]]; } - if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) { - return [[bindingRow]]; - } throw new Error(`Unexpected SQL: ${sql}`); }, }; @@ -597,6 +598,65 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a ); }); +test('syncSelectedToGoosed writes selected provider to every configured target', async () => { + const encrypted = encryptSecret('sk-test', 'unit-test-secret'); + const row = { + id: 'key-deepseek', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek', + api_url: null, + base_path: null, + models_json: JSON.stringify(['deepseek-chat']), + goosed_provider_id: null, + engine: 'openai', + relay_provider: null, + api_key_ciphertext: encrypted.ciphertext, + api_key_iv: encrypted.iv, + api_key_tag: encrypted.tag, + default_model: 'deepseek-chat', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const pool = { + async query(sql) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]]; + if (sql.includes('is_selected = 1')) return [[row]]; + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const calls = []; + const mockFetch = async (url, init) => { + calls.push({ url: String(url), body: JSON.parse(init.body) }); + if (String(url).includes('/chat/completions')) { + return { + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }), + }; + } + return { ok: true, text: async () => '' }; + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiTargets: ['https://127.0.0.1:18006', 'https://127.0.0.1:18007'], + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + apiFetchImpl: mockFetch, + }); + const result = await service.syncSelectedToGoosed(); + assert.equal(result.ok, true); + assert.deepEqual(result.targets, ['https://127.0.0.1:18006', 'https://127.0.0.1:18007']); + assert.deepEqual( + calls + .filter((call) => call.url.includes('/config/upsert') && call.body.key === 'GOOSE_PROVIDER') + .map((call) => new URL(call.url).port), + ['18006', '18007'], + ); +}); + test('syncProfileToGoosed tolerates missing /config/upsert endpoint', async () => { const mockFetch = async (_url, init) => { if (String(_url).includes('/config/custom-providers')) { diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 5642c1a..15ad61d 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -138,14 +138,15 @@ async function copyNodeModules() { if (!(await exists(nodeModulesDir))) { throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`); } + const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir); console.log('==> 拷贝生产运行依赖 node_modules'); - await copyDir(nodeModulesDir, path.join(runtimeRoot, 'node_modules')); - await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules')); + await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules')); + await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir); } -async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir) { +async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) { console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径'); - const localNodeModulesRoot = `${nodeModulesDir}${path.sep}`; + const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`; async function visit(dir) { const entries = await fs.readdir(dir); @@ -199,8 +200,11 @@ async function writeMetadata() { 'export NODE_ENV=production', 'export H5_PORT="${H5_PORT:-8081}"', 'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"', + 'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"', 'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"', 'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"', + 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"', + 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"', '', 'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"', 'if [[ ! -x "${NODE_BIN}" ]]; then', @@ -232,7 +236,7 @@ async function writeMetadata() { 'Key runtime differences must stay in .env, not in the artifact:', ' DATABASE_URL / MYSQL_*', ' H5_PUBLIC_BASE_URL', - ' TKMIND_API_TARGET / TKMIND_API_TARGET_1', + ' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1', ' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT', '', 'Deployment and operations transport:', diff --git a/server.mjs b/server.mjs index c619976..9193611 100644 --- a/server.mjs +++ b/server.mjs @@ -117,12 +117,21 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(__dirname, '../../.env.local')); loadEnvFile(path.join(__dirname, '.env')); +function parseApiTargets() { + const csvTargets = (process.env.TKMIND_API_TARGETS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const legacyTargets = [ + process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', + process.env.TKMIND_API_TARGET_1, + ].filter(Boolean); + return [...new Set([...csvTargets, ...legacyTargets])]; +} + const PORT = Number(process.env.H5_PORT ?? 8081); -const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; -const API_TARGETS = [ - API_TARGET, - ...(process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : []), -]; +const API_TARGETS = parseApiTargets(); +const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; @@ -486,6 +495,7 @@ async function bootstrapUserAuth() { await userAuth.ensureAdminUser(); llmProviderService = createLlmProviderService(pool, { apiTarget: API_TARGET, + apiTargets: API_TARGETS, apiSecret: API_SECRET, }); wordFilterService = createWordFilterService(pool); diff --git a/user-auth.mjs b/user-auth.mjs index 36115b3..b46d3d0 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1662,9 +1662,10 @@ export function createUserAuth(pool, options = {}) { try { const layout = await publishLayoutFor(user, { migrateLegacy: false }); sandboxMcp = { - serverPath: resolveSandboxMcpServerPath(), + serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), sandboxRoot: layout.publishDir, userId: user.id, + nodeExecPath: process.env.GOOSED_MCP_NODE_PATH, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);