2026-06-26 13:36:05 +08:00
import path from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
2026-09-09 22:05:02 +08:00
import {
isMcpCompactActive ,
resolveMcpCompactBudgetChars ,
resolveMcpCompactMode ,
resolveMcpCompactTtlSeconds ,
} from './mcp-result-compactor.mjs' ;
2026-07-27 15:34:35 +08:00
import {
mintMindSpaceMcpScopedToken ,
} from './mindspace-mcp-scoped-token.mjs' ;
2026-06-15 15:04:43 -07:00
import { resolveAgentGooseMode } from './policies.mjs' ;
2026-07-27 15:34:35 +08:00
const SCOPED _WORKSPACE _MCP _TOOLS =
new Set ( [
'create_dir' ,
'edit_file' ,
'generate_long_image' ,
'list_dir' ,
'publish_page' ,
'read_file' ,
'write_file' ,
] ) ;
2026-07-24 18:39:24 +08:00
function resolveBundledMcpServerPath ( filename , overridePath , runtimeRoot ) {
2026-06-27 21:31:02 +08:00
const normalized = String ( overridePath ? ? '' ) . trim ( ) ;
if ( normalized ) return normalized ;
2026-07-24 18:39:24 +08:00
const normalizedRuntimeRoot = String (
runtimeRoot ? ? process . env . MEMIND _PORTAL _H5 _ROOT ? ? '' ,
) . trim ( ) ;
return path . join (
normalizedRuntimeRoot || path . dirname ( fileURLToPath ( import . meta . url ) ) ,
filename ,
) ;
}
/** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */
export function resolveSandboxMcpServerPath ( overridePath , runtimeRoot ) {
return resolveBundledMcpServerPath (
'mindspace-sandbox-mcp.mjs' ,
overridePath ,
runtimeRoot ,
) ;
2026-06-26 13:36:05 +08:00
}
2026-06-27 21:31:02 +08:00
export function resolveSandboxMcpNodeExecPath ( overridePath ) {
const normalized = String ( overridePath ? ? '' ) . trim ( ) ;
if ( normalized ) return normalized ;
return process . execPath ;
}
2026-07-17 10:44:41 +08:00
const LOOPBACK _PG _HOSTS = new Set ( [ '127.0.0.1' , 'localhost' , '::1' ] ) ;
2026-08-01 22:26:47 +08:00
export function isChatRecallExtensionSupported ( env = process . env ) {
const sessionDbUrl = String ( env ? . GOOSE _SESSION _DB _URL ? ? '' ) . trim ( ) . toLowerCase ( ) ;
if ( ! sessionDbUrl ) return true ;
// Goose chatrecall still uses SQLite transaction syntax when persisting extension state.
return ! sessionDbUrl . startsWith ( 'postgres' ) ;
}
2026-07-24 18:39:24 +08:00
function rewritePgUnixSocketUrlForContainer ( sourceUrl , hostGateway ) {
const raw = String ( sourceUrl ? ? '' ) . trim ( ) ;
const authorityMatch = raw . match ( /^(postgres(?:ql)?:\/\/[^@/]+@)\/(.*)$/i ) ;
if ( ! authorityMatch ) return null ;
try {
const parsed = new URL ( ` ${ authorityMatch [ 1 ] } localhost/ ${ authorityMatch [ 2 ] } ` ) ;
const socketHost = String ( parsed . searchParams . get ( 'host' ) ? ? '' ) . trim ( ) ;
if ( ! socketHost . startsWith ( '/' ) ) return null ;
const port = String ( parsed . searchParams . get ( 'port' ) ? ? '5432' ) . trim ( ) || '5432' ;
parsed . hostname = String ( hostGateway || 'host.docker.internal' ) . trim ( ) ;
parsed . port = port ;
parsed . searchParams . delete ( 'host' ) ;
parsed . searchParams . delete ( 'port' ) ;
return parsed . toString ( ) ;
} catch {
return null ;
}
}
2026-07-17 10:44:41 +08:00
export function resolveSandboxMcpUserDataPgUrl ( {
portalUrl ,
mcpUrl ,
containerized = false ,
hostGateway = 'host.docker.internal' ,
} = { } ) {
const explicitMcpUrl = String ( mcpUrl ? ? '' ) . trim ( ) ;
if ( explicitMcpUrl ) return explicitMcpUrl ;
const sourceUrl = String ( portalUrl ? ? '' ) . trim ( ) ;
if ( ! sourceUrl || ! containerized ) return sourceUrl ;
try {
const parsed = new URL ( sourceUrl ) ;
if ( LOOPBACK _PG _HOSTS . has ( parsed . hostname ) ) {
parsed . hostname = String ( hostGateway || 'host.docker.internal' ) . trim ( ) ;
}
return parsed . toString ( ) ;
} catch {
2026-07-24 18:39:24 +08:00
const rewrittenSocketUrl = rewritePgUnixSocketUrlForContainer ( sourceUrl , hostGateway ) ;
if ( rewrittenSocketUrl ) {
const parsed = new URL ( rewrittenSocketUrl ) ;
if ( ! parsed . password ) {
const error = new Error (
'Containerized private-data MCP cannot convert a passwordless PostgreSQL Unix-socket DSN to TCP. Configure MINDSPACE_USERDATA_MCP_PG_URL with a container-reachable authenticated DSN.' ,
) ;
error . code = 'MINDSPACE_USERDATA_MCP_PG_URL_REQUIRED' ;
throw error ;
}
return rewrittenSocketUrl ;
}
2026-07-17 10:44:41 +08:00
// Preserve the configured value so the MCP reports the real configuration
// error instead of silently falling back to its local Unix socket default.
return sourceUrl ;
}
}
2026-07-24 23:53:32 +08:00
export function resolveSandboxMcpControlDbEnv (
sourceEnv = { } ,
{
containerized = false ,
hostGateway = 'host.docker.internal' ,
} = { } ,
) {
const resolved = { } ;
for ( const key of [
'DATABASE_URL' ,
'MYSQL_HOST' ,
'MYSQL_PORT' ,
'MYSQL_USER' ,
'MYSQL_PASSWORD' ,
'MYSQL_DATABASE' ,
'PRIVATE_DATA_MAX_BYTES' ,
] ) {
if ( sourceEnv [ key ] ) resolved [ key ] = String ( sourceEnv [ key ] ) ;
}
if ( ! containerized ) return resolved ;
const gateway = String ( hostGateway || 'host.docker.internal' ) . trim ( ) ;
2026-07-25 00:09:15 +08:00
if (
resolved . MYSQL _HOST &&
LOOPBACK _PG _HOSTS . has ( resolved . MYSQL _HOST . trim ( ) )
) {
2026-07-24 23:53:32 +08:00
resolved . MYSQL _HOST = gateway ;
}
if ( resolved . DATABASE _URL ) {
try {
const parsed = new URL ( resolved . DATABASE _URL ) ;
if ( LOOPBACK _PG _HOSTS . has ( parsed . hostname ) ) {
parsed . hostname = gateway ;
resolved . DATABASE _URL = parsed . toString ( ) ;
}
} catch {
// Keep the configured value so the MCP reports the real parse error.
}
}
return resolved ;
}
2026-07-24 18:39:24 +08:00
export function resolveMindSearchMcpServerPath ( overridePath , runtimeRoot ) {
return resolveBundledMcpServerPath (
'tkmind-search-mcp.mjs' ,
overridePath ,
runtimeRoot ,
) ;
2026-07-15 13:46:17 +08:00
}
2026-07-24 09:53:06 +08:00
export function resolveMindSearchMcpEndpoint (
endpoint ,
{ hostGateway = process . env . TKMIND _SEARCH _MCP _HOST _GATEWAY || 'host.docker.internal' } = { } ,
) {
const source = String ( endpoint ? ? '' ) . trim ( ) ;
if ( ! source ) return source ;
try {
const parsed = new URL ( source ) ;
if ( LOOPBACK _PG _HOSTS . has ( parsed . hostname ) ) {
parsed . hostname = String ( hostGateway || 'host.docker.internal' ) . trim ( ) ;
}
return parsed . toString ( ) ;
} catch {
return source ;
}
}
2026-07-24 18:39:24 +08:00
export function resolveExcelMcpServerPath ( overridePath , runtimeRoot ) {
return resolveBundledMcpServerPath (
'tkmind-excel-mcp.mjs' ,
overridePath ,
runtimeRoot ,
) ;
2026-07-17 11:52:21 +08:00
}
2026-08-31 19:32:39 +08:00
/** Prefer explicit env overrides; TKMIND_* matches search MCP naming on 103/.env. */
export function resolveExcelMcpOverridePath ( env = process . env ) {
return String (
env . GOOSED _EXCEL _MCP _SERVER _PATH
? ? env . TKMIND _EXCEL _MCP _SERVER _PATH
? ? '' ,
) . trim ( ) ;
}
2026-07-25 23:01:55 +08:00
function isGoosedMcpContainerized ( sandboxMcp = null ) {
const containerFlag = String ( process . env . GOOSED _MCP _CONTAINERIZED ? ? '' ) . trim ( ) ;
if ( containerFlag ) return containerFlag === '1' ;
return Boolean ( sandboxMcp ? . containerized ) ;
}
2026-07-25 09:47:25 +08:00
function resolveBundledMcpRuntimeRoot ( sandboxMcp ) {
2026-07-25 23:01:55 +08:00
// Prefer the sandbox MCP's container-visible directory when present.
if ( sandboxMcp ? . containerized && sandboxMcp ? . serverPath ) {
return path . dirname ( sandboxMcp . serverPath ) ;
}
// tkmind-search / excel can still be enabled when sandbox-fs is absent
// (e.g. sandbox setup failed). Keep their stdio paths on the same
// container-canonical root as GOOSED_MCP_SERVER_PATH so reconcile does not
// oscillate between host and /opt/portal paths.
if ( isGoosedMcpContainerized ( sandboxMcp ) ) {
const serverPath = String (
sandboxMcp ? . serverPath || process . env . GOOSED _MCP _SERVER _PATH || '' ,
) . trim ( ) ;
if ( serverPath ) return path . dirname ( serverPath ) ;
}
return undefined ;
2026-07-25 09:47:25 +08:00
}
2026-06-15 15:04:43 -07:00
export const CAPABILITY _CATALOG = [
{
key : 'shell' ,
label : 'Shell 命令' ,
description : '执行 bash/shell 命令(developer.shell) ' ,
risk : 'high' ,
category : 'developer' ,
} ,
{
key : 'static_publish' ,
label : '用户沙箱目录' ,
description :
2026-06-26 13:36:05 +08:00
'在 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 创建问卷、表单、清单等私有结构化数据表' ,
2026-06-15 15:04:43 -07:00
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' ,
} ,
2026-07-06 16:06:26 +08:00
{
key : 'web' ,
label : '网页搜索' ,
description : '联网搜索与抓取(platform/web: web_search、fetch_url) ' ,
risk : 'medium' ,
category : 'knowledge' ,
} ,
2026-07-15 13:46:17 +08:00
{
key : 'search_external' ,
label : 'MindSearch 外部搜索增强' ,
description : '可插拔的外部搜索补充能力;不替代现有网页搜索,默认关闭' ,
risk : 'medium' ,
category : 'knowledge' ,
} ,
2026-06-15 15:04:43 -07:00
{
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' ,
} ,
2026-06-26 13:36:05 +08:00
{
key : 'openhands' ,
label : 'OpenHands 编码' ,
description : '复杂多文件编码与仓库级任务委托(openhands) ' ,
risk : 'high' ,
category : 'developer' ,
} ,
2026-06-15 15:04:43 -07:00
] ;
/** Capabilities that must never be enabled for regular users, even via DB overrides. */
2026-07-13 15:29:29 +08:00
export const USER _NON _GRANTABLE _CAPABILITIES = new Set ( [ 'extension_admin' , 'apps' ] ) ;
2026-06-15 15:04:43 -07:00
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 ,
2026-06-26 13:36:05 +08:00
private _data _space : true ,
2026-06-15 15:04:43 -07:00
filesystem : false ,
code _browse : false ,
image _read : true ,
2026-06-15 22:09:38 -07:00
skills : true ,
2026-06-15 15:04:43 -07:00
subagent : false ,
code _sandbox : false ,
2026-06-15 22:09:38 -07:00
chat _recall : true ,
2026-06-15 15:04:43 -07:00
context _memory : true ,
2026-06-15 22:09:38 -07:00
memory _store : true ,
2026-06-15 15:04:43 -07:00
extension _admin : false ,
apps : false ,
todo : false ,
2026-07-06 16:06:26 +08:00
web : true ,
2026-07-15 13:46:17 +08:00
search _external : false ,
2026-06-15 15:04:43 -07:00
computer : false ,
charts : false ,
aider : false ,
2026-06-26 13:36:05 +08:00
openhands : false ,
2026-06-15 15:04:43 -07:00
} ;
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 ;
}
2026-06-17 16:39:39 -07:00
/**
* 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 ) {
2026-06-26 13:36:05 +08:00
const tools = [ ] ;
if ( capabilities . static _publish ) {
2026-07-19 18:39:46 +08:00
tools . push (
'read_file' ,
'write_file' ,
'edit_file' ,
'create_dir' ,
2026-07-27 15:34:35 +08:00
'publish_page' ,
2026-07-19 18:39:46 +08:00
'generate_image' ,
'generate_docx' ,
'generate_long_image' ,
) ;
2026-06-26 13:36:05 +08:00
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' ,
2026-07-08 21:10:47 +08:00
'private_data_register_dataset' ,
'private_data_set_page_policy' ,
'private_data_close_page_dataset' ,
'private_data_bind_workspace_page' ,
2026-06-26 13:36:05 +08:00
'schedule_create_item' ,
'schedule_create_reminder' ,
'schedule_list_items' ,
2026-07-31 08:02:49 +08:00
'scheduled_task_create' ,
'scheduled_task_list' ,
'scheduled_task_cancel' ,
2026-08-29 09:47:39 +08:00
'scheduled_task_update_spec' ,
2026-06-26 13:36:05 +08:00
) ;
}
2026-06-17 16:39:39 -07:00
return tools ;
}
2026-06-15 15:04:43 -07:00
export function developerToolsFromPolicy ( sessionPolicy ) {
const developer = sessionPolicy ? . extensionOverrides ? . find ( ( ext ) => ext . name === 'developer' ) ;
2026-06-17 16:39:39 -07:00
const sandboxFs = sessionPolicy ? . extensionOverrides ? . find ( ( ext ) => ext . name === 'sandbox-fs' ) ;
return ( sandboxFs ? ? developer ) ? . available _tools ? ? [ ] ;
2026-06-15 15:04:43 -07:00
}
2026-07-27 19:20:23 +08:00
/**
* Visual inspection is optional for H5 Agent runs. When the active text model
* rejects image content, remove read_image for the recovery session without
* weakening any workspace or data capability boundary.
*/
export function withoutSessionImageRead ( sessionPolicy ) {
if ( ! sessionPolicy || ! Array . isArray ( sessionPolicy . extensionOverrides ) ) {
return sessionPolicy ;
}
let changed = false ;
const extensionOverrides = sessionPolicy . extensionOverrides
. map ( ( extension ) => {
if ( String ( extension ? . name ? ? '' ) !== 'developer' ) return extension ;
const tools = Array . isArray ( extension . available _tools )
? extension . available _tools
: [ ] ;
const availableTools = tools . filter ( ( tool ) => tool !== 'read_image' ) ;
if ( availableTools . length === tools . length ) return extension ;
changed = true ;
if ( availableTools . length === 0 ) return null ;
return {
... extension ,
available _tools : availableTools ,
} ;
} )
. filter ( Boolean ) ;
if ( ! changed ) return sessionPolicy ;
return {
... sessionPolicy ,
extensionOverrides ,
} ;
}
2026-06-15 15:04:43 -07:00
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 ;
}
2026-07-03 08:40:28 +08:00
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 ) : '' ;
}
2026-06-26 13:36:05 +08:00
function sandboxMcpEnvs ( sandboxMcp , mcpTools ) {
2026-07-03 08:40:28 +08:00
const localRoot = resolveSandboxMcpLocalRoot ( sandboxMcp ) ;
const compatRoot = resolveSandboxMcpCompatRoot ( sandboxMcp ) ;
2026-06-26 13:36:05 +08:00
const envs = {
ALLOWED _TOOLS : mcpTools . join ( ',' ) ,
} ;
2026-07-03 08:40:28 +08:00
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 ;
2026-07-27 15:34:35 +08:00
const scopedTools = [
... new Set ( [
... mcpTools . filter (
( tool ) =>
SCOPED _WORKSPACE _MCP _TOOLS . has (
tool ,
) ,
) ,
... ( mcpTools . includes ( 'generate_docx' )
? [ 'write_binary_file' ]
: [ ] ) ,
] ) ,
] ;
if (
sandboxMcp . mcpBaseUrl &&
sandboxMcp . mcpTokenSecret &&
sandboxMcp . workspaceRef &&
sandboxMcp . sessionId &&
sandboxMcp . packageId &&
scopedTools . length > 0
) {
const tokenBucketMs = 30 * 60 * 1000 ;
const tokenNow =
Math . floor ( Date . now ( ) / tokenBucketMs ) *
tokenBucketMs ;
envs . MINDSPACE _MCP _BASE _URL =
String ( sandboxMcp . mcpBaseUrl )
. trim ( )
. replace ( /\/+$/ , '' ) ;
envs . MINDSPACE _MCP _SCOPED _TOKEN =
mintMindSpaceMcpScopedToken ( {
secret :
sandboxMcp . mcpTokenSecret ,
userId : sandboxMcp . userId ,
sessionId :
sandboxMcp . sessionId ,
packageId :
sandboxMcp . packageId ,
workspaceRef :
sandboxMcp . workspaceRef ,
tools : scopedTools ,
ttlSeconds : 60 * 60 ,
now : tokenNow ,
tokenId :
` ${ sandboxMcp . sessionId } : ${ tokenNow } ` ,
} ) ;
envs . MINDSPACE _SESSION _ID =
String ( sandboxMcp . sessionId ) ;
envs . MINDSPACE _PACKAGE _ID =
String ( sandboxMcp . packageId ) ;
}
2026-06-26 13:36:05 +08:00
if ( sandboxMcp . userId ) envs . PRIVATE _DATA _USER _ID = sandboxMcp . userId ;
2026-07-24 18:39:24 +08:00
for ( const [ key , value ] of [
[ 'H5_PUBLIC_BASE_URL' , sandboxMcp . publicBaseUrl ] ,
[ 'H5_PORTAL_BASE_URL' , sandboxMcp . portalBaseUrl ] ,
[ 'H5_PORT' , sandboxMcp . portalPort ] ,
[
'MEMIND_PAGE_DATA_DELIVERY_BASE_URL' ,
sandboxMcp . pageDataDeliveryBaseUrl ,
] ,
] ) {
if ( value != null && String ( value ) . trim ( ) ) {
envs [ key ] = String ( value ) . trim ( ) ;
}
}
2026-08-10 20:49:36 +08:00
// goosed stdio MCPs do not inherit Portal env; forward worker flags so
// scheduled_task_create can report the same enablement as server.mjs.
for ( const key of [ 'H5_REMINDER_WORKER_ENABLED' , 'H5_SCHEDULED_TASK_WORKER_ENABLED' ] ) {
const value = process . env [ key ] ;
if ( value != null && String ( value ) . trim ( ) !== '' ) {
envs [ key ] = String ( value ) . trim ( ) ;
}
}
2026-07-19 18:39:46 +08:00
if ( mcpTools . includes ( 'generate_image' ) ) {
if ( sandboxMcp . agentApiBaseUrl ) {
envs . MINDSPACE _AGENT _API _BASE _URL = sandboxMcp . agentApiBaseUrl ;
}
if ( sandboxMcp . internalAgentSecret ) {
envs . MINDSPACE _INTERNAL _AGENT _SECRET = sandboxMcp . internalAgentSecret ;
}
}
2026-07-17 10:44:41 +08:00
if ( mcpTools . includes ( 'private_data_info' ) ) {
const userDataPgUrl = resolveSandboxMcpUserDataPgUrl ( {
portalUrl : sandboxMcp . userDataPgUrl ? ? process . env . MINDSPACE _USERDATA _PG _URL ,
mcpUrl : sandboxMcp . userDataMcpPgUrl ? ? process . env . MINDSPACE _USERDATA _MCP _PG _URL ,
containerized : Boolean ( sandboxMcp . containerized ) ,
hostGateway :
sandboxMcp . userDataPgHostGateway ? ? process . env . MINDSPACE _USERDATA _MCP _PG _HOST ? ? 'host.docker.internal' ,
} ) ;
envs . MINDSPACE _USERDATA _BACKEND = sandboxMcp . userDataBackend ? ? process . env . MINDSPACE _USERDATA _BACKEND ? ? 'postgres' ;
if ( userDataPgUrl ) envs . MINDSPACE _USERDATA _PG _URL = userDataPgUrl ;
const autoProvision = sandboxMcp . userDataAutoProvision ? ? process . env . MINDSPACE _USERDATA _AUTO _PROVISION ;
if ( autoProvision != null && String ( autoProvision ) . trim ( ) ) {
envs . MINDSPACE _USERDATA _AUTO _PROVISION = String ( autoProvision ) . trim ( ) ;
}
}
2026-07-24 23:53:32 +08:00
Object . assign (
envs ,
resolveSandboxMcpControlDbEnv ( process . env , {
containerized : Boolean ( sandboxMcp . containerized ) ,
hostGateway :
2026-07-25 00:09:15 +08:00
sandboxMcp . controlDbHostGateway ? ?
process . env . MINDSPACE _CONTROL _DB _MCP _HOST ? ?
'host.docker.internal' ,
2026-07-24 23:53:32 +08:00
} ) ,
) ;
2026-06-26 13:36:05 +08:00
return envs ;
}
2026-06-15 15:04:43 -07:00
/**
* Build goose agent/start extension_overrides from resolved capability flags.
* Returns null when the caller should use server defaults (admin / unrestricted).
2026-06-17 16:39:39 -07:00
*
2026-07-03 08:40:28 +08:00
* 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.
2026-06-15 15:04:43 -07:00
*/
export function buildAgentExtensionPolicy (
capabilities ,
2026-07-23 22:27:07 +08:00
{
unrestricted = false ,
policies = null ,
sandboxMcp = null ,
toolMode = 'chat' ,
mindSearchConfig = null ,
userId = null ,
} = { } ,
2026-06-15 15:04:43 -07:00
) {
if ( unrestricted ) {
return { extensionOverrides : null , enableContextMemory : true , gooseMode : 'auto' } ;
}
const extensions = [ ] ;
2026-07-25 09:47:25 +08:00
const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot ( sandboxMcp ) ;
2026-06-26 13:36:05 +08:00
if ( capabilities . static _publish || ( capabilities . private _data _space && sandboxMcp ) ) {
2026-07-03 08:40:28 +08:00
const localRoot = resolveSandboxMcpLocalRoot ( sandboxMcp ) ;
const compatRoot = resolveSandboxMcpCompatRoot ( sandboxMcp ) ;
if ( sandboxMcp ? . serverPath && localRoot ) {
2026-06-17 16:39:39 -07:00
// Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level.
// Replaces the built-in developer extension so path traversal is impossible.
2026-07-27 15:34:35 +08:00
const scopedWorkspaceReady = Boolean (
sandboxMcp . mcpBaseUrl &&
sandboxMcp . mcpTokenSecret &&
sandboxMcp . workspaceRef &&
sandboxMcp . sessionId &&
sandboxMcp . packageId ,
) ;
const mcpTools =
sandboxMcpTools ( capabilities ) . filter (
( tool ) =>
tool !== 'publish_page' ||
scopedWorkspaceReady ,
) ;
2026-06-17 16:39:39 -07:00
if ( mcpTools . length > 0 ) {
extensions . push ( {
type : 'stdio' ,
name : 'sandbox-fs' ,
2026-06-26 13:36:05 +08:00
description :
2026-07-17 10:44:41 +08:00
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户隔离的 PostgreSQL schema,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。' ,
2026-06-17 16:39:39 -07:00
display _name : 'sandbox-fs' ,
bundled : false ,
2026-06-27 21:31:02 +08:00
cmd : resolveSandboxMcpNodeExecPath ( sandboxMcp . nodeExecPath ) ,
2026-07-03 08:40:28 +08:00
// 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 ] ,
2026-06-17 16:39:39 -07:00
// envs (goosed field name) as belt-and-suspenders backup
2026-06-26 13:36:05 +08:00
envs : sandboxMcpEnvs ( sandboxMcp , mcpTools ) ,
2026-06-17 16:39:39 -07:00
available _tools : mcpTools ,
} ) ;
}
// Keep developer extension only for image reading when applicable.
if ( capabilities . image _read ) {
extensions . push ( makeExtension ( 'platform' , 'developer' , [ 'read_image' ] ) ) ;
}
2026-06-26 13:36:05 +08:00
} else if ( capabilities . static _publish ) {
2026-06-17 16:39:39 -07:00
// 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 ) ) ;
}
2026-06-15 15:04:43 -07:00
}
2026-06-26 13:36:05 +08:00
if ( capabilities . static _publish ) {
extensions . push ( makeExtension ( 'platform' , 'skills' , [ ] ) ) ;
extensions . push ( makeExtension ( 'platform' , 'summon' , [ 'load_skill' ] ) ) ;
extensions . push ( makeExtension ( 'platform' , 'projectmemory' , [ ] ) ) ;
}
2026-06-15 15:04:43 -07:00
} 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 ) ) ;
}
}
2026-06-27 21:31:02 +08:00
const enableCodeExecutionExtension = /^(1|true|yes)$/i . test (
process . env . TKMIND _ENABLE _CODE _EXECUTION _EXTENSION ? ? '' ,
) ;
if ( capabilities . code _sandbox && enableCodeExecutionExtension ) {
2026-06-15 15:04:43 -07:00
extensions . push ( makeExtension ( 'platform' , 'code_execution' , [ ] ) ) ;
}
2026-08-01 22:26:47 +08:00
if ( capabilities . chat _recall && isChatRecallExtensionSupported ( process . env ) ) {
2026-06-15 15:04:43 -07:00
extensions . push ( makeExtension ( 'platform' , 'chatrecall' , [ ] ) ) ;
}
2026-07-25 23:01:55 +08:00
if (
capabilities . context _memory
&& ! extensions . some ( ( ext ) => ext . name === 'projectmemory' )
) {
2026-06-15 15:04:43 -07:00
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' , [ ] ) ) ;
}
2026-07-06 16:06:26 +08:00
if ( capabilities . web ) {
extensions . push ( makeExtension ( 'platform' , 'web' , [ 'web_search' , 'fetch_url' ] ) ) ;
}
2026-07-15 13:46:17 +08:00
const searchEnabled = capabilities . search _external && mindSearchConfig ? . enabled && mindSearchConfig . mode !== 'off' ;
if ( searchEnabled && ( ! policies || policies . network _egress !== 'deny' ) ) {
2026-07-24 09:53:06 +08:00
const mcpServices = ( mindSearchConfig . services ? ? [ ] ) . map ( ( service ) => ( {
... service ,
endpoint : resolveMindSearchMcpEndpoint ( service . endpoint ) ,
} ) ) ;
const researchService = mcpServices . find ( ( service ) =>
service . id === mindSearchConfig . routes ? . research && service . adapter === 'research-http' ) ;
const deepSearchService = mcpServices . find ( ( service ) =>
service . id === 'deep-search' && service . adapter === 'research-http' ) ;
const hasResearchService = Boolean ( researchService ? . enabled ) ;
const githubGatewayUrl = resolveMindSearchMcpEndpoint (
process . env . TKMIND _SEARCH _GITHUB _GATEWAY _URL
|| deepSearchService ? . endpoint
|| researchService ? . endpoint
|| '' ,
) ;
2026-07-23 21:38:36 +08:00
extensions . push ( {
type : 'stdio' ,
name : 'tkmind-search' ,
description : 'MindSearch 外部搜索增强(补充能力)' ,
display _name : 'tkmind-search' ,
bundled : false ,
cmd : resolveSandboxMcpNodeExecPath ( process . env . GOOSED _MCP _NODE _PATH ) ,
2026-07-25 09:47:25 +08:00
args : [
resolveMindSearchMcpServerPath (
process . env . TKMIND _SEARCH _MCP _SERVER _PATH ,
bundledMcpRuntimeRoot ,
) ,
] ,
2026-07-23 21:38:36 +08:00
envs : {
TKMIND _SEARCH _ENABLED : '1' ,
TKMIND _SEARCH _MODE : mindSearchConfig . mode ,
TKMIND _SEARCH _PROVIDER _SEARXNG _ENABLED : mindSearchConfig . providers ? . searxng ? '1' : '0' ,
TKMIND _SEARCH _PROVIDER _GITHUB _ENABLED : mindSearchConfig . providers ? . github ? '1' : '0' ,
TKMIND _SEARCH _READER _ENABLED : mindSearchConfig . providers ? . reader ? '1' : '0' ,
2026-07-24 09:53:06 +08:00
TKMIND _SEARCH _SEARXNG _URL : resolveMindSearchMcpEndpoint (
mindSearchConfig . settings ? . searxngEndpoint ? ? '' ,
) ,
2026-07-23 21:38:36 +08:00
TKMIND _SEARCH _MAX _RESULTS : String ( mindSearchConfig . settings ? . maxResults ? ? 10 ) ,
TKMIND _SEARCH _TIMEOUT _MS : String ( mindSearchConfig . settings ? . timeoutMs ? ? 8000 ) ,
TKMIND _SEARCH _READER _MAX _CHARS : String ( mindSearchConfig . settings ? . readerMaxChars ? ? 12000 ) ,
2026-07-24 09:53:06 +08:00
TKMIND _SEARCH _SERVICES _JSON : JSON . stringify ( mcpServices ) ,
2026-07-23 21:38:36 +08:00
TKMIND _SEARCH _ROUTES _JSON : JSON . stringify ( mindSearchConfig . routes ? ? { } ) ,
2026-07-23 22:27:07 +08:00
TKMIND _SEARCH _USER _ID : userId ? String ( userId ) : '' ,
TKMIND _DEEP _SEARCH _SECRET : process . env . TKMIND _DEEP _SEARCH _SECRET ? ? '' ,
2026-07-24 09:53:06 +08:00
TKMIND _SEARCH _GITHUB _GATEWAY _URL : githubGatewayUrl ,
TKMIND _SEARCH _GITHUB _GATEWAY _SECRET :
process . env . TKMIND _SEARCH _GITHUB _GATEWAY _SECRET
? ? process . env . TKMIND _DEEP _SEARCH _SECRET
? ? '' ,
2026-09-09 22:05:02 +08:00
MEMIND _MCP _COMPACT _MODE : resolveMcpCompactMode ( ) ,
MEMIND _MCP _COMPACT _BUDGET _CHARS : String ( resolveMcpCompactBudgetChars ( ) ) ,
MEMIND _MCP _COMPACT _TTL _SECONDS : String ( resolveMcpCompactTtlSeconds ( ) ) ,
MEMIND _RUNTIME _REDIS _URL : process . env . MEMIND _RUNTIME _REDIS _URL ? ? process . env . REDIS _URL ? ? '' ,
2026-07-23 21:38:36 +08:00
} ,
2026-07-23 22:27:07 +08:00
available _tools : [
'tkmind_search' ,
'tkmind_read' ,
... ( hasResearchService
? [ 'tkmind_research' , 'tkmind_research_status' , 'tkmind_research_cancel' ]
: [ ] ) ,
2026-09-09 22:05:02 +08:00
... ( isMcpCompactActive ( ) ? [ 'ctx_fetch' ] : [ ] ) ,
2026-07-23 22:27:07 +08:00
] ,
2026-07-23 21:38:36 +08:00
} ) ;
2026-07-15 13:46:17 +08:00
}
2026-07-17 11:52:21 +08:00
if ( capabilities . excel _analysis ) {
const excelWorkspaceRoot = resolveSandboxMcpLocalRoot ( sandboxMcp ) ;
if ( excelWorkspaceRoot ) {
extensions . push ( {
type : 'stdio' ,
name : 'tkmind-excel' ,
description : 'Excel Analyst:当前用户工作区内 .xlsx 的只读检查、统计分析、图表与一致性校验报告。' ,
display _name : 'Excel Analyst' ,
bundled : false ,
cmd : resolveSandboxMcpNodeExecPath ( sandboxMcp ? . nodeExecPath ) ,
2026-07-25 09:47:25 +08:00
args : [
resolveExcelMcpServerPath (
2026-08-31 19:32:39 +08:00
resolveExcelMcpOverridePath ( ) ,
2026-07-25 09:47:25 +08:00
bundledMcpRuntimeRoot ,
) ,
excelWorkspaceRoot ,
] ,
2026-07-17 11:52:21 +08:00
envs : {
EXCEL _ANALYST _ENABLED : '1' ,
MINDSPACE _WORKSPACE _ROOT : excelWorkspaceRoot ,
2026-09-09 22:05:02 +08:00
MEMIND _MCP _COMPACT _MODE : resolveMcpCompactMode ( ) ,
MEMIND _MCP _COMPACT _BUDGET _CHARS : String ( resolveMcpCompactBudgetChars ( ) ) ,
MEMIND _MCP _COMPACT _TTL _SECONDS : String ( resolveMcpCompactTtlSeconds ( ) ) ,
MEMIND _RUNTIME _REDIS _URL : process . env . MEMIND _RUNTIME _REDIS _URL ? ? process . env . REDIS _URL ? ? '' ,
2026-07-17 11:52:21 +08:00
... ( sandboxMcp ? . workspaceRef ? { MINDSPACE _WORKSPACE _REF : sandboxMcp . workspaceRef } : { } ) ,
} ,
2026-09-09 22:05:02 +08:00
available _tools : [
'excel_inspect' ,
'excel_analyze' ,
'excel_chart' ,
'excel_report' ,
... ( isMcpCompactActive ( ) ? [ 'ctx_fetch' ] : [ ] ) ,
] ,
2026-07-17 11:52:21 +08:00
} ) ;
}
}
2026-06-15 15:04:43 -07:00
if ( capabilities . computer ) {
extensions . push ( makeExtension ( 'builtin' , 'computercontroller' , [ ] ) ) ;
}
if ( capabilities . charts ) {
extensions . push ( makeExtension ( 'builtin' , 'autovisualiser' , [ ] ) ) ;
}
2026-07-02 07:12:18 +08:00
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' } ,
} ) ;
2026-06-15 15:04:43 -07:00
}
2026-07-02 07:12:18 +08:00
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' } ,
} ) ;
2026-06-26 13:36:05 +08:00
}
2026-06-15 15:04:43 -07:00
return {
extensionOverrides : extensions ,
enableContextMemory : Boolean (
capabilities . context _memory || capabilities . chat _recall || capabilities . static _publish ,
) ,
gooseMode : resolveAgentGooseMode ( capabilities , policies ) ,
} ;
}
2026-06-15 22:09:38 -07:00
/**
* 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' ) ;
2026-06-17 16:39:39 -07:00
const canShell =
baseDeveloper ? . available _tools ? . includes ( 'shell' ) ||
basePolicy ? . capabilities ? . shell === true ;
2026-06-15 22:09:38 -07:00
const extensions = canShell ? [ makeExtension ( 'platform' , 'developer' , [ 'shell' ] ) ] : [ ] ;
return {
... basePolicy ,
extensionOverrides : extensions ,
enableContextMemory : false ,
gooseMode : 'auto' ,
} ;
}
2026-06-15 15:04:43 -07:00
export function normalizeCapabilityPatch ( patch ) {
const normalized = { } ;
for ( const [ key , value ] of Object . entries ( patch ? ? { } ) ) {
if ( ! isValidCapabilityKey ( key ) ) continue ;
normalized [ key ] = Boolean ( value ) ;
}
return normalized ;
}