From 9ed4fd48d785deda1daed976c451e9098e8e11ea Mon Sep 17 00:00:00 2001 From: john Date: Fri, 26 Jun 2026 13:36:05 +0800 Subject: [PATCH] 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 --- capabilities.mjs | 87 ++++++- capabilities.test.mjs | 68 ++++- scripts/build-portal-runtime.mjs | 246 ++++++++++++++++++ user-auth.mjs | 417 +++++++++++++++++++++++++++---- 4 files changed, 761 insertions(+), 57 deletions(-) create mode 100755 scripts/build-portal-runtime.mjs diff --git a/capabilities.mjs b/capabilities.mjs index 8e113a8..fee7b5d 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -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, diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 4a786e0..34ec989 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -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' }; diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs new file mode 100755 index 0000000..ae25c00 --- /dev/null +++ b/scripts/build-portal-runtime.mjs @@ -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); +}); diff --git a/user-auth.mjs b/user-auth.mjs index e201510..5ca6d1b 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -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();