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:
john
2026-06-26 13:36:05 +08:00
parent d51df2fb0a
commit 9ed4fd48d7
4 changed files with 761 additions and 57 deletions
+74 -13
View File
@@ -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
View File
@@ -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' };
+246
View File
@@ -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
View File
@@ -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();