Fix sandbox MCP path resolution for session policy sync.
Resolve mindspace-sandbox-mcp.mjs from the code module location instead of h5Root, and copy it into the portal runtime artifact so goosed can spawn sandbox-fs on production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+74
-13
@@ -1,5 +1,12 @@
|
||||
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() {
|
||||
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'mindspace-sandbox-mcp.mjs');
|
||||
}
|
||||
|
||||
export const CAPABILITY_CATALOG = [
|
||||
{
|
||||
key: 'shell',
|
||||
@@ -12,7 +19,15 @@ export const CAPABILITY_CATALOG = [
|
||||
key: 'static_publish',
|
||||
label: '用户沙箱目录',
|
||||
description:
|
||||
'在 MindSpace/<用户ID>/ 内可使用 write、edit、shell、tree(不可越出该目录;由 static-page-publish 技能开通)',
|
||||
'在 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',
|
||||
},
|
||||
@@ -121,6 +136,13 @@ export const CAPABILITY_CATALOG = [
|
||||
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. */
|
||||
@@ -139,6 +161,7 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries(
|
||||
const defaults = {
|
||||
shell: false,
|
||||
static_publish: false,
|
||||
private_data_space: true,
|
||||
filesystem: false,
|
||||
code_browse: false,
|
||||
image_read: true,
|
||||
@@ -154,6 +177,7 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries(
|
||||
computer: false,
|
||||
charts: false,
|
||||
aider: false,
|
||||
openhands: false,
|
||||
};
|
||||
return [key, defaults[key] ?? false];
|
||||
}),
|
||||
@@ -199,8 +223,22 @@ export function sandboxDeveloperTools(capabilities) {
|
||||
* read_file is always included because edit_file requires reading first.
|
||||
*/
|
||||
export function sandboxMcpTools(capabilities) {
|
||||
const tools = ['read_file', 'write_file', 'edit_file', 'create_dir'];
|
||||
if (capabilities.shell || capabilities.code_browse) tools.push('list_dir');
|
||||
const tools = [];
|
||||
if (capabilities.static_publish) {
|
||||
tools.push('read_file', 'write_file', 'edit_file', 'create_dir');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -219,6 +257,26 @@ function mergeDeveloperTools(capabilities) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
function sandboxMcpEnvs(sandboxMcp, mcpTools) {
|
||||
const envs = {
|
||||
SANDBOX_ROOT: sandboxMcp.sandboxRoot,
|
||||
ALLOWED_TOOLS: mcpTools.join(','),
|
||||
};
|
||||
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).
|
||||
@@ -236,7 +294,7 @@ export function buildAgentExtensionPolicy(
|
||||
}
|
||||
|
||||
const extensions = [];
|
||||
if (capabilities.static_publish) {
|
||||
if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) {
|
||||
if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) {
|
||||
// Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level.
|
||||
// Replaces the built-in developer extension so path traversal is impossible.
|
||||
@@ -245,17 +303,15 @@ export function buildAgentExtensionPolicy(
|
||||
extensions.push({
|
||||
type: 'stdio',
|
||||
name: 'sandbox-fs',
|
||||
description: '工作区沙箱文件系统(路径限制在用户工作区内)',
|
||||
description:
|
||||
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。',
|
||||
display_name: 'sandbox-fs',
|
||||
bundled: false,
|
||||
cmd: sandboxMcp.nodeExecPath ?? process.execPath,
|
||||
// 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
|
||||
envs: {
|
||||
SANDBOX_ROOT: sandboxMcp.sandboxRoot,
|
||||
ALLOWED_TOOLS: mcpTools.join(','),
|
||||
},
|
||||
envs: sandboxMcpEnvs(sandboxMcp, mcpTools),
|
||||
available_tools: mcpTools,
|
||||
});
|
||||
}
|
||||
@@ -263,7 +319,7 @@ export function buildAgentExtensionPolicy(
|
||||
if (capabilities.image_read) {
|
||||
extensions.push(makeExtension('platform', 'developer', ['read_image']));
|
||||
}
|
||||
} else {
|
||||
} 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);
|
||||
@@ -271,9 +327,11 @@ export function buildAgentExtensionPolicy(
|
||||
extensions.push(makeExtension('platform', 'developer', sandboxTools));
|
||||
}
|
||||
}
|
||||
extensions.push(makeExtension('platform', 'skills', []));
|
||||
extensions.push(makeExtension('platform', 'summon', ['load_skill']));
|
||||
extensions.push(makeExtension('platform', 'projectmemory', []));
|
||||
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) {
|
||||
@@ -322,6 +380,9 @@ export function buildAgentExtensionPolicy(
|
||||
if (capabilities.aider) {
|
||||
extensions.push(makeExtension('platform', 'aider', []));
|
||||
}
|
||||
if (capabilities.openhands) {
|
||||
extensions.push(makeExtension('platform', 'openhands', []));
|
||||
}
|
||||
|
||||
return {
|
||||
extensionOverrides: extensions,
|
||||
|
||||
+67
-1
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
buildAgentExtensionPolicy,
|
||||
buildPageEditAgentPolicy,
|
||||
@@ -7,20 +8,30 @@ import {
|
||||
clampUserCapabilities,
|
||||
DEFAULT_USER_CAPABILITIES,
|
||||
normalizeCapabilityPatch,
|
||||
resolveSandboxMcpServerPath,
|
||||
sandboxDeveloperTools,
|
||||
sandboxMcpTools,
|
||||
} from './capabilities.mjs';
|
||||
import { applyPoliciesToCapabilities } from './policies.mjs';
|
||||
|
||||
test('resolveSandboxMcpServerPath resolves to an existing MCP entry file', () => {
|
||||
const serverPath = resolveSandboxMcpServerPath();
|
||||
assert.match(serverPath, /mindspace-sandbox-mcp\.mjs$/);
|
||||
assert.ok(fs.existsSync(serverPath));
|
||||
});
|
||||
|
||||
test('default user policy blocks dangerous capabilities', () => {
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.shell, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.filesystem, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.extension_admin, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.static_publish, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.private_data_space, true);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.code_browse, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.memory_store, true);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.skills, true);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.chat_recall, true);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.aider, false);
|
||||
assert.equal(DEFAULT_USER_CAPABILITIES.openhands, false);
|
||||
});
|
||||
|
||||
test('buildAgentExtensionPolicy returns null overrides and auto mode for unrestricted users', () => {
|
||||
@@ -73,6 +84,16 @@ test('buildAgentExtensionPolicy filters developer tools', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAgentExtensionPolicy includes aider and openhands when granted', () => {
|
||||
const policy = buildAgentExtensionPolicy({
|
||||
...DEFAULT_USER_CAPABILITIES,
|
||||
aider: true,
|
||||
openhands: true,
|
||||
});
|
||||
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'aider'));
|
||||
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'openhands'));
|
||||
});
|
||||
|
||||
test('normalizeCapabilityPatch ignores unknown keys', () => {
|
||||
assert.deepEqual(normalizeCapabilityPatch({ shell: true, unknown: true }), { shell: true });
|
||||
});
|
||||
@@ -139,7 +160,19 @@ test('buildPageEditAgentPolicy grants shell for static_publish sandbox users wit
|
||||
|
||||
test('sandboxMcpTools returns correct tool list based on capabilities', () => {
|
||||
const base = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
|
||||
assert.deepEqual(sandboxMcpTools(base), ['read_file', 'write_file', 'edit_file', 'create_dir']);
|
||||
assert.deepEqual(sandboxMcpTools(base), [
|
||||
'read_file',
|
||||
'write_file',
|
||||
'edit_file',
|
||||
'create_dir',
|
||||
'private_data_info',
|
||||
'private_data_schema',
|
||||
'private_data_query',
|
||||
'private_data_execute',
|
||||
'schedule_create_item',
|
||||
'schedule_create_reminder',
|
||||
'schedule_list_items',
|
||||
]);
|
||||
|
||||
const withBrowse = { ...base, code_browse: true };
|
||||
assert.ok(sandboxMcpTools(withBrowse).includes('list_dir'));
|
||||
@@ -148,6 +181,39 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
|
||||
assert.ok(sandboxMcpTools(withShell).includes('list_dir'));
|
||||
});
|
||||
|
||||
test('private_data_space alone exposes private data tools through sandbox MCP', () => {
|
||||
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: false, private_data_space: true };
|
||||
assert.deepEqual(sandboxMcpTools(caps), [
|
||||
'private_data_info',
|
||||
'private_data_schema',
|
||||
'private_data_query',
|
||||
'private_data_execute',
|
||||
'schedule_create_item',
|
||||
'schedule_create_reminder',
|
||||
'schedule_list_items',
|
||||
]);
|
||||
|
||||
const policy = buildAgentExtensionPolicy(caps, {
|
||||
sandboxMcp: {
|
||||
serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs',
|
||||
sandboxRoot: '/opt/h5/MindSpace/user-1',
|
||||
userId: 'user-1',
|
||||
},
|
||||
});
|
||||
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
|
||||
assert.ok(sandboxExt);
|
||||
assert.equal(sandboxExt.envs.PRIVATE_DATA_USER_ID, 'user-1');
|
||||
assert.deepEqual(sandboxExt.available_tools, [
|
||||
'private_data_info',
|
||||
'private_data_schema',
|
||||
'private_data_query',
|
||||
'private_data_execute',
|
||||
'schedule_create_item',
|
||||
'schedule_create_reminder',
|
||||
'schedule_list_items',
|
||||
]);
|
||||
});
|
||||
|
||||
test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of developer', () => {
|
||||
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
|
||||
const sandboxMcp = { serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', sandboxRoot: '/opt/h5/MindSpace/abc123' };
|
||||
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(__dirname, '..');
|
||||
const runtimeRoot = path.join(root, '.runtime', 'portal');
|
||||
const distDir = path.join(root, 'dist');
|
||||
const publicDir = path.join(root, 'public');
|
||||
const nodeModulesDir = path.join(root, 'node_modules');
|
||||
const schemaFile = path.join(root, 'schema.sql');
|
||||
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
|
||||
const skipBuild = process.argv.includes('--skip-build');
|
||||
const skipNodeModules = process.argv.includes('--skip-node-modules');
|
||||
|
||||
const externalPackages = [
|
||||
'@node-rs/argon2',
|
||||
'@resvg/resvg-js',
|
||||
'debug',
|
||||
'express',
|
||||
'http-proxy-middleware',
|
||||
'jsonrepair',
|
||||
'mysql2',
|
||||
'mysql2/promise',
|
||||
'qrcode',
|
||||
'redis',
|
||||
'undici',
|
||||
];
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
...options,
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function exists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(targetPath) {
|
||||
await fs.rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copyDir(source, target) {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.cp(source, target, { recursive: true, dereference: false });
|
||||
}
|
||||
|
||||
async function writeFile(targetPath, content) {
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, content, 'utf8');
|
||||
}
|
||||
|
||||
async function buildFrontend() {
|
||||
if (skipBuild) return;
|
||||
console.log('==> 构建 Portal 前端 dist');
|
||||
await run('npm', ['run', 'build']);
|
||||
}
|
||||
|
||||
async function bundleServer() {
|
||||
if (!(await exists(esbuildBin))) {
|
||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||
}
|
||||
console.log('==> 打包 Portal 后端为单文件 runtime');
|
||||
const args = [
|
||||
'server.mjs',
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=esm',
|
||||
'--target=node24',
|
||||
'--outfile=.runtime/portal/server.mjs',
|
||||
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
];
|
||||
for (const pkg of externalPackages) {
|
||||
args.push(`--external:${pkg}`);
|
||||
}
|
||||
await run(esbuildBin, args);
|
||||
}
|
||||
|
||||
async function copyRuntimeAssets() {
|
||||
console.log('==> 拷贝运行时静态资源');
|
||||
await remove(runtimeRoot);
|
||||
await fs.mkdir(runtimeRoot, { recursive: true });
|
||||
|
||||
if (!(await exists(distDir))) {
|
||||
throw new Error(`缺少 dist 目录: ${distDir}`);
|
||||
}
|
||||
await copyDir(distDir, path.join(runtimeRoot, 'dist'));
|
||||
|
||||
if (await exists(publicDir)) {
|
||||
await copyDir(publicDir, path.join(runtimeRoot, 'public'));
|
||||
}
|
||||
|
||||
if (await exists(schemaFile)) {
|
||||
await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql'));
|
||||
}
|
||||
|
||||
const sandboxMcpFile = path.join(root, 'mindspace-sandbox-mcp.mjs');
|
||||
if (await exists(sandboxMcpFile)) {
|
||||
await fs.copyFile(sandboxMcpFile, path.join(runtimeRoot, 'mindspace-sandbox-mcp.mjs'));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyNodeModules() {
|
||||
if (skipNodeModules) return;
|
||||
if (!(await exists(nodeModulesDir))) {
|
||||
throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`);
|
||||
}
|
||||
console.log('==> 拷贝生产运行依赖 node_modules');
|
||||
await copyDir(nodeModulesDir, path.join(runtimeRoot, 'node_modules'));
|
||||
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'));
|
||||
}
|
||||
|
||||
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir) {
|
||||
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
|
||||
const localNodeModulesRoot = `${nodeModulesDir}${path.sep}`;
|
||||
|
||||
async function visit(dir) {
|
||||
const entries = await fs.readdir(dir);
|
||||
for (const entryName of entries) {
|
||||
const fullPath = path.join(dir, entryName);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const linkTarget = await fs.readlink(fullPath);
|
||||
const normalizedTarget = path.normalize(linkTarget);
|
||||
if (!normalizedTarget.startsWith(localNodeModulesRoot)) continue;
|
||||
const relativeInsideNodeModules = normalizedTarget.slice(localNodeModulesRoot.length);
|
||||
const runtimeTarget = path.join(runtimeNodeModulesDir, relativeInsideNodeModules);
|
||||
const replacement = path.relative(path.dirname(fullPath), runtimeTarget);
|
||||
await fs.unlink(fullPath);
|
||||
await fs.symlink(replacement, fullPath);
|
||||
continue;
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
await visit(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(runtimeNodeModulesDir);
|
||||
}
|
||||
|
||||
async function writeMetadata() {
|
||||
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const runtimePackageJson = {
|
||||
name: `${packageJson.name}-portal-runtime`,
|
||||
private: true,
|
||||
type: 'module',
|
||||
};
|
||||
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
|
||||
await writeFile(
|
||||
path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
'ROOT="$(cd "$(dirname "$0")/.." && pwd)"',
|
||||
'cd "$ROOT"',
|
||||
'',
|
||||
'if [[ -f "${ROOT}/.env" ]]; then',
|
||||
' set -a',
|
||||
' # shellcheck disable=SC1091',
|
||||
' source "${ROOT}/.env"',
|
||||
' set +a',
|
||||
'fi',
|
||||
'',
|
||||
'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_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}"',
|
||||
'',
|
||||
'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"',
|
||||
'if [[ ! -x "${NODE_BIN}" ]]; then',
|
||||
' NODE_BIN="$(command -v node)"',
|
||||
'fi',
|
||||
'',
|
||||
'exec "${NODE_BIN}" "${ROOT}/server.mjs"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(runtimeRoot, 'RUNBOOK.txt'),
|
||||
[
|
||||
'Portal runtime artifact',
|
||||
'',
|
||||
'This directory is meant to run on production without the source tree.',
|
||||
'Required persisted items to inherit on the host:',
|
||||
' .env',
|
||||
' MindSpace/',
|
||||
' data/',
|
||||
' users/',
|
||||
' .tailscale/',
|
||||
' public/plaza-covers/',
|
||||
' logs/',
|
||||
'',
|
||||
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
||||
' mindspace-sandbox-mcp.mjs',
|
||||
'',
|
||||
'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',
|
||||
' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT',
|
||||
'',
|
||||
'Deployment and operations transport:',
|
||||
' 105 fixed IP: 120.26.184.105',
|
||||
' 103 / Studio fixed IP: 58.38.22.103',
|
||||
' Do not switch back to 10.10.* LAN paths unless explicitly required.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await buildFrontend();
|
||||
await copyRuntimeAssets();
|
||||
await bundleServer();
|
||||
await copyNodeModules();
|
||||
await writeMetadata();
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
|
||||
console.log('');
|
||||
console.log(`Portal runtime 已生成: ${runtimeRoot}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
+374
-43
@@ -1,5 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2';
|
||||
import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs';
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
DEFAULT_USER_CAPABILITIES,
|
||||
isValidCapabilityKey,
|
||||
normalizeCapabilityPatch,
|
||||
resolveSandboxMcpServerPath,
|
||||
USER_NON_GRANTABLE_CAPABILITIES,
|
||||
} from './capabilities.mjs';
|
||||
import {
|
||||
@@ -25,9 +27,11 @@ import {
|
||||
import {
|
||||
ensurePublishSkillInstalled,
|
||||
ensureUserPublishLayout,
|
||||
ensureWorkspaceHintsInstalled,
|
||||
PUBLISH_ROOT_DIR,
|
||||
PUBLISH_SKILL_NAME,
|
||||
resolvePublicBaseUrl,
|
||||
resolveLegacyPublishDir,
|
||||
} from './user-publish.mjs';
|
||||
import {
|
||||
ensureUserSpaceLayout,
|
||||
@@ -128,6 +132,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
|
||||
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
|
||||
const persistSessions = options.persistSessions !== false && Boolean(pool);
|
||||
let rechargeNotifier =
|
||||
typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null;
|
||||
const subscriptionService = options.subscriptionService ?? null;
|
||||
const sessions = new Map();
|
||||
const loginFailures = new Map();
|
||||
|
||||
@@ -170,23 +177,19 @@ export function createUserAuth(pool, options = {}) {
|
||||
fs.mkdirSync(workspaceRoot, { recursive: true });
|
||||
};
|
||||
|
||||
const resolveAdminWorkspaceRoot = () => {
|
||||
const configured = env.H5_ADMIN_WORKSPACE_ROOT?.trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
return path.dirname(usersRoot);
|
||||
};
|
||||
|
||||
const isAdminRole = (user) => user?.role === 'admin';
|
||||
|
||||
const getUserById = async (userId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
w.balance_cents, w.tokens_used,
|
||||
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
|
||||
FROM h5_billing_ledger l
|
||||
WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
|
||||
FROM h5_users u
|
||||
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
|
||||
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
||||
WHERE u.id = ?`,
|
||||
[userId],
|
||||
@@ -210,8 +213,14 @@ export function createUserAuth(pool, options = {}) {
|
||||
balanceCents,
|
||||
totalCreditCents: balanceCents + spentCents,
|
||||
tokensUsed: Number(row.tokens_used ?? 0),
|
||||
spaceQuotaBytes: Number(row.quota_bytes ?? 0),
|
||||
spaceUsedBytes: Number(row.used_bytes ?? 0),
|
||||
spaceReservedBytes: Number(row.reserved_bytes ?? 0),
|
||||
spaceAvailableBytes: Math.max(
|
||||
0,
|
||||
Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0),
|
||||
),
|
||||
};
|
||||
if (row.role === 'admin') return base;
|
||||
const publishKey = row.id;
|
||||
return {
|
||||
...base,
|
||||
@@ -238,13 +247,19 @@ export function createUserAuth(pool, options = {}) {
|
||||
slug: web.slug,
|
||||
workspaceRoot: web.publishDir,
|
||||
});
|
||||
ensurePublishSkillInstalled(web.publishDir, {
|
||||
const hintsContext = {
|
||||
slug: web.slug,
|
||||
username: user.username ?? web.username,
|
||||
displayName: user.displayName,
|
||||
publicBaseUrl,
|
||||
publishDir: web.publishDir,
|
||||
});
|
||||
};
|
||||
ensurePublishSkillInstalled(web.publishDir, hintsContext);
|
||||
ensureWorkspaceHintsInstalled(web.publishDir, hintsContext);
|
||||
const legacyPublishDir = resolveLegacyPublishDir(h5Root, user);
|
||||
if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs.existsSync(legacyPublishDir)) {
|
||||
ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir });
|
||||
}
|
||||
ensureUserMemoryProfile(web.publishDir, {
|
||||
userId: user.id,
|
||||
displayName: user.displayName ?? user.display_name,
|
||||
@@ -293,7 +308,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
};
|
||||
|
||||
const syncUserPublishWorkspace = async (user) => {
|
||||
if (!user || user.role === 'admin') return null;
|
||||
if (!user) return null;
|
||||
const layout = await publishLayoutFor(user);
|
||||
const current = path.resolve(user.workspace_root);
|
||||
const target = path.resolve(layout.publishDir);
|
||||
@@ -386,6 +401,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
username: normalized,
|
||||
slug: normalized,
|
||||
});
|
||||
if (subscriptionService) {
|
||||
subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
|
||||
}
|
||||
const user = await getUserById(userId);
|
||||
return { ok: true, user: publicUser(user) };
|
||||
} catch (err) {
|
||||
@@ -582,18 +600,13 @@ export function createUserAuth(pool, options = {}) {
|
||||
const resolveWorkingDir = async (userId) => {
|
||||
const user = await getUserById(userId);
|
||||
if (!user) throw new Error('用户不存在');
|
||||
if (isAdminRole(user)) {
|
||||
const root = resolveAdminWorkspaceRoot();
|
||||
ensureWorkspace(root);
|
||||
return root;
|
||||
}
|
||||
const layout = await syncUserPublishWorkspace(user);
|
||||
return layout.publishDir;
|
||||
};
|
||||
|
||||
const getUserPublishLayout = async (userId) => {
|
||||
const user = await getUserById(userId);
|
||||
if (!user || user.role === 'admin') return null;
|
||||
if (!user) return null;
|
||||
return syncUserPublishWorkspace(user);
|
||||
};
|
||||
|
||||
@@ -678,6 +691,29 @@ export function createUserAuth(pool, options = {}) {
|
||||
if (isAdminRole(user)) {
|
||||
return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
|
||||
}
|
||||
|
||||
// Subscription check: active subscription with remaining quota bypasses balance requirement.
|
||||
if (subscriptionService) {
|
||||
const sub = await subscriptionService.getActiveSubscription(userId);
|
||||
if (sub) {
|
||||
const unlimited = sub.periodTokensLimit === 0;
|
||||
const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit;
|
||||
const balanceCents = Number(user.balance_cents ?? 0);
|
||||
if (hasQuota) {
|
||||
return { ok: true, balanceCents, subscription: sub };
|
||||
}
|
||||
// Quota exhausted — allow if balance covers overage, otherwise block.
|
||||
if (balanceCents > 0) {
|
||||
return { ok: true, balanceCents, subscription: sub, overQuota: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: '本月额度已用完,余额不足,请充值或升级套餐',
|
||||
...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const balanceCents = Number(user.balance_cents ?? 0);
|
||||
if (balanceCents <= 0) {
|
||||
return {
|
||||
@@ -709,8 +745,10 @@ export function createUserAuth(pool, options = {}) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
||||
FROM h5_users u
|
||||
LEFT JOIN h5_user_spaces s ON s.user_id = u.id
|
||||
LEFT JOIN h5_user_wallets w ON w.user_id = u.id
|
||||
${where}
|
||||
ORDER BY u.created_at DESC
|
||||
@@ -725,6 +763,11 @@ export function createUserAuth(pool, options = {}) {
|
||||
};
|
||||
};
|
||||
|
||||
const getUserPublic = async (userId) => {
|
||||
const user = await getUserById(userId);
|
||||
return user ? publicUser(user) : null;
|
||||
};
|
||||
|
||||
const createUser = async ({
|
||||
username,
|
||||
password,
|
||||
@@ -744,9 +787,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
|
||||
const isAdmin = role === 'admin';
|
||||
const userId = crypto.randomUUID();
|
||||
const root = isAdmin
|
||||
? path.resolve(workspaceRoot || path.join(usersRoot, normalized))
|
||||
: (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
|
||||
const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
|
||||
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -790,6 +831,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
await conn.commit();
|
||||
ensureWorkspace(root);
|
||||
if (subscriptionService && !isAdmin) {
|
||||
subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
|
||||
}
|
||||
const user = await getUserById(userId);
|
||||
return { ok: true, user: publicUser(user) };
|
||||
} catch (err) {
|
||||
@@ -854,10 +898,156 @@ export function createUserAuth(pool, options = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
if (patch.spaceQuotaBytes !== undefined) {
|
||||
const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes));
|
||||
if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) {
|
||||
return { ok: false, message: '空间大小无效' };
|
||||
}
|
||||
const [spaceRows] = await pool.query(
|
||||
`SELECT id, quota_bytes, used_bytes, reserved_bytes
|
||||
FROM h5_user_spaces
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
const currentSpace = spaceRows[0];
|
||||
const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0);
|
||||
if (quotaBytes < occupiedBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `空间不能小于已使用容量 ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`,
|
||||
};
|
||||
}
|
||||
if (currentSpace?.id) {
|
||||
await pool.query(
|
||||
`UPDATE h5_user_spaces
|
||||
SET quota_bytes = ?, updated_at = ?
|
||||
WHERE user_id = ?`,
|
||||
[quotaBytes, now, userId],
|
||||
);
|
||||
} else {
|
||||
await initializeDefaultSpace(pool, userId, {
|
||||
quotaBytes,
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await getUserById(userId);
|
||||
return { ok: true, user: publicUser(updated) };
|
||||
};
|
||||
|
||||
const purchaseSpaceQuota = async (userId, sizeMb) => {
|
||||
const purchaseMb = Math.floor(Number(sizeMb));
|
||||
if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) {
|
||||
return { ok: false, message: '扩容大小无效' };
|
||||
}
|
||||
|
||||
const deltaBytes = purchaseMb * 1024 * 1024;
|
||||
const costCents = purchaseMb * 200;
|
||||
const now = Date.now();
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [spaceRows] = await conn.query(
|
||||
`SELECT id, quota_bytes, used_bytes, reserved_bytes
|
||||
FROM h5_user_spaces
|
||||
WHERE user_id = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE`,
|
||||
[userId],
|
||||
);
|
||||
if (!spaceRows[0]) {
|
||||
await initializeDefaultSpace(conn, userId, { now });
|
||||
}
|
||||
|
||||
const [walletRows] = await conn.query(
|
||||
`SELECT balance_cents
|
||||
FROM h5_user_wallets
|
||||
WHERE user_id = ?
|
||||
FOR UPDATE`,
|
||||
[userId],
|
||||
);
|
||||
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
|
||||
if (balanceCents < costCents) {
|
||||
await conn.rollback();
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
message: '余额不足,请先充值后再购买空间',
|
||||
balanceCents,
|
||||
minRechargeCents: Math.max(500, costCents - balanceCents),
|
||||
suggestedTiers: loadRechargeConfig().tiersCents,
|
||||
};
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`UPDATE h5_user_wallets
|
||||
SET balance_cents = balance_cents - ?, updated_at = ?
|
||||
WHERE user_id = ?`,
|
||||
[costCents, now, userId],
|
||||
);
|
||||
await conn.query(
|
||||
`UPDATE h5_user_spaces
|
||||
SET quota_bytes = quota_bytes + ?, updated_at = ?
|
||||
WHERE user_id = ?`,
|
||||
[deltaBytes, now, userId],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_billing_ledger
|
||||
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
|
||||
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
|
||||
[userId, costCents, `space_purchase:${purchaseMb}MB`, now],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_user_notifications
|
||||
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
'空间扩容成功',
|
||||
`已购买 ${purchaseMb} MB 空间,支付 ¥${(costCents / 100).toFixed(2)}。`,
|
||||
JSON.stringify({ purchaseMb, deltaBytes, costCents }),
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
const [updatedSpaceRows] = await pool.query(
|
||||
`SELECT quota_bytes, used_bytes, reserved_bytes
|
||||
FROM h5_user_spaces
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
const updatedSpace = updatedSpaceRows[0] ?? {};
|
||||
const updatedUser = await getUserById(userId);
|
||||
return {
|
||||
ok: true,
|
||||
balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)),
|
||||
quota: {
|
||||
quotaBytes: Number(updatedSpace.quota_bytes ?? 0),
|
||||
usedBytes: Number(updatedSpace.used_bytes ?? 0),
|
||||
reservedBytes: Number(updatedSpace.reserved_bytes ?? 0),
|
||||
availableBytes: Math.max(
|
||||
0,
|
||||
Number(updatedSpace.quota_bytes ?? 0)
|
||||
- Number(updatedSpace.used_bytes ?? 0)
|
||||
- Number(updatedSpace.reserved_bytes ?? 0),
|
||||
),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
const recharge = async (userId, amountCents, operatorId, note = '', options = {}) => {
|
||||
const amount = Number(amountCents);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
@@ -870,6 +1060,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
const ledgerNote = paymentOrderId
|
||||
? `order:${paymentOrderId}`
|
||||
: note || (operatorId ? '管理员充值' : '账户充值');
|
||||
const rechargeType = paymentOrderId ? 'self_recharge' : operatorId ? 'admin_recharge' : 'recharge';
|
||||
const now = Date.now();
|
||||
const ownsConnection = !options.conn;
|
||||
const conn = options.conn ?? (await pool.getConnection());
|
||||
@@ -889,6 +1080,31 @@ export function createUserAuth(pool, options = {}) {
|
||||
VALUES (?, 'recharge', ?, 0, ?, ?, ?)`,
|
||||
[userId, amount, ledgerNote, operatorId, now],
|
||||
);
|
||||
const title = paymentOrderId ? '充值成功' : operatorId ? '管理员已充值' : '账户充值成功';
|
||||
const body = paymentOrderId
|
||||
? `你已成功充值 ¥${(amount / 100).toFixed(2)},余额已更新。`
|
||||
: operatorId
|
||||
? `管理员已为你充值 ¥${(amount / 100).toFixed(2)},余额已更新。`
|
||||
: `你的账户已充值 ¥${(amount / 100).toFixed(2)},余额已更新。`;
|
||||
await conn.query(
|
||||
`INSERT INTO h5_user_notifications
|
||||
(id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
rechargeType,
|
||||
title,
|
||||
body,
|
||||
JSON.stringify({
|
||||
amountCents: amount,
|
||||
operatorId: operatorId ?? null,
|
||||
paymentOrderId,
|
||||
}),
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
if (user.status === 'suspended') {
|
||||
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
|
||||
now,
|
||||
@@ -897,6 +1113,25 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
if (ownsConnection) await conn.commit();
|
||||
const updated = await getUserById(userId);
|
||||
if (rechargeNotifier) {
|
||||
try {
|
||||
await rechargeNotifier({
|
||||
userId,
|
||||
amountCents: amount,
|
||||
operatorId: operatorId ?? null,
|
||||
paymentOrderId,
|
||||
notificationType: rechargeType,
|
||||
title,
|
||||
body,
|
||||
user: publicUser(updated),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Recharge notifier failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ok: true, user: publicUser(updated) };
|
||||
} catch (err) {
|
||||
if (ownsConnection) await conn.rollback();
|
||||
@@ -956,7 +1191,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
};
|
||||
}
|
||||
const config = loadBillingConfig();
|
||||
const costCents = computeDeltaCostCents(previous, tokenState, config);
|
||||
let costCents = computeDeltaCostCents(previous, tokenState, config);
|
||||
const deltaIn = Math.max(
|
||||
0,
|
||||
tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0),
|
||||
@@ -991,6 +1226,16 @@ export function createUserAuth(pool, options = {}) {
|
||||
],
|
||||
);
|
||||
|
||||
// Subscription quota check: consume tokens from active plan before touching balance.
|
||||
if (costCents > 0 && subscriptionService) {
|
||||
const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn);
|
||||
if (coverage.fullyCovers) {
|
||||
costCents = 0;
|
||||
} else if (coverage.overageRate < 1.0) {
|
||||
costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
|
||||
}
|
||||
}
|
||||
|
||||
let balanceAfter = null;
|
||||
let tokensUsedAfter = null;
|
||||
if (costCents > 0) {
|
||||
@@ -1339,9 +1584,12 @@ export function createUserAuth(pool, options = {}) {
|
||||
const resolveUserCapabilities = async (user) => {
|
||||
if (!user) return { unrestricted: true, capabilities: {} };
|
||||
if (user.role === 'admin') {
|
||||
const skillMap = Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
|
||||
return {
|
||||
unrestricted: true,
|
||||
capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])),
|
||||
skills: skillMap,
|
||||
grantedSkills: grantedSkillNames(skillMap),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1384,15 +1632,17 @@ export function createUserAuth(pool, options = {}) {
|
||||
capabilityState.capabilities,
|
||||
policyState.policies,
|
||||
);
|
||||
// For static_publish users, wire up the sandbox MCP so file operations are
|
||||
// enforced at the OS level rather than relying on text prompt constraints.
|
||||
// Wire up the sandbox MCP for user workspace tools and the private data space.
|
||||
// File operations are OS-bound to the user's workspace; private_data_* tools
|
||||
// only touch the user's single SQLite data space inside that workspace.
|
||||
let sandboxMcp = null;
|
||||
if (effectiveCapabilities.static_publish) {
|
||||
if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
|
||||
try {
|
||||
const layout = await publishLayoutFor(user, { migrateLegacy: false });
|
||||
sandboxMcp = {
|
||||
serverPath: path.join(h5Root, 'mindspace-sandbox-mcp.mjs'),
|
||||
serverPath: resolveSandboxMcpServerPath(),
|
||||
sandboxRoot: layout.publishDir,
|
||||
userId: user.id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
|
||||
@@ -1621,40 +1871,45 @@ export function createUserAuth(pool, options = {}) {
|
||||
const ensureAdminUser = async () => {
|
||||
const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? 'admin');
|
||||
const adminPassword = process.env.H5_ADMIN_PASSWORD;
|
||||
if (!adminPassword) return;
|
||||
|
||||
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
|
||||
adminUsername,
|
||||
]);
|
||||
const adminWorkspace = resolveAdminWorkspaceRoot();
|
||||
if (rows.length === 0) {
|
||||
if (!adminPassword) return;
|
||||
await createUser({
|
||||
username: adminUsername,
|
||||
password: adminPassword,
|
||||
displayName: '管理员',
|
||||
workspaceRoot: adminWorkspace,
|
||||
balanceCents: 9_999_999_99,
|
||||
role: 'admin',
|
||||
});
|
||||
} else {
|
||||
const adminId = rows[0].id;
|
||||
const now = Date.now();
|
||||
const adminLayout = await publishLayoutFor({
|
||||
id: adminId,
|
||||
username: adminUsername,
|
||||
displayName: '管理员',
|
||||
});
|
||||
await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [
|
||||
adminWorkspace,
|
||||
adminLayout.publishDir,
|
||||
now,
|
||||
adminId,
|
||||
]);
|
||||
await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]);
|
||||
await pool.query(
|
||||
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
|
||||
[adminId, adminWorkspace],
|
||||
[adminId, adminLayout.publishDir],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`,
|
||||
[9_999_999_99, now, adminId],
|
||||
);
|
||||
}
|
||||
await syncAdminPassword();
|
||||
if (adminPassword) {
|
||||
await syncAdminPassword();
|
||||
}
|
||||
await seedRoleCapabilityDefaults();
|
||||
await upgradeMemoryStoreCapability();
|
||||
await upgradeDefaultUserCapabilities();
|
||||
@@ -1768,24 +2023,20 @@ export function createUserAuth(pool, options = {}) {
|
||||
status = 'active',
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const existing = await getWechatAgentRoute(appId, openid);
|
||||
if (existing) {
|
||||
await pool.query(
|
||||
`UPDATE h5_wechat_agent_routes
|
||||
SET user_id = ?, agent_session_id = ?, status = ?, updated_at = ?
|
||||
WHERE app_id = ? AND openid = ?`,
|
||||
[userId, agentSessionId, status, now, appId, openid],
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO h5_wechat_agent_routes
|
||||
(id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
user_id = VALUES(user_id),
|
||||
agent_session_id = VALUES(agent_session_id),
|
||||
status = VALUES(status),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[id, userId, appId, openid, agentSessionId, status, now, now],
|
||||
);
|
||||
return id;
|
||||
const route = await getWechatAgentRoute(appId, openid);
|
||||
return route?.id ?? id;
|
||||
};
|
||||
|
||||
const clearWechatAgentRoute = async (appId, openid) => {
|
||||
@@ -1842,6 +2093,62 @@ export function createUserAuth(pool, options = {}) {
|
||||
);
|
||||
};
|
||||
|
||||
const insertWechatMpMessageDetail = async ({
|
||||
appId,
|
||||
openid,
|
||||
userId = null,
|
||||
msgId = null,
|
||||
msgType,
|
||||
displayText = '',
|
||||
agentText = '',
|
||||
mediaId = null,
|
||||
mediaUrl = null,
|
||||
mediaPublicUrl = null,
|
||||
mediaFormat = null,
|
||||
locationLat = null,
|
||||
locationLng = null,
|
||||
locationLabel = null,
|
||||
linkUrl = null,
|
||||
linkTitle = null,
|
||||
rawXmlHash = null,
|
||||
rawJson = null,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
if (!appId || !openid || !msgType) return null;
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO h5_wechat_mp_message_details
|
||||
(id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text,
|
||||
media_id, media_url, media_public_url, media_format,
|
||||
location_lat, location_lng, location_label,
|
||||
link_url, link_title, raw_xml_hash, raw_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
appId,
|
||||
openid,
|
||||
userId,
|
||||
msgId ? String(msgId) : null,
|
||||
String(msgType),
|
||||
displayText || null,
|
||||
agentText || null,
|
||||
mediaId,
|
||||
mediaUrl,
|
||||
mediaPublicUrl,
|
||||
mediaFormat,
|
||||
locationLat,
|
||||
locationLng,
|
||||
locationLabel,
|
||||
linkUrl,
|
||||
linkTitle,
|
||||
rawXmlHash,
|
||||
rawJson ? JSON.stringify(rawJson) : null,
|
||||
now,
|
||||
],
|
||||
);
|
||||
return id;
|
||||
};
|
||||
|
||||
const bindWechatToUser = async ({
|
||||
userId,
|
||||
appId,
|
||||
@@ -2082,6 +2389,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
username: normalized,
|
||||
slug: normalized,
|
||||
});
|
||||
if (subscriptionService) {
|
||||
subscriptionService.grantSubscription(userId, 'free', null, null, '注册赠送免费套餐').catch(() => {});
|
||||
}
|
||||
const user = await getUserById(userId);
|
||||
return { ok: true, user: publicUser(user) };
|
||||
} catch (err) {
|
||||
@@ -2313,12 +2623,16 @@ export function createUserAuth(pool, options = {}) {
|
||||
getWechatPendingBind,
|
||||
getWechatBindingStatus,
|
||||
getWechatOpenidForUser,
|
||||
setRechargeNotifier(callback) {
|
||||
rechargeNotifier = typeof callback === 'function' ? callback : null;
|
||||
},
|
||||
findWechatUserByOpenid,
|
||||
getWechatAgentRoute,
|
||||
upsertWechatAgentRoute,
|
||||
clearWechatAgentRoute,
|
||||
recordWechatMpMessage,
|
||||
finishWechatMpMessage,
|
||||
insertWechatMpMessageDetail,
|
||||
resetPassword,
|
||||
verify,
|
||||
revoke,
|
||||
@@ -2335,9 +2649,12 @@ export function createUserAuth(pool, options = {}) {
|
||||
ownsSession,
|
||||
listOwnedSessionIds,
|
||||
canUseChat,
|
||||
getUserById,
|
||||
getUserPublic,
|
||||
listUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
purchaseSpaceQuota,
|
||||
recharge,
|
||||
billSessionUsage,
|
||||
listUsageRecords,
|
||||
@@ -2449,12 +2766,26 @@ export function resolveCookieDomainForRequest(req) {
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
return '.localhost';
|
||||
}
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
net.isIP(hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = String(req?.get?.('host') ?? req?.hostname ?? '')
|
||||
.split(':')[0]
|
||||
.toLowerCase();
|
||||
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
if (
|
||||
!hostname ||
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
net.isIP(hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolveCookieDomain();
|
||||
|
||||
Reference in New Issue
Block a user