fix: make goosed mcp paths container safe

This commit is contained in:
john
2026-06-27 21:31:02 +08:00
parent def9b0b5eb
commit fc5b50c936
7 changed files with 157 additions and 30 deletions
+14 -3
View File
@@ -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) {
+28
View File
@@ -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');
});
+23 -10
View File
@@ -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,
};
},
+66 -6
View File
@@ -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')) {
+9 -5
View File
@@ -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:',
+15 -5
View File
@@ -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);
+2 -1
View File
@@ -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);