14a00774d9
- 新增 web 能力并挂载 platform/web(web_search/fetch_url) - 实时查询强制 web skill,router fallback 与 await session Finish - Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
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;
|
||
}
|
||
|
||
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: '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']);
|
||
|
||
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,
|
||
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/<userId>/ — 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_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',
|
||
'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;
|
||
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' } = {},
|
||
) {
|
||
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:
|
||
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。',
|
||
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']));
|
||
}
|
||
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;
|
||
}
|