Files
memind/capabilities.mjs
T
john 52c7082c70 feat: add generate_docx sandbox MCP tool for public Word downloads
Expose generate_docx in mindspace-sandbox-mcp so agents can write public/*.docx
before linking HTML download pages, with tests mirroring the Mark summary flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 00:18:43 +08:00

470 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '会话级项目记忆注入(projectmemoryH5 引导用)',
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: '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,
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.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;
}