fix: make goosed mcp paths container safe
This commit is contained in:
+14
-3
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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')) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user