From e94052ff244167ab5d51bf06da246c758fe0cfc6 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 27 Jul 2026 15:34:35 +0800 Subject: [PATCH] mindspace: close authority boundaries --- capabilities.mjs | 80 +- capabilities.test.mjs | 103 +++ docs/local-dev.md | 31 + ...d-control-execution-split-plan-20260702.md | 78 ++ docs/mindspace-seamless-migration-runbook.md | 15 + .../mindspace-publish-and-chat-finish.md | 134 ++- mindspace-agent-jobs.mjs | 7 +- mindspace-agent-runner.mjs | 13 +- mindspace-assets.mjs | 27 + mindspace-chat-plaza.mjs | 25 +- mindspace-chat-plaza.test.mjs | 2 - mindspace-chat-save-service.mjs | 343 +++++++ mindspace-chat-save-service.test.mjs | 351 +++++++ ...-conversation-package-artifact-service.mjs | 342 +++++++ ...ersation-package-artifact-service.test.mjs | 186 ++++ ...space-conversation-package-public-html.mjs | 3 +- ...-conversation-package-public-html.test.mjs | 2 +- mindspace-h5-html-finish-guard.mjs | 57 +- mindspace-local-runtime-services.mjs | 67 ++ mindspace-local-runtime-services.test.mjs | 31 + mindspace-local-server-adapter.mjs | 5 +- mindspace-local-server-adapter.test.mjs | 2 +- mindspace-mcp-scoped-token.mjs | 247 +++++ mindspace-mcp-scoped-token.test.mjs | 120 +++ ...age-data-finish-guard.integration.test.mjs | 24 +- mindspace-page-data-finish-guard.mjs | 140 ++- mindspace-public-finish-service.mjs | 557 +++++++++++ mindspace-public-finish-sync.mjs | 18 + mindspace-public-finish-sync.test.mjs | 651 +++++++++++++ mindspace-public-page-context.mjs | 16 +- mindspace-publications.mjs | 10 +- mindspace-remote-server-adapter.test.mjs | 460 +++++++++ mindspace-sandbox-mcp.mjs | 288 +++++- mindspace-sandbox-mcp.test.mjs | 416 +++++++++ mindspace-server-adapter-contract.mjs | 49 +- mindspace-service/mindspace-rpc-server.mjs | 194 +++- .../mindspace-rpc-server.test.mjs | 631 ++++++++++++- .../mindspace-service-bootstrap.mjs | 11 - mindspace-wechat-html-delivery.mjs | 598 ++++++++++++ mindspace-wechat-html-delivery.test.mjs | 349 +++++++ ...workspace-publication-delivery-service.mjs | 568 ++++++++++++ ...pace-publication-delivery-service.test.mjs | 365 ++++++++ mindspace-workspace-tool-service.mjs | 875 ++++++++++++++++++ mindspace-workspace-tool-service.test.mjs | 477 ++++++++++ package.json | 5 +- scripts/build-mindspace-service-runtime.mjs | 50 +- scripts/smoke-mindspace-split-service.mjs | 315 +++++++ .../verify-mindspace-authority-boundary.mjs | 757 +++++++++++++++ scripts/verify-mindspace-publish-guards.mjs | 21 + scripts/verify-public-finish-sync-runtime.mjs | 23 +- server.mjs | 185 ++-- server/portal-agent-job-routes.mjs | 10 +- server/portal-agent-job-routes.test.mjs | 9 +- server/portal-domain-services-bootstrap.mjs | 56 +- .../portal-domain-services-bootstrap.test.mjs | 53 +- server/portal-gateway-services-bootstrap.mjs | 156 +--- ...portal-gateway-services-bootstrap.test.mjs | 161 ++-- .../portal-integration-services-bootstrap.mjs | 31 +- ...al-integration-services-bootstrap.test.mjs | 37 +- server/portal-mindspace-asset-routes.mjs | 31 +- server/portal-mindspace-asset-routes.test.mjs | 41 +- server/portal-mindspace-chat-save-routes.mjs | 140 +-- ...portal-mindspace-chat-save-routes.test.mjs | 133 +-- server/portal-mindspace-chat-share-routes.mjs | 100 +- ...ortal-mindspace-chat-share-routes.test.mjs | 99 +- server/portal-publication-routes.mjs | 130 +-- server/portal-publication-routes.test.mjs | 81 +- server/portal-session-routes.mjs | 310 ++++--- server/portal-session-routes.test.mjs | 189 +++- .../portal-workspace-publication-delivery.mjs | 463 ++++----- ...al-workspace-publication-delivery.test.mjs | 328 +++---- tkmind-proxy.mjs | 94 +- user-auth.mjs | 34 +- user-auth.test.mjs | 53 +- wechat-mp.mjs | 737 +++++++-------- wechat-mp.test.mjs | 288 ++++-- wechat/verify/page-artifact.mjs | 24 + wechat/verify/share-preview.mjs | 12 + 78 files changed, 11962 insertions(+), 2162 deletions(-) create mode 100644 mindspace-chat-save-service.mjs create mode 100644 mindspace-chat-save-service.test.mjs create mode 100644 mindspace-conversation-package-artifact-service.mjs create mode 100644 mindspace-conversation-package-artifact-service.test.mjs create mode 100644 mindspace-mcp-scoped-token.mjs create mode 100644 mindspace-mcp-scoped-token.test.mjs create mode 100644 mindspace-public-finish-service.mjs create mode 100644 mindspace-wechat-html-delivery.mjs create mode 100644 mindspace-wechat-html-delivery.test.mjs create mode 100644 mindspace-workspace-publication-delivery-service.mjs create mode 100644 mindspace-workspace-publication-delivery-service.test.mjs create mode 100644 mindspace-workspace-tool-service.mjs create mode 100644 mindspace-workspace-tool-service.test.mjs create mode 100644 scripts/smoke-mindspace-split-service.mjs create mode 100644 scripts/verify-mindspace-authority-boundary.mjs diff --git a/capabilities.mjs b/capabilities.mjs index 334ae80..4e5b86e 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -1,7 +1,21 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + mintMindSpaceMcpScopedToken, +} from './mindspace-mcp-scoped-token.mjs'; import { resolveAgentGooseMode } from './policies.mjs'; +const SCOPED_WORKSPACE_MCP_TOOLS = + new Set([ + 'create_dir', + 'edit_file', + 'generate_long_image', + 'list_dir', + 'publish_page', + 'read_file', + 'write_file', + ]); + function resolveBundledMcpServerPath(filename, overridePath, runtimeRoot) { const normalized = String(overridePath ?? '').trim(); if (normalized) return normalized; @@ -425,6 +439,7 @@ export function sandboxMcpTools(capabilities) { 'write_file', 'edit_file', 'create_dir', + 'publish_page', 'generate_image', 'generate_docx', 'generate_long_image', @@ -483,6 +498,57 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) { if (compatRoot) envs.SANDBOX_ROOT = compatRoot; if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; + const scopedTools = [ + ...new Set([ + ...mcpTools.filter( + (tool) => + SCOPED_WORKSPACE_MCP_TOOLS.has( + tool, + ), + ), + ...(mcpTools.includes('generate_docx') + ? ['write_binary_file'] + : []), + ]), + ]; + if ( + sandboxMcp.mcpBaseUrl && + sandboxMcp.mcpTokenSecret && + sandboxMcp.workspaceRef && + sandboxMcp.sessionId && + sandboxMcp.packageId && + scopedTools.length > 0 + ) { + const tokenBucketMs = 30 * 60 * 1000; + const tokenNow = + Math.floor(Date.now() / tokenBucketMs) * + tokenBucketMs; + envs.MINDSPACE_MCP_BASE_URL = + String(sandboxMcp.mcpBaseUrl) + .trim() + .replace(/\/+$/, ''); + envs.MINDSPACE_MCP_SCOPED_TOKEN = + mintMindSpaceMcpScopedToken({ + secret: + sandboxMcp.mcpTokenSecret, + userId: sandboxMcp.userId, + sessionId: + sandboxMcp.sessionId, + packageId: + sandboxMcp.packageId, + workspaceRef: + sandboxMcp.workspaceRef, + tools: scopedTools, + ttlSeconds: 60 * 60, + now: tokenNow, + tokenId: + `${sandboxMcp.sessionId}:${tokenNow}`, + }); + envs.MINDSPACE_SESSION_ID = + String(sandboxMcp.sessionId); + envs.MINDSPACE_PACKAGE_ID = + String(sandboxMcp.packageId); + } if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; for (const [key, value] of [ ['H5_PUBLIC_BASE_URL', sandboxMcp.publicBaseUrl], @@ -566,7 +632,19 @@ export function buildAgentExtensionPolicy( if (sandboxMcp?.serverPath && localRoot) { // Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level. // Replaces the built-in developer extension so path traversal is impossible. - const mcpTools = sandboxMcpTools(capabilities); + const scopedWorkspaceReady = Boolean( + sandboxMcp.mcpBaseUrl && + sandboxMcp.mcpTokenSecret && + sandboxMcp.workspaceRef && + sandboxMcp.sessionId && + sandboxMcp.packageId, + ); + const mcpTools = + sandboxMcpTools(capabilities).filter( + (tool) => + tool !== 'publish_page' || + scopedWorkspaceReady, + ); if (mcpTools.length > 0) { extensions.push({ type: 'stdio', diff --git a/capabilities.test.mjs b/capabilities.test.mjs index d9e65e6..6ceebb0 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -18,6 +18,9 @@ import { sandboxDeveloperTools, sandboxMcpTools, } from './capabilities.mjs'; +import { + verifyMindSpaceMcpScopedToken, +} from './mindspace-mcp-scoped-token.mjs'; import { applyPoliciesToCapabilities } from './policies.mjs'; test('resolveSandboxMcpControlDbEnv rewrites loopback MySQL endpoints for container MCP', () => { @@ -355,6 +358,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => { 'write_file', 'edit_file', 'create_dir', + 'publish_page', 'generate_image', 'generate_docx', 'generate_long_image', @@ -472,6 +476,105 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of assert.ok(!allTools.includes('shell'), 'shell should not be exposed'); }); +test('sandboxMcp emits a scoped logical workspace token only for a bound session', () => { + const secret = + 'mindspace-mcp-policy-secret-1234'; + const caps = { + ...DEFAULT_USER_CAPABILITIES, + static_publish: true, + }; + const sandboxMcp = { + serverPath: + '/opt/h5/mindspace-sandbox-mcp.mjs', + sandboxRoot: + '/opt/h5/MindSpace/user-1', + workspaceRoot: + '/srv/h5/MindSpace/user-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + mcpBaseUrl: + 'http://mindspace.internal:8082/', + mcpTokenSecret: secret, + }; + + const policy = + buildAgentExtensionPolicy(caps, { + sandboxMcp, + }); + const sandboxExt = + policy.extensionOverrides.find( + (extension) => + extension.name === 'sandbox-fs', + ); + const claims = + verifyMindSpaceMcpScopedToken({ + token: + sandboxExt.envs + .MINDSPACE_MCP_SCOPED_TOKEN, + secret, + tool: 'publish_page', + }); + + assert.equal( + sandboxExt.envs.MINDSPACE_MCP_BASE_URL, + 'http://mindspace.internal:8082', + ); + assert.equal( + sandboxExt.envs.MINDSPACE_SESSION_ID, + 'session-1', + ); + assert.equal( + sandboxExt.envs.MINDSPACE_PACKAGE_ID, + 'cp_session-1', + ); + assert.equal(claims.userId, 'user-1'); + assert.equal( + claims.workspaceRef, + sandboxMcp.workspaceRef, + ); + assert.ok( + claims.tools.includes('write_file'), + ); + assert.ok( + claims.tools.includes( + 'generate_long_image', + ), + ); + assert.ok( + claims.tools.includes( + 'write_binary_file', + ), + ); + assert.equal( + Object.values(sandboxExt.envs).includes( + secret, + ), + false, + ); + + const unboundPolicy = + buildAgentExtensionPolicy(caps, { + sandboxMcp: { + ...sandboxMcp, + sessionId: null, + packageId: null, + }, + }); + const unboundSandbox = + unboundPolicy.extensionOverrides.find( + (extension) => + extension.name === 'sandbox-fs', + ); + assert.equal( + unboundSandbox.envs + .MINDSPACE_MCP_SCOPED_TOKEN, + undefined, + ); +}); + test('sandboxMcp honors container node executable override', () => { const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; const policy = buildAgentExtensionPolicy(caps, { diff --git a/docs/local-dev.md b/docs/local-dev.md index 4026b2d..9fa22e0 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -102,6 +102,37 @@ launchctl kickstart -k "gui/$(id -u)/cn.tkmind.mindspace-service" pnpm dev ``` +如需验证 Goose 的逻辑 workspace MCP,再让 Portal 与 MindSpace Service +使用相同的本地签名 secret;该值至少 16 个字符,只放本机 `.env`, +不得提交: + +```bash +MINDSPACE_MCP_BASE_URL=http://127.0.0.1:8082 +MINDSPACE_MCP_TOKEN_SECRET=replace-with-a-local-secret +# 可选;默认 12 MiB,必须大于等于 1024 +MINDSPACE_MCP_MAX_BODY_BYTES=12582912 +``` + +Portal 用 secret 签发绑定 user/session/package/workspace/tool allowlist +的短期 token,MindSpace Service 用同一 secret 验证;Goose extension +只收到 scoped token,不会收到签名 secret。该 MCP 配置目前用于 +`split-service` 联调。配置完整时 DOCX 目标写入和长图渲染也会经过 +MindSpace Service;未配置时不会开放 `publish_page`,其它工具保留 +本地兼容行为。 + +验证当前源码的本地 split-service 链路可运行: + +```bash +npm run smoke:mindspace-split-service +``` + +该 smoke 会构建本地 `.runtime/mindspace-service`、启动临时独立 +MindSpace RPC 服务,并通过 remote adapter 验证 health/contract、 +workspace 写读、微信 HTML 交付与 fresh thumbnail;不会发布或上传 +runtime artifact。它会读取本机配置的开发数据库并创建唯一的 +`split-service-smoke-*` session/package 记录,退出时会清理对应 +package/artifact,并把临时文件根删除。 + 启动后 Portal 日志应出现:`[Portal] Runtime profile: MEMIND_RUNTIME_PROFILE=local, MINDSPACE_SERVER_ADAPTER=local, ...` **禁止:** 把 103 生产 `.env`、RDS 连接串、`MINDSPACE_REMOTE_AUTH_TOKEN` 生产值复制进本机 Git 仓库。 diff --git a/docs/memind-control-execution-split-plan-20260702.md b/docs/memind-control-execution-split-plan-20260702.md index 46c2237..418e937 100644 --- a/docs/memind-control-execution-split-plan-20260702.md +++ b/docs/memind-control-execution-split-plan-20260702.md @@ -339,6 +339,51 @@ MindSpace Domain - 现有 H5、公开页、页面发布、文件下载行为不变。 - 代码搜索可以证明新增业务不再直接依赖物理路径。 +2026-07-27 增量进展: + +- Chat Save 的错误资源引用修复、私有图片物化和 HTML 写回已收进 + `chatSaveService.materializeWorkspaceHtml`。 +- local adapter 与 remote RPC 使用同一 contract;Portal 不再在 + `resolveChatSaveBundle` 中读取 `storageRoot` 或直接 `writeFile`。 +- 已增加源码门禁,防止这条已完成的写路径重新泄漏回 `server.mjs`。 +- 公开页交付、Finish public HTML 同步和其它 Portal 路径仍需按后续 + P1 切片继续收口;本增量不代表 P1 整体完成。 +- 新增 `conversationArtifactService`,local 与 remote adapter/RPC + 共用同一契约。 +- Finish 的 public HTML、聊天导出的 DOCX、发布长图不再由 Portal + 直接调用 conversation package registry;物理目录解析、backing file + 校验、artifact/package 登记与 manifest 刷新由 MindSpace 负责。 +- Portal 的 Finish 保留 SSE/锁/页面同步时序,仅向 artifact 服务传 + user/session/relativePath/messageId 等逻辑信息。 +- 新增 `publicFinishService`:SSE `write_file/edit_file` 兼容落盘、 + Finish HTML/私有资源物化、DOCX 同步、workspace asset sync、 + artifact 登记和 canonical URL 生成均在 MindSpace 内完成。 +- Quick Share 的私有资源内联、`public/shared/*.html` 落盘和 public + URL 生成也已收进 `chatSaveService.createSharedHtml`。 +- `workspacePublicationDeliveryService` 已成为 `/MindSpace`、 + `/u/:slug/public`、workspace 长图、recent HTML discovery 和 + Agent Run deliverable validation 的统一读交付入口;返回 HTML 或 + Base64 body 等逻辑交付对象,禁止把绝对路径返回 Portal。 +- publication resolve 由 MindSpace 返回 `workspacePublicUrl`; + Portal 不再拼接 canonical workspace URL。 +- Finish 的 HTML 完整性检查、Page Data 自动绑定/验收,以及 + `workspacePageDeliveryService.syncAndDeliver` 已迁入 local/remote + adapter;`portal-session-routes` 不再接收 `publishDir`、 + `storageRoot` 或 `h5Root`。 +- 微信 Page Data 验收、HTML 链接交付和 fresh thumbnail 校验/修复 + 均改为调用 `publicFinishService.prepareWechatPageDataDelivery`、 + `prepareWechatHtmlDelivery` 与 `ensureWechatFreshPageThumbnails`; + Portal bootstrap 只注入这些逻辑能力,不再向微信模块注入 MindSpace + pool、storage root、publishDir 或 H5 root。 +- 资产下载、from-asset 读取和 Agent Job 输入资产交付改为 + `bodyBase64` 契约;远程 adapter 不再暴露会泄漏物理路径的 + `readAsset` / `readPublicAsset`。 +- Portal 仅保留鉴权、HTTP 响应头、HTML 展示层注入、Finish 锁、 + 修复触发与 analytics 转发,并等待流式写入完成后才标记页面 ready。 +- P1 的 H5/Portal/微信 HTML/Page Data 主读写与交付链路已收拢;P4 + 仍需补齐真实 split-service 端到端 smoke,并继续收敛剩余 + `workingDir` 兼容上下文。 + ### P2: Conversation Package 首先落地 目标: 用户每次对话生成的图片、文件、页面、公开链接都能在一个包里看到。 @@ -409,6 +454,39 @@ MindSpace Domain - Agent 生成文件后,MindSpace package manifest 自动更新。 - Policy 泄漏时也只能访问当前 package 范围。 +2026-07-27 第一批实现进展: + +- 新增 `workspaceToolService`,以 + `workspaceRef + sessionId + packageId + relativePath` 承载 + `read_file`、`write_file`、`edit_file`、目录操作和 + `publish_page`;local/remote adapter 使用同一契约,返回值不暴露绝对路径。 +- MindSpace Service 新增专用 `/mindspace/v1/mcp/:tool` 入口。 + HMAC scoped token 同时绑定 user、session、package、workspace 和 + tool allowlist;请求不能覆盖 token 中的作用域。 +- Agent policy 在取得真实 session 后重新生成 scoped extension 配置; + 签名 secret 只留在 Portal/MindSpace Service,Goose sandbox 只接收 + 短期 token。 +- sandbox MCP 在配置 scoped endpoint 时通过 MindSpace Service 完成 + 逻辑读写;`publish_page` 只在该配置完整时开放。 +- `write_file` / `edit_file` 对公开 HTML 使用专用 public artifact + 登记,对其它文件使用通用 workspace artifact 登记;每次写入均刷新 + package manifest,不依赖 asset sync 是否因相同 checksum 跳过。 +- 本批已完成核心工具的逻辑写入与 package 自动登记,但 P4 尚未整体 + 关闭:当前 scoped token 能防止跨 user/session/package 身份写入, + `read_file` 的内容范围仍是该用户的逻辑 workspace,并非仅限当前 + package 已登记对象。要满足“token 泄漏也只能读取当前 package” + 的完整验收,还需增加 package artifact read capability。 +- 第二批已新增受 scoped token 保护的 `write_binary_file` 内部操作: + `generate_docx` 只在 sandbox 临时目录生成文件,随后把二进制交给 + MindSpace Service 写入目标 workspace 并登记 package;remote 模式 + 不再把 DOCX 直接写到 Goose 的 workspace path。 +- `generate_long_image` 已改为由 MindSpace Service 校验 backing HTML、 + 生成 canonical source URL、渲染 PNG、写入 workspace 并登记 artifact; + sandbox 不再读取目标 HTML 的物理路径或写目标 PNG。 +- 微信 HTML 交付与 fresh thumbnail 已改为 MindSpace Service 内部解析 + backing file、生成 canonical URL 并返回逻辑 artifact;真实 + split-service 端到端 smoke 仍需后续切片收口。 + ### P5: NAS/S3 Adapter 目标: 存储后端可替换。 diff --git a/docs/mindspace-seamless-migration-runbook.md b/docs/mindspace-seamless-migration-runbook.md index f39f688..8d467d3 100644 --- a/docs/mindspace-seamless-migration-runbook.md +++ b/docs/mindspace-seamless-migration-runbook.md @@ -132,6 +132,21 @@ Expected result: - final read parity still reports no drift after write-path validation - any service started by the verifier is stopped before exit +For validating the current Memind checkout before publishing a standalone +runtime, use the repo-local split-service smoke: + +```bash +cd /Users/john/Project/Memind +npm run smoke:mindspace-split-service +``` + +It builds `.runtime/mindspace-service`, starts a temporary standalone RPC +service on loopback, and verifies remote health/contract, workspace +write/read, WeChat HTML delivery, and fresh thumbnail generation. +The smoke uses the configured local development database to create a unique +`split-service-smoke-*` session/package and removes those package/artifact +rows plus its temporary filesystem root before exit. + Current verified local user: - `1c99b83b-0454-474f-a5d2-129d34506a32` diff --git a/docs/regression-guards/mindspace-publish-and-chat-finish.md b/docs/regression-guards/mindspace-publish-and-chat-finish.md index cd5221c..00ce0af 100644 --- a/docs/regression-guards/mindspace-publish-and-chat-finish.md +++ b/docs/regression-guards/mindspace-publish-and-chat-finish.md @@ -16,7 +16,8 @@ | 位置 | 行为 | |------|------| | `mindspace-public-finish-sync.mjs` | `edit_file` 读取 baseline + `old_str`/`new_str` patch,再 `writeFileSync` 覆盖 | -| `server.mjs` | Finish / SSE 流式事件调用 `materializePublicHtmlWritesFromSessionEvent` | +| `mindspace-public-finish-service.mjs` | 在 MindSpace 内调用流式物化与最终 Finish sync,并生成 canonical URL | +| `server/portal-session-routes.mjs` | Finish / SSE 流式事件只调用 `publicFinishService`,等待流式写入完成后再标记交付 ready | ### 守卫 @@ -25,6 +26,136 @@ --- +## 1.1 Chat Save:HTML 修复与落盘权威必须在 MindSpace + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `mindspace-chat-save-service.mjs` | 修复错误私有图片引用、物化私有图片并受限写回 `public/*.html`;预览缩略图生成与 sidecar 写入 | +| `mindspace-chat-save-service.mjs` | quick share 的私有资源内联、`public/shared/*.html` 写入和 public URL 生成 | +| `mindspace-server-adapter-contract.mjs` | `chatSaveService.materializeWorkspaceHtml`、`ensurePreviewThumbnail`、`renderPreviewThumbnailSvg`、`createSharedHtml`、`readWorkspaceHtml` 同时暴露给 local / remote adapter | +| `server.mjs` | 只调用 `mindSpaceChatSave.materializeWorkspaceHtml`,禁止重新读取 `storageRoot` 或直接 `writeFile` | +| `server/portal-mindspace-chat-save-routes.mjs` | 预览缩略图与分析/保存流程只调用 `getMindSpaceChatSave()`,禁止直接解析 `publishDir` 或调用 `ensureWorkspaceHtmlThumbnail` | +| `server/portal-mindspace-chat-share-routes.mjs` | quick share 只调用 `createSharedHtml`;quick Plaza 的 workspace HTML 读取只调用 `readWorkspaceHtml`;禁止直接解析 publishDir、写文件、读取 storage root 或生成 public URL | + +这条边界用于保证 `split-service` 下 Portal 不再成为 Chat Save +的物理文件写权威。修复与图片物化继续保持幂等和 best-effort; +真正的工作区写失败仍须明确返回错误,禁止静默回退到 Portal 本地写入。 + +### 守卫 + +- 单测:`mindspace-chat-save-service.test.mjs` +- local / remote / RPC:`mindspace-local-runtime-services.test.mjs`、 + `mindspace-remote-server-adapter.test.mjs`、 + `mindspace-service/mindspace-rpc-server.test.mjs` +- 源码门禁:`scripts/verify-mindspace-authority-boundary.mjs` +- 综合验证:`npm run verify:mindspace-publish-guards` +- runtime 双边一致性:构建 Portal 与 MindSpace service runtime 后执行 + `npm run verify:mindspace-publish-guards:full` + +--- + +## 1.2 Conversation Artifact:登记与 manifest 写入权威必须在 MindSpace + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `mindspace-conversation-package-artifact-service.mjs` | 从逻辑 user/session/artifact ref 解析工作区文件,登记 public HTML、聊天 DOCX、发布长图和通用 workspace 写入,并刷新 package manifest | +| `mindspace-server-adapter-contract.mjs` | `conversationArtifactService` 的四类登记方法同时暴露给 local / remote adapter;`conversationPackageRegistry` 的远程契约只保留读取方法 | +| `server/portal-session-routes.mjs` | Finish 只传 user/session/relativePath/messageId,禁止把 `publishDir` 传入 artifact 登记 | +| `server/portal-mindspace-chat-save-routes.mjs` | DOCX 生成后调用 `registerChatDocxArtifact`,禁止直接取得 conversation package registry | +| `server.mjs` | public HTML 与发布长图登记只调用 `mindSpaceConversationArtifacts`;禁止直接 `putObjectForSession`、`recordArtifact` 或 `writeManifestForSession` | + +该边界保证 local 与 split-service 使用同一个产物登记入口。MindSpace +负责 backing file 校验、确定性 artifact ID、对象写入和 manifest +刷新;Portal 仍只负责鉴权、Finish 时序与生成结果转发。 + +Finish 中受第 1 节保护的 `edit_file` 落盘兼容逻辑已迁入 +`publicFinishService`,但仍复用同一纯实现和回归用例;禁止在 Portal +增加本地写回 fallback,也不得破坏已验证的覆盖语义。 + +### 守卫 + +- 单测:`mindspace-conversation-package-artifact-service.test.mjs` +- local / remote / RPC:`mindspace-local-runtime-services.test.mjs`、 + `mindspace-remote-server-adapter.test.mjs`、 + `mindspace-service/mindspace-rpc-server.test.mjs` +- Portal 路由:`server/portal-session-routes.test.mjs`、 + `server/portal-mindspace-chat-save-routes.test.mjs` +- 源码门禁:`scripts/verify-mindspace-authority-boundary.mjs` +- 综合验证:`npm run verify:mindspace-publish-guards` + +--- + +## 1.3 Workspace 读交付:物理路径与 canonical URL 必须在 MindSpace + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `mindspace-workspace-publication-delivery-service.mjs` | 解析 `/MindSpace` 请求、校验 backing file 与 delivery contract、读取 HTML/二进制、补私有资源、生成缩略图/长图、扫描最近 HTML,并验证 Agent Run deliverables | +| `mindspace-publications.mjs` | `resolvePublic` 返回 `workspacePublicUrl`;canonical workspace URL 不由 Portal 拼接 | +| `mindspace-public-finish-service.mjs` | Finish 内完成 HTML 交付完整性检查、H5/微信 Page Data preparation、微信 HTML 链接交付与 fresh thumbnail 校验/修复;跨 RPC 返回的结果必须移除绝对路径与 HTML 原文 | +| `mindspace-local-runtime-services.mjs` | 构造 `workspacePublicationDeliveryService` 与 `workspacePageDeliveryService`;local/remote 使用同一 adapter contract | +| `server/portal-workspace-publication-delivery.mjs` | 只把逻辑 request path 交给 MindSpace,并代理 HTML/Base64 body;禁止读取、校验或返回本地绝对路径 | +| `server/portal-publication-routes.mjs` | `/u/:slug/public` 调用 `readOwnerPublicAsset`;publication redirect 使用 `workspacePublicUrl` | +| `server/portal-session-routes.mjs` | 先同步页面记录,再调用 `preparePageDataAfterFinish` 并消费逻辑结果;禁止解析 `publishDir`、`storageRoot` 或 `h5Root` | +| `server/portal-integration-services-bootstrap.mjs` + `wechat-mp.mjs` | 微信只接收 `prepareWechatPageDataDelivery`、`prepareWechatHtmlDelivery`、`ensureWechatFreshPageThumbnails` 逻辑能力;禁止注入或读取 MindSpace 的 pool/storage path / publishDir / backing file path | +| `server/portal-mindspace-asset-routes.mjs` | 下载和 from-asset 只消费 `readAssetContent` / `readPublicAssetContent` 的 Base64 body | + +Portal 可以继续负责鉴权、viewer-specific HTML 注入、CSP/缓存响应头和 +repair turn 的触发,但不能获得 backing file 路径,也不能在 remote +模式下回退读取 Portal 本机目录。 + +### 守卫 + +- 核心服务:`mindspace-workspace-publication-delivery-service.test.mjs` +- Portal edge:`server/portal-workspace-publication-delivery.test.mjs`、 + `server/portal-publication-routes.test.mjs` +- local / remote / RPC:`mindspace-local-runtime-services.test.mjs`、 + `mindspace-remote-server-adapter.test.mjs`、 + `mindspace-service/mindspace-rpc-server.test.mjs` +- 源码门禁:`scripts/verify-mindspace-authority-boundary.mjs` +- 综合验证:`npm run verify:mindspace-publish-guards` +- runtime 双边一致性:构建两侧 runtime 后执行 + `npm run verify:mindspace-publish-guards:full` + +--- + +## 1.4 Agent MCP:逻辑 workspace/package 与 scoped token 必须成套 + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `mindspace-workspace-tool-service.mjs` | 只接受逻辑 `workspaceRef`、当前 `sessionId/packageId` 和相对路径;阻止绝对路径、路径穿越、symlink escape 与浏览器持久存储 | +| `mindspace-mcp-scoped-token.mjs` | token 同时绑定 user、session、package、workspace 与工具 allowlist,并校验签名和有效期 | +| `mindspace-service/mindspace-rpc-server.mjs` | `/mindspace/v1/mcp/:tool` 从 token 注入作用域,拒绝请求体覆盖 user/session/package/workspace | +| `mindspace-sandbox-mcp.mjs` | scoped 模式下通用 workspace 工具和 `generate_long_image` 只经 MindSpace Service;DOCX 仅在临时目录生成,再通过内部 `write_binary_file` 上传 | +| `capabilities.mjs` + `user-auth.mjs` | 取得真实 session 后才生成 scoped token;签名 secret 不得进入 Goose extension env | +| `mindspace-conversation-package-artifact-service.mjs` | 非公开 workspace 写入也必须直接登记 artifact 并刷新 manifest,不能把 asset sync 的结果当作唯一登记保证 | +| `mindspace-workspace-tool-service.mjs` | 二进制写入和长图目标写入必须落盘、登记 artifact、刷新 manifest,并且响应不得暴露绝对路径 | + +`publish_page` 在 scoped endpoint/token/workspace/session/package 任一项 +缺失时不得暴露。保留旧本地文件工具只是未配置 scoped split-service +时的兼容路径,不得让它成为 remote 模式的静默 fallback。 +scoped 模式下 `generate_docx` / `generate_long_image` 也不得在 RPC +失败时回退写 Goose 本地目标路径。 + +### 守卫 + +- 单测:`mindspace-workspace-tool-service.test.mjs`、 + `mindspace-mcp-scoped-token.test.mjs`、 + `mindspace-sandbox-mcp.test.mjs`、`capabilities.test.mjs` +- RPC / policy:`mindspace-service/mindspace-rpc-server.test.mjs`、 + `user-auth.test.mjs`、`tkmind-proxy.test.mjs` +- 源码门禁:`scripts/verify-mindspace-authority-boundary.mjs` +- 综合验证:`npm run verify:mindspace-publish-guards` + +--- + ## 2. 聊天 Finish:禁止清空对话 / 禁止暴露内部前缀 ### 症状 @@ -123,6 +254,7 @@ npm run verify:mindspace-publish-guards:full - [ ] 是否仍 merge 本地与服务器消息(Finish / UpdateConversation)? - [ ] 是否仍过滤/剥离用户消息中的 agent-only 前缀? - [ ] `edit_file` 是否仍会 materialize 到 `MindSpace//public/`? +- [ ] Chat Save HTML 修复与落盘是否仍只经 `chatSaveService`? - [ ] 是否已跑 `npm run verify:mindspace-publish-guards`? - [ ] 是否更新了本页或相关单测? diff --git a/mindspace-agent-jobs.mjs b/mindspace-agent-jobs.mjs index 52642f9..b93c9e5 100644 --- a/mindspace-agent-jobs.mjs +++ b/mindspace-agent-jobs.mjs @@ -604,12 +604,17 @@ export function createAgentJobService(pool, options = {}) { if (!hasPathAccess(asset)) { throw agentJobError('任务资产尚未通过安全检查', 'security_scan_required'); } + const assetPath = + await resolveReadableStoragePath( + asset.storage_key, + ); + const body = await fs.readFile(assetPath); return { assetId: asset.id, assetVersionId: asset.asset_version_id, displayName: asset.display_name, mimeType: asset.mime_type, - path: await resolveReadableStoragePath(asset.storage_key), + bodyBase64: body.toString('base64'), }; }; diff --git a/mindspace-agent-runner.mjs b/mindspace-agent-runner.mjs index d5df4cf..a7db5bf 100644 --- a/mindspace-agent-runner.mjs +++ b/mindspace-agent-runner.mjs @@ -344,7 +344,10 @@ export function createMindSpaceAgentRunner({ } const workingDir = await userAuth.resolveWorkingDir(claim.userId); - const sessionPolicy = await userAuth.getAgentSessionPolicy(claim.userId); + let sessionPolicy = + await userAuth.getAgentSessionPolicy( + claim.userId, + ); const publishLayout = await userAuth.getUserPublishLayout(claim.userId); const startSession = await readJsonResponse( await apiFetch('/agent/start', { @@ -362,6 +365,14 @@ export function createMindSpaceAgentRunner({ if (!sessionId) { throw runnerError('Agent 会话启动失败', 'worker_unavailable'); } + sessionPolicy = + await userAuth.getAgentSessionPolicy( + claim.userId, + { + sessionId, + packageId: `cp_${sessionId}`, + }, + ); await agentJobService.setSessionId(jobId, claim.jobToken, sessionId); await sessionStore.registerAgentSession(claim.userId, sessionId); await reconcileAgentSession( diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index 59b7661..925734f 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -1127,6 +1127,31 @@ export function createAssetService(pool, options = {}) { }; }; + const readAssetContent = async ( + userId, + assetId, + ) => { + const { asset, path: assetPath } = + await readAsset(userId, assetId); + const body = await fs.readFile(assetPath); + return { + asset, + bodyBase64: body.toString('base64'), + }; + }; + + const readPublicAssetContent = async ( + assetId, + ) => { + const { asset, path: assetPath } = + await readPublicAsset(assetId); + const body = await fs.readFile(assetPath); + return { + asset, + bodyBase64: body.toString('base64'), + }; + }; + const createChatAsset = async ( userId, { categoryCode, buffer, filename, displayName, sourceType = 'chat' }, @@ -1464,6 +1489,8 @@ export function createAssetService(pool, options = {}) { deleteAsset, readAsset, readPublicAsset, + readAssetContent, + readPublicAssetContent, renderAssetPreview, extractAssetText, renderAssetThumbnail, diff --git a/mindspace-chat-plaza.mjs b/mindspace-chat-plaza.mjs index 591be8f..0c0099c 100644 --- a/mindspace-chat-plaza.mjs +++ b/mindspace-chat-plaza.mjs @@ -1,4 +1,3 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; @@ -251,13 +250,11 @@ export async function ensurePlazaPostForPublication({ export async function quickPlazaFromChat({ user, - h5Root, bundle, body, mindSpacePages, mindSpacePublications, plazaPosts, - publishDir, }) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); @@ -374,7 +371,7 @@ export async function quickPlazaFromPublicHtml({ mindSpacePages, mindSpacePublications, plazaPosts, - publishDir, + readWorkspaceHtml, }) { if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); @@ -388,18 +385,16 @@ export async function quickPlazaFromPublicHtml({ mindSpacePages, }); - const resolvedRoot = path.resolve(publishDir); - const filePath = path.resolve(publishDir, normalized); - if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { - throw Object.assign(new Error('路径越界'), { code: 'invalid_public_html_path' }); - } - - let content; - try { - content = await fs.readFile(filePath, 'utf8'); - } catch { - throw Object.assign(new Error('页面文件不存在'), { code: 'static_page_not_found' }); + if (typeof readWorkspaceHtml !== 'function') { + throw Object.assign( + new Error('MindSpace 页面读取服务不可用'), + { code: 'mindspace_read_unavailable' }, + ); } + const { html: content } = await readWorkspaceHtml({ + userId: user.id, + relativePath: normalized, + }); const page = await ensureWorkspaceHtmlPageForPlaza({ userId: user.id, diff --git a/mindspace-chat-plaza.test.mjs b/mindspace-chat-plaza.test.mjs index 9503e07..84360e5 100644 --- a/mindspace-chat-plaza.test.mjs +++ b/mindspace-chat-plaza.test.mjs @@ -25,7 +25,6 @@ test('quickPlazaFromPublicHtml rejects non-public html paths', async () => { mindSpacePages: {}, mindSpacePublications: {}, plazaPosts: {}, - publishDir: '/tmp/publish', }), ), (error) => error?.code === 'invalid_public_html_path', @@ -76,7 +75,6 @@ test('quickPlazaFromPublicHtml blocks duplicate plaza posts for the same workspa }, mindSpacePublications: {}, plazaPosts: {}, - publishDir: '/tmp/publish', }), (error) => error?.code === 'ALREADY_PUBLISHED' && error?.details?.post_id === 'post-chat', ); diff --git a/mindspace-chat-save-service.mjs b/mindspace-chat-save-service.mjs new file mode 100644 index 0000000..368e3b0 --- /dev/null +++ b/mindspace-chat-save-service.mjs @@ -0,0 +1,343 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + createAssetDataUriResolver, + materializePrivateAssetsInWorkspaceHtml, + repairMissingHtmlAssetReferences, + resolvePublishHtmlAbsolutePath, +} from './mindspace-chat-save.mjs'; +import { inlinePrivateAssetsInHtml } from './mindspace-pages.mjs'; +import { rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; +import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; +import { + ensureWorkspaceHtmlThumbnail, + workspaceThumbnailRelativePath, +} from './mindspace-workspace-thumbnails.mjs'; +import { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs'; + +function chatSaveError(message, code) { + return Object.assign(new Error(message), { code }); +} + +function normalizePublicHtmlRelativePath(value) { + const raw = String(value ?? '').trim().replace(/\\/g, '/'); + if ( + !raw || + raw + .replace(/^\/+/, '') + .split('/') + .some((segment) => segment === '.' || segment === '..') + ) { + throw chatSaveError('无效的页面路径', 'invalid_page_path'); + } + const relativePath = normalizeWorkspaceRelativePath(raw); + if ( + !relativePath.startsWith('public/') || + path.posix.extname(relativePath).toLowerCase() !== '.html' + ) { + throw chatSaveError('聊天页面必须位于 public 目录', 'invalid_page_path'); + } + return relativePath; +} + +export function createMindSpaceChatSaveService({ + pool, + h5Root, + storageRoot, + env = process.env, + repairMissingHtmlAssetReferencesFn = + repairMissingHtmlAssetReferences, + materializePrivateAssetsInWorkspaceHtmlFn = + materializePrivateAssetsInWorkspaceHtml, + resolvePublishHtmlAbsolutePathFn = + resolvePublishHtmlAbsolutePath, + resolveMindSpaceUserPublishDirFn = + resolveMindSpaceUserPublishDir, + ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, + workspaceThumbnailRelativePathFn = + workspaceThumbnailRelativePath, + createAssetDataUriResolverFn = createAssetDataUriResolver, + generateHtmlThumbnailFn = generateHtmlThumbnail, + inlinePrivateAssetsInHtmlFn = inlinePrivateAssetsInHtml, + rewriteWorkspacePublicAssetReferencesFn = + rewriteWorkspacePublicAssetReferences, + buildMindSpacePublicUrlForUserFn = + buildMindSpacePublicUrlForUser, + mkdirFn = fs.mkdir, + readFileFn = fs.readFile, + writeFileFn = fs.writeFile, + randomUUIDFn = crypto.randomUUID, +} = {}) { + if (!pool || typeof pool.query !== 'function') { + throw new Error( + 'createMindSpaceChatSaveService requires a database pool', + ); + } + if (!h5Root || !storageRoot) { + throw new Error( + 'createMindSpaceChatSaveService requires h5Root and storageRoot', + ); + } + + return { + async readWorkspaceHtml({ + userId, + relativePath, + } = {}) { + const ownerId = String(userId ?? '').trim().toLowerCase(); + if (!ownerId) { + throw chatSaveError( + '缺少页面用户', + 'invalid_page_input', + ); + } + const normalizedRelativePath = + normalizePublicHtmlRelativePath(relativePath); + const absolutePath = resolvePublishHtmlAbsolutePathFn( + h5Root, + ownerId, + normalizedRelativePath, + ); + let html; + try { + html = await readFileFn(absolutePath, 'utf8'); + } catch { + throw chatSaveError( + '页面文件不存在', + 'static_page_not_found', + ); + } + return { + html, + relativePath: normalizedRelativePath, + }; + }, + + async createSharedHtml({ + userId, + html, + sourceFilename = 'page.html', + } = {}) { + const ownerId = String(userId ?? '').trim().toLowerCase(); + if (!ownerId || typeof html !== 'string' || !html.trim()) { + throw chatSaveError('缺少分享页面内容', 'invalid_page_input'); + } + const { html: localizedHtml } = + await inlinePrivateAssetsInHtmlFn( + pool, + storageRoot, + ownerId, + html, + ); + const basename = + path.posix + .basename( + String(sourceFilename ?? 'page.html') + .replace(/\\/g, '/'), + ) + .replace(/\.html$/i, '') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'page'; + const filename = + `${basename}-${randomUUIDFn().slice(0, 8)}.html`; + const relativePath = `public/shared/${filename}`; + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + { id: ownerId }, + ); + const destination = path.resolve( + publishDir, + relativePath, + ); + if ( + destination !== publishDir && + !destination.startsWith( + `${publishDir}${path.sep}`, + ) + ) { + throw chatSaveError( + '无效的分享页面路径', + 'invalid_page_path', + ); + } + const sharedHtml = + rewriteWorkspacePublicAssetReferencesFn( + localizedHtml, + relativePath, + ); + await mkdirFn(path.dirname(destination), { + recursive: true, + }); + await writeFileFn(destination, sharedHtml, 'utf8'); + return { + publicUrl: buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: ownerId, + relativePath, + }), + filename, + relativePath, + }; + }, + + async materializeWorkspaceHtml({ + userId, + sourceContent = '', + html, + relativePath, + } = {}) { + const ownerId = String(userId ?? '').trim().toLowerCase(); + if (!ownerId || typeof html !== 'string' || !html.trim()) { + throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); + } + const normalizedRelativePath = + normalizePublicHtmlRelativePath(relativePath); + + const repaired = + await repairMissingHtmlAssetReferencesFn({ + pool, + userId: ownerId, + sourceContent, + html, + }).catch(() => ({ html, changed: false })); + + const repairedHtml = + typeof repaired?.html === 'string' ? repaired.html : html; + const materialized = + await materializePrivateAssetsInWorkspaceHtmlFn({ + pool, + storageRoot, + h5Root, + userId: ownerId, + html: repairedHtml, + htmlRelativePath: normalizedRelativePath, + writeBack: false, + }).catch(() => ({ + html: repairedHtml, + count: 0, + changed: false, + })); + + const materializedHtml = + typeof materialized?.html === 'string' + ? materialized.html + : repairedHtml; + const changed = materializedHtml !== html; + if (changed) { + const absolutePath = resolvePublishHtmlAbsolutePathFn( + h5Root, + ownerId, + normalizedRelativePath, + ); + await writeFileFn(absolutePath, materializedHtml, 'utf8'); + } + + return { + html: materializedHtml, + relativePath: normalizedRelativePath, + changed, + repairedAssetReferences: repaired?.changed === true, + materializedAssetCount: Number(materialized?.count ?? 0), + }; + }, + + async ensurePreviewThumbnail({ + userId, + relativePath, + html, + title = '', + subtitle = '', + force = false, + } = {}) { + const ownerId = String(userId ?? '').trim().toLowerCase(); + if (!ownerId || typeof html !== 'string' || !html.trim()) { + throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); + } + const normalizedRelativePath = + normalizePublicHtmlRelativePath(relativePath); + const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, { + id: ownerId, + }); + await ensureWorkspaceHtmlThumbnailFn( + publishDir, + normalizedRelativePath, + html, + { + title: String(title ?? '').trim(), + subtitle: String(subtitle ?? '').trim(), + force: Boolean(force), + }, + ); + return { + relativePath: normalizedRelativePath, + ready: true, + }; + }, + + async renderPreviewThumbnailSvg({ + userId, + relativePath, + html, + title = '', + subtitle = '', + force = false, + } = {}) { + const ownerId = String(userId ?? '').trim().toLowerCase(); + if (!ownerId || typeof html !== 'string' || !html.trim()) { + throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); + } + const normalizedRelativePath = + normalizePublicHtmlRelativePath(relativePath); + const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, { + id: ownerId, + }); + const thumbRel = workspaceThumbnailRelativePathFn( + normalizedRelativePath, + ); + const htmlPath = path.join(publishDir, normalizedRelativePath); + await ensureWorkspaceHtmlThumbnailFn( + publishDir, + normalizedRelativePath, + html, + { + title: String(title ?? '').trim(), + subtitle: String(subtitle ?? '').trim(), + force: Boolean(force), + }, + ).catch(() => {}); + const resolveAssetDataUri = createAssetDataUriResolverFn( + pool, + storageRoot, + ownerId, + ); + const svg = await generateHtmlThumbnailFn( + publishDir, + thumbRel, + html, + { + title: String(title ?? '').trim(), + subtitle: String(subtitle ?? '').trim(), + contentBaseDir: path.dirname(htmlPath), + resolveAssetDataUri, + force: Boolean(force), + }, + ); + return { + svg, + contentType: 'image/svg+xml; charset=utf-8', + relativePath: normalizedRelativePath, + }; + }, + }; +} + +export const mindspaceChatSaveServiceInternals = { + normalizePublicHtmlRelativePath, +}; diff --git a/mindspace-chat-save-service.test.mjs b/mindspace-chat-save-service.test.mjs new file mode 100644 index 0000000..a2b9b41 --- /dev/null +++ b/mindspace-chat-save-service.test.mjs @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + createMindSpaceChatSaveService, + mindspaceChatSaveServiceInternals, +} from './mindspace-chat-save-service.mjs'; + +const USER_ID = '11111111-1111-4111-8111-111111111111'; + +test('chat save authority creates localized shared HTML and its public URL inside MindSpace', async () => { + const calls = []; + const service = createMindSpaceChatSaveService({ + pool: { query: async () => [[]] }, + h5Root: '/srv/h5', + storageRoot: '/srv/storage', + resolveMindSpaceUserPublishDirFn: () => + '/srv/h5/MindSpace/user-1', + async inlinePrivateAssetsInHtmlFn( + pool, + storageRoot, + userId, + html, + ) { + calls.push({ + kind: 'inline', + pool, + storageRoot, + userId, + html, + }); + return { html: 'localized' }; + }, + rewriteWorkspacePublicAssetReferencesFn( + html, + relativePath, + ) { + calls.push({ + kind: 'rewrite', + html, + relativePath, + }); + return 'shared'; + }, + async mkdirFn(directory, options) { + calls.push({ kind: 'mkdir', directory, options }); + }, + async writeFileFn(...args) { + calls.push({ kind: 'write', args }); + }, + randomUUIDFn: () => + '12345678-1234-4123-8123-123456789abc', + buildMindSpacePublicUrlForUserFn(input) { + calls.push({ kind: 'url', input }); + return 'https://example.com/MindSpace/user-1/public/shared/report-12345678.html'; + }, + }); + + const result = await service.createSharedHtml({ + userId: 'user-1', + html: 'private', + sourceFilename: '../unsafe report.html', + }); + + assert.equal( + result.relativePath, + 'public/shared/unsafe-report-12345678.html', + ); + assert.equal( + result.publicUrl, + 'https://example.com/MindSpace/user-1/public/shared/report-12345678.html', + ); + assert.equal(calls[0].kind, 'inline'); + assert.equal(calls[1].kind, 'rewrite'); + assert.equal(calls[2].kind, 'mkdir'); + assert.equal(calls[3].kind, 'write'); + assert.equal( + calls[3].args[0], + '/srv/h5/MindSpace/user-1/public/shared/unsafe-report-12345678.html', + ); + assert.equal(calls[3].args[1], 'shared'); + assert.equal(calls[4].kind, 'url'); +}); + +test('chat save authority validates and reads workspace HTML inside MindSpace', async () => { + const calls = []; + const service = createMindSpaceChatSaveService({ + pool: { query: async () => [[]] }, + h5Root: '/srv/h5', + storageRoot: '/srv/storage', + resolvePublishHtmlAbsolutePathFn( + h5Root, + userId, + relativePath, + ) { + calls.push({ + kind: 'resolve', + h5Root, + userId, + relativePath, + }); + return '/srv/h5/MindSpace/user-1/public/page.html'; + }, + async readFileFn(...args) { + calls.push({ kind: 'read', args }); + return 'Page'; + }, + }); + + const result = await service.readWorkspaceHtml({ + userId: 'user-1', + relativePath: 'public/page.html', + }); + + assert.equal( + result.html, + 'Page', + ); + assert.equal(result.relativePath, 'public/page.html'); + assert.equal(calls[0].kind, 'resolve'); + assert.equal(calls[1].kind, 'read'); +}); + +test('chat save authority repairs, materializes, and writes workspace HTML inside MindSpace', async () => { + const h5Root = await fs.mkdtemp( + path.join(os.tmpdir(), 'mindspace-chat-save-authority-'), + ); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const storageKey = 'users/u1/assets/asset-good/source.png'; + const htmlPath = path.join( + h5Root, + 'MindSpace', + USER_ID, + 'public', + 'report.html', + ); + await fs.mkdir(path.dirname(htmlPath), { recursive: true }); + await fs.mkdir( + path.dirname(path.join(storageRoot, storageKey)), + { recursive: true }, + ); + await fs.writeFile(htmlPath, 'old', 'utf8'); + await fs.writeFile( + path.join(storageRoot, storageKey), + Buffer.from('png payload'), + ); + + const pool = { + async query(sql) { + if (sql.includes('SELECT id FROM h5_assets')) { + return [[{ id: 'asset-good' }]]; + } + if (sql.includes('JOIN h5_asset_versions')) { + return [[{ + id: 'asset-good', + mime_type: 'image/png', + original_filename: 'source.png', + storage_key: storageKey, + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + let writeCount = 0; + const service = createMindSpaceChatSaveService({ + pool, + h5Root, + storageRoot, + writeFileFn: async (...args) => { + writeCount += 1; + return fs.writeFile(...args); + }, + }); + const sourceContent = + `/api/mindspace/v1/assets/asset-good/download`; + const html = + ''; + + const first = await service.materializeWorkspaceHtml({ + userId: USER_ID, + sourceContent, + html, + relativePath: 'public/report.html', + }); + + assert.equal(first.changed, true); + assert.equal(first.repairedAssetReferences, true); + assert.equal(first.materializedAssetCount, 1); + assert.equal(first.relativePath, 'public/report.html'); + assert.match(first.html, /\.tmp-images\/asset-good\.png/); + assert.doesNotMatch(first.html, /\/api\/mindspace\/v1\/assets\//); + assert.equal(await fs.readFile(htmlPath, 'utf8'), first.html); + assert.equal( + ( + await fs.readFile( + path.join( + h5Root, + 'MindSpace', + USER_ID, + 'public', + '.tmp-images', + 'asset-good.png', + ), + ) + ).toString(), + 'png payload', + ); + + const second = await service.materializeWorkspaceHtml({ + userId: USER_ID, + sourceContent, + html: first.html, + relativePath: 'public/report.html', + }); + assert.equal(second.changed, false); + assert.equal(writeCount, 1); +}); + +test('chat save authority renders preview thumbnail svg inside MindSpace', async () => { + const h5Root = await fs.mkdtemp( + path.join(os.tmpdir(), 'mindspace-chat-save-thumb-'), + ); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const htmlPath = path.join( + h5Root, + 'MindSpace', + USER_ID, + 'public', + 'report.html', + ); + await fs.mkdir(path.dirname(htmlPath), { recursive: true }); + await fs.writeFile(htmlPath, 'Report', 'utf8'); + + const pool = { query: async () => [[]] }; + const service = createMindSpaceChatSaveService({ + pool, + h5Root, + storageRoot, + }); + const rendered = await service.renderPreviewThumbnailSvg({ + userId: USER_ID, + relativePath: 'public/report.html', + html: 'Report', + title: 'Report', + subtitle: 'Summary', + force: true, + }); + + assert.match(rendered.svg, / { + const h5Root = await fs.mkdtemp( + path.join(os.tmpdir(), 'mindspace-chat-save-ensure-'), + ); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const htmlPath = path.join( + h5Root, + 'MindSpace', + USER_ID, + 'public', + 'report.html', + ); + await fs.mkdir(path.dirname(htmlPath), { recursive: true }); + await fs.writeFile(htmlPath, 'Report', 'utf8'); + + const service = createMindSpaceChatSaveService({ + pool: { query: async () => [[]] }, + h5Root, + storageRoot, + }); + const result = await service.ensurePreviewThumbnail({ + userId: USER_ID, + relativePath: 'public/report.html', + html: 'Report', + title: 'Report', + subtitle: 'Summary', + force: true, + }); + + assert.equal(result.ready, true); + assert.equal(result.relativePath, 'public/report.html'); + await fs.access( + path.join( + h5Root, + 'MindSpace', + USER_ID, + 'public', + 'report.thumbnail.svg', + ), + ); +}); + +test('chat save authority rejects paths outside the public HTML zone', async () => { + const service = createMindSpaceChatSaveService({ + pool: { query: async () => [[]] }, + h5Root: '/tmp/h5', + storageRoot: '/tmp/storage', + }); + + await assert.rejects( + () => + service.materializeWorkspaceHtml({ + userId: USER_ID, + html: 'unsafe', + relativePath: '../private/report.html', + }), + (error) => error?.code === 'invalid_page_path', + ); + assert.throws( + () => + mindspaceChatSaveServiceInternals + .normalizePublicHtmlRelativePath('oa/report.html'), + (error) => error?.code === 'invalid_page_path', + ); +}); + +test('chat save authority preserves best-effort repair behavior', async () => { + let writeCalled = false; + const service = createMindSpaceChatSaveService({ + pool: { query: async () => [[]] }, + h5Root: '/tmp/h5', + storageRoot: '/tmp/storage', + repairMissingHtmlAssetReferencesFn: async () => { + throw new Error('repair unavailable'); + }, + materializePrivateAssetsInWorkspaceHtmlFn: async () => { + throw new Error('storage unavailable'); + }, + writeFileFn: async () => { + writeCalled = true; + }, + }); + const html = 'unchanged'; + + const result = await service.materializeWorkspaceHtml({ + userId: USER_ID, + sourceContent: 'source', + html, + relativePath: 'report.html', + }); + + assert.equal(result.html, html); + assert.equal(result.relativePath, 'public/report.html'); + assert.equal(result.changed, false); + assert.equal(writeCalled, false); +}); diff --git a/mindspace-conversation-package-artifact-service.mjs b/mindspace-conversation-package-artifact-service.mjs new file mode 100644 index 0000000..837c8f7 --- /dev/null +++ b/mindspace-conversation-package-artifact-service.mjs @@ -0,0 +1,342 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; +import { registerChatDocxArtifactForConversation } from './mindspace-chat-docx-package.mjs'; +import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs'; +import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs'; + +function normalizeBuffer(value) { + if (Buffer.isBuffer(value)) return value; + if (typeof value === 'string') { + return Buffer.from(value, 'utf8'); + } + if (ArrayBuffer.isView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + return null; +} + +function generatedArtifactMetadata( + relativePath, +) { + const extension = path.posix + .extname(relativePath) + .toLowerCase(); + const byExtension = { + '.csv': [ + 'generated_file', + 'text/csv', + ], + '.doc': [ + 'docx', + 'application/msword', + ], + '.docx': [ + 'docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + '.gif': ['generated_image', 'image/gif'], + '.jpeg': [ + 'generated_image', + 'image/jpeg', + ], + '.jpg': [ + 'generated_image', + 'image/jpeg', + ], + '.json': [ + 'generated_file', + 'application/json', + ], + '.md': [ + 'generated_file', + 'text/markdown', + ], + '.pdf': ['pdf', 'application/pdf'], + '.png': [ + 'generated_image', + 'image/png', + ], + '.svg': [ + 'generated_image', + 'image/svg+xml', + ], + '.txt': [ + 'generated_file', + 'text/plain', + ], + '.webp': [ + 'generated_image', + 'image/webp', + ], + '.xlsx': [ + 'generated_file', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + }; + const [artifactKind, mimeType] = + byExtension[extension] ?? [ + 'generated_file', + 'application/octet-stream', + ]; + return { artifactKind, mimeType }; +} + +export function createMindSpaceConversationArtifactService({ + conversationPackageRegistry, + h5Root, + env = process.env, + resolveMindSpaceUserPublishDirFn = + resolveMindSpaceUserPublishDir, + registerPublicHtmlArtifactsFn = + registerPublicHtmlArtifactsForConversationPackage, + registerChatDocxArtifactFn = + registerChatDocxArtifactForConversation, +} = {}) { + if (!conversationPackageRegistry) { + throw new Error( + 'createMindSpaceConversationArtifactService requires a conversation package registry', + ); + } + if (!h5Root) { + throw new Error( + 'createMindSpaceConversationArtifactService requires h5Root', + ); + } + + return { + async registerPublicHtmlArtifacts({ + userId, + sessionId, + title = null, + relativePaths = [], + artifactRefs = [], + } = {}) { + const normalizedUserId = String(userId ?? '').trim(); + if ( + !normalizedUserId || + !String(sessionId ?? '').trim() + ) { + return []; + } + const user = { id: normalizedUserId }; + const publishDir = resolveMindSpaceUserPublishDirFn( + h5Root, + user, + ); + return registerPublicHtmlArtifactsFn({ + conversationPackageRegistry, + h5Root, + env, + user, + publishDir, + sessionId, + title, + relativePaths, + artifactRefs, + }); + }, + + async registerChatDocxArtifact({ + userId, + sessionId, + messageId, + selectedLinkIndex = 0, + title = null, + filename, + body, + } = {}) { + const buffer = normalizeBuffer(body); + if ( + !String(userId ?? '').trim() || + !String(sessionId ?? '').trim() || + !String(messageId ?? '').trim() || + !String(filename ?? '').trim() || + !buffer + ) { + return null; + } + return registerChatDocxArtifactFn({ + registry: conversationPackageRegistry, + user: { id: String(userId).trim() }, + bundle: { + source: { + session: { + name: String(title ?? '').trim() || null, + }, + }, + }, + requestBody: { + session_id: String(sessionId).trim(), + message_id: String(messageId).trim(), + selected_link_index: selectedLinkIndex, + }, + filename: String(filename).trim(), + buffer, + }); + }, + + async registerPublishedLongImageArtifact({ + ownerId, + sessionId, + messageId = null, + publicationId, + pageId = null, + title = null, + filename = null, + body, + canonicalUrl = null, + } = {}) { + const normalizedOwnerId = String(ownerId ?? '').trim(); + const normalizedSessionId = String(sessionId ?? '').trim(); + const normalizedPublicationId = + String(publicationId ?? '').trim(); + const buffer = normalizeBuffer(body); + if ( + !normalizedOwnerId || + !normalizedSessionId || + !normalizedPublicationId || + !buffer + ) { + return null; + } + + const hash = crypto + .createHash('sha256') + .update( + `${normalizedOwnerId}:${normalizedSessionId}:${normalizedPublicationId}:long-image`, + ) + .digest('hex') + .slice(0, 16); + const artifactId = `ca_long_image_${hash}`; + const displayName = + String(filename ?? '').trim() || + `${normalizedPublicationId}.long.png`; + const relativePath = + `artifacts/${artifactId}/${path.posix.basename(displayName)}`; + const now = Date.now(); + const { packageRecord, writeResult } = + await conversationPackageRegistry.putObjectForSession({ + userId: normalizedOwnerId, + sessionId: normalizedSessionId, + title: String(title ?? '').trim() || null, + relativePath, + body: buffer, + }); + const artifact = + await conversationPackageRegistry.recordArtifact({ + id: artifactId, + packageId: packageRecord.id, + artifactKind: 'long_image', + role: 'assistant', + pageId: String(pageId ?? '').trim() || null, + publicationId: normalizedPublicationId, + messageId: String(messageId ?? '').trim() || null, + displayName: path.posix.basename(displayName), + mimeType: 'image/png', + sizeBytes: buffer.length, + storageKey: writeResult.key, + canonicalUrl: + String(canonicalUrl ?? '').trim() || null, + sortOrder: now, + now, + }); + await conversationPackageRegistry.writeManifestForSession({ + userId: normalizedOwnerId, + sessionId: normalizedSessionId, + }); + return artifact; + }, + + async registerWorkspaceFileArtifact({ + userId, + sessionId, + messageId = null, + title = null, + relativePath, + body, + } = {}) { + const normalizedUserId = String( + userId ?? '', + ).trim(); + const normalizedSessionId = String( + sessionId ?? '', + ).trim(); + const normalizedRelativePath = String( + relativePath ?? '', + ) + .trim() + .replace(/\\/g, '/'); + const buffer = normalizeBuffer(body); + if ( + !normalizedUserId || + !normalizedSessionId || + !normalizedRelativePath || + !buffer + ) { + return null; + } + const hash = crypto + .createHash('sha256') + .update( + `${normalizedUserId}:${normalizedSessionId}:${normalizedRelativePath}`, + ) + .digest('hex') + .slice(0, 20); + const artifactId = + `ca_workspace_write_${hash}`; + const displayName = + path.posix.basename( + normalizedRelativePath, + ); + const packageRelativePath = + `artifacts/${artifactId}/${displayName}`; + const metadata = + generatedArtifactMetadata( + normalizedRelativePath, + ); + const now = Date.now(); + const { packageRecord, writeResult } = + await conversationPackageRegistry + .putObjectForSession({ + userId: normalizedUserId, + sessionId: normalizedSessionId, + title: + String(title ?? '').trim() || + null, + relativePath: + packageRelativePath, + body: buffer, + }); + const artifact = + await conversationPackageRegistry + .recordArtifact({ + id: artifactId, + packageId: packageRecord.id, + artifactKind: + metadata.artifactKind, + role: 'assistant', + messageId: + String(messageId ?? '').trim() || + null, + displayName, + mimeType: metadata.mimeType, + sizeBytes: buffer.length, + storageKey: writeResult.key, + canonicalUrl: null, + sortOrder: now, + now, + }); + await conversationPackageRegistry + .writeManifestForSession({ + userId: normalizedUserId, + sessionId: normalizedSessionId, + }); + return artifact; + }, + }; +} + +export const mindSpaceConversationArtifactServiceInternals = { + normalizeBuffer, + generatedArtifactMetadata, +}; diff --git a/mindspace-conversation-package-artifact-service.test.mjs b/mindspace-conversation-package-artifact-service.test.mjs new file mode 100644 index 0000000..ed42b7e --- /dev/null +++ b/mindspace-conversation-package-artifact-service.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMindSpaceConversationArtifactService } from './mindspace-conversation-package-artifact-service.mjs'; + +function createRegistry() { + const calls = []; + return { + calls, + async putObjectForSession(input) { + calls.push({ kind: 'put', input }); + return { + packageRecord: { id: 'package-1' }, + writeResult: { key: 'packages/object' }, + }; + }, + async recordArtifact(input) { + calls.push({ kind: 'record', input }); + return input; + }, + async writeManifestForSession(input) { + calls.push({ kind: 'manifest', input }); + return { ok: true }; + }, + }; +} + +test('public HTML artifact registration resolves the physical publish directory inside MindSpace', async () => { + const registry = createRegistry(); + const calls = []; + const service = createMindSpaceConversationArtifactService({ + conversationPackageRegistry: registry, + h5Root: '/srv/h5', + env: { H5_PUBLIC_BASE_URL: 'https://example.com' }, + resolveMindSpaceUserPublishDirFn(h5Root, user) { + calls.push({ kind: 'resolve', h5Root, user }); + return '/srv/h5/MindSpace/user-1'; + }, + async registerPublicHtmlArtifactsFn(input) { + calls.push({ kind: 'register', input }); + return [{ artifactId: 'artifact-1' }]; + }, + }); + + const result = await service.registerPublicHtmlArtifacts({ + userId: 'user-1', + sessionId: 'session-1', + artifactRefs: [ + { + relativePath: 'public/page.html', + messageId: 'message-1', + }, + ], + }); + + assert.deepEqual(result, [{ artifactId: 'artifact-1' }]); + assert.equal(calls[0].kind, 'resolve'); + assert.equal(calls[0].h5Root, '/srv/h5'); + assert.equal(calls[1].kind, 'register'); + assert.equal( + calls[1].input.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.equal( + calls[1].input.artifactRefs[0].relativePath, + 'public/page.html', + ); +}); + +test('DOCX artifact registration accepts RPC-revived binary data and delegates to the package helper', async () => { + const registry = createRegistry(); + const calls = []; + const service = createMindSpaceConversationArtifactService({ + conversationPackageRegistry: registry, + h5Root: '/srv/h5', + async registerChatDocxArtifactFn(input) { + calls.push(input); + return { id: 'docx-1' }; + }, + }); + + const body = new Uint8Array([1, 2, 3]); + const result = await service.registerChatDocxArtifact({ + userId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + selectedLinkIndex: 2, + title: 'Chat title', + filename: 'chat.docx', + body, + }); + + assert.deepEqual(result, { id: 'docx-1' }); + assert.ok(Buffer.isBuffer(calls[0].buffer)); + assert.equal(calls[0].buffer.length, 3); + assert.equal( + calls[0].requestBody.selected_link_index, + 2, + ); + assert.equal( + calls[0].bundle.source.session.name, + 'Chat title', + ); +}); + +test('published long image registration stores the object, artifact, and refreshed manifest idempotently', async () => { + const registry = createRegistry(); + const service = createMindSpaceConversationArtifactService({ + conversationPackageRegistry: registry, + h5Root: '/srv/h5', + }); + + const input = { + ownerId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + publicationId: 'publication-1', + pageId: 'page-1', + title: 'Page title', + body: Buffer.from('png'), + canonicalUrl: 'https://example.com/page.long.png', + }; + const result = + await service.registerPublishedLongImageArtifact(input); + const repeated = + await service.registerPublishedLongImageArtifact(input); + + assert.equal(result.artifactKind, 'long_image'); + assert.equal(repeated.id, result.id); + assert.equal(result.publicationId, 'publication-1'); + assert.equal(result.pageId, 'page-1'); + assert.equal(result.messageId, 'message-1'); + assert.equal(registry.calls[0].kind, 'put'); + assert.equal(registry.calls[1].kind, 'record'); + assert.equal(registry.calls[2].kind, 'manifest'); + assert.equal( + registry.calls[0].input.body.toString('utf8'), + 'png', + ); + assert.match( + registry.calls[1].input.id, + /^ca_long_image_[a-f0-9]{16}$/, + ); + assert.equal(registry.calls[4].input.id, result.id); +}); + +test('workspace file registration stores every logical write in the conversation package', async () => { + const registry = createRegistry(); + const service = createMindSpaceConversationArtifactService({ + conversationPackageRegistry: registry, + h5Root: '/srv/h5', + }); + const input = { + userId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + title: 'Workspace output', + relativePath: 'oa/notes.txt', + body: 'generated notes', + }; + + const result = + await service.registerWorkspaceFileArtifact(input); + const repeated = + await service.registerWorkspaceFileArtifact(input); + + assert.equal(result.artifactKind, 'generated_file'); + assert.equal(result.mimeType, 'text/plain'); + assert.equal(result.messageId, 'message-1'); + assert.equal(repeated.id, result.id); + assert.match( + result.id, + /^ca_workspace_write_[a-f0-9]{20}$/, + ); + assert.equal(registry.calls[0].kind, 'put'); + assert.equal( + registry.calls[0].input.body.toString('utf8'), + 'generated notes', + ); + assert.match( + registry.calls[0].input.relativePath, + /^artifacts\/ca_workspace_write_[a-f0-9]{20}\/notes\.txt$/, + ); + assert.equal(registry.calls[1].kind, 'record'); + assert.equal(registry.calls[2].kind, 'manifest'); + assert.equal(registry.calls[4].input.id, result.id); +}); diff --git a/mindspace-conversation-package-public-html.mjs b/mindspace-conversation-package-public-html.mjs index b519b68..2de73cc 100644 --- a/mindspace-conversation-package-public-html.mjs +++ b/mindspace-conversation-package-public-html.mjs @@ -121,8 +121,7 @@ export function createConversationPackagePublicHtmlHydrator({ }); return ( (await registerPublicHtmlArtifactsForConversation?.({ - user, - publishDir, + userId: user.id, sessionId, title: snapshot?.session?.name, artifactRefs, diff --git a/mindspace-conversation-package-public-html.test.mjs b/mindspace-conversation-package-public-html.test.mjs index e9818e1..3a18f97 100644 --- a/mindspace-conversation-package-public-html.test.mjs +++ b/mindspace-conversation-package-public-html.test.mjs @@ -77,6 +77,6 @@ test('createConversationPackagePublicHtmlHydrator resolves snapshot artifacts th assert.equal(result.length, 1); assert.equal(calls.length, 1); - assert.equal(calls[0].publishDir, publishDir); + assert.equal('publishDir' in calls[0], false); assert.equal(calls[0].artifactRefs[0].relativePath, 'public/landing.html'); }); diff --git a/mindspace-h5-html-finish-guard.mjs b/mindspace-h5-html-finish-guard.mjs index 666872a..890b035 100644 --- a/mindspace-h5-html-finish-guard.mjs +++ b/mindspace-h5-html-finish-guard.mjs @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { extractStaticPageLinks } from './mindspace-chat-save.mjs'; import { - createPublicHtmlLinkExists, shouldRetryHtmlGenerationReply, } from './wechat-mp.mjs'; import { @@ -73,6 +72,47 @@ function publicHtmlIsMissingOrStub(publishDir, relativePath) { } } +function createPublicHtmlLinkExists(publishDir) { + const workspaceRoot = + path.resolve(String(publishDir ?? '')); + const workspaceKey = + path.basename(workspaceRoot); + return (urlText) => { + let url; + try { + url = new URL(urlText); + } catch { + return true; + } + const parts = url.pathname + .split('/') + .filter(Boolean) + .map((part) => { + try { + return decodeURIComponent(part); + } catch { + return part; + } + }); + if ( + parts.length < 4 || + parts[0] !== 'MindSpace' || + parts[2] !== 'public' + ) { + return true; + } + if (parts[1] !== workspaceKey) { + return false; + } + const relativePath = + `public/${parts.slice(3).join('/')}`; + return publicFileExists( + workspaceRoot, + relativePath, + ); + }; +} + export function isH5HtmlFinishGuardEnabled(env = process.env) { return envFlag(env.MEMIND_H5_HTML_FINISH_GUARD, false); } @@ -236,17 +276,20 @@ export async function maybeRepairH5HtmlAfterFinish({ messages, publishDir, syncResult = null, + evaluation: suppliedEvaluation = null, tkmindProxy = null, maxAttempts = 1, env = process.env, logger = console, } = {}) { - const evaluation = evaluateH5HtmlFinishGuard({ - messages, - currentUser, - publishDir, - syncResult, - }); + const evaluation = + suppliedEvaluation ?? + evaluateH5HtmlFinishGuard({ + messages, + currentUser, + publishDir, + syncResult, + }); if (!evaluation.needsRepair) { resetH5HtmlFinishGuardAttempts(sessionId); return { repaired: false, skipped: 'ok', ...evaluation }; diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index 5af8f24..66ac407 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -2,13 +2,22 @@ import { createAssetService } from './mindspace-assets.mjs'; import { createConversationPackageRegistry } from './mindspace-conversation-package-registry.mjs'; import { createConversationPackageStore } from './mindspace-conversation-package-store.mjs'; import { createCleanupService } from './mindspace-cleanup.mjs'; +import { createMindSpaceChatSaveService } from './mindspace-chat-save-service.mjs'; +import { createMindSpaceConversationArtifactService } from './mindspace-conversation-package-artifact-service.mjs'; import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createAssetAgentService } from './mindspace-asset-agent.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; import { createPageService } from './mindspace-pages.mjs'; import { createPageSyncService } from './mindspace-page-sync-service.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; +import { createMindSpacePublicFinishService } from './mindspace-public-finish-service.mjs'; import { createMindSpaceLocalRuntime } from './mindspace-runtime-config.mjs'; +import { createMindSpaceWorkspacePublicationDeliveryService } from './mindspace-workspace-publication-delivery-service.mjs'; +import { createMindSpaceWorkspaceToolService } from './mindspace-workspace-tool-service.mjs'; +import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs'; +import { + ensurePageDataHtmlPagesBound, +} from './page-data-workspace-ensure.mjs'; export function createMindSpaceLocalRuntimeServices({ pool, @@ -62,12 +71,64 @@ export function createMindSpaceLocalRuntimeServices({ syncWorkspaceAssets, logger, }); + const chatSaveService = createMindSpaceChatSaveService({ + pool, + h5Root, + storageRoot: runtime.storageRoot, + }); + const conversationArtifactService = + createMindSpaceConversationArtifactService({ + conversationPackageRegistry, + h5Root, + env, + }); + const publicFinishService = + createMindSpacePublicFinishService({ + pool, + h5Root, + storageRoot: runtime.storageRoot, + env, + syncWorkspaceAssets, + conversationArtifactService, + }); + const workspacePublicationDeliveryService = + createMindSpaceWorkspacePublicationDeliveryService({ + pool, + h5Root, + storageRoot: runtime.storageRoot, + logger, + }); const publicationService = createPublicationService(pool, { h5Root, storageRoot: runtime.storageRoot, publicPageLimit, conversationPackageRegistry, }); + const workspacePageDeliveryService = + createWorkspacePageDeliverService({ + pool, + pageService, + publicationService, + pageSyncService, + pageDataEnsure: { + ensurePageDataHtmlPagesBound, + }, + h5Root, + storageRoot: runtime.storageRoot, + findPageByRelativePath: + pageService.findPageByRelativePath.bind( + pageService, + ), + logger, + }); + const workspaceToolService = + createMindSpaceWorkspaceToolService({ + h5Root, + env, + syncWorkspaceAssets, + conversationArtifactService, + workspacePageDeliveryService, + }); const cleanupService = createCleanupService(pool, { storageRoot: runtime.storageRoot, h5Root, @@ -93,7 +154,13 @@ export function createMindSpaceLocalRuntimeServices({ assetService, pageService, pageSyncService, + chatSaveService, + conversationArtifactService, + publicFinishService, + workspacePublicationDeliveryService, publicationService, + workspacePageDeliveryService, + workspaceToolService, cleanupService, agentJobService, pageLiveEditService, diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs index 19b09a0..3103314 100644 --- a/mindspace-local-runtime-services.test.mjs +++ b/mindspace-local-runtime-services.test.mjs @@ -32,6 +32,37 @@ test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service assert.ok(services.assetService); assert.ok(services.pageService); assert.ok(services.pageSyncService); + assert.ok(services.chatSaveService); + assert.ok(services.conversationArtifactService); + assert.ok(services.publicFinishService); + assert.equal( + typeof services.publicFinishService + .prepareWechatHtmlDelivery, + 'function', + ); + assert.equal( + typeof services.publicFinishService + .ensureWechatFreshPageThumbnails, + 'function', + ); + assert.ok( + services + .workspacePublicationDeliveryService, + ); + assert.ok( + services.workspacePageDeliveryService, + ); + assert.ok(services.workspaceToolService); + assert.equal( + typeof services.workspaceToolService + .writeBinaryFile, + 'function', + ); + assert.equal( + typeof services.workspaceToolService + .generateLongImage, + 'function', + ); assert.ok(services.publicationService); assert.ok(services.cleanupService); assert.ok(services.agentJobService); diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs index 2d2b96b..484eab0 100644 --- a/mindspace-local-server-adapter.mjs +++ b/mindspace-local-server-adapter.mjs @@ -37,7 +37,10 @@ export function createMindSpaceLocalServerAdapter({ conversationPackagePublicHtmlHydrator: createConversationPackagePublicHtmlHydrator({ h5Root, resolveSessionSnapshot, - registerPublicHtmlArtifactsForConversation, + registerPublicHtmlArtifactsForConversation: (payload) => + registerPublicHtmlArtifactsForConversation?.(payload) ?? + services?.conversationArtifactService + ?.registerPublicHtmlArtifacts(payload), }), logger, syncWorkspaceAssets: syncWorkspaceAssetsEnabled diff --git a/mindspace-local-server-adapter.test.mjs b/mindspace-local-server-adapter.test.mjs index 4598ca6..e041308 100644 --- a/mindspace-local-server-adapter.test.mjs +++ b/mindspace-local-server-adapter.test.mjs @@ -67,7 +67,7 @@ test('createMindSpaceLocalServerAdapter wires backfill and public html hydrator assert.ok(Array.isArray(hydrated)); assert.equal(hydrated.length, 1); assert.equal(registered.length, 1); - assert.equal(registered[0].publishDir, path.join(h5Root, 'MindSpace', 'user-1')); + assert.equal('publishDir' in registered[0], false); assert.equal(registered[0].title, 'Published from chat'); assert.equal(registered[0].artifactRefs[0].relativePath, 'public/landing/index.html'); }); diff --git a/mindspace-mcp-scoped-token.mjs b/mindspace-mcp-scoped-token.mjs new file mode 100644 index 0000000..319ac59 --- /dev/null +++ b/mindspace-mcp-scoped-token.mjs @@ -0,0 +1,247 @@ +import crypto from 'node:crypto'; + +const TOKEN_PREFIX = 'msm1'; +const TOKEN_AUDIENCE = 'mindspace-mcp'; +const DEFAULT_TTL_SECONDS = 15 * 60; +const MAX_TTL_SECONDS = 60 * 60; + +function scopedTokenError(message, code) { + return Object.assign(new Error(message), { code }); +} + +function requiredString(value, field) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + throw scopedTokenError( + `${field} is required`, + 'invalid_mcp_scope', + ); + } + return normalized; +} + +function normalizeTools(tools) { + const normalized = [ + ...new Set( + (Array.isArray(tools) ? tools : []) + .map((tool) => String(tool ?? '').trim()) + .filter(Boolean), + ), + ].sort(); + if (normalized.length === 0) { + throw scopedTokenError( + 'tools scope is required', + 'invalid_mcp_scope', + ); + } + return normalized; +} + +function normalizeSecret(secret) { + const normalized = String(secret ?? '').trim(); + if (normalized.length < 16) { + throw scopedTokenError( + 'MindSpace MCP token secret must contain at least 16 characters', + 'invalid_mcp_token_secret', + ); + } + return normalized; +} + +function encodeJson(value) { + return Buffer.from( + JSON.stringify(value), + 'utf8', + ).toString('base64url'); +} + +function decodeJson(value) { + try { + return JSON.parse( + Buffer.from( + String(value ?? ''), + 'base64url', + ).toString('utf8'), + ); + } catch { + throw scopedTokenError( + 'Malformed MindSpace MCP token payload', + 'invalid_mcp_token', + ); + } +} + +function signPayload(payloadSegment, secret) { + return crypto + .createHmac('sha256', normalizeSecret(secret)) + .update( + `${TOKEN_PREFIX}.${payloadSegment}`, + 'utf8', + ) + .digest('base64url'); +} + +function signaturesMatch(left, right) { + const leftBuffer = Buffer.from( + String(left ?? ''), + 'utf8', + ); + const rightBuffer = Buffer.from( + String(right ?? ''), + 'utf8', + ); + return ( + leftBuffer.length === rightBuffer.length && + crypto.timingSafeEqual( + leftBuffer, + rightBuffer, + ) + ); +} + +export function mintMindSpaceMcpScopedToken({ + secret, + userId, + sessionId, + packageId, + workspaceRef, + tools, + ttlSeconds = DEFAULT_TTL_SECONDS, + now = Date.now(), + tokenId = crypto.randomUUID(), +} = {}) { + const issuedAt = Math.floor(Number(now) / 1000); + const normalizedTtl = Math.min( + MAX_TTL_SECONDS, + Math.max(60, Number(ttlSeconds) || 0), + ); + const payload = { + v: 1, + aud: TOKEN_AUDIENCE, + sub: requiredString(userId, 'userId'), + sessionId: requiredString( + sessionId, + 'sessionId', + ), + packageId: requiredString( + packageId, + 'packageId', + ), + workspaceRef: requiredString( + workspaceRef, + 'workspaceRef', + ), + tools: normalizeTools(tools), + iat: issuedAt, + exp: issuedAt + normalizedTtl, + jti: requiredString(tokenId, 'tokenId'), + }; + const payloadSegment = encodeJson(payload); + const signature = signPayload( + payloadSegment, + secret, + ); + return `${TOKEN_PREFIX}.${payloadSegment}.${signature}`; +} + +export function verifyMindSpaceMcpScopedToken({ + token, + secret, + tool = null, + now = Date.now(), + clockSkewSeconds = 30, +} = {}) { + const segments = String(token ?? '').split('.'); + if ( + segments.length !== 3 || + segments[0] !== TOKEN_PREFIX + ) { + throw scopedTokenError( + 'Malformed MindSpace MCP token', + 'invalid_mcp_token', + ); + } + const expectedSignature = signPayload( + segments[1], + secret, + ); + if ( + !signaturesMatch( + segments[2], + expectedSignature, + ) + ) { + throw scopedTokenError( + 'Invalid MindSpace MCP token signature', + 'invalid_mcp_token', + ); + } + const payload = decodeJson(segments[1]); + const currentSeconds = + Math.floor(Number(now) / 1000); + const skew = Math.max( + 0, + Number(clockSkewSeconds) || 0, + ); + if ( + payload?.v !== 1 || + payload?.aud !== TOKEN_AUDIENCE + ) { + throw scopedTokenError( + 'Invalid MindSpace MCP token audience', + 'invalid_mcp_token', + ); + } + if ( + !Number.isFinite(Number(payload.iat)) || + !Number.isFinite(Number(payload.exp)) || + Number(payload.iat) > currentSeconds + skew || + Number(payload.exp) < currentSeconds - skew + ) { + throw scopedTokenError( + 'Expired or not-yet-valid MindSpace MCP token', + 'expired_mcp_token', + ); + } + const claims = { + userId: requiredString(payload.sub, 'userId'), + sessionId: requiredString( + payload.sessionId, + 'sessionId', + ), + packageId: requiredString( + payload.packageId, + 'packageId', + ), + workspaceRef: requiredString( + payload.workspaceRef, + 'workspaceRef', + ), + tools: normalizeTools(payload.tools), + issuedAt: Number(payload.iat) * 1000, + expiresAt: Number(payload.exp) * 1000, + tokenId: requiredString( + payload.jti, + 'tokenId', + ), + }; + const normalizedTool = String(tool ?? '').trim(); + if ( + normalizedTool && + !claims.tools.includes(normalizedTool) + ) { + throw scopedTokenError( + `MindSpace MCP tool is not allowed: ${normalizedTool}`, + 'mcp_tool_forbidden', + ); + } + return claims; +} + +export const mindSpaceMcpScopedTokenInternals = { + DEFAULT_TTL_SECONDS, + MAX_TTL_SECONDS, + TOKEN_AUDIENCE, + TOKEN_PREFIX, + normalizeTools, +}; diff --git a/mindspace-mcp-scoped-token.test.mjs b/mindspace-mcp-scoped-token.test.mjs new file mode 100644 index 0000000..cc9aeba --- /dev/null +++ b/mindspace-mcp-scoped-token.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + mintMindSpaceMcpScopedToken, + verifyMindSpaceMcpScopedToken, +} from './mindspace-mcp-scoped-token.mjs'; + +const secret = 'mindspace-test-secret-1234'; + +test('MindSpace MCP token binds user, session, package, workspace, and tools', () => { + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['write_file', 'read_file'], + now: 1_000_000, + tokenId: 'token-1', + }); + + const claims = verifyMindSpaceMcpScopedToken({ + token, + secret, + tool: 'write_file', + now: 1_001_000, + }); + + assert.deepEqual(claims, { + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['read_file', 'write_file'], + issuedAt: 1_000_000, + expiresAt: 1_900_000, + tokenId: 'token-1', + }); +}); + +test('MindSpace MCP token rejects tampering, expiry, and tools outside the allowlist', () => { + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['read_file'], + ttlSeconds: 60, + now: 1_000_000, + tokenId: 'token-1', + }); + + assert.throws( + () => + verifyMindSpaceMcpScopedToken({ + token: `${token}x`, + secret, + now: 1_001_000, + }), + /signature/, + ); + assert.throws( + () => + verifyMindSpaceMcpScopedToken({ + token, + secret, + tool: 'write_file', + now: 1_001_000, + }), + (error) => + error?.code === 'mcp_tool_forbidden', + ); + assert.throws( + () => + verifyMindSpaceMcpScopedToken({ + token, + secret, + now: 2_000_000, + clockSkewSeconds: 0, + }), + (error) => + error?.code === 'expired_mcp_token', + ); +}); + +test('MindSpace MCP token refuses incomplete scopes and weak secrets', () => { + assert.throws( + () => + mintMindSpaceMcpScopedToken({ + secret: 'short', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['read_file'], + }), + (error) => + error?.code === + 'invalid_mcp_token_secret', + ); + assert.throws( + () => + mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: [], + }), + (error) => + error?.code === 'invalid_mcp_scope', + ); +}); diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs index c27e575..aac6661 100644 --- a/mindspace-page-data-finish-guard.integration.test.mjs +++ b/mindspace-page-data-finish-guard.integration.test.mjs @@ -13,6 +13,7 @@ import { evaluatePageDataFinishGuard, maybeAutoBindPageDataHtmlPages, maybeRepairPageDataAfterFinish, + resolvePageDataCollectOutcomeAsync, } from './mindspace-page-data-finish-guard.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { writePageAccessPolicy } from './page-data-policy-store.mjs'; @@ -176,7 +177,28 @@ test('integration: wechat mp blocks localStorage survey delivery with page-data progressDelayMs: 0, }, userAuth: createWechatUserAuth(workspaceRoot), - pageDataFinishGuard: null, + pageDataFinishGuard: { + async prepareWechatPageDataDelivery({ + reply, + intent, + requestStartedAt, + }) { + const outcome = + await resolvePageDataCollectOutcomeAsync({ + reply, + intent, + publishDir: workspaceRoot, + requestStartedAt, + }); + return { + outcome, + autoBind: null, + deliveryArtifacts: [], + deliveryCheck: null, + rewrittenText: reply?.text ?? '', + }; + }, + }, sessionApiFetch: async (sessionId, pathname) => { if (pathname === `/sessions/${sessionId}/events`) { return new Response( diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index 5bf6dd5..ec74f76 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -649,6 +649,91 @@ export async function ensurePageDataDeliveryReady({ return { ok: failures.length === 0, failures }; } +export async function preparePageDataAfterFinish({ + userId, + publishDir, + messages, + pool, + h5Root, + storageRoot, + userText = '', +} = {}) { + const recentUserText = String( + userText ?? '', + ).trim(); + const pageService = pool + ? createPageService(pool, { + h5Root, + storageRoot, + }) + : null; + const findPageByRelativePath = + pageService?.findPageByRelativePath?.bind( + pageService, + ) ?? null; + const evaluation = + await evaluatePageDataFinishGuardAsync({ + publishDir, + agentText: + resolvePageDataGuardAgentText({ + agentText: recentUserText, + messages, + }), + messages, + pool, + userId, + findPageByRelativePath, + }); + + if ( + !evaluation.structuralPageData && + evaluation.relevantFiles.length === 0 + ) { + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation, + }; + } + + const autoBind = + await maybeAutoBindPageDataHtmlPages({ + pool, + userId, + publishDir, + h5Root, + storageRoot, + onlyRelativePaths: + evaluation.relevantFiles.length + ? evaluation.relevantFiles.map( + (file) => file.relativePath, + ) + : null, + findPageByRelativePath, + }); + + const afterBind = + await evaluatePageDataFinishGuardAsync({ + publishDir, + agentText: + resolvePageDataGuardAgentText({ + agentText: recentUserText, + messages, + }), + messages, + pool, + userId, + findPageByRelativePath, + }); + return { + autoBind, + evaluation: afterBind, + }; +} + export async function maybeRepairPageDataAfterFinish({ sessionId, userId, @@ -662,43 +747,38 @@ export async function maybeRepairPageDataAfterFinish({ env = process.env, logger = console, userText = '', + preparation: + suppliedPreparation = null, } = {}) { - const recentUserText = String(userText ?? '').trim(); - const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null; - const evaluation = await evaluatePageDataFinishGuardAsync({ - publishDir, - agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), - messages, - pool, - userId, - findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, - }); + const preparation = + suppliedPreparation ?? + (await preparePageDataAfterFinish({ + userId, + publishDir, + messages, + pool, + h5Root, + storageRoot, + userText, + })); + const evaluation = + preparation?.evaluation ?? { + structuralPageData: false, + relevantFiles: [], + }; if (!evaluation.structuralPageData && evaluation.relevantFiles.length === 0) { resetPageDataFinishGuardAttempts(sessionId); return { repaired: false, skipped: 'not_page_data', ...evaluation }; } - const autoBind = await maybeAutoBindPageDataHtmlPages({ - pool, - userId, - publishDir, - h5Root, - storageRoot, - onlyRelativePaths: evaluation.relevantFiles.length - ? evaluation.relevantFiles.map((file) => file.relativePath) - : null, - findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, - }); - - const afterBind = await evaluatePageDataFinishGuardAsync({ - publishDir, - agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), - messages, - pool, - userId, - findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, - }); + const autoBind = + preparation?.autoBind ?? { + bound: [], + skipped: [], + errors: [], + }; + const afterBind = evaluation; if (!afterBind.needsRepair) { resetPageDataFinishGuardAttempts(sessionId); diff --git a/mindspace-public-finish-service.mjs b/mindspace-public-finish-service.mjs new file mode 100644 index 0000000..e307f7d --- /dev/null +++ b/mindspace-public-finish-service.mjs @@ -0,0 +1,557 @@ +import { + collectOwnPublicHtmlArtifactRefs, + materializePublicHtmlWritesFromSessionEvent, + syncPublicHtmlAfterFinish, +} from './mindspace-public-finish-sync.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; +import { + evaluateH5HtmlFinishGuard, +} from './mindspace-h5-html-finish-guard.mjs'; +import { + buildPageDataDeliveryArtifactsFromBindResult, + ensurePageDataDeliveryReady, + maybeAutoBindPageDataHtmlPages, + preparePageDataAfterFinish, + resolvePageDataCollectOutcomeAsync, + rewritePageDataDeliveryLinks, +} from './mindspace-page-data-finish-guard.mjs'; +import { + createPageService, +} from './mindspace-pages.mjs'; +import { + ensureWechatFreshPageThumbnailsAtWorkspace, + prepareWechatHtmlDeliveryAtWorkspace, +} from './mindspace-wechat-html-delivery.mjs'; + +function eventMessages(event, recentCount) { + if (event?.type === 'Message' && event.message) { + return [event.message]; + } + if ( + event?.type === 'UpdateConversation' && + Array.isArray(event.conversation) + ) { + return event.conversation.slice( + -Math.max(1, recentCount), + ); + } + return []; +} + +function sanitizePageDataPreparation(value) { + if (!value) return null; + return JSON.parse( + JSON.stringify(value, (key, item) => { + if ( + [ + 'absolutePath', + 'content', + 'localPath', + ].includes(key) + ) { + return undefined; + } + if (item instanceof Map) { + return Object.fromEntries(item); + } + return item; + }), + ); +} + +export function createMindSpacePublicFinishService({ + pool, + h5Root, + storageRoot, + env = process.env, + syncWorkspaceAssets = null, + conversationArtifactService, + resolveMindSpaceUserPublishDirFn = + resolveMindSpaceUserPublishDir, + buildMindSpacePublicUrlForUserFn = + buildMindSpacePublicUrlForUser, + materializeSessionEventFn = + materializePublicHtmlWritesFromSessionEvent, + collectArtifactRefsFn = + collectOwnPublicHtmlArtifactRefs, + syncAfterFinishFn = syncPublicHtmlAfterFinish, + evaluateH5HtmlFinishGuardFn = + evaluateH5HtmlFinishGuard, + preparePageDataAfterFinishFn = + preparePageDataAfterFinish, + resolvePageDataCollectOutcomeAsyncFn = + resolvePageDataCollectOutcomeAsync, + maybeAutoBindPageDataHtmlPagesFn = + maybeAutoBindPageDataHtmlPages, + buildPageDataDeliveryArtifactsFromBindResultFn = + buildPageDataDeliveryArtifactsFromBindResult, + ensurePageDataDeliveryReadyFn = + ensurePageDataDeliveryReady, + rewritePageDataDeliveryLinksFn = + rewritePageDataDeliveryLinks, + createPageServiceFn = createPageService, + prepareWechatHtmlDeliveryAtWorkspaceFn = + prepareWechatHtmlDeliveryAtWorkspace, + ensureWechatFreshPageThumbnailsAtWorkspaceFn = + ensureWechatFreshPageThumbnailsAtWorkspace, +} = {}) { + if ( + !pool || + typeof pool.query !== 'function' || + !h5Root || + !storageRoot + ) { + throw new Error( + 'createMindSpacePublicFinishService requires pool, h5Root, and storageRoot', + ); + } + if ( + !conversationArtifactService || + typeof conversationArtifactService + .registerPublicHtmlArtifacts !== 'function' + ) { + throw new Error( + 'createMindSpacePublicFinishService requires conversationArtifactService', + ); + } + + const resolveContext = (userId) => { + const normalizedUserId = String(userId ?? '').trim(); + if (!normalizedUserId) { + throw Object.assign( + new Error('MindSpace public finish requires userId'), + { code: 'invalid_input' }, + ); + } + const currentUser = { id: normalizedUserId }; + return { + currentUser, + publishDir: resolveMindSpaceUserPublishDirFn( + h5Root, + currentUser, + ), + }; + }; + + const attachCanonicalUrls = (result, currentUser) => { + const artifactRefs = Array.isArray( + result?.publicHtmlArtifactRefs, + ) + ? result.publicHtmlArtifactRefs + : []; + return { + ...result, + publicHtmlArtifacts: artifactRefs.map((ref) => ({ + ...ref, + canonicalUrl: buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: currentUser, + relativePath: ref.relativePath, + }), + })), + }; + }; + + const buildCanonicalUrl = ( + currentUser, + relativePath, + ) => + buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: currentUser, + relativePath, + }); + + const syncAndRegisterWechatArtifacts = async ({ + currentUser, + sessionId, + relativePaths, + }) => { + const normalizedRelativePaths = [ + ...new Set( + relativePaths + .map((value) => + String(value ?? '').trim(), + ) + .filter(Boolean), + ), + ]; + if (normalizedRelativePaths.length === 0) { + return; + } + if ( + typeof syncWorkspaceAssets === 'function' + ) { + await syncWorkspaceAssets(currentUser.id, { + categoryCode: 'public', + sourceSessionId: + String(sessionId ?? '').trim() || + null, + onlyRelativePaths: + normalizedRelativePaths, + }); + } + if (String(sessionId ?? '').trim()) { + await conversationArtifactService + .registerPublicHtmlArtifacts({ + userId: currentUser.id, + sessionId, + relativePaths: + normalizedRelativePaths.filter( + (relativePath) => + relativePath + .toLowerCase() + .endsWith('.html'), + ), + }); + } + }; + + const preparePageDataForUser = async ({ + userId, + messages, + userText = '', + } = {}) => { + const { currentUser, publishDir } = + resolveContext(userId); + return sanitizePageDataPreparation( + await preparePageDataAfterFinishFn({ + userId: currentUser.id, + publishDir, + messages, + pool, + h5Root, + storageRoot, + userText, + }), + ); + }; + + const prepareWechatPageDataForUser = async ({ + userId, + reply = null, + intent = null, + publicBaseUrl = null, + requestStartedAt = 0, + } = {}) => { + const { currentUser, publishDir } = + resolveContext(userId); + const normalizedReply = { + text: String(reply?.text ?? ''), + messages: Array.isArray(reply?.messages) + ? reply.messages + : [], + }; + const normalizedIntent = { + agentText: String(intent?.agentText ?? ''), + displayText: String( + intent?.displayText ?? '', + ), + }; + let outcome = + await resolvePageDataCollectOutcomeAsyncFn({ + reply: normalizedReply, + intent: normalizedIntent, + publishDir, + requestStartedAt, + pool, + userId: currentUser.id, + findPageByRelativePath: null, + apiBase: publicBaseUrl, + }); + let autoBind = null; + if (outcome?.action === 'skip') { + return sanitizePageDataPreparation({ + outcome, + autoBind, + deliveryArtifacts: [], + deliveryCheck: null, + rewrittenText: normalizedReply.text, + }); + } + + const pageService = createPageServiceFn(pool, { + h5Root, + storageRoot, + }); + const findPageByRelativePath = + pageService.findPageByRelativePath.bind( + pageService, + ); + autoBind = + await maybeAutoBindPageDataHtmlPagesFn({ + pool, + userId: currentUser.id, + publishDir, + h5Root, + storageRoot, + findPageByRelativePath, + }); + outcome = + await resolvePageDataCollectOutcomeAsyncFn({ + reply: normalizedReply, + intent: normalizedIntent, + publishDir, + requestStartedAt, + pool, + userId: currentUser.id, + findPageByRelativePath, + apiBase: publicBaseUrl, + }); + + let deliveryArtifacts = []; + let deliveryCheck = null; + let rewrittenText = normalizedReply.text; + if (outcome?.action === 'send') { + deliveryArtifacts = + buildPageDataDeliveryArtifactsFromBindResultFn( + autoBind, + publishDir, + { publicBaseUrl }, + ); + if (deliveryArtifacts.length > 0) { + deliveryCheck = + await ensurePageDataDeliveryReadyFn({ + publishDir, + userId: currentUser.id, + pool, + h5Root, + storageRoot, + apiBase: publicBaseUrl, + artifacts: deliveryArtifacts, + }); + rewrittenText = + rewritePageDataDeliveryLinksFn( + normalizedReply.text, + deliveryArtifacts, + ); + } + } + return sanitizePageDataPreparation({ + outcome, + autoBind, + deliveryArtifacts, + deliveryCheck, + rewrittenText, + }); + }; + + const prepareWechatHtmlForUser = async ({ + userId, + sessionId = null, + reply = null, + intent = null, + requestStartedAt = 0, + allowRecentArtifacts = true, + } = {}) => { + const { currentUser, publishDir } = + resolveContext(userId); + const normalizedReply = { + text: String(reply?.text ?? ''), + messages: Array.isArray(reply?.messages) + ? reply.messages + : [], + }; + const normalizedIntent = { + agentText: String( + intent?.agentText ?? '', + ), + displayText: String( + intent?.displayText ?? '', + ), + }; + const prepared = + prepareWechatHtmlDeliveryAtWorkspaceFn({ + reply: normalizedReply, + intent: normalizedIntent, + publishDir, + requestStartedAt, + allowRecentArtifacts: + allowRecentArtifacts === true, + buildCanonicalUrl: (relativePath) => + buildCanonicalUrl( + currentUser, + relativePath, + ), + }); + + await syncAfterFinishFn({ + messages: normalizedReply.messages, + currentUser, + publishDir, + sessionId, + pool, + storageRoot, + h5Root, + syncWorkspaceAssets, + registerPublicHtmlArtifacts: + (_registeredUserId, options) => + conversationArtifactService + .registerPublicHtmlArtifacts({ + userId: currentUser.id, + sessionId: options?.sessionId, + relativePaths: + options?.relativePaths, + artifactRefs: + options?.artifactRefs, + }), + }); + const confirmedRelativePaths = + prepared.confirmedArtifacts.map( + (artifact) => artifact.relativePath, + ); + await syncAndRegisterWechatArtifacts({ + currentUser, + sessionId, + relativePaths: confirmedRelativePaths, + }); + return prepared; + }; + + const ensureWechatFreshPageThumbnailsForUser = + async ({ + userId, + sessionId = null, + artifacts = [], + images = [], + messages = [], + repairEnabled = false, + } = {}) => { + const { currentUser, publishDir } = + resolveContext(userId); + const result = + await ensureWechatFreshPageThumbnailsAtWorkspaceFn( + { + artifacts, + images, + messages: Array.isArray(messages) + ? messages + : [], + publishDir, + repairEnabled: + repairEnabled === true, + buildCanonicalUrl: (relativePath) => + buildCanonicalUrl( + currentUser, + relativePath, + ), + }, + ); + if (result.ok) { + await syncAndRegisterWechatArtifacts({ + currentUser, + sessionId, + relativePaths: [ + ...result.matchRelativePaths, + ...result.thumbnailRelativePaths, + ], + }); + } + return result; + }; + + return { + async materializeSessionEvent({ + userId, + event, + recentCount = 20, + } = {}) { + const { currentUser, publishDir } = + resolveContext(userId); + const result = materializeSessionEventFn(event, { + publishDir, + recentCount, + }); + const publicHtmlArtifactRefs = collectArtifactRefsFn({ + messages: eventMessages(event, recentCount), + currentUser, + publishDir, + materialized: result?.materialized, + skipped: result?.skipped, + }); + return attachCanonicalUrls( + { + ...result, + publicHtmlRelativePaths: + publicHtmlArtifactRefs.map( + (ref) => ref.relativePath, + ), + publicHtmlArtifactRefs, + }, + currentUser, + ); + }, + + async syncAfterFinish({ + userId, + sessionId, + messages, + currentUser: suppliedCurrentUser = null, + } = {}) { + const { currentUser, publishDir } = + resolveContext(userId); + const result = await syncAfterFinishFn({ + messages, + currentUser, + publishDir, + sessionId, + pool, + storageRoot, + h5Root, + syncWorkspaceAssets, + registerPublicHtmlArtifacts: + (_registeredUserId, options) => + conversationArtifactService + .registerPublicHtmlArtifacts({ + userId: currentUser.id, + sessionId: options?.sessionId, + relativePaths: options?.relativePaths, + artifactRefs: options?.artifactRefs, + }), + }); + const evaluated = + evaluateH5HtmlFinishGuardFn({ + messages, + currentUser: { + ...suppliedCurrentUser, + id: currentUser.id, + }, + publishDir, + syncResult: result, + }); + const { + linkExists: _linkExists, + ...deliveryEvaluation + } = evaluated ?? {}; + return attachCanonicalUrls( + { + ...result, + deliveryEvaluation, + }, + currentUser, + ); + }, + + preparePageDataAfterFinish: + preparePageDataForUser, + + prepareWechatPageDataDelivery: + prepareWechatPageDataForUser, + + prepareWechatHtmlDelivery: + prepareWechatHtmlForUser, + + ensureWechatFreshPageThumbnails: + ensureWechatFreshPageThumbnailsForUser, + }; +} + +export const mindSpacePublicFinishServiceInternals = { + eventMessages, + sanitizePageDataPreparation, +}; diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index 1dca9cc..d1aa670 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -458,6 +458,24 @@ function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) { return false; } +export function hasPublicHtmlWriteRequestInSessionEvent( + event, + { recentCount = 20 } = {}, +) { + const messages = + event?.type === 'Message' && event.message + ? [event.message] + : event?.type === 'UpdateConversation' && + Array.isArray(event.conversation) + ? event.conversation.slice( + -Math.max(1, recentCount), + ) + : []; + return messages.some((message) => + messageHasPublicHtmlToolRequest(message), + ); +} + export function hasRecentOwnPublicHtmlReference( messages, currentUser, diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs index c5a4906..a3985c5 100644 --- a/mindspace-public-finish-sync.test.mjs +++ b/mindspace-public-finish-sync.test.mjs @@ -6,6 +6,7 @@ import path from 'node:path'; import { hasRecentOwnPublicHtmlReference, + hasPublicHtmlWriteRequestInSessionEvent, isStubPublicHtmlContent, collectOwnPublicHtmlArtifactRefs, collectOwnPublicHtmlRelativePaths, @@ -14,6 +15,7 @@ import { syncPublicDocxDownloads, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; +import { createMindSpacePublicFinishService } from './mindspace-public-finish-service.mjs'; const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; @@ -77,6 +79,43 @@ test('detects a public html tool request even when the message has no text', () assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true); }); +test('stream event prefilter detects public HTML writes without filesystem access', () => { + assert.equal( + hasPublicHtmlWriteRequestInSessionEvent({ + type: 'Message', + message: { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'edit_file', + arguments: { + path: 'public/page.html', + old_str: 'before', + new_str: 'after', + }, + }, + }, + }, + ], + }, + }), + true, + ); + assert.equal( + hasPublicHtmlWriteRequestInSessionEvent({ + type: 'Message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + }, + }), + false, + ); +}); + test('detects public html references beyond the old eight message window', () => { const messages = [ { @@ -746,3 +785,615 @@ test('materializePublicHtmlWritesFromSessionEvent writes public html from messag fs.rmSync(publishDir, { recursive: true, force: true }); } }); + +function createPublicFinishService(overrides = {}) { + const calls = []; + const service = createMindSpacePublicFinishService({ + pool: { query: async () => [[]] }, + h5Root: '/srv/h5', + storageRoot: '/srv/storage', + env: { + H5_PUBLIC_BASE_URL: 'https://example.com', + }, + conversationArtifactService: { + async registerPublicHtmlArtifacts(input) { + calls.push({ kind: 'artifacts', input }); + return []; + }, + }, + resolveMindSpaceUserPublishDirFn() { + return '/srv/h5/MindSpace/user-1'; + }, + buildMindSpacePublicUrlForUserFn({ + user, + relativePath, + }) { + return `https://example.com/MindSpace/${user.id}/${relativePath}`; + }, + evaluateH5HtmlFinishGuardFn(input) { + calls.push({ + kind: 'delivery-evaluation', + input, + }); + return { + needsRepair: false, + missingHtml: [], + missingAssets: [], + browserStorageViolations: [], + htmlGenerationNeedsRetry: false, + linkExists() { + return true; + }, + }; + }, + async preparePageDataAfterFinishFn( + input, + ) { + calls.push({ + kind: 'page-data-preparation', + input, + }); + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [ + { + relativePath: + 'public/page.html', + absolutePath: + '/srv/h5/MindSpace/user-1/public/page.html', + content: '', + }, + ], + htmlIssues: [], + unboundFiles: [], + needsRepair: false, + }, + }; + }, + ...overrides, + }); + return { calls, service }; +} + +test('public finish service resolves stream writes and canonical URLs inside MindSpace', async () => { + const calls = []; + const setup = createPublicFinishService({ + materializeSessionEventFn(event, options) { + calls.push({ kind: 'materialize', event, options }); + return { + materialized: ['public/page.html'], + skipped: [], + }; + }, + collectArtifactRefsFn(input) { + calls.push({ kind: 'collect', input }); + return [ + { + relativePath: 'public/page.html', + messageId: 'message-1', + }, + ]; + }, + }); + const event = { + type: 'Message', + message: { + id: 'message-1', + role: 'assistant', + }, + }; + + const result = + await setup.service.materializeSessionEvent({ + userId: 'user-1', + event, + }); + + assert.equal( + calls[0].options.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.deepEqual(result.publicHtmlRelativePaths, [ + 'public/page.html', + ]); + assert.equal( + result.publicHtmlArtifacts[0].canonicalUrl, + 'https://example.com/MindSpace/user-1/public/page.html', + ); +}); + +test('public finish service owns WeChat HTML preparation, sync, and package registration', async () => { + const calls = []; + const setup = createPublicFinishService({ + prepareWechatHtmlDeliveryAtWorkspaceFn( + input, + ) { + calls.push({ + kind: 'prepare-html', + input, + }); + return { + materialization: { + materialized: [ + 'public/page.html', + ], + skipped: [], + }, + publishedArtifacts: [ + { + relativePath: + 'public/page.html', + url: + input.buildCanonicalUrl( + 'public/page.html', + ), + exists: true, + isStub: false, + }, + ], + expectedArtifacts: [], + recentArtifacts: [], + confirmedArtifacts: [ + { + relativePath: + 'public/page.html', + url: + input.buildCanonicalUrl( + 'public/page.html', + ), + exists: true, + isStub: false, + }, + ], + verifiedArtifacts: [], + validReplyUrls: [], + hasValidReplyLink: false, + }; + }, + async syncAfterFinishFn(input) { + calls.push({ + kind: 'finish-sync', + input, + }); + return { synced: true }; + }, + async syncWorkspaceAssets( + userId, + options, + ) { + calls.push({ + kind: 'asset-sync', + userId, + options, + }); + }, + conversationArtifactService: { + async registerPublicHtmlArtifacts( + input, + ) { + calls.push({ + kind: 'artifact-register', + input, + }); + return []; + }, + }, + }); + + const result = + await setup.service + .prepareWechatHtmlDelivery({ + userId: 'user-1', + sessionId: 'session-1', + reply: { + text: 'done', + messages: [ + { + role: 'assistant', + }, + ], + }, + intent: { + agentText: 'build page', + }, + }); + + assert.equal( + calls[0].input.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.equal( + result.confirmedArtifacts[0].url, + 'https://example.com/MindSpace/user-1/public/page.html', + ); + assert.equal( + Object.hasOwn( + result.confirmedArtifacts[0], + 'localPath', + ), + false, + ); + assert.ok( + calls.some( + (call) => + call.kind === 'asset-sync' && + call.options.onlyRelativePaths + .includes('public/page.html'), + ), + ); + assert.ok( + calls.some( + (call) => + call.kind === + 'artifact-register' && + call.input.sessionId === + 'session-1', + ), + ); +}); + +test('public finish service owns WeChat fresh thumbnail rendering and metadata sync', async () => { + const calls = []; + const setup = createPublicFinishService({ + async ensureWechatFreshPageThumbnailsAtWorkspaceFn( + input, + ) { + calls.push({ + kind: 'fresh-thumbnail', + input, + }); + return { + ok: true, + reason: null, + matchRelativePaths: [ + 'public/page.html', + ], + thumbnailRelativePaths: [ + 'public/page.thumbnail.svg', + ], + }; + }, + async syncWorkspaceAssets( + userId, + options, + ) { + calls.push({ + kind: 'asset-sync', + userId, + options, + }); + }, + conversationArtifactService: { + async registerPublicHtmlArtifacts( + input, + ) { + calls.push({ + kind: 'artifact-register', + input, + }); + return []; + }, + }, + }); + + const result = + await setup.service + .ensureWechatFreshPageThumbnails({ + userId: 'user-1', + sessionId: 'session-1', + artifacts: [ + { + relativePath: + 'public/page.html', + }, + ], + images: [ + { + jobId: 'job-1', + }, + ], + repairEnabled: true, + }); + + assert.equal(result.ok, true); + assert.deepEqual( + calls.find( + (call) => + call.kind === 'asset-sync', + ).options.onlyRelativePaths, + [ + 'public/page.html', + 'public/page.thumbnail.svg', + ], + ); + assert.deepEqual( + calls.find( + (call) => + call.kind === + 'artifact-register', + ).input.relativePaths, + ['public/page.html'], + ); +}); + +test('public finish service owns WeChat Page Data delivery preparation', async () => { + const calls = []; + let outcomeCount = 0; + const setup = createPublicFinishService({ + createPageServiceFn(pool, options) { + calls.push({ + kind: 'page-service', + pool, + options, + }); + return { + async findPageByRelativePath() { + return { id: 'page-1' }; + }, + }; + }, + async resolvePageDataCollectOutcomeAsyncFn( + input, + ) { + calls.push({ kind: 'outcome', input }); + outcomeCount += 1; + return outcomeCount === 1 + ? { action: 'retry' } + : { + action: 'send', + evaluation: { + relevantFiles: [ + { + relativePath: + 'public/page.html', + absolutePath: + '/srv/h5/MindSpace/user-1/public/page.html', + content: '', + }, + ], + }, + }; + }, + async maybeAutoBindPageDataHtmlPagesFn( + input, + ) { + calls.push({ kind: 'auto-bind', input }); + return { + bound: [ + { + relativePath: 'public/page.html', + workspaceUrl: + 'https://example.com/MindSpace/user-1/public/page.html', + }, + ], + skipped: [], + errors: [], + }; + }, + buildPageDataDeliveryArtifactsFromBindResultFn( + autoBind, + publishDir, + options, + ) { + calls.push({ + kind: 'artifacts', + autoBind, + publishDir, + options, + }); + return [ + { + relativePath: 'public/page.html', + localPath: + '/srv/h5/MindSpace/user-1/public/page.html', + url: 'https://example.com/MindSpace/user-1/public/page.html', + }, + ]; + }, + async ensurePageDataDeliveryReadyFn(input) { + calls.push({ + kind: 'delivery-check', + input, + }); + return { ok: true, failures: [] }; + }, + rewritePageDataDeliveryLinksFn( + text, + artifacts, + ) { + calls.push({ + kind: 'rewrite', + text, + artifacts, + }); + return '已发布:workspace-url'; + }, + }); + + const result = + await setup.service + .prepareWechatPageDataDelivery({ + userId: 'user-1', + reply: { + text: '已发布:legacy-url', + messages: [], + }, + intent: { + agentText: 'build survey', + }, + publicBaseUrl: + 'https://example.com', + requestStartedAt: 123, + }); + + assert.deepEqual( + calls + .map((call) => call.kind), + [ + 'outcome', + 'page-service', + 'auto-bind', + 'outcome', + 'artifacts', + 'delivery-check', + 'rewrite', + ], + ); + assert.equal( + calls[0].input.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.equal( + calls[2].input.storageRoot, + '/srv/storage', + ); + assert.equal(result.outcome.action, 'send'); + assert.equal(result.deliveryCheck.ok, true); + assert.equal( + result.rewrittenText, + '已发布:workspace-url', + ); + assert.equal( + Object.hasOwn( + result.deliveryArtifacts[0], + 'localPath', + ), + false, + ); + assert.equal( + Object.hasOwn( + result.outcome.evaluation + .relevantFiles[0], + 'absolutePath', + ), + false, + ); +}); + +test('public finish service keeps workspace sync and artifact registration inside MindSpace', async () => { + const calls = []; + const setup = createPublicFinishService({ + async syncWorkspaceAssets(userId, options) { + calls.push({ + kind: 'workspace-assets', + userId, + options, + }); + }, + async syncAfterFinishFn(input) { + calls.push({ kind: 'finish', input }); + await input.syncWorkspaceAssets('user-1', { + categoryCode: 'public', + }); + await input.registerPublicHtmlArtifacts( + 'user-1', + { + sessionId: 'session-1', + relativePaths: ['public/page.html'], + artifactRefs: [ + { + relativePath: 'public/page.html', + messageId: 'message-1', + }, + ], + }, + ); + return { + materialized: ['public/page.html'], + skipped: [], + publicHtmlRelativePaths: [ + 'public/page.html', + ], + publicHtmlArtifactRefs: [ + { + relativePath: 'public/page.html', + messageId: 'message-1', + }, + ], + }; + }, + }); + + const result = await setup.service.syncAfterFinish({ + userId: 'user-1', + sessionId: 'session-1', + messages: [], + }); + + assert.equal(calls[0].kind, 'finish'); + assert.equal( + calls[0].input.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.equal(calls[0].input.storageRoot, '/srv/storage'); + assert.equal(calls[1].kind, 'workspace-assets'); + assert.equal(setup.calls[0].kind, 'artifacts'); + assert.equal( + setup.calls[0].input.userId, + 'user-1', + ); + assert.equal( + result.publicHtmlArtifacts[0].canonicalUrl, + 'https://example.com/MindSpace/user-1/public/page.html', + ); + assert.equal( + result.deliveryEvaluation.needsRepair, + false, + ); + assert.equal( + Object.hasOwn( + result.deliveryEvaluation, + 'linkExists', + ), + false, + ); + const pageDataPreparation = + await setup.service.preparePageDataAfterFinish({ + userId: 'user-1', + messages: [], + userText: 'build page', + }); + assert.equal( + pageDataPreparation.evaluation + .relevantFiles[0].relativePath, + 'public/page.html', + ); + assert.equal( + Object.hasOwn( + pageDataPreparation.evaluation + .relevantFiles[0], + 'absolutePath', + ), + false, + ); + assert.equal( + Object.hasOwn( + pageDataPreparation.evaluation + .relevantFiles[0], + 'content', + ), + false, + ); + const preparationCall = setup.calls.find( + (call) => + call.kind === 'page-data-preparation', + ); + assert.equal( + preparationCall.input.publishDir, + '/srv/h5/MindSpace/user-1', + ); + assert.equal( + preparationCall.input.storageRoot, + '/srv/storage', + ); + assert.equal( + preparationCall.input.userText, + 'build page', + ); +}); diff --git a/mindspace-public-page-context.mjs b/mindspace-public-page-context.mjs index b08908b..c524a08 100644 --- a/mindspace-public-page-context.mjs +++ b/mindspace-public-page-context.mjs @@ -19,13 +19,19 @@ export function buildPublishedHtmlViewContext({ origin = '', requestPath = '', filePath = '', + servedName = '', + fallbackImageName = '', thumbnailPngPathForSvg = (value) => value, fileExists = fs.existsSync, } = {}) { const cleanPath = String(requestPath ?? '').split('?')[0].split('#')[0]; - const servedName = path.basename(String(filePath ?? '')); + const resolvedServedName = + String(servedName ?? '').trim() || + path.basename(String(filePath ?? '')); const urlLast = decodeURIComponent(cleanPath.split('/').filter(Boolean).pop() ?? ''); - const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); + const isImplicitIndex = + urlLast.toLowerCase() !== + resolvedServedName.toLowerCase(); const pageUrl = !origin ? '' : `${origin}${isImplicitIndex && !cleanPath.endsWith('/') ? `${cleanPath}/` : cleanPath}`; @@ -36,8 +42,12 @@ export function buildPublishedHtmlViewContext({ : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf('/') + 1)}`; let fallbackImageUrl = ''; + const logicalFallbackName = + String(fallbackImageName ?? '').trim(); const svgSibling = String(filePath ?? '').replace(/\.[^./]+$/, '.thumbnail.svg'); - if (pageDirUrl && fileExists(svgSibling)) { + if (pageDirUrl && logicalFallbackName) { + fallbackImageUrl = `${pageDirUrl}${encodeURIComponent(logicalFallbackName)}`; + } else if (pageDirUrl && fileExists(svgSibling)) { const pngName = path.basename(thumbnailPngPathForSvg(svgSibling)); fallbackImageUrl = `${pageDirUrl}${pngName}`; } diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 5dc2ae1..ad8be2a 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -1214,11 +1214,19 @@ export function createPublicationService(pool, options = {}) { ], ); const html = await readPublicationHtmlWithWorkspaceFallback(row); + const workspaceRelativePath = + resolvePageWorkspaceRelativePath(row); return { html: await refreshPublishedHtmlDownloadLinks(row, html), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }), ownerId: row.owner_id, - workspaceRelativePath: resolvePageWorkspaceRelativePath(row), + workspaceRelativePath, + workspacePublicUrl: + resolveWorkspaceMindSpacePublicUrl({ + h5Root, + userId: row.owner_id, + relativePath: workspaceRelativePath, + }), pageSource: { pageId: row.page_id, title: row.title, diff --git a/mindspace-remote-server-adapter.test.mjs b/mindspace-remote-server-adapter.test.mjs index 1aa6c51..1123854 100644 --- a/mindspace-remote-server-adapter.test.mjs +++ b/mindspace-remote-server-adapter.test.mjs @@ -101,6 +101,462 @@ test('createMindSpaceRemoteServerAdapter exposes conversation package read metho assert.equal(calls[1].init.headers.Authorization, 'Bearer secret-token'); }); +test('createMindSpaceRemoteServerAdapter exposes the chat save write authority', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + html: 'ready', + relativePath: 'public/report.html', + changed: true, + }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + const input = { + userId: 'user-1', + sourceContent: 'source', + html: 'draft', + relativePath: 'public/report.html', + }; + + const result = + await adapter.chatSaveService.materializeWorkspaceHtml(input); + + assert.equal(result.changed, true); + assert.match( + calls[0].url, + /chatSaveService\/materializeWorkspaceHtml$/, + ); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [input], + }); + assert.equal( + calls[0].init.headers.Authorization, + 'Bearer secret-token', + ); +}); + +test('createMindSpaceRemoteServerAdapter creates shared HTML through MindSpace', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + publicUrl: + 'https://example.com/MindSpace/user-1/public/shared/page.html', + filename: 'page.html', + }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + const input = { + userId: 'user-1', + html: 'page', + sourceFilename: 'page.html', + }; + + const result = + await adapter.chatSaveService.createSharedHtml(input); + + assert.equal(result.filename, 'page.html'); + assert.match( + calls[0].url, + /chatSaveService\/createSharedHtml$/, + ); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [input], + }); + + const workspaceInput = { + userId: 'user-1', + sessionId: 'session-1', + relativePath: 'oa/notes.txt', + body: 'generated notes', + }; + await adapter.conversationArtifactService + .registerWorkspaceFileArtifact( + workspaceInput, + ); + assert.match( + calls[1].url, + /conversationArtifactService\/registerWorkspaceFileArtifact$/, + ); + assert.deepEqual(JSON.parse(calls[1].init.body), { + args: [workspaceInput], + }); +}); + +test('createMindSpaceRemoteServerAdapter exposes conversation artifact write authority', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify([ + { artifactId: 'artifact-1' }, + ]); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + const input = { + userId: 'user-1', + sessionId: 'session-1', + artifactRefs: [ + { + relativePath: 'public/report.html', + messageId: 'message-1', + }, + ], + }; + + const result = + await adapter.conversationArtifactService + .registerPublicHtmlArtifacts(input); + + assert.deepEqual(result, [ + { artifactId: 'artifact-1' }, + ]); + assert.match( + calls[0].url, + /conversationArtifactService\/registerPublicHtmlArtifacts$/, + ); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [input], + }); +}); + +test('createMindSpaceRemoteServerAdapter exposes public Finish write authority', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + materialized: ['public/report.html'], + publicHtmlRelativePaths: [ + 'public/report.html', + ], + }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + const input = { + userId: 'user-1', + sessionId: 'session-1', + messages: [], + }; + + const result = + await adapter.publicFinishService + .syncAfterFinish(input); + + assert.deepEqual(result.publicHtmlRelativePaths, [ + 'public/report.html', + ]); + assert.match( + calls[0].url, + /publicFinishService\/syncAfterFinish$/, + ); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [input], + }); +}); + +test('createMindSpaceRemoteServerAdapter exposes WeChat Page Data delivery authority', async () => { + const calls = []; + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + outcome: { action: 'send' }, + deliveryArtifacts: [ + { + relativePath: + 'public/report.html', + url: 'https://example.com/MindSpace/user-1/public/report.html', + }, + ], + }); + }, + }; + }, + logger: { log() {}, warn() {}, error() {} }, + }); + const input = { + userId: 'user-1', + reply: { + text: 'published', + messages: [], + }, + intent: { + agentText: 'build a survey', + }, + publicBaseUrl: 'https://example.com', + }; + + const result = + await adapter.publicFinishService + .prepareWechatPageDataDelivery(input); + + assert.equal(result.outcome.action, 'send'); + assert.match( + calls[0].url, + /publicFinishService\/prepareWechatPageDataDelivery$/, + ); + assert.deepEqual(JSON.parse(calls[0].init.body), { + args: [input], + }); +}); + +test('createMindSpaceRemoteServerAdapter exposes WeChat HTML and thumbnail authority', async () => { + const calls = []; + const adapter = + createMindSpaceRemoteServerAdapter({ + endpoint: + 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + const thumbnail = + String(url).endsWith( + '/ensureWechatFreshPageThumbnails', + ); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify( + thumbnail + ? { ok: true } + : { + confirmedArtifacts: [ + { + relativePath: + 'public/page.html', + url: + 'https://example.com/MindSpace/user-1/public/page.html', + }, + ], + }, + ); + }, + }; + }, + logger: { + log() {}, + warn() {}, + error() {}, + }, + }); + const prepareInput = { + userId: 'user-1', + sessionId: 'session-1', + reply: { + text: 'done', + messages: [], + }, + }; + const thumbnailInput = { + userId: 'user-1', + sessionId: 'session-1', + artifacts: [ + { + relativePath: + 'public/page.html', + }, + ], + }; + + const prepared = + await adapter.publicFinishService + .prepareWechatHtmlDelivery( + prepareInput, + ); + const thumbnail = + await adapter.publicFinishService + .ensureWechatFreshPageThumbnails( + thumbnailInput, + ); + + assert.equal( + prepared.confirmedArtifacts[0] + .relativePath, + 'public/page.html', + ); + assert.equal(thumbnail.ok, true); + assert.match( + calls[0].url, + /publicFinishService\/prepareWechatHtmlDelivery$/, + ); + assert.match( + calls[1].url, + /publicFinishService\/ensureWechatFreshPageThumbnails$/, + ); + assert.deepEqual( + JSON.parse(calls[0].init.body), + { args: [prepareInput] }, + ); + assert.deepEqual( + JSON.parse(calls[1].init.body), + { args: [thumbnailInput] }, + ); +}); + +test('createMindSpaceRemoteServerAdapter exposes logical workspace delivery authority', async () => { + const calls = []; + const adapter = + createMindSpaceRemoteServerAdapter({ + endpoint: + 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + action: 'deliver', + kind: 'html', + ownerId: 'user-1', + relativePath: + 'public/report.html', + html: '', + }); + }, + }; + }, + logger: { + log() {}, + warn() {}, + error() {}, + }, + }); + const input = { + requestPath: + '/user-1/public/report.html', + }; + + const result = + await adapter + .workspacePublicationDeliveryService + .resolveWorkspaceRequest(input); + + assert.equal(result.kind, 'html'); + assert.match( + calls[0].url, + /workspacePublicationDeliveryService\/resolveWorkspaceRequest$/, + ); + assert.deepEqual( + JSON.parse(calls[0].init.body), + { args: [input] }, + ); +}); + +test('createMindSpaceRemoteServerAdapter exposes binary and long-image workspace tools', async () => { + const calls = []; + const adapter = + createMindSpaceRemoteServerAdapter({ + endpoint: + 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async (url, init) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + async text() { + return JSON.stringify({ + relativePath: + 'public/output.bin', + }); + }, + }; + }, + logger: { + log() {}, + warn() {}, + error() {}, + }, + }); + const binaryInput = { + workspaceRef: + 'mindspace://users/user-1/workspace', + sessionId: 'session-1', + packageId: 'cp_session-1', + path: 'public/report.docx', + bodyBase64: + Buffer.from('PK').toString('base64'), + }; + const imageInput = { + workspaceRef: + 'mindspace://users/user-1/workspace', + sessionId: 'session-1', + packageId: 'cp_session-1', + htmlPath: 'public/report.html', + }; + + await adapter.workspaceToolService + .writeBinaryFile(binaryInput); + await adapter.workspaceToolService + .generateLongImage(imageInput); + + assert.match( + calls[0].url, + /workspaceToolService\/writeBinaryFile$/, + ); + assert.match( + calls[1].url, + /workspaceToolService\/generateLongImage$/, + ); + assert.deepEqual( + JSON.parse(calls[0].init.body), + { args: [binaryInput] }, + ); + assert.deepEqual( + JSON.parse(calls[1].init.body), + { args: [imageInput] }, + ); +}); + test('createMindSpaceRemoteServerAdapter rejects unknown binding methods', () => { const adapter = createMindSpaceRemoteServerAdapter({ endpoint: 'https://mindspace.example.com', @@ -109,6 +565,10 @@ test('createMindSpaceRemoteServerAdapter rejects unknown binding methods', () => }); assert.throws(() => adapter.assetService.notARealMethod, /does not expose/); + assert.throws( + () => adapter.conversationPackageRegistry.recordArtifact, + /does not expose/, + ); }); test('createMindSpaceRemoteServerAdapter surfaces remote conversation package errors with binding context', async () => { diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index ccfd046..d5be57d 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -10,6 +10,7 @@ import path from 'node:path'; import fs from 'node:fs'; +import os from 'node:os'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; @@ -46,6 +47,36 @@ const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200); const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim(); const AGENT_API_BASE_URL = process.env.MINDSPACE_AGENT_API_BASE_URL?.trim(); const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET?.trim(); +const MINDSPACE_MCP_BASE_URL = + process.env.MINDSPACE_MCP_BASE_URL + ?.trim() + .replace(/\/+$/, ''); +const MINDSPACE_MCP_SCOPED_TOKEN = + process.env.MINDSPACE_MCP_SCOPED_TOKEN + ?.trim(); +const MINDSPACE_WORKSPACE_REF = + process.env.MINDSPACE_WORKSPACE_REF + ?.trim(); +const MINDSPACE_SESSION_ID = + process.env.MINDSPACE_SESSION_ID?.trim(); +const MINDSPACE_PACKAGE_ID = + process.env.MINDSPACE_PACKAGE_ID?.trim(); +const LOGICAL_WORKSPACE_TOOLS = new Set([ + 'create_dir', + 'edit_file', + 'generate_long_image', + 'list_dir', + 'publish_page', + 'read_file', + 'write_file', +]); +const LOGICAL_WORKSPACE_ENABLED = Boolean( + MINDSPACE_MCP_BASE_URL && + MINDSPACE_MCP_SCOPED_TOKEN && + MINDSPACE_WORKSPACE_REF && + MINDSPACE_SESSION_ID && + MINDSPACE_PACKAGE_ID, +); const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim(); const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null; @@ -99,16 +130,29 @@ function normalizeDocxSections(sections) { }); } -function runGenerateDocxScript({ outputPath, title, sections }) { +function runGenerateDocxScript({ + outputPath, + physicalOutputPath = null, + title, + sections, +}) { const script = resolveDocxGenerateScript(); const python = process.env.PYTHON_BIN?.trim() || 'python3'; + const targetOutputPath = + physicalOutputPath ?? outputPath; const payload = JSON.stringify({ title: String(title ?? ''), sections: normalizeDocxSections(sections), }); const stdout = execFileSync( python, - [script, '--json', '-', '--output', outputPath], + [ + script, + '--json', + '-', + '--output', + targetOutputPath, + ], { cwd: SANDBOX, input: payload, @@ -116,7 +160,9 @@ function runGenerateDocxScript({ outputPath, title, sections }) { maxBuffer: 10 * 1024 * 1024, }, ); - const abs = resolveSandboxed(outputPath); + const abs = physicalOutputPath + ? path.resolve(physicalOutputPath) + : resolveSandboxed(outputPath); if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { throw new Error(`generate_docx: 脚本执行后未找到 ${outputPath}`); } @@ -124,7 +170,11 @@ function runGenerateDocxScript({ outputPath, title, sections }) { if (size < 64) { throw new Error(`generate_docx: ${outputPath} 体积异常(${size} 字节)`); } - return { bytes: size, stdout: String(stdout ?? '').trim() }; + return { + body: fs.readFileSync(abs), + bytes: size, + stdout: String(stdout ?? '').trim(), + }; } async function generateMindSpaceImage({ purpose, prompt, negativePrompt, idempotencyKey }) { @@ -221,6 +271,22 @@ const ALL_TOOLS = [ required: ['path'], }, }, + { + name: 'publish_page', + description: + '通过 MindSpace Service 登记并交付已写入的 public/*.html 页面,返回 canonical workspace URL。仅在 scoped workspace RPC 启用时可用。', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: + '页面工作区相对路径,例如 public/report.html', + }, + }, + required: ['path'], + }, + }, { name: 'generate_long_image', description: @@ -551,10 +617,181 @@ function resolveH5RootFromSandbox() { return SANDBOX_MODULE_DIR; } +async function callLogicalWorkspaceTool( + name, + args, +) { + const endpoint = new URL( + `mindspace/v1/mcp/${encodeURIComponent( + name, + )}`, + `${MINDSPACE_MCP_BASE_URL}/`, + ); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: + `Bearer ${MINDSPACE_MCP_SCOPED_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + arguments: args ?? {}, + }), + signal: AbortSignal.timeout( + Number( + process.env + .MINDSPACE_MCP_REQUEST_TIMEOUT_MS ?? + (name === 'generate_long_image' + ? 90_000 + : 30_000), + ), + ), + }); + let payload = null; + try { + payload = await response.json(); + } catch { + // The bounded error below intentionally avoids echoing an upstream body. + } + if (!response.ok || !payload?.ok) { + const error = new Error( + `MindSpace workspace tool ${name} failed (${response.status}): ${ + String( + payload?.message ?? + payload?.code ?? + 'unknown_error', + ).slice(0, 240) + }`, + ); + error.code = + payload?.code ?? + 'mindspace_workspace_tool_failed'; + throw error; + } + const result = payload.result ?? {}; + switch (name) { + case 'read_file': + return [ + { + type: 'text', + text: String(result.content ?? ''), + }, + ]; + case 'list_dir': { + const lines = ( + Array.isArray(result.entries) + ? result.entries + : [] + ).map( + (entry) => + `${entry?.type === 'directory' ? '[目录]' : '[文件]'} ${entry?.name ?? ''}`, + ); + return [ + { + type: 'text', + text: lines.join('\n') || '(空目录)', + }, + ]; + } + case 'write_file': + return [ + { + type: 'text', + text: + `已通过 MindSpace 写入 ${result.relativePath ?? args?.path}` + + `(${Number(result.sizeBytes ?? 0)} 字节,package ${result.packageId ?? MINDSPACE_PACKAGE_ID})`, + }, + ]; + case 'edit_file': + return [ + { + type: 'text', + text: + `已通过 MindSpace 编辑 ${result.relativePath ?? args?.path}` + + `(package ${result.packageId ?? MINDSPACE_PACKAGE_ID})`, + }, + ]; + case 'generate_long_image': + return [ + { + type: 'text', + text: JSON.stringify( + { + relativePath: + result.relativePath, + canonicalUrl: + result.canonicalUrl, + packageId: + result.packageId, + sizeBytes: + result.sizeBytes, + }, + null, + 2, + ), + }, + ]; + case 'write_binary_file': + return [ + { + type: 'text', + text: + `已通过 MindSpace 写入二进制文件 ${result.relativePath ?? args?.path}` + + `(${Number(result.sizeBytes ?? 0)} 字节,package ${result.packageId ?? MINDSPACE_PACKAGE_ID})`, + }, + ]; + case 'create_dir': + return [ + { + type: 'text', + text: + `已通过 MindSpace 创建目录 ${result.relativePath ?? args?.path}`, + }, + ]; + case 'publish_page': + return [ + { + type: 'text', + text: JSON.stringify( + { + relativePath: + result.relativePath, + canonicalUrl: + result.canonicalUrl, + packageId: + result.packageId, + }, + null, + 2, + ), + }, + ]; + default: + throw new Error( + `Unsupported logical workspace tool: ${name}`, + ); + } +} + async function callTool(name, args) { if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) { throw new Error(`工具 ${name} 未授权`); } + if ( + LOGICAL_WORKSPACE_ENABLED && + LOGICAL_WORKSPACE_TOOLS.has(name) + ) { + return callLogicalWorkspaceTool( + name, + args, + ); + } + if (name === 'publish_page') { + throw new Error( + 'publish_page: MindSpace scoped workspace RPC 未配置', + ); + } switch (name) { case 'read_file': { const abs = resolveSandboxed(args.path); @@ -634,7 +871,48 @@ async function callTool(name, args) { throw new Error('generate_docx: title 不能为空'); } const sections = args.sections ?? args.payload?.sections; - const result = runGenerateDocxScript({ outputPath, title, sections }); + let result; + if (LOGICAL_WORKSPACE_ENABLED) { + const tempDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-docx-', + ), + ); + try { + result = runGenerateDocxScript({ + outputPath, + physicalOutputPath: + path.join( + tempDir, + 'generated.docx', + ), + title, + sections, + }); + await callLogicalWorkspaceTool( + 'write_binary_file', + { + path: outputPath, + bodyBase64: + result.body.toString( + 'base64', + ), + }, + ); + } finally { + fs.rmSync(tempDir, { + recursive: true, + force: true, + }); + } + } else { + result = runGenerateDocxScript({ + outputPath, + title, + sections, + }); + } return [ { type: 'text', diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs index ca32c02..37207b0 100644 --- a/mindspace-sandbox-mcp.test.mjs +++ b/mindspace-sandbox-mcp.test.mjs @@ -176,6 +176,422 @@ test('sandbox MCP rejects browser storage in generated page code', async (t) => assert.doesNotMatch(fs.readFileSync(path.join(root, 'public', 'ledger.html'), 'utf8'), /indexedDB/); }); +test('sandbox MCP delegates scoped workspace tools to MindSpace without local file writes', async (t) => { + const requests = []; + const api = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const parsedBody = JSON.parse(body); + requests.push({ + url: req.url, + authorization: + req.headers.authorization, + body: parsedBody, + }); + const tool = req.url.split('/').at(-1); + if ( + tool === 'generate_long_image' && + parsedBody.arguments + ?.output_path === + 'public/fail.long.png' + ) { + res.statusCode = 503; + res.setHeader( + 'content-type', + 'application/json', + ); + res.end( + JSON.stringify({ + ok: false, + code: + 'long_image_unavailable', + }), + ); + return; + } + const results = { + write_file: { + relativePath: 'public/page.html', + packageId: 'cp_session-1', + sizeBytes: 20, + }, + read_file: { + relativePath: 'public/page.html', + packageId: 'cp_session-1', + content: 'remote', + }, + publish_page: { + relativePath: 'public/page.html', + packageId: 'cp_session-1', + canonicalUrl: + 'https://example.com/MindSpace/user-1/public/page.html', + }, + generate_long_image: { + relativePath: + 'public/page.long.png', + packageId: 'cp_session-1', + sizeBytes: 4096, + canonicalUrl: + 'https://example.com/MindSpace/user-1/public/page.long.png', + }, + }; + res.setHeader( + 'content-type', + 'application/json', + ); + res.end( + JSON.stringify({ + ok: true, + result: results[tool] ?? {}, + }), + ); + }); + }); + api.listen(0, '127.0.0.1'); + await once(api, 'listening'); + const root = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-sandbox-rpc-', + ), + ); + const sandbox = startSandbox(root, { + ALLOWED_TOOLS: + 'read_file,write_file,publish_page,generate_long_image', + MINDSPACE_MCP_BASE_URL: + `http://127.0.0.1:${api.address().port}`, + MINDSPACE_MCP_SCOPED_TOKEN: + 'scoped-token', + MINDSPACE_WORKSPACE_REF: + 'mindspace://users/user-1/workspace', + MINDSPACE_SESSION_ID: 'session-1', + MINDSPACE_PACKAGE_ID: 'cp_session-1', + }); + t.after(() => sandbox.child.kill()); + t.after(() => api.close()); + + await sandbox.request('initialize'); + const written = await sandbox.request( + 'tools/call', + { + name: 'write_file', + arguments: { + path: 'public/page.html', + content: 'remote', + }, + }, + ); + const read = await sandbox.request( + 'tools/call', + { + name: 'read_file', + arguments: { + path: 'public/page.html', + }, + }, + ); + const published = await sandbox.request( + 'tools/call', + { + name: 'publish_page', + arguments: { + path: 'public/page.html', + }, + }, + ); + const longImage = await sandbox.request( + 'tools/call', + { + name: 'generate_long_image', + arguments: { + html_path: 'public/page.html', + output_path: + 'public/page.long.png', + }, + }, + ); + const failedLongImage = + await sandbox.request( + 'tools/call', + { + name: 'generate_long_image', + arguments: { + html_path: + 'public/page.html', + output_path: + 'public/fail.long.png', + }, + }, + ); + + assert.equal(written.result.isError, false); + assert.match( + written.result.content[0].text, + /package cp_session-1/, + ); + assert.equal( + read.result.content[0].text, + 'remote', + ); + assert.match( + published.result.content[0].text, + /canonicalUrl/, + ); + assert.match( + longImage.result.content[0].text, + /page\.long\.png/, + ); + assert.equal( + failedLongImage.result.isError, + true, + ); + assert.equal( + fs.existsSync( + path.join(root, 'public', 'page.html'), + ), + false, + ); + assert.equal( + fs.existsSync( + path.join( + root, + 'public', + 'page.long.png', + ), + ), + false, + ); + assert.equal( + fs.existsSync( + path.join( + root, + 'public', + 'fail.long.png', + ), + ), + false, + ); + assert.deepEqual( + requests.map((request) => request.url), + [ + '/mindspace/v1/mcp/write_file', + '/mindspace/v1/mcp/read_file', + '/mindspace/v1/mcp/publish_page', + '/mindspace/v1/mcp/generate_long_image', + '/mindspace/v1/mcp/generate_long_image', + ], + ); + assert.ok( + requests.every( + (request) => + request.authorization === + 'Bearer scoped-token', + ), + ); + assert.deepEqual(requests[0].body, { + arguments: { + path: 'public/page.html', + content: 'remote', + }, + }); +}); + +test('generate_docx uploads its binary through scoped MindSpace RPC without writing the target workspace path', async (t) => { + const requests = []; + const api = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + requests.push({ + url: req.url, + authorization: + req.headers.authorization, + body: JSON.parse(body), + }); + res.setHeader( + 'content-type', + 'application/json', + ); + res.end( + JSON.stringify({ + ok: true, + result: { + relativePath: + 'public/report.docx', + packageId: + 'cp_session-1', + sizeBytes: 1024, + }, + }), + ); + }); + }); + api.listen(0, '127.0.0.1'); + await once(api, 'listening'); + const root = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-sandbox-docx-rpc-', + ), + ); + copyDocxGenerateSkill(root); + const sandbox = startSandbox(root, { + ALLOWED_TOOLS: 'generate_docx', + MINDSPACE_MCP_BASE_URL: + `http://127.0.0.1:${api.address().port}`, + MINDSPACE_MCP_SCOPED_TOKEN: + 'scoped-token', + MINDSPACE_WORKSPACE_REF: + 'mindspace://users/user-1/workspace', + MINDSPACE_SESSION_ID: 'session-1', + MINDSPACE_PACKAGE_ID: 'cp_session-1', + }); + t.after(() => sandbox.child.kill()); + t.after(() => api.close()); + + await sandbox.request('initialize'); + const generated = await sandbox.request( + 'tools/call', + { + name: 'generate_docx', + arguments: { + output_path: + 'public/report.docx', + title: 'Scoped report', + sections: + summarySections('Scoped report'), + }, + }, + ); + + assert.equal( + generated.result.isError, + false, + generated.result.content?.[0]?.text, + ); + assert.equal( + fs.existsSync( + path.join( + root, + 'public', + 'report.docx', + ), + ), + false, + ); + assert.equal( + requests[0].url, + '/mindspace/v1/mcp/write_binary_file', + ); + assert.equal( + requests[0].authorization, + 'Bearer scoped-token', + ); + assert.equal( + requests[0].body.arguments.path, + 'public/report.docx', + ); + const uploaded = Buffer.from( + requests[0].body.arguments + .bodyBase64, + 'base64', + ); + assert.equal( + uploaded.subarray(0, 2).toString( + 'utf8', + ), + 'PK', + ); + assert.ok(uploaded.length > 500); +}); + +test('scoped DOCX generation fails closed when MindSpace rejects the binary write', async (t) => { + const api = http.createServer( + (req, res) => { + req.resume(); + req.on('end', () => { + res.statusCode = 503; + res.setHeader( + 'content-type', + 'application/json', + ); + res.end( + JSON.stringify({ + ok: false, + code: + 'mindspace_unavailable', + }), + ); + }); + }, + ); + api.listen(0, '127.0.0.1'); + await once(api, 'listening'); + const root = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-sandbox-docx-fail-', + ), + ); + copyDocxGenerateSkill(root); + const sandbox = startSandbox(root, { + ALLOWED_TOOLS: 'generate_docx', + MINDSPACE_MCP_BASE_URL: + `http://127.0.0.1:${api.address().port}`, + MINDSPACE_MCP_SCOPED_TOKEN: + 'scoped-token', + MINDSPACE_WORKSPACE_REF: + 'mindspace://users/user-1/workspace', + MINDSPACE_SESSION_ID: 'session-1', + MINDSPACE_PACKAGE_ID: 'cp_session-1', + }); + t.after(() => sandbox.child.kill()); + t.after(() => api.close()); + + await sandbox.request('initialize'); + const generated = await sandbox.request( + 'tools/call', + { + name: 'generate_docx', + arguments: { + output_path: + 'public/report.docx', + title: 'Rejected report', + sections: + summarySections( + 'Rejected report', + ), + }, + }, + ); + + assert.equal( + generated.result.isError, + true, + ); + assert.match( + generated.result.content[0].text, + /mindspace_unavailable/, + ); + assert.equal( + fs.existsSync( + path.join( + root, + 'public', + 'report.docx', + ), + ), + false, + ); +}); + test('sandbox MCP exposes and protects the user private data space', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-')); const server = startSandbox(root); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index 48e4f2e..69f2860 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -1,12 +1,49 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ serviceFacade: Object.freeze(['prepareConversationPackageRead']), + chatSaveService: Object.freeze([ + 'createSharedHtml', + 'materializeWorkspaceHtml', + 'readWorkspaceHtml', + 'ensurePreviewThumbnail', + 'renderPreviewThumbnailSvg', + ]), + conversationArtifactService: Object.freeze([ + 'registerChatDocxArtifact', + 'registerPublicHtmlArtifacts', + 'registerPublishedLongImageArtifact', + 'registerWorkspaceFileArtifact', + ]), + publicFinishService: Object.freeze([ + 'ensureWechatFreshPageThumbnails', + 'materializeSessionEvent', + 'preparePageDataAfterFinish', + 'prepareWechatHtmlDelivery', + 'prepareWechatPageDataDelivery', + 'syncAfterFinish', + ]), + workspacePublicationDeliveryService: + Object.freeze([ + 'listRecentlyModifiedPublicHtml', + 'readOwnerPublicAsset', + 'renderLongImage', + 'resolveWorkspaceRequest', + 'validateRunDeliverables', + ]), + workspacePageDeliveryService: + Object.freeze(['syncAndDeliver']), + workspaceToolService: Object.freeze([ + 'createDirectory', + 'editFile', + 'generateLongImage', + 'listDirectory', + 'publishPage', + 'readFile', + 'writeBinaryFile', + 'writeFile', + ]), conversationPackageRegistry: Object.freeze([ - 'ensurePackage', - 'putObjectForSession', - 'recordArtifact', 'readArtifactObject', 'readManifestForSession', - 'writeManifestForSession', ]), assetService: Object.freeze([ 'cancelUpload', @@ -16,8 +53,8 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'createUpload', 'deleteAsset', 'listAssets', - 'readAsset', - 'readPublicAsset', + 'readAssetContent', + 'readPublicAssetContent', 'renderAssetPreview', 'renderAssetThumbnail', 'syncWorkspaceAssets', diff --git a/mindspace-service/mindspace-rpc-server.mjs b/mindspace-service/mindspace-rpc-server.mjs index 5968bac..b501a18 100644 --- a/mindspace-service/mindspace-rpc-server.mjs +++ b/mindspace-service/mindspace-rpc-server.mjs @@ -3,6 +3,16 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MCP_TOOL_METHODS = Object.freeze({ + create_dir: 'createDirectory', + edit_file: 'editFile', + generate_long_image: 'generateLongImage', + list_dir: 'listDirectory', + publish_page: 'publishPage', + read_file: 'readFile', + write_binary_file: 'writeBinaryFile', + write_file: 'writeFile', +}); async function loadContract(env = process.env) { const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source'); @@ -11,19 +21,48 @@ async function loadContract(env = process.env) { ); } +async function loadMcpScopedTokenVerifier(env = process.env) { + const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source'); + return import( + pathToFileURL(path.join(memindRoot, 'mindspace-mcp-scoped-token.mjs')).href + ); +} + function parseBearerToken(headers) { const auth = String(headers.authorization ?? ''); if (!auth.startsWith('Bearer ')) return ''; return auth.slice('Bearer '.length).trim(); } -async function readJsonBody(req) { +async function readJsonBody( + req, + { maxBytes = 32 * 1024 * 1024 } = {}, +) { const chunks = []; + let receivedBytes = 0; for await (const chunk of req) { - chunks.push(chunk); + const buffer = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk); + receivedBytes += buffer.length; + if (receivedBytes > maxBytes) { + throw Object.assign( + new Error('MindSpace RPC request body is too large'), + { code: 'request_body_too_large' }, + ); + } + chunks.push(buffer); } const text = Buffer.concat(chunks).toString('utf8').trim(); - return text ? JSON.parse(text) : {}; + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + throw Object.assign( + new Error('MindSpace RPC request body must be valid JSON'), + { code: 'invalid_json_body' }, + ); + } } function reviveRpcValue(value) { @@ -67,17 +106,39 @@ function resolveRpcErrorStatus(error) { case 'publication_owner_not_found': case 'page_not_found': case 'category_not_found': + case 'static_page_not_found': return 404; case 'publication_login_required': return 401; case 'publication_password_required': + case 'mcp_tool_forbidden': + case 'mcp_scope_mismatch': return 403; + case 'invalid_mcp_token': + case 'expired_mcp_token': + return 401; case 'invalid_input': + case 'invalid_page_input': + case 'invalid_page_path': + case 'empty_page_content': case 'invalid_publish_input': case 'invalid_state_transition': case 'slug_conflict': case 'security_ack_required': + case 'invalid_workspace_tool_input': + case 'invalid_workspace_ref': + case 'invalid_workspace_path': + case 'invalid_workspace_package_scope': + case 'invalid_workspace_user_scope': + case 'invalid_workspace_page_path': + case 'invalid_workspace_binary': + case 'invalid_json_body': return 400; + case 'workspace_entry_not_found': + return 404; + case 'workspace_file_too_large': + case 'request_body_too_large': + return 413; default: return 500; } @@ -99,6 +160,35 @@ export async function createMindSpaceRpcRequestHandler({ .trim() .replace(/\/+$/, '') || '/mindspace/v1/adapter'; const authToken = String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(); + const mcpOperationBasePath = String( + serviceMeta.mcpOperationBasePath ?? + env.MINDSPACE_MCP_OPERATION_BASE_PATH ?? + '/mindspace/v1/mcp', + ) + .trim() + .replace(/\/+$/, '') || + '/mindspace/v1/mcp'; + const mcpTokenSecret = String( + env.MINDSPACE_MCP_TOKEN_SECRET ?? '', + ).trim(); + if ( + mcpTokenSecret && + mcpTokenSecret.length < 16 + ) { + throw new Error( + 'MINDSPACE_MCP_TOKEN_SECRET must contain at least 16 characters', + ); + } + const mcpTokenVerifier = mcpTokenSecret + ? await loadMcpScopedTokenVerifier(env) + : null; + const mcpMaxBodyBytes = Math.max( + 1024, + Number( + env.MINDSPACE_MCP_MAX_BODY_BYTES ?? + 12 * 1024 * 1024, + ) || 12 * 1024 * 1024, + ); return async function handleMindSpaceRpc(req, res) { try { @@ -110,6 +200,9 @@ export async function createMindSpaceRpcRequestHandler({ adapterKind: adapter.kind, implementationStatus: adapter.implementationStatus, operationBasePath, + mcpOperationBasePath, + mcpScopedToolsEnabled: + Boolean(mcpTokenSecret), backgroundJobsManagedLocally: adapter.kind === 'local', }); } @@ -119,6 +212,101 @@ export async function createMindSpaceRpcRequestHandler({ bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, }); } + if ( + req.method === 'POST' && + url.pathname.startsWith( + `${mcpOperationBasePath}/`, + ) + ) { + if (!mcpTokenSecret) { + return json(res, 503, { + message: + 'MindSpace MCP scoped tokens are not configured', + }); + } + const segments = url.pathname + .slice( + mcpOperationBasePath.length + 1, + ) + .split('/') + .filter(Boolean); + if (segments.length !== 1) { + return json(res, 400, { + message: + 'Expected /mindspace/v1/mcp/:tool', + }); + } + const tool = decodeURIComponent( + segments[0], + ); + const method = MCP_TOOL_METHODS[tool]; + if ( + !method || + typeof adapter?.workspaceToolService?.[ + method + ] !== 'function' + ) { + return json(res, 404, { + message: `Unknown MCP tool: ${tool}`, + }); + } + const claims = + mcpTokenVerifier.verifyMindSpaceMcpScopedToken({ + token: parseBearerToken( + req.headers, + ), + secret: mcpTokenSecret, + tool, + }); + const body = await readJsonBody(req, { + maxBytes: mcpMaxBodyBytes, + }); + const suppliedInput = + body?.arguments && + typeof body.arguments === 'object' + ? reviveRpcValue(body.arguments) + : {}; + for (const [ + field, + expected, + ] of Object.entries({ + userId: claims.userId, + sessionId: claims.sessionId, + packageId: claims.packageId, + workspaceRef: + claims.workspaceRef, + })) { + const supplied = String( + suppliedInput?.[field] ?? '', + ).trim(); + if ( + supplied && + supplied !== expected + ) { + throw Object.assign( + new Error( + `${field} does not match the scoped MCP token`, + ), + { code: 'mcp_scope_mismatch' }, + ); + } + } + const result = + await adapter.workspaceToolService[ + method + ]({ + ...suppliedInput, + userId: claims.userId, + sessionId: claims.sessionId, + packageId: claims.packageId, + workspaceRef: + claims.workspaceRef, + }); + return json(res, 200, { + ok: true, + result, + }); + } if (req.method !== 'POST' || !url.pathname.startsWith(`${operationBasePath}/`)) { return json(res, 404, { message: 'Not found' }); } diff --git a/mindspace-service/mindspace-rpc-server.test.mjs b/mindspace-service/mindspace-rpc-server.test.mjs index c453298..a43e16b 100644 --- a/mindspace-service/mindspace-rpc-server.test.mjs +++ b/mindspace-service/mindspace-rpc-server.test.mjs @@ -1,10 +1,18 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; +import { + mintMindSpaceMcpScopedToken, +} from '../mindspace-mcp-scoped-token.mjs'; import { createMindSpaceRpcRequestHandler } from './mindspace-rpc-server.mjs'; function createStubAdapter() { const calls = { writeUploadContent: [], + registerChatDocxArtifact: [], + workspaceTools: [], }; return { calls, @@ -15,6 +23,175 @@ function createStubAdapter() { return []; }, }, + chatSaveService: { + async createSharedHtml() { + return { + publicUrl: 'https://example.com/shared.html', + filename: 'shared.html', + }; + }, + async materializeWorkspaceHtml() { + return { changed: false }; + }, + async readWorkspaceHtml() { + return { + html: '', + relativePath: 'public/page.html', + }; + }, + async ensurePreviewThumbnail() { + return { ready: true }; + }, + async renderPreviewThumbnailSvg() { + return { svg: '', contentType: 'image/svg+xml; charset=utf-8' }; + }, + }, + conversationArtifactService: { + async registerChatDocxArtifact(...args) { + calls.registerChatDocxArtifact.push(args); + return { id: 'docx-1' }; + }, + async registerPublicHtmlArtifacts() { + return [{ artifactId: 'html-1' }]; + }, + async registerPublishedLongImageArtifact() { + return { id: 'image-1' }; + }, + async registerWorkspaceFileArtifact() { + return { id: 'file-1' }; + }, + }, + publicFinishService: { + async materializeSessionEvent() { + return { materialized: [], skipped: [] }; + }, + async syncAfterFinish() { + return { + materialized: [], + skipped: [], + publicHtmlRelativePaths: [], + }; + }, + async preparePageDataAfterFinish() { + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [], + }, + }; + }, + async prepareWechatPageDataDelivery() { + return { + outcome: { action: 'skip' }, + deliveryArtifacts: [], + }; + }, + async prepareWechatHtmlDelivery() { + return { + confirmedArtifacts: [], + }; + }, + async ensureWechatFreshPageThumbnails() { + return { ok: true }; + }, + }, + workspacePublicationDeliveryService: { + async listRecentlyModifiedPublicHtml() { + return { relativePaths: [] }; + }, + async readOwnerPublicAsset() { + return { action: 'not_found' }; + }, + async renderLongImage() { + return { + bodyBase64: '', + servedName: 'page.long.png', + }; + }, + async resolveWorkspaceRequest() { + return { action: 'not_found' }; + }, + async validateRunDeliverables() { + return { errors: [] }; + }, + }, + workspacePageDeliveryService: { + async syncAndDeliver() { + return { sync: {} }; + }, + }, + workspaceToolService: { + async createDirectory() { + calls.workspaceTools.push([ + 'createDirectory', + ...arguments, + ]); + return { relativePath: 'public/assets' }; + }, + async editFile() { + calls.workspaceTools.push([ + 'editFile', + ...arguments, + ]); + return { relativePath: 'public/page.html' }; + }, + async generateLongImage() { + calls.workspaceTools.push([ + 'generateLongImage', + ...arguments, + ]); + return { + relativePath: + 'public/page.long.png', + }; + }, + async listDirectory() { + calls.workspaceTools.push([ + 'listDirectory', + ...arguments, + ]); + return { entries: [] }; + }, + async publishPage() { + calls.workspaceTools.push([ + 'publishPage', + ...arguments, + ]); + return { + canonicalUrl: + 'https://example.com/MindSpace/user-1/public/page.html', + }; + }, + async readFile() { + calls.workspaceTools.push([ + 'readFile', + ...arguments, + ]); + return { content: 'hello' }; + }, + async writeFile() { + calls.workspaceTools.push([ + 'writeFile', + ...arguments, + ]); + return { relativePath: 'public/page.html' }; + }, + async writeBinaryFile() { + calls.workspaceTools.push([ + 'writeBinaryFile', + ...arguments, + ]); + return { + relativePath: + 'public/report.docx', + }; + }, + }, conversationPackageRegistry: { async ensurePackage() { return { ok: true }; }, async putObjectForSession() { return { ok: true }; }, @@ -31,8 +208,12 @@ function createStubAdapter() { async createUpload() { return { ok: true }; }, async deleteAsset() { return { ok: true }; }, async listAssets() { return []; }, - async readAsset() { return { ok: true }; }, - async readPublicAsset() { return { ok: true }; }, + async readAssetContent() { + return { ok: true, bodyBase64: '' }; + }, + async readPublicAssetContent() { + return { ok: true, bodyBase64: '' }; + }, async renderAssetPreview() { return { preview: true }; }, async renderAssetThumbnail() { return { thumbnail: true }; }, async syncWorkspaceAssets() { return { synced: true }; }, @@ -148,6 +329,107 @@ test('health and contract endpoints are exposed without auth', async () => { assert.equal(health.body.ok, true); assert.equal(contract.statusCode, 200); assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview')); + assert.ok( + contract.body.bindings.chatSaveService.includes( + 'materializeWorkspaceHtml', + ), + ); + assert.ok( + contract.body.bindings.conversationArtifactService + .includes('registerPublicHtmlArtifacts'), + ); + assert.ok( + contract.body.bindings.publicFinishService.includes( + 'syncAfterFinish', + ), + ); + assert.ok( + contract.body.bindings.publicFinishService.includes( + 'preparePageDataAfterFinish', + ), + ); + assert.ok( + contract.body.bindings.publicFinishService.includes( + 'prepareWechatPageDataDelivery', + ), + ); + assert.ok( + contract.body.bindings.publicFinishService.includes( + 'prepareWechatHtmlDelivery', + ), + ); + assert.ok( + contract.body.bindings.publicFinishService.includes( + 'ensureWechatFreshPageThumbnails', + ), + ); + assert.ok( + contract.body.bindings + .workspacePublicationDeliveryService + .includes('resolveWorkspaceRequest'), + ); + assert.ok( + contract.body.bindings + .workspaceToolService + .includes('writeBinaryFile'), + ); + assert.ok( + contract.body.bindings + .workspaceToolService + .includes('generateLongImage'), + ); +}); + +test('RPC startup rejects an undersized MCP signing secret', async () => { + await assert.rejects( + createMindSpaceRpcRequestHandler({ + adapter: createStubAdapter(), + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_MCP_TOKEN_SECRET: + 'too-short', + }, + }), + /at least 16 characters/, + ); +}); + +test('RPC startup does not require MCP token verifier when scoped MCP is disabled', async (t) => { + const memindRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'mindspace-rpc-no-mcp-'), + ); + t.after(() => { + fs.rmSync(memindRoot, { + recursive: true, + force: true, + }); + }); + fs.copyFileSync( + path.resolve( + 'mindspace-server-adapter-contract.mjs', + ), + path.join( + memindRoot, + 'mindspace-server-adapter-contract.mjs', + ), + ); + const handler = + await createMindSpaceRpcRequestHandler({ + adapter: createStubAdapter(), + env: { + MINDSPACE_MEMIND_ROOT: memindRoot, + }, + }); + const health = await runRequest(handler, { + method: 'GET', + path: '/health', + }); + + assert.equal(health.statusCode, 200); + assert.equal( + health.body.mcpScopedToolsEnabled, + false, + ); }); test('rpc invocation enforces auth token and dispatches args to adapter method', async () => { @@ -199,6 +481,39 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async () assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true); }); +test('rpc revives generated artifact bodies before dispatching to MindSpace', async () => { + const adapter = createStubAdapter(); + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + const payload = { + userId: 'user-1', + sessionId: 'session-1', + messageId: 'message-1', + filename: 'chat.docx', + body: Buffer.from('docx payload'), + }; + + const response = await runRequest(handler, { + method: 'POST', + path: + '/mindspace/v1/adapter/conversationArtifactService/registerChatDocxArtifact', + body: JSON.stringify({ args: [payload] }), + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { id: 'docx-1' }); + assert.equal( + Buffer.isBuffer( + adapter.calls.registerChatDocxArtifact[0][0].body, + ), + true, + ); +}); + test('rpc maps publication_not_found to 404 with error code', async () => { const adapter = createStubAdapter(); adapter.publicationService.resolvePublic = async () => { @@ -247,3 +562,315 @@ test('rpc maps publication_login_required to 401 with error code', async () => { assert.equal(response.statusCode, 401); assert.equal(response.body.code, 'publication_login_required'); }); + +test('rpc maps chat save validation errors to 400 with error code', async () => { + const adapter = createStubAdapter(); + adapter.chatSaveService.materializeWorkspaceHtml = + async () => { + const error = new Error('无效的页面路径'); + error.code = 'invalid_page_path'; + throw error; + }; + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + + const response = await runRequest(handler, { + method: 'POST', + path: + '/mindspace/v1/adapter/chatSaveService/materializeWorkspaceHtml', + body: JSON.stringify({ + args: [{ + userId: 'user-1', + html: 'unsafe', + relativePath: '../unsafe.html', + }], + }), + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.body.code, 'invalid_page_path'); +}); + +test('scoped MCP RPC injects token claims and dispatches only allowed workspace tools', async () => { + const adapter = createStubAdapter(); + const secret = + 'mindspace-mcp-test-secret-1234'; + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['write_file'], + }); + const handler = + await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_MCP_TOKEN_SECRET: + secret, + }, + }); + + const response = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/mcp/write_file', + headers: { + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + arguments: { + path: 'public/page.html', + content: '', + }, + }), + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.body.ok, true); + assert.deepEqual( + adapter.calls.workspaceTools[0], + [ + 'writeFile', + { + path: 'public/page.html', + content: '', + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + }, + ], + ); +}); + +test('scoped MCP RPC dispatches binary writes and long-image generation to MindSpace', async () => { + const adapter = createStubAdapter(); + const secret = + 'mindspace-mcp-test-secret-1234'; + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: [ + 'generate_long_image', + 'write_binary_file', + ], + }); + const handler = + await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_MCP_TOKEN_SECRET: + secret, + }, + }); + const headers = { + authorization: `Bearer ${token}`, + }; + + const binary = await runRequest(handler, { + method: 'POST', + path: + '/mindspace/v1/mcp/write_binary_file', + headers, + body: JSON.stringify({ + arguments: { + path: 'public/report.docx', + bodyBase64: + Buffer.from('PK').toString( + 'base64', + ), + }, + }), + }); + const longImage = await runRequest( + handler, + { + method: 'POST', + path: + '/mindspace/v1/mcp/generate_long_image', + headers, + body: JSON.stringify({ + arguments: { + html_path: + 'public/page.html', + output_path: + 'public/page.long.png', + }, + }), + }, + ); + + assert.equal(binary.statusCode, 200); + assert.equal(longImage.statusCode, 200); + assert.deepEqual( + adapter.calls.workspaceTools.map( + ([method]) => method, + ), + [ + 'writeBinaryFile', + 'generateLongImage', + ], + ); + assert.equal( + adapter.calls.workspaceTools[0][1] + .packageId, + 'cp_session-1', + ); +}); + +test('scoped MCP RPC rejects tools and scope overrides outside the token', async () => { + const adapter = createStubAdapter(); + const secret = + 'mindspace-mcp-test-secret-1234'; + const readToken = + mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['read_file'], + }); + const writeToken = + mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['write_file'], + }); + const handler = + await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_MCP_TOKEN_SECRET: + secret, + }, + }); + + const forbidden = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/mcp/write_file', + headers: { + authorization: `Bearer ${readToken}`, + }, + body: JSON.stringify({ + arguments: { + path: 'public/page.html', + content: '', + }, + }), + }); + const scopeMismatch = await runRequest( + handler, + { + method: 'POST', + path: + '/mindspace/v1/mcp/write_file', + headers: { + authorization: `Bearer ${writeToken}`, + }, + body: JSON.stringify({ + arguments: { + workspaceRef: + 'mindspace://users/user-2/workspace', + path: 'public/page.html', + content: '', + }, + }), + }, + ); + + assert.equal(forbidden.statusCode, 403); + assert.equal( + forbidden.body.code, + 'mcp_tool_forbidden', + ); + assert.equal(scopeMismatch.statusCode, 403); + assert.equal( + scopeMismatch.body.code, + 'mcp_scope_mismatch', + ); + assert.equal( + adapter.calls.workspaceTools.length, + 0, + ); +}); + +test('scoped MCP RPC bounds request bodies and reports invalid JSON as client errors', async () => { + const secret = + 'mindspace-mcp-test-secret-1234'; + const token = mintMindSpaceMcpScopedToken({ + secret, + userId: 'user-1', + sessionId: 'session-1', + packageId: 'cp_session-1', + workspaceRef: + 'mindspace://users/user-1/workspace', + tools: ['write_file'], + }); + const handler = + await createMindSpaceRpcRequestHandler({ + adapter: createStubAdapter(), + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_MCP_TOKEN_SECRET: + secret, + MINDSPACE_MCP_MAX_BODY_BYTES: + '1024', + }, + }); + const headers = { + authorization: `Bearer ${token}`, + }; + + const tooLarge = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/mcp/write_file', + headers, + body: JSON.stringify({ + arguments: { + path: 'oa/large.txt', + content: 'x'.repeat(1200), + }, + }), + }); + const invalidJson = await runRequest( + handler, + { + method: 'POST', + path: + '/mindspace/v1/mcp/write_file', + headers, + body: '{invalid', + }, + ); + + assert.equal(tooLarge.statusCode, 413); + assert.equal( + tooLarge.body.code, + 'request_body_too_large', + ); + assert.equal(invalidJson.statusCode, 400); + assert.equal( + invalidJson.body.code, + 'invalid_json_body', + ); +}); diff --git a/mindspace-service/mindspace-service-bootstrap.mjs b/mindspace-service/mindspace-service-bootstrap.mjs index a481812..6d10ac4 100644 --- a/mindspace-service/mindspace-service-bootstrap.mjs +++ b/mindspace-service/mindspace-service-bootstrap.mjs @@ -54,7 +54,6 @@ async function loadMemindModules(memindRoot) { config, conversationMemory, localServerAdapter, - publicHtmlHelpers, runtimeConfig, sessionSnapshot, userAuth, @@ -67,7 +66,6 @@ async function loadMemindModules(memindRoot) { importFromMemindRoot(memindRoot, 'mindspace-config.mjs'), importFromMemindRoot(memindRoot, 'conversation-memory.mjs'), importFromMemindRoot(memindRoot, 'mindspace-local-server-adapter.mjs'), - importFromMemindRoot(memindRoot, 'mindspace-conversation-package-public-html.mjs'), importFromMemindRoot(memindRoot, 'mindspace-runtime-config.mjs'), importFromMemindRoot(memindRoot, 'session-snapshot.mjs'), importFromMemindRoot(memindRoot, 'user-auth.mjs'), @@ -81,7 +79,6 @@ async function loadMemindModules(memindRoot) { ...config, ...conversationMemory, ...localServerAdapter, - ...publicHtmlHelpers, ...runtimeConfig, ...sessionSnapshot, ...userAuth, @@ -137,7 +134,6 @@ export async function bootstrapMindSpaceService({ createSessionSnapshotService, createUserAuth, createExperienceService, - registerPublicHtmlArtifactsForConversationPackage, resolveMindSpaceServerRuntimeOptions, startWorkspaceAssetSyncWatcher, startWorkspaceThumbnailWatcher, @@ -172,13 +168,6 @@ export async function bootstrapMindSpaceService({ resolveUserIdForAgentSession: (sessionId) => resolveUserIdForAgentSession(pool, sessionId), resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService.get(sessionId), - registerPublicHtmlArtifactsForConversation: (payload) => - registerPublicHtmlArtifactsForConversationPackage({ - conversationPackageRegistry: adapter.conversationPackageRegistry, - h5Root: standaloneDataRoot, - env, - ...payload, - }), logger, }); diff --git a/mindspace-wechat-html-delivery.mjs b/mindspace-wechat-html-delivery.mjs new file mode 100644 index 0000000..4b856c8 --- /dev/null +++ b/mindspace-wechat-html-delivery.mjs @@ -0,0 +1,598 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + extractPublicHtmlWriteArtifacts, + isStubPublicHtmlContent, + materializeMissingPublicHtmlWrites, + normalizePublicHtmlRelativePath, +} from './mindspace-public-finish-sync.mjs'; +import { + repairUnambiguousFreshWechatPageThumbnail, + verifyFreshWechatPageThumbnails, +} from './wechat/verify/generated-thumbnail.mjs'; +import { + ensureWorkspaceHtmlThumbnail, + workspaceThumbnailRelativePath, +} from './mindspace-workspace-thumbnails.mjs'; +import { + verifySharePreviewMeta, +} from './wechat/verify/share-preview.mjs'; + +const PUBLIC_HTML_LINK_PATTERN = + /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; +const AUXILIARY_PAGE_PATH_PATTERN = + /(?:^|[-_.\/])(?:admin|manage|management|backend|password|redirect|download|error)(?:[-_.\/]|$)/i; + +function replyMessages(reply) { + return Array.isArray(reply?.messages) + ? reply.messages + : []; +} + +function extractHtmlWriteTargets(messages = []) { + const targets = new Set(); + for (const message of messages) { + for (const item of message?.content ?? []) { + if (item?.type !== 'toolRequest') continue; + const toolCall = item.toolCall?.value; + const args = toolCall?.arguments ?? {}; + const name = String(toolCall?.name ?? '').trim(); + const action = String(args.action ?? '').trim().toLowerCase(); + const writeLike = + (name === 'developer' && + (action === 'write' || action === 'edit')) || + name === 'write' || + name.endsWith('__write') || + name === 'write_file' || + name === 'edit_file' || + name.endsWith('__write_file') || + name.endsWith('__edit_file'); + if (!writeLike || typeof args.path !== 'string') { + continue; + } + const candidate = String(args.path).trim(); + if (candidate.toLowerCase().endsWith('.html')) { + targets.add(candidate); + } + } + } + return [...targets]; +} + +function extractRequestedHtmlTarget(text) { + const value = String(text ?? ''); + if (!value) return ''; + const explicitPublicMatch = + value.match( + /(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i, + ); + if (explicitPublicMatch?.[1]) { + return explicitPublicMatch[1]; + } + const bareMatch = + value.match( + /(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i, + ); + return bareMatch?.[1] + ? `public/${bareMatch[1]}` + : ''; +} + +function collectLinkedHtmlFilenames(text) { + const filenames = new Set(); + for ( + const match of String(text ?? '') + .matchAll(PUBLIC_HTML_LINK_PATTERN) + ) { + const filename = + path.posix.basename( + String(match[2] ?? '').trim(), + ); + if (filename) filenames.add(filename); + } + return filenames; +} + +function readArtifactDescriptor( + target, + { + publishDir, + buildCanonicalUrl, + copyIntoPublic = true, + }, +) { + const workspaceRoot = + path.resolve(String(publishDir ?? '')); + if (!workspaceRoot) return null; + const candidate = String(target ?? '').trim(); + if (!candidate) return null; + const source = path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(workspaceRoot, candidate); + if ( + source !== workspaceRoot && + !source.startsWith( + `${workspaceRoot}${path.sep}`, + ) + ) { + return null; + } + try { + if ( + !fs.existsSync(source) || + !fs.statSync(source).isFile() + ) { + return null; + } + } catch { + return null; + } + + const publicRoot = + path.join(workspaceRoot, 'public'); + let publishedPath = source; + if ( + copyIntoPublic && + source !== publicRoot && + !source.startsWith(`${publicRoot}${path.sep}`) + ) { + fs.mkdirSync(publicRoot, { recursive: true }); + publishedPath = path.join( + publicRoot, + path.basename(source), + ); + if (publishedPath !== source) { + fs.copyFileSync(source, publishedPath); + } + } + + const relativePath = path + .relative(workspaceRoot, publishedPath) + .replace(/\\/g, '/'); + if ( + !relativePath || + relativePath.startsWith('..') || + path.isAbsolute(relativePath) + ) { + return null; + } + const normalizedRelativePath = + normalizePublicHtmlRelativePath(relativePath); + if ( + !normalizedRelativePath + .toLowerCase() + .endsWith('.html') + ) { + return null; + } + let content = ''; + let stat; + try { + content = fs.readFileSync( + publishedPath, + 'utf8', + ); + stat = fs.statSync(publishedPath); + } catch { + return null; + } + const canonicalUrl = + String( + buildCanonicalUrl?.( + normalizedRelativePath, + ) ?? '', + ).trim(); + const auxiliary = + AUXILIARY_PAGE_PATH_PATTERN.test( + normalizedRelativePath, + ) || + /]*name=["']mindspace-page-role["'][^>]*content=["'](?:admin|auxiliary)["']/i.test( + content, + ) || + /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test( + content, + ); + const sharePreview = + verifySharePreviewMeta(content); + return { + localPath: publishedPath, + relativePath: normalizedRelativePath, + url: canonicalUrl, + canonicalUrl, + exists: true, + isStub: isStubPublicHtmlContent(content), + isHtmlDocument: + /<(?:html|body|main|article)\b/i.test( + content, + ), + sharePreview, + auxiliary, + sizeBytes: Number(stat.size ?? 0), + mtimeMs: Number(stat.mtimeMs ?? 0), + }; +} + +function collectRecentArtifacts({ + publishDir, + buildCanonicalUrl, + replyText = '', + requestStartedAt = 0, + limit = 20, +}) { + const publicRoot = path.join( + path.resolve(publishDir), + 'public', + ); + try { + if ( + !fs.existsSync(publicRoot) || + !fs.statSync(publicRoot).isDirectory() + ) { + return []; + } + } catch { + return []; + } + const linkedFilenames = + collectLinkedHtmlFilenames(replyText); + const artifacts = []; + const stack = [publicRoot]; + while ( + stack.length > 0 && + artifacts.length < limit + ) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { + withFileTypes: true, + }); + } catch { + continue; + } + for (const entry of entries) { + const absolutePath = + path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + if ( + !entry.isFile() || + !entry.name.toLowerCase().endsWith('.html') + ) { + continue; + } + const artifact = readArtifactDescriptor( + absolutePath, + { + publishDir, + buildCanonicalUrl, + copyIntoPublic: false, + }, + ); + if (!artifact) continue; + if ( + requestStartedAt > 0 && + artifact.mtimeMs + 1 < requestStartedAt + ) { + continue; + } + const filename = path.posix.basename( + artifact.relativePath, + ); + if ( + linkedFilenames.size > 0 && + !linkedFilenames.has(filename) + ) { + continue; + } + artifacts.push(artifact); + if (artifacts.length >= limit) break; + } + } + return artifacts.sort( + (left, right) => + right.mtimeMs - left.mtimeMs, + ); +} + +function uniqueArtifacts(artifacts = []) { + const byRelativePath = new Map(); + for (const artifact of artifacts) { + const relativePath = String( + artifact?.relativePath ?? '', + ).trim(); + if (relativePath) { + byRelativePath.set(relativePath, artifact); + } + } + return [...byRelativePath.values()]; +} + +function sanitizeArtifact(artifact) { + if (!artifact) return null; + const { + localPath: _localPath, + mtimeMs: _mtimeMs, + ...safeArtifact + } = artifact; + return safeArtifact; +} + +function sanitizeArtifacts(artifacts = []) { + return artifacts + .map(sanitizeArtifact) + .filter(Boolean); +} + +function buildValidReplyUrls( + replyText, + confirmedArtifacts, +) { + const artifactsByFilename = new Map( + confirmedArtifacts.map((artifact) => [ + path.posix.basename(artifact.relativePath), + artifact, + ]), + ); + const urls = []; + for ( + const match of String(replyText ?? '') + .matchAll(PUBLIC_HTML_LINK_PATTERN) + ) { + const filename = path.posix.basename( + String(match[2] ?? '').trim(), + ); + if (artifactsByFilename.has(filename)) { + urls.push(match[0]); + } + } + return [...new Set(urls)]; +} + +export function prepareWechatHtmlDeliveryAtWorkspace({ + reply = null, + intent = null, + publishDir, + buildCanonicalUrl, + requestStartedAt = 0, + allowRecentArtifacts = true, +} = {}) { + const messages = replyMessages(reply); + const materialization = + materializeMissingPublicHtmlWrites({ + messages, + publishDir, + }); + const publishedArtifacts = uniqueArtifacts( + extractHtmlWriteTargets(messages) + .map((target) => + readArtifactDescriptor(target, { + publishDir, + buildCanonicalUrl, + }), + ) + .filter(Boolean), + ); + const expectedTarget = + extractRequestedHtmlTarget( + intent?.agentText ?? + intent?.displayText ?? + '', + ); + const expectedArtifacts = expectedTarget + ? [ + readArtifactDescriptor(expectedTarget, { + publishDir, + buildCanonicalUrl, + }), + ].filter(Boolean) + : []; + const recentArtifacts = allowRecentArtifacts + ? collectRecentArtifacts({ + publishDir, + buildCanonicalUrl, + replyText: reply?.text, + requestStartedAt, + }) + : []; + const confirmedArtifacts = uniqueArtifacts([ + ...publishedArtifacts, + ...expectedArtifacts, + ...recentArtifacts, + ]); + const linkedFilenames = + collectLinkedHtmlFilenames(reply?.text); + const matchedArtifacts = + linkedFilenames.size > 0 + ? confirmedArtifacts.filter((artifact) => + linkedFilenames.has( + path.posix.basename( + artifact.relativePath, + ), + ), + ) + : confirmedArtifacts; + const validReplyUrls = buildValidReplyUrls( + reply?.text, + confirmedArtifacts, + ); + return { + materialization, + publishedArtifacts: + sanitizeArtifacts(publishedArtifacts), + expectedArtifacts: + sanitizeArtifacts(expectedArtifacts), + recentArtifacts: + sanitizeArtifacts(recentArtifacts), + confirmedArtifacts: + sanitizeArtifacts(confirmedArtifacts), + verifiedArtifacts: + sanitizeArtifacts(matchedArtifacts), + validReplyUrls, + hasValidReplyLink: + validReplyUrls.length > 0, + }; +} + +function restoreWorkspaceArtifacts({ + artifacts, + publishDir, + buildCanonicalUrl, +}) { + return uniqueArtifacts( + artifacts + .map((artifact) => + readArtifactDescriptor( + artifact?.relativePath, + { + publishDir, + buildCanonicalUrl, + copyIntoPublic: false, + }, + ), + ) + .filter(Boolean), + ); +} + +function sanitizeFreshVerification( + verification, + repair = null, +) { + return { + ok: verification?.ok === true, + reason: + verification?.reason == null + ? null + : String(verification.reason), + artifact: sanitizeArtifact( + verification?.artifact, + ), + cover: + String(verification?.cover ?? '').trim() || + null, + matchRelativePaths: Array.isArray( + verification?.matches, + ) + ? verification.matches + .map((match) => + String( + match?.artifact?.relativePath ?? '', + ).trim(), + ) + .filter(Boolean) + : [], + skippedRelativePaths: Array.isArray( + verification?.skippedArtifacts, + ) + ? verification.skippedArtifacts + .map((artifact) => + String( + artifact?.relativePath ?? '', + ).trim(), + ) + .filter(Boolean) + : [], + repair: repair + ? { + ok: repair.ok === true, + reason: + repair.reason == null + ? null + : String(repair.reason), + relativePath: + String( + repair?.artifact?.relativePath ?? '', + ).trim() || null, + cover: + String(repair?.cover ?? '').trim() || + null, + } + : null, + }; +} + +export async function ensureWechatFreshPageThumbnailsAtWorkspace({ + artifacts = [], + images = [], + messages = [], + publishDir, + buildCanonicalUrl, + repairEnabled = false, +} = {}) { + const workspaceArtifacts = + restoreWorkspaceArtifacts({ + artifacts, + publishDir, + buildCanonicalUrl, + }); + let verification = + verifyFreshWechatPageThumbnails( + workspaceArtifacts, + images, + ); + let repair = null; + if ( + !verification.ok && + repairEnabled + ) { + repair = + repairUnambiguousFreshWechatPageThumbnail({ + artifacts: workspaceArtifacts, + images, + currentRunHtmlArtifacts: + extractPublicHtmlWriteArtifacts( + messages, + { publishDir }, + ), + verificationReason: + verification.reason, + }); + if (repair.ok) { + verification = + verifyFreshWechatPageThumbnails( + restoreWorkspaceArtifacts({ + artifacts, + publishDir, + buildCanonicalUrl, + }), + images, + ); + } + } + if (verification.ok) { + await Promise.all( + verification.matches.map((match) => + ensureWorkspaceHtmlThumbnail( + publishDir, + match.artifact.relativePath, + ), + ), + ); + } + return { + ...sanitizeFreshVerification( + verification, + repair, + ), + thumbnailRelativePaths: + verification.ok + ? verification.matches.map((match) => + workspaceThumbnailRelativePath( + match.artifact.relativePath, + ), + ) + : [], + }; +} + +export const mindSpaceWechatHtmlDeliveryInternals = { + buildValidReplyUrls, + collectLinkedHtmlFilenames, + extractHtmlWriteTargets, + extractRequestedHtmlTarget, + readArtifactDescriptor, + sanitizeArtifact, +}; diff --git a/mindspace-wechat-html-delivery.test.mjs b/mindspace-wechat-html-delivery.test.mjs new file mode 100644 index 0000000..890b16d --- /dev/null +++ b/mindspace-wechat-html-delivery.test.mjs @@ -0,0 +1,349 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + ensureWechatFreshPageThumbnailsAtWorkspace, + prepareWechatHtmlDeliveryAtWorkspace, +} from './mindspace-wechat-html-delivery.mjs'; + +function pageHtml({ + title = 'Page', + cover = '', +} = {}) { + const coverField = cover + ? `,"cover":"${cover}"` + : ''; + return [ + '', + `${title}`, + ``, + '
', + 'x'.repeat(600), + '
', + ].join(''); +} + +function canonical(relativePath) { + return `https://example.com/MindSpace/user-1/${relativePath}`; +} + +test('MindSpace prepares WeChat HTML delivery with logical artifacts only', (t) => { + const publishDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-wechat-html-', + ), + ); + t.after(() => { + fs.rmSync(publishDir, { + recursive: true, + force: true, + }); + }); + const content = pageHtml({ + title: 'Hello', + }); + const result = + prepareWechatHtmlDeliveryAtWorkspace({ + publishDir, + buildCanonicalUrl: canonical, + requestStartedAt: Date.now() - 100, + reply: { + text: + '页面完成:https://wrong.example/MindSpace/user-1/public/hello.html', + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: + 'public/hello.html', + content, + }, + }, + }, + }, + ], + }, + ], + }, + intent: { + agentText: + '生成 public/hello.html', + }, + }); + + assert.equal( + fs.readFileSync( + path.join( + publishDir, + 'public', + 'hello.html', + ), + 'utf8', + ), + content, + ); + assert.equal( + result.confirmedArtifacts.length, + 1, + ); + assert.deepEqual( + result.confirmedArtifacts[0], + { + relativePath: 'public/hello.html', + url: canonical('public/hello.html'), + canonicalUrl: + canonical('public/hello.html'), + exists: true, + isStub: false, + isHtmlDocument: true, + sharePreview: { + ok: false, + reason: 'missing_description', + }, + auxiliary: false, + sizeBytes: + Buffer.byteLength(content), + }, + ); + assert.equal( + Object.hasOwn( + result.confirmedArtifacts[0], + 'localPath', + ), + false, + ); + assert.equal(result.hasValidReplyLink, true); + assert.deepEqual(result.validReplyUrls, [ + 'https://wrong.example/MindSpace/user-1/public/hello.html', + ]); +}); + +test('MindSpace copies a generated root HTML file into public before delivery', (t) => { + const publishDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-wechat-root-html-', + ), + ); + t.after(() => { + fs.rmSync(publishDir, { + recursive: true, + force: true, + }); + }); + fs.writeFileSync( + path.join(publishDir, 'guide.html'), + pageHtml({ title: 'Guide' }), + 'utf8', + ); + const result = + prepareWechatHtmlDeliveryAtWorkspace({ + publishDir, + buildCanonicalUrl: canonical, + reply: { + text: 'done', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'developer', + arguments: { + action: 'write', + path: 'guide.html', + }, + }, + }, + }, + ], + }, + ], + }, + intent: { + agentText: '生成 guide.html', + }, + allowRecentArtifacts: false, + }); + + assert.equal( + fs.existsSync( + path.join( + publishDir, + 'public', + 'guide.html', + ), + ), + true, + ); + assert.equal( + result.publishedArtifacts[0] + .relativePath, + 'public/guide.html', + ); +}); + +test('MindSpace verifies and renders fresh WeChat page thumbnails without returning paths', async (t) => { + const publishDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-wechat-thumbnail-', + ), + ); + t.after(() => { + fs.rmSync(publishDir, { + recursive: true, + force: true, + }); + }); + const relativePath = 'public/fresh.html'; + const htmlPath = + path.join(publishDir, relativePath); + fs.mkdirSync(path.dirname(htmlPath), { + recursive: true, + }); + fs.writeFileSync( + htmlPath, + pageHtml({ + title: 'Fresh', + cover: 'fresh.png', + }), + 'utf8', + ); + const result = + await ensureWechatFreshPageThumbnailsAtWorkspace( + { + publishDir, + buildCanonicalUrl: canonical, + artifacts: [ + { + relativePath, + exists: true, + }, + ], + images: [ + { + jobId: 'job-1', + purpose: 'hero', + mimeType: 'image/png', + htmlSrc: 'fresh.png', + }, + ], + }, + ); + + assert.equal(result.ok, true); + assert.deepEqual( + result.matchRelativePaths, + [relativePath], + ); + assert.deepEqual( + result.thumbnailRelativePaths, + ['public/fresh.thumbnail.svg'], + ); + assert.equal( + fs.existsSync( + path.join( + publishDir, + 'public', + 'fresh.thumbnail.svg', + ), + ), + true, + ); + assert.equal( + JSON.stringify(result).includes( + publishDir, + ), + false, + ); +}); + +test('MindSpace owns unambiguous cover repair before thumbnail delivery', async (t) => { + const publishDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-wechat-repair-', + ), + ); + t.after(() => { + fs.rmSync(publishDir, { + recursive: true, + force: true, + }); + }); + const relativePath = + 'public/repaired.html'; + const htmlPath = + path.join(publishDir, relativePath); + fs.mkdirSync(path.dirname(htmlPath), { + recursive: true, + }); + const original = pageHtml({ + title: 'Repair', + }); + fs.writeFileSync( + htmlPath, + original, + 'utf8', + ); + const result = + await ensureWechatFreshPageThumbnailsAtWorkspace( + { + publishDir, + buildCanonicalUrl: canonical, + artifacts: [{ relativePath }], + images: [ + { + jobId: 'job-repair', + purpose: 'hero', + mimeType: 'image/png', + htmlSrc: 'repair.png', + }, + ], + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: relativePath, + content: original, + }, + }, + }, + }, + ], + }, + ], + repairEnabled: true, + }, + ); + + assert.equal(result.ok, true); + assert.equal(result.repair.ok, true); + assert.equal( + result.repair.relativePath, + relativePath, + ); + assert.match( + fs.readFileSync(htmlPath, 'utf8'), + /repair\.png/, + ); +}); diff --git a/mindspace-workspace-publication-delivery-service.mjs b/mindspace-workspace-publication-delivery-service.mjs new file mode 100644 index 0000000..f778a17 --- /dev/null +++ b/mindspace-workspace-publication-delivery-service.mjs @@ -0,0 +1,568 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { + htmlReferencesPrivateAssets, + materializePrivateAssetsInWorkspaceHtml, + resolveClosestHtmlRelativePath, +} from './mindspace-chat-save.mjs'; +import { + scanWorkspaceFilesForProhibitedBrowserStorage, +} from './mindspace-browser-storage-policy.mjs'; +import { getPageDeliveryContract } from './mindspace-delivery-contract.mjs'; +import { renderLongImageBuffer } from './mindspace-long-image.mjs'; +import { resolveMindSpacePublicRequest } from './mindspace-public-route.mjs'; +import { + listRecentlyModifiedPublicHtmlRelativePaths, +} from './mindspace-run-public-html-scope.mjs'; +import { + normalizeWorkspaceRelativePath, +} from './mindspace-pages.mjs'; +import { + evaluatePageDataHtmlContent, +} from './mindspace-page-data-finish-guard.mjs'; +import { + detectPageDataDatasetUsageFromHtml, +} from './page-data-html-detect.mjs'; +import { + policyAllowsAction, +} from './page-access-policy.mjs'; +import { + readPageAccessPolicy, +} from './page-data-policy-store.mjs'; +import { + buildMindSpacePublicRoutePath, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; +import { ensureThumbnailPng } from './mindspace-thumbnail-png.mjs'; +import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs'; +import { PUBLIC_ZONE_DIR } from './user-publish.mjs'; + +function normalizeRelativePathFromRoot(root, absolutePath) { + const relativePath = path + .relative(root, absolutePath) + .replace(/\\/g, '/'); + if ( + !relativePath || + relativePath === '..' || + relativePath.startsWith('../') || + path.posix.isAbsolute(relativePath) + ) { + return null; + } + return relativePath; +} + +function decodeRequestSegments(requestPath) { + const segments = []; + for (const rawSegment of String(requestPath ?? '').split('/')) { + if (!rawSegment) continue; + let segment; + try { + segment = decodeURIComponent(rawSegment); + } catch { + return null; + } + if (!segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) { + return null; + } + segments.push(segment); + } + return segments; +} + +async function statFile(statFn, targetPath) { + try { + const stat = await statFn(targetPath); + return stat?.isFile?.() ? stat : null; + } catch { + return null; + } +} + +export function createMindSpaceWorkspacePublicationDeliveryService({ + pool, + h5Root, + storageRoot, + logger = console, + resolvePublicRequestFn = resolveMindSpacePublicRequest, + resolveUserPublishDirFn = resolveMindSpaceUserPublishDir, + resolveClosestHtmlFn = resolveClosestHtmlRelativePath, + ensureWorkspaceThumbnailFn = ensureWorkspaceHtmlThumbnail, + getDeliveryContractFn = getPageDeliveryContract, + materializePrivateAssetsFn = materializePrivateAssetsInWorkspaceHtml, + ensureThumbnailFn = ensureThumbnailPng, + renderLongImageBufferFn = renderLongImageBuffer, + listRecentlyModifiedPublicHtmlRelativePathsFn = + listRecentlyModifiedPublicHtmlRelativePaths, + evaluatePageDataHtmlContentFn = + evaluatePageDataHtmlContent, + detectPageDataDatasetUsageFromHtmlFn = + detectPageDataDatasetUsageFromHtml, + policyAllowsActionFn = policyAllowsAction, + readPageAccessPolicyFn = + readPageAccessPolicy, + scanWorkspaceFilesForProhibitedBrowserStorageFn = + scanWorkspaceFilesForProhibitedBrowserStorage, + readFileFn = fsPromises.readFile, + statFn = fsPromises.stat, + existsSyncFn = fs.existsSync, +} = {}) { + if (!pool || typeof pool.query !== 'function') { + throw new Error( + 'createMindSpaceWorkspacePublicationDeliveryService requires a database pool', + ); + } + if (!h5Root || !storageRoot) { + throw new Error( + 'createMindSpaceWorkspacePublicationDeliveryService requires h5Root and storageRoot', + ); + } + + async function resolveUsernameToUserId(username) { + const [rows] = await pool.query( + `SELECT id + FROM h5_users + WHERE LOWER(username) = ? + OR LOWER(COALESCE(slug, username)) = ? + LIMIT 1`, + [username, username], + ); + return rows?.[0]?.id ? String(rows[0].id) : null; + } + + async function prepareFileDelivery({ + filePath, + ownerId, + publishDir, + }) { + const relativePath = + normalizeRelativePathFromRoot( + publishDir, + filePath, + ); + if (!relativePath) { + return { + action: 'forbidden', + reason: 'outside_publish_root', + }; + } + + let readablePath = filePath; + if ( + /\.thumbnail\.png$/i.test(readablePath) && + !(await statFile(statFn, readablePath)) + ) { + const svgSibling = readablePath.replace( + /\.png$/i, + '.svg', + ); + if (existsSyncFn(svgSibling)) { + readablePath = + ensureThumbnailFn(svgSibling) ?? + readablePath; + } + } + + const fileStat = await statFile( + statFn, + readablePath, + ); + if (!fileStat) { + return { + action: 'not_found', + reason: 'missing_file', + ownerId, + }; + } + + const htmlDelivery = + /\.html$/i.test(relativePath); + if (htmlDelivery) { + const contract = + await getDeliveryContractFn({ + pool, + userId: ownerId, + relativePath, + }).catch(() => null); + if ( + contract && + contract.status !== 'ready' + ) { + return { + action: 'not_ready', + ownerId, + relativePath, + }; + } + + let html = await readFileFn( + readablePath, + 'utf8', + ); + if ( + relativePath.startsWith( + `${PUBLIC_ZONE_DIR}/`, + ) && + htmlReferencesPrivateAssets(html) + ) { + const materialized = + await materializePrivateAssetsFn({ + pool, + storageRoot, + h5Root, + userId: ownerId, + html, + htmlRelativePath: relativePath, + writeBack: true, + }).catch(() => ({ + html, + changed: false, + })); + if (materialized.changed) { + html = materialized.html; + } + } + + const thumbnailRelativePath = + relativePath.replace( + /\.html$/i, + '.thumbnail.svg', + ); + const thumbnailAbsolutePath = + path.resolve( + publishDir, + thumbnailRelativePath, + ); + return { + action: 'deliver', + kind: 'html', + ownerId, + relativePath, + servedName: path.posix.basename( + relativePath, + ), + canonicalPath: + buildMindSpacePublicRoutePath( + ownerId, + relativePath.split('/'), + ), + thumbnailName: existsSyncFn( + thumbnailAbsolutePath, + ) + ? path.posix + .basename(thumbnailRelativePath) + .replace(/\.svg$/i, '.png') + : null, + html, + }; + } + + const body = await readFileFn(readablePath); + return { + action: 'deliver', + kind: 'binary', + ownerId, + relativePath, + servedName: path.posix.basename( + relativePath, + ), + canonicalPath: + buildMindSpacePublicRoutePath( + ownerId, + relativePath.split('/'), + ), + bodyBase64: body.toString('base64'), + cacheControl: /\.thumbnail\.png$/i.test( + relativePath, + ) + ? 'public, max-age=300' + : null, + }; + } + + return { + async listRecentlyModifiedPublicHtml({ + userId, + sinceMs, + } = {}) { + const ownerId = String( + userId ?? '', + ).trim().toLowerCase(); + if (!ownerId) { + return { relativePaths: [] }; + } + const publishDir = + resolveUserPublishDirFn(h5Root, { + id: ownerId, + }); + return { + relativePaths: + listRecentlyModifiedPublicHtmlRelativePathsFn( + publishDir, + { sinceMs }, + ), + }; + }, + + async validateRunDeliverables({ + userId, + deliverables, + } = {}) { + const ownerId = String( + userId ?? '', + ).trim().toLowerCase(); + if (!ownerId) { + return { errors: [] }; + } + const publishDir = + resolveUserPublishDirFn(h5Root, { + id: ownerId, + }); + const resolvedPublishDir = + path.resolve(publishDir); + const pageDataErrors = []; + const relativePaths = []; + + for (const page of + deliverables?.pages ?? []) { + const relativePath = + normalizeWorkspaceRelativePath( + page.workspaceRelativePath, + ); + if ( + !relativePath?.startsWith( + `${PUBLIC_ZONE_DIR}/`, + ) + ) { + continue; + } + relativePaths.push(relativePath); + const filePath = path.resolve( + publishDir, + relativePath, + ); + if ( + !filePath.startsWith( + `${resolvedPublishDir}${path.sep}`, + ) || + !(await statFile(statFn, filePath)) + ) { + continue; + } + const html = await readFileFn( + filePath, + 'utf8', + ); + const evaluation = + evaluatePageDataHtmlContentFn( + html, + { relativePath }, + ); + if (!evaluation.usesPageDataApi) { + continue; + } + for (const issue of + evaluation.issues) { + pageDataErrors.push({ + code: issue, + message: `${relativePath} Page Data HTML 不可交付:${issue}`, + }); + } + const policy = page.pageId + ? readPageAccessPolicyFn( + publishDir, + page.pageId, + ) + : null; + for (const [dataset, actions] of + detectPageDataDatasetUsageFromHtmlFn( + html, + )) { + for (const action of [ + 'read', + 'insert', + ]) { + if ( + actions?.[action] && + !policyAllowsActionFn( + policy, + dataset, + action, + ) + ) { + pageDataErrors.push({ + code: + 'page_data_policy_action_missing', + message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, + }); + } + } + } + } + + const violations = + scanWorkspaceFilesForProhibitedBrowserStorageFn( + { + publishDir, + relativePaths, + }, + ); + return { + errors: [ + ...pageDataErrors, + ...violations.map( + (violation) => ({ + code: + 'browser_storage_forbidden', + message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, + }), + ), + ], + }; + }, + + async resolveWorkspaceRequest({ + requestPath, + } = {}) { + const result = + await resolvePublicRequestFn({ + h5Root, + requestPath, + resolveUsernameToUserId, + resolveClosestHtmlRelativePath: + resolveClosestHtmlFn, + ensureThumbnail: async ( + publishDir, + relativePath, + ) => { + await ensureWorkspaceThumbnailFn( + publishDir, + relativePath, + ).catch(() => {}); + }, + logger, + }); + if (result.action !== 'serve') { + return result; + } + const ownerId = String( + result.ownerKey ?? '', + ).trim().toLowerCase(); + const publishDir = + resolveUserPublishDirFn(h5Root, { + id: ownerId, + }); + return prepareFileDelivery({ + filePath: result.filePath, + ownerId, + publishDir, + }); + }, + + async readOwnerPublicAsset({ + ownerSlug, + requestPath, + } = {}) { + const slug = String( + ownerSlug ?? '', + ).trim().toLowerCase(); + const segments = + decodeRequestSegments(requestPath); + if (!slug || !segments?.length) { + return { + action: 'not_found', + reason: slug + ? 'missing_file' + : 'missing_owner', + }; + } + const ownerId = + await resolveUsernameToUserId(slug); + if (!ownerId) { + return { + action: 'not_found', + reason: 'missing_owner', + }; + } + const publishDir = + resolveUserPublishDirFn(h5Root, { + id: ownerId, + }); + const filePath = path.resolve( + publishDir, + PUBLIC_ZONE_DIR, + ...segments, + ); + const publicRoot = path.resolve( + publishDir, + PUBLIC_ZONE_DIR, + ); + if ( + filePath !== publicRoot && + !filePath.startsWith( + `${publicRoot}${path.sep}`, + ) + ) { + return { + action: 'forbidden', + reason: 'outside_public_root', + }; + } + const fileStat = await statFile( + statFn, + filePath, + ); + if (!fileStat) { + return { + action: 'not_found', + reason: 'missing_file', + ownerId, + }; + } + const body = await readFileFn(filePath); + const relativePath = path.posix.join( + PUBLIC_ZONE_DIR, + ...segments, + ); + return { + action: 'deliver', + kind: 'binary', + ownerId, + relativePath, + servedName: path.posix.basename( + relativePath, + ), + canonicalPath: + buildMindSpacePublicRoutePath( + ownerId, + relativePath.split('/'), + ), + bodyBase64: body.toString('base64'), + }; + }, + + async renderLongImage({ pageUrl } = {}) { + const url = String(pageUrl ?? '').trim(); + if (!/^https?:\/\//i.test(url)) { + throw Object.assign( + new Error('缺少可渲染的页面 URL'), + { code: 'invalid_page_url' }, + ); + } + const image = + await renderLongImageBufferFn({ url }); + return { + bodyBase64: image.toString('base64'), + servedName: + 'mindspace-public-page.long.png', + }; + }, + }; +} + +export const mindspaceWorkspacePublicationDeliveryInternals = + Object.freeze({ + decodeRequestSegments, + normalizeRelativePathFromRoot, + }); diff --git a/mindspace-workspace-publication-delivery-service.test.mjs b/mindspace-workspace-publication-delivery-service.test.mjs new file mode 100644 index 0000000..66744d1 --- /dev/null +++ b/mindspace-workspace-publication-delivery-service.test.mjs @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createMindSpaceWorkspacePublicationDeliveryService, +} from './mindspace-workspace-publication-delivery-service.mjs'; + +function fileStat() { + return { + isFile: () => true, + }; +} + +function createSetup(overrides = {}) { + const calls = []; + const pool = { + async query(sql, params) { + calls.push(['query', sql, params]); + return [[{ id: 'user-1' }]]; + }, + }; + const service = + createMindSpaceWorkspacePublicationDeliveryService({ + pool, + h5Root: '/project', + storageRoot: '/storage', + async resolvePublicRequestFn(options) { + calls.push([ + 'resolve-workspace', + options.requestPath, + ]); + return { + action: 'serve', + ownerKey: 'user-1', + filePath: + '/project/MindSpace/user-1/public/page.html', + }; + }, + resolveUserPublishDirFn( + _h5Root, + user, + ) { + return `/project/MindSpace/${user.id}`; + }, + async getDeliveryContractFn(options) { + calls.push(['contract', options]); + return { status: 'ready' }; + }, + async materializePrivateAssetsFn( + options, + ) { + calls.push(['materialize', options]); + return { + html: + '

localized

', + changed: true, + }; + }, + async readFileFn(targetPath, encoding) { + calls.push([ + 'read', + targetPath, + encoding, + ]); + if (encoding === 'utf8') { + return ''; + } + return Buffer.from('asset-body'); + }, + async statFn() { + return fileStat(); + }, + existsSyncFn(targetPath) { + return targetPath.endsWith( + '.thumbnail.svg', + ); + }, + async renderLongImageBufferFn({ + url, + }) { + calls.push(['long-image', url]); + return Buffer.from('png-body'); + }, + listRecentlyModifiedPublicHtmlRelativePathsFn( + publishDir, + options, + ) { + calls.push([ + 'recent-html', + publishDir, + options, + ]); + return ['public/recent.html']; + }, + evaluatePageDataHtmlContentFn() { + return { + usesPageDataApi: true, + issues: [ + 'forbidden_local_storage', + ], + }; + }, + detectPageDataDatasetUsageFromHtmlFn() { + return new Map([ + [ + 'survey', + { + read: true, + insert: false, + }, + ], + ]); + }, + readPageAccessPolicyFn() { + return { datasets: {} }; + }, + policyAllowsActionFn() { + return false; + }, + scanWorkspaceFilesForProhibitedBrowserStorageFn( + input, + ) { + calls.push([ + 'browser-storage-scan', + input, + ]); + return [ + { + relativePath: + 'public/page.html', + apis: ['localStorage'], + }, + ]; + }, + ...overrides, + }); + return { calls, pool, service }; +} + +test('resolves workspace HTML into a logical delivery without exposing a physical path', async () => { + const setup = createSetup(); + const result = + await setup.service + .resolveWorkspaceRequest({ + requestPath: + '/user-1/public/page.html', + }); + assert.deepEqual(result, { + action: 'deliver', + kind: 'html', + ownerId: 'user-1', + relativePath: 'public/page.html', + servedName: 'page.html', + canonicalPath: + '/MindSpace/user-1/public/page.html', + thumbnailName: 'page.thumbnail.png', + html: + '

localized

', + }); + assert.equal( + Object.hasOwn(result, 'filePath'), + false, + ); + const materialize = setup.calls.find( + ([kind]) => kind === 'materialize', + ); + assert.equal( + materialize[1].htmlRelativePath, + 'public/page.html', + ); + assert.equal( + materialize[1].writeBack, + true, + ); +}); + +test('enforces the page delivery contract inside MindSpace', async () => { + const setup = createSetup({ + async getDeliveryContractFn() { + return { status: 'validating' }; + }, + }); + const result = + await setup.service + .resolveWorkspaceRequest({ + requestPath: + '/user-1/public/page.html', + }); + assert.deepEqual(result, { + action: 'not_ready', + ownerId: 'user-1', + relativePath: 'public/page.html', + }); + assert.equal( + setup.calls.some( + ([kind]) => kind === 'read', + ), + false, + ); +}); + +test('materializes and returns thumbnail binaries through the logical contract', async () => { + const setup = createSetup({ + async resolvePublicRequestFn() { + return { + action: 'serve', + ownerKey: 'user-1', + filePath: + '/project/MindSpace/user-1/public/page.thumbnail.png', + }; + }, + async statFn(targetPath) { + if ( + targetPath.endsWith( + '.thumbnail.png', + ) + ) { + return fileStat(); + } + return null; + }, + }); + const result = + await setup.service + .resolveWorkspaceRequest({ + requestPath: + '/user-1/public/page.thumbnail.png', + }); + assert.equal(result.action, 'deliver'); + assert.equal(result.kind, 'binary'); + assert.equal( + Buffer.from( + result.bodyBase64, + 'base64', + ).toString(), + 'asset-body', + ); + assert.equal( + result.cacheControl, + 'public, max-age=300', + ); +}); + +test('reads /u owner public assets without returning storage paths', async () => { + const setup = createSetup(); + const result = + await setup.service + .readOwnerPublicAsset({ + ownerSlug: 'Alice', + requestPath: '/assets/demo.txt', + }); + assert.equal(result.action, 'deliver'); + assert.equal( + result.relativePath, + 'public/assets/demo.txt', + ); + assert.equal( + Buffer.from( + result.bodyBase64, + 'base64', + ).toString(), + 'asset-body', + ); + assert.equal( + Object.hasOwn(result, 'filePath'), + false, + ); + const query = setup.calls.find( + ([kind]) => kind === 'query', + ); + assert.deepEqual(query[2], [ + 'alice', + 'alice', + ]); +}); + +test('rejects invalid owner-public paths and renders long images in MindSpace', async () => { + const setup = createSetup(); + const rejected = + await setup.service + .readOwnerPublicAsset({ + ownerSlug: 'alice', + requestPath: '/%2e%2e/secret.txt', + }); + assert.equal( + rejected.action, + 'not_found', + ); + + const rendered = + await setup.service.renderLongImage({ + pageUrl: + 'https://portal.example/MindSpace/user-1/public/page.html', + }); + assert.equal( + Buffer.from( + rendered.bodyBase64, + 'base64', + ).toString(), + 'png-body', + ); + assert.equal( + rendered.servedName, + 'mindspace-public-page.long.png', + ); +}); + +test('scopes recent workspace discovery inside MindSpace', async () => { + const setup = createSetup(); + const result = + await setup.service + .listRecentlyModifiedPublicHtml({ + userId: 'user-1', + sinceMs: 1234, + }); + assert.deepEqual(result, { + relativePaths: ['public/recent.html'], + }); + assert.deepEqual( + setup.calls.find( + ([kind]) => kind === 'recent-html', + ), + [ + 'recent-html', + '/project/MindSpace/user-1', + { sinceMs: 1234 }, + ], + ); +}); + +test('validates run deliverables without exposing workspace paths to Portal', async () => { + const setup = createSetup(); + const result = + await setup.service + .validateRunDeliverables({ + userId: 'user-1', + deliverables: { + pages: [ + { + pageId: 'page-1', + workspaceRelativePath: + 'public/page.html', + }, + ], + }, + }); + assert.deepEqual( + result.errors.map(({ code }) => code), + [ + 'forbidden_local_storage', + 'page_data_policy_action_missing', + 'browser_storage_forbidden', + ], + ); + assert.deepEqual( + setup.calls.find( + ([kind]) => + kind === 'browser-storage-scan', + )[1], + { + publishDir: + '/project/MindSpace/user-1', + relativePaths: ['public/page.html'], + }, + ); +}); diff --git a/mindspace-workspace-tool-service.mjs b/mindspace-workspace-tool-service.mjs new file mode 100644 index 0000000..c451b26 --- /dev/null +++ b/mindspace-workspace-tool-service.mjs @@ -0,0 +1,875 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + assertNoProhibitedBrowserStorage, +} from './mindspace-browser-storage-policy.mjs'; +import { + buildMindSpacePublicUrlForUser, + resolveMindSpaceUserPublishDir, +} from './mindspace-runtime-config.mjs'; +import { + renderLongImage, +} from './mindspace-long-image.mjs'; +import { + UPLOAD_ZONE_CODES, +} from './user-space.mjs'; + +function workspaceToolError(message, code) { + return Object.assign(new Error(message), { code }); +} + +function requireServiceMethod( + service, + methodName, +) { + if ( + !service || + typeof service[methodName] !== 'function' + ) { + throw new Error( + `createMindSpaceWorkspaceToolService requires conversationArtifactService.${methodName}`, + ); + } +} + +function requiredString(value, field) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + throw workspaceToolError( + `${field} is required`, + 'invalid_workspace_tool_input', + ); + } + return normalized; +} + +export function parseMindSpaceWorkspaceRef( + workspaceRef, +) { + const normalized = requiredString( + workspaceRef, + 'workspaceRef', + ); + let parsed; + try { + parsed = new URL(normalized); + } catch { + throw workspaceToolError( + 'Invalid MindSpace workspaceRef', + 'invalid_workspace_ref', + ); + } + let segments; + try { + segments = parsed.pathname + .split('/') + .filter(Boolean) + .map((segment) => + decodeURIComponent(segment), + ); + } catch { + throw workspaceToolError( + 'Invalid MindSpace workspaceRef encoding', + 'invalid_workspace_ref', + ); + } + if ( + parsed.protocol !== 'mindspace:' || + parsed.hostname !== 'users' || + parsed.username || + parsed.password || + parsed.port || + parsed.search || + parsed.hash || + segments.length !== 2 || + segments[1] !== 'workspace' || + segments[0] === '.' || + segments[0] === '..' || + /[\\/]/.test(segments[0]) + ) { + throw workspaceToolError( + 'Invalid MindSpace workspaceRef', + 'invalid_workspace_ref', + ); + } + return { + workspaceRef: normalized, + userId: requiredString( + segments[0], + 'workspaceRef.userId', + ), + }; +} + +export function normalizeMindSpaceWorkspacePath( + value, + { allowRoot = false } = {}, +) { + const raw = String(value ?? '') + .normalize('NFKC') + .trim() + .replace(/\\/g, '/'); + if ( + (allowRoot && (!raw || raw === '.')) || + (allowRoot && raw === './') + ) { + return ''; + } + if ( + !raw || + raw.startsWith('/') || + /^[a-z]:\//i.test(raw) || + raw.includes('\0') + ) { + throw workspaceToolError( + 'Workspace path must be relative', + 'invalid_workspace_path', + ); + } + const segments = raw + .split('/') + .filter(Boolean); + if ( + segments.some( + (segment) => + segment === '.' || + segment === '..', + ) || + segments[0] === '.mindspace' + ) { + throw workspaceToolError( + 'Workspace path is outside the allowed logical workspace', + 'invalid_workspace_path', + ); + } + return segments.join('/'); +} + +function pathInsideRoot(root, target) { + return ( + target === root || + target.startsWith(`${root}${path.sep}`) + ); +} + +async function ensureNoSymlinkTraversal( + root, + relativePath, + { includeLeaf = true } = {}, +) { + const rootReal = await fs.realpath(root); + const segments = relativePath + ? relativePath.split('/') + : []; + const limit = includeLeaf + ? segments.length + : Math.max(0, segments.length - 1); + let current = rootReal; + for (let index = 0; index < limit; index += 1) { + current = path.join( + current, + segments[index], + ); + let stat; + try { + stat = await fs.lstat(current); + } catch (error) { + if (error?.code === 'ENOENT') break; + throw error; + } + if (stat.isSymbolicLink()) { + throw workspaceToolError( + 'Workspace symlink traversal is forbidden', + 'invalid_workspace_path', + ); + } + } + return rootReal; +} + +async function resolveExistingWorkspacePath( + root, + relativePath, +) { + const rootReal = + await ensureNoSymlinkTraversal( + root, + relativePath, + ); + const target = path.join( + rootReal, + ...relativePath.split('/'), + ); + let targetReal; + try { + targetReal = await fs.realpath(target); + } catch (error) { + if (error?.code === 'ENOENT') { + throw workspaceToolError( + `Workspace entry not found: ${relativePath}`, + 'workspace_entry_not_found', + ); + } + throw error; + } + if (!pathInsideRoot(rootReal, targetReal)) { + throw workspaceToolError( + 'Workspace path escaped its logical root', + 'invalid_workspace_path', + ); + } + return targetReal; +} + +async function resolveWritableWorkspacePath( + root, + relativePath, +) { + const rootReal = + await ensureNoSymlinkTraversal( + root, + relativePath, + { includeLeaf: false }, + ); + const target = path.join( + rootReal, + ...relativePath.split('/'), + ); + await fs.mkdir(path.dirname(target), { + recursive: true, + }); + const parentReal = await fs.realpath( + path.dirname(target), + ); + if (!pathInsideRoot(rootReal, parentReal)) { + throw workspaceToolError( + 'Workspace path escaped its logical root', + 'invalid_workspace_path', + ); + } + try { + const stat = await fs.lstat(target); + if (stat.isSymbolicLink()) { + throw workspaceToolError( + 'Workspace symlink writes are forbidden', + 'invalid_workspace_path', + ); + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + return target; +} + +function assertPackageScope( + sessionId, + packageId, +) { + const expected = `cp_${sessionId}`; + if (packageId !== expected) { + throw workspaceToolError( + 'packageId does not match the scoped session', + 'invalid_workspace_package_scope', + ); + } +} + +function mimeSafeByteLength(content) { + return Buffer.byteLength( + String(content ?? ''), + 'utf8', + ); +} + +function decodeWorkspaceBinary( + value, + { maxBytes }, +) { + const encoded = String(value ?? '').trim(); + if ( + !encoded || + encoded.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) + ) { + throw workspaceToolError( + 'Workspace binary body must be valid base64', + 'invalid_workspace_binary', + ); + } + const body = Buffer.from(encoded, 'base64'); + if ( + body.length === 0 || + body.length > maxBytes || + body.toString('base64') !== encoded + ) { + throw workspaceToolError( + body.length > maxBytes + ? 'Workspace binary file exceeds the write limit' + : 'Workspace binary body must be valid base64', + body.length > maxBytes + ? 'workspace_file_too_large' + : 'invalid_workspace_binary', + ); + } + return body; +} + +export function createMindSpaceWorkspaceToolService({ + h5Root, + env = process.env, + syncWorkspaceAssets = null, + conversationArtifactService, + workspacePageDeliveryService = null, + resolveMindSpaceUserPublishDirFn = + resolveMindSpaceUserPublishDir, + buildMindSpacePublicUrlForUserFn = + buildMindSpacePublicUrlForUser, + renderLongImageFn = renderLongImage, + maxReadBytes = 5 * 1024 * 1024, + maxWriteBytes = 10 * 1024 * 1024, + maxBinaryWriteBytes = 8 * 1024 * 1024, + maxGeneratedBinaryBytes = + 40 * 1024 * 1024, +} = {}) { + if (!h5Root) { + throw new Error( + 'createMindSpaceWorkspaceToolService requires h5Root', + ); + } + requireServiceMethod( + conversationArtifactService, + 'registerPublicHtmlArtifacts', + ); + requireServiceMethod( + conversationArtifactService, + 'registerWorkspaceFileArtifact', + ); + if ( + !workspacePageDeliveryService || + typeof workspacePageDeliveryService + .syncAndDeliver !== 'function' + ) { + throw new Error( + 'createMindSpaceWorkspaceToolService requires workspacePageDeliveryService.syncAndDeliver', + ); + } + + const resolveScope = (input = {}) => { + const { + workspaceRef, + sessionId, + packageId, + } = input; + const parsed = + parseMindSpaceWorkspaceRef(workspaceRef); + const suppliedUserId = String( + input.userId ?? '', + ).trim(); + if ( + suppliedUserId && + suppliedUserId !== parsed.userId + ) { + throw workspaceToolError( + 'userId does not match workspaceRef', + 'invalid_workspace_user_scope', + ); + } + const normalizedSessionId = + requiredString(sessionId, 'sessionId'); + const normalizedPackageId = + requiredString(packageId, 'packageId'); + assertPackageScope( + normalizedSessionId, + normalizedPackageId, + ); + const workspaceRoot = + resolveMindSpaceUserPublishDirFn( + h5Root, + { id: parsed.userId }, + ); + return { + ...parsed, + sessionId: normalizedSessionId, + packageId: normalizedPackageId, + workspaceRoot, + }; + }; + + const registerWrite = async ({ + scope, + relativePath, + messageId = null, + body = null, + }) => { + let publicHtmlArtifacts = []; + let workspaceFileArtifact = null; + if ( + relativePath.startsWith('public/') && + relativePath.toLowerCase().endsWith('.html') && + typeof conversationArtifactService + ?.registerPublicHtmlArtifacts === + 'function' + ) { + publicHtmlArtifacts = + await conversationArtifactService + .registerPublicHtmlArtifacts({ + userId: scope.userId, + sessionId: scope.sessionId, + relativePaths: [relativePath], + artifactRefs: [ + { + relativePath, + messageId: + String(messageId ?? '').trim() || + null, + }, + ], + }); + } else if ( + body != null && + typeof conversationArtifactService + ?.registerWorkspaceFileArtifact === + 'function' + ) { + workspaceFileArtifact = + await conversationArtifactService + .registerWorkspaceFileArtifact({ + userId: scope.userId, + sessionId: scope.sessionId, + messageId: + String(messageId ?? '').trim() || + null, + relativePath, + body, + }); + } + const categoryCode = + relativePath.split('/')[0]; + let assetSync = null; + if ( + typeof syncWorkspaceAssets === 'function' && + UPLOAD_ZONE_CODES.includes(categoryCode) + ) { + assetSync = await syncWorkspaceAssets( + scope.userId, + { + categoryCode, + onlyRelativePaths: [relativePath], + }, + ); + } + return { + assetSync, + publicHtmlArtifacts, + workspaceFileArtifact, + }; + }; + + return { + async readFile(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + ); + const target = + await resolveExistingWorkspacePath( + scope.workspaceRoot, + relativePath, + ); + const stat = await fs.stat(target); + if (!stat.isFile()) { + throw workspaceToolError( + 'Workspace path is not a file', + 'invalid_workspace_path', + ); + } + if (stat.size > maxReadBytes) { + throw workspaceToolError( + 'Workspace file exceeds the read limit', + 'workspace_file_too_large', + ); + } + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + content: await fs.readFile( + target, + 'utf8', + ), + sizeBytes: stat.size, + }; + }, + + async writeFile(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + ); + const content = String( + input.content ?? '', + ); + const sizeBytes = + mimeSafeByteLength(content); + if (sizeBytes > maxWriteBytes) { + throw workspaceToolError( + 'Workspace file exceeds the write limit', + 'workspace_file_too_large', + ); + } + assertNoProhibitedBrowserStorage( + content, + { relativePath }, + ); + const target = + await resolveWritableWorkspacePath( + scope.workspaceRoot, + relativePath, + ); + await fs.writeFile(target, content, 'utf8'); + const registration = await registerWrite({ + scope, + relativePath, + messageId: input.messageId, + body: content, + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + sizeBytes, + registration, + }; + }, + + async writeBinaryFile(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + ); + const body = decodeWorkspaceBinary( + input.bodyBase64, + { maxBytes: maxBinaryWriteBytes }, + ); + const target = + await resolveWritableWorkspacePath( + scope.workspaceRoot, + relativePath, + ); + await fs.writeFile(target, body); + const registration = await registerWrite({ + scope, + relativePath, + messageId: input.messageId, + body, + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + sizeBytes: body.length, + registration, + }; + }, + + async editFile(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + ); + const target = + await resolveExistingWorkspacePath( + scope.workspaceRoot, + relativePath, + ); + const original = await fs.readFile( + target, + 'utf8', + ); + const oldString = String( + input.oldString ?? input.old_str ?? '', + ); + if ( + oldString && + !original.includes(oldString) + ) { + throw workspaceToolError( + 'edit_file: old_str was not found', + 'workspace_edit_not_found', + ); + } + const newString = String( + input.newString ?? input.new_str ?? '', + ); + const updated = oldString + ? original.replace(oldString, newString) + : newString; + const sizeBytes = + mimeSafeByteLength(updated); + if (sizeBytes > maxWriteBytes) { + throw workspaceToolError( + 'Workspace file exceeds the write limit', + 'workspace_file_too_large', + ); + } + assertNoProhibitedBrowserStorage( + updated, + { relativePath }, + ); + await fs.writeFile(target, updated, 'utf8'); + const registration = await registerWrite({ + scope, + relativePath, + messageId: input.messageId, + body: updated, + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + sizeBytes, + registration, + }; + }, + + async listDirectory(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + { allowRoot: true }, + ); + const target = relativePath + ? await resolveExistingWorkspacePath( + scope.workspaceRoot, + relativePath, + ) + : await fs.realpath( + scope.workspaceRoot, + ); + const entries = await fs.readdir(target, { + withFileTypes: true, + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + entries: entries + .filter( + (entry) => + entry.name !== '.mindspace', + ) + .map((entry) => ({ + name: entry.name, + type: entry.isDirectory() + ? 'directory' + : entry.isFile() + ? 'file' + : 'other', + })) + .sort((left, right) => + left.name.localeCompare(right.name), + ), + }; + }, + + async createDirectory(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path, + ); + const target = + await resolveWritableWorkspacePath( + scope.workspaceRoot, + `${relativePath}/.mindspace-create-placeholder`, + ); + await fs.mkdir(path.dirname(target), { + recursive: true, + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + }; + }, + + async generateLongImage(input = {}) { + const scope = resolveScope(input); + const htmlRelativePath = + normalizeMindSpaceWorkspacePath( + input.htmlPath ?? + input.html_path ?? + input.path, + ); + if ( + !htmlRelativePath + .toLowerCase() + .endsWith('.html') + ) { + throw workspaceToolError( + 'generate_long_image requires an HTML source', + 'invalid_workspace_page_path', + ); + } + const outputRelativePath = + normalizeMindSpaceWorkspacePath( + input.outputPath ?? + input.output_path ?? + htmlRelativePath.replace( + /\.html$/i, + '.long.png', + ), + ); + if ( + !outputRelativePath + .toLowerCase() + .endsWith('.png') + ) { + throw workspaceToolError( + 'generate_long_image output must be PNG', + 'invalid_workspace_binary', + ); + } + const htmlTarget = + await resolveExistingWorkspacePath( + scope.workspaceRoot, + htmlRelativePath, + ); + const htmlStat = await fs.stat(htmlTarget); + if (!htmlStat.isFile()) { + throw workspaceToolError( + 'Long-image source is not a file', + 'invalid_workspace_path', + ); + } + const outputTarget = + await resolveWritableWorkspacePath( + scope.workspaceRoot, + outputRelativePath, + ); + const sourceCanonicalUrl = + buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: { + id: scope.userId, + }, + relativePath: + htmlRelativePath, + }); + await renderLongImageFn({ + url: sourceCanonicalUrl, + outputPath: outputTarget, + }); + const outputStat = + await fs.stat(outputTarget); + if ( + !outputStat.isFile() || + outputStat.size <= 0 + ) { + throw workspaceToolError( + 'Long-image renderer did not produce a file', + 'workspace_entry_not_found', + ); + } + if ( + outputStat.size > + maxGeneratedBinaryBytes + ) { + throw workspaceToolError( + 'Generated long image exceeds the write limit', + 'workspace_file_too_large', + ); + } + const registration = await registerWrite({ + scope, + relativePath: outputRelativePath, + messageId: input.messageId, + body: await fs.readFile(outputTarget), + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath: outputRelativePath, + sizeBytes: outputStat.size, + sourceCanonicalUrl, + canonicalUrl: + outputRelativePath.startsWith( + 'public/', + ) + ? buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: { + id: scope.userId, + }, + relativePath: + outputRelativePath, + }) + : null, + registration, + }; + }, + + async publishPage(input = {}) { + const scope = resolveScope(input); + const relativePath = + normalizeMindSpaceWorkspacePath( + input.path ?? input.relativePath, + ); + if ( + !relativePath.startsWith('public/') || + !relativePath.toLowerCase().endsWith('.html') + ) { + throw workspaceToolError( + 'publish_page requires public/*.html', + 'invalid_workspace_page_path', + ); + } + await resolveExistingWorkspacePath( + scope.workspaceRoot, + relativePath, + ); + const registration = await registerWrite({ + scope, + relativePath, + messageId: input.messageId, + }); + const delivery = + await workspacePageDeliveryService + .syncAndDeliver(scope.userId, { + pageDataRelativePaths: [ + relativePath, + ], + }); + return { + workspaceRef: scope.workspaceRef, + packageId: scope.packageId, + relativePath, + canonicalUrl: + buildMindSpacePublicUrlForUserFn({ + h5Root, + env, + user: { id: scope.userId }, + relativePath, + }), + registration, + delivery, + }; + }, + }; +} + +export const mindSpaceWorkspaceToolServiceInternals = { + assertPackageScope, + decodeWorkspaceBinary, + ensureNoSymlinkTraversal, + requireServiceMethod, + resolveExistingWorkspacePath, + resolveWritableWorkspacePath, +}; diff --git a/mindspace-workspace-tool-service.test.mjs b/mindspace-workspace-tool-service.test.mjs new file mode 100644 index 0000000..d4e5e4e --- /dev/null +++ b/mindspace-workspace-tool-service.test.mjs @@ -0,0 +1,477 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + createMindSpaceWorkspaceToolService, + normalizeMindSpaceWorkspacePath, + parseMindSpaceWorkspaceRef, +} from './mindspace-workspace-tool-service.mjs'; + +function createSetup() { + const calls = []; + const workspaceRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-workspace-tools-', + ), + ); + fs.mkdirSync( + path.join(workspaceRoot, 'public'), + { recursive: true }, + ); + const service = + createMindSpaceWorkspaceToolService({ + h5Root: '/srv/h5', + resolveMindSpaceUserPublishDirFn() { + return workspaceRoot; + }, + async syncWorkspaceAssets( + userId, + options, + ) { + calls.push({ + kind: 'asset-sync', + userId, + options, + }); + return { + imported: 1, + updated: 0, + skipped: 0, + }; + }, + conversationArtifactService: { + async registerPublicHtmlArtifacts( + input, + ) { + calls.push({ + kind: 'public-html', + input, + }); + return [ + { artifactId: 'artifact-1' }, + ]; + }, + async registerWorkspaceFileArtifact( + input, + ) { + calls.push({ + kind: 'workspace-artifact', + input, + }); + return { + id: 'artifact-workspace-1', + }; + }, + }, + workspacePageDeliveryService: { + async syncAndDeliver( + userId, + options, + ) { + calls.push({ + kind: 'deliver', + userId, + options, + }); + return { + publish: { + published: 1, + }, + }; + }, + }, + buildMindSpacePublicUrlForUserFn({ + user, + relativePath, + }) { + return `https://example.com/MindSpace/${user.id}/${relativePath}`; + }, + async renderLongImageFn({ + url, + outputPath, + }) { + calls.push({ + kind: 'render-long-image', + url, + outputPath, + }); + fs.writeFileSync( + outputPath, + Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, + ]), + ); + return { bytes: 4 }; + }, + }); + return { + calls, + service, + workspaceRoot, + scope: { + workspaceRef: + 'mindspace://users/user-1/workspace', + sessionId: 'session-1', + packageId: 'cp_session-1', + }, + }; +} + +test('workspace tool service reads, writes, edits, and registers generated artifacts', async (t) => { + const setup = createSetup(); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + }); + + const written = + await setup.service.writeFile({ + ...setup.scope, + path: 'public/report.html', + content: + 'Report', + messageId: 'message-1', + }); + const edited = + await setup.service.editFile({ + ...setup.scope, + path: 'public/report.html', + old_str: 'Report', + new_str: 'Updated', + messageId: 'message-1', + }); + const read = await setup.service.readFile({ + ...setup.scope, + path: 'public/report.html', + }); + const listed = + await setup.service.listDirectory({ + ...setup.scope, + path: 'public', + }); + + assert.equal( + written.relativePath, + 'public/report.html', + ); + assert.equal( + edited.packageId, + 'cp_session-1', + ); + assert.match(read.content, /Updated/); + assert.deepEqual(listed.entries, [ + { name: 'report.html', type: 'file' }, + ]); + assert.deepEqual( + setup.calls + .map((call) => call.kind), + [ + 'public-html', + 'asset-sync', + 'public-html', + 'asset-sync', + ], + ); + assert.deepEqual( + setup.calls[1].options.onlyRelativePaths, + ['public/report.html'], + ); +}); + +test('non-public workspace writes are synchronized and registered even when asset sync may skip unchanged content', async (t) => { + const setup = createSetup(); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + }); + + const result = + await setup.service.writeFile({ + ...setup.scope, + path: 'oa/notes.txt', + content: 'generated notes', + messageId: 'message-2', + }); + + assert.deepEqual( + setup.calls.map((call) => call.kind), + ['workspace-artifact', 'asset-sync'], + ); + assert.equal( + setup.calls[0].input.relativePath, + 'oa/notes.txt', + ); + assert.equal( + setup.calls[0].input.body, + 'generated notes', + ); + assert.equal( + result.registration.workspaceFileArtifact.id, + 'artifact-workspace-1', + ); +}); + +test('binary workspace writes persist and register package artifacts without exposing physical paths', async (t) => { + const setup = createSetup(); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + }); + const body = Buffer.from( + 'PK generated docx', + 'utf8', + ); + + const result = + await setup.service.writeBinaryFile({ + ...setup.scope, + path: 'public/report.docx', + bodyBase64: body.toString('base64'), + messageId: 'message-docx', + }); + + assert.deepEqual( + fs.readFileSync( + path.join( + setup.workspaceRoot, + 'public', + 'report.docx', + ), + ), + body, + ); + assert.deepEqual( + setup.calls.map((call) => call.kind), + ['workspace-artifact', 'asset-sync'], + ); + assert.equal( + Buffer.isBuffer( + setup.calls[0].input.body, + ), + true, + ); + assert.equal(result.sizeBytes, body.length); + assert.doesNotMatch( + JSON.stringify(result), + new RegExp(setup.workspaceRoot), + ); +}); + +test('long-image generation runs inside MindSpace and registers the generated PNG', async (t) => { + const setup = createSetup(); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + }); + fs.writeFileSync( + path.join( + setup.workspaceRoot, + 'public', + 'report.html', + ), + 'Report', + ); + + const result = + await setup.service.generateLongImage({ + ...setup.scope, + htmlPath: 'public/report.html', + outputPath: + 'public/report.long.png', + messageId: 'message-image', + }); + + assert.deepEqual( + setup.calls.map((call) => call.kind), + [ + 'render-long-image', + 'workspace-artifact', + 'asset-sync', + ], + ); + assert.equal( + setup.calls[1].input.relativePath, + 'public/report.long.png', + ); + assert.equal( + setup.calls[0].url, + 'https://example.com/MindSpace/user-1/public/report.html', + ); + assert.equal( + result.canonicalUrl, + 'https://example.com/MindSpace/user-1/public/report.long.png', + ); + assert.doesNotMatch( + JSON.stringify(result), + new RegExp(setup.workspaceRoot), + ); +}); + +test('workspace tool service refuses to start without direct package artifact authority', () => { + assert.throws( + () => + createMindSpaceWorkspaceToolService({ + h5Root: '/srv/h5', + conversationArtifactService: { + async registerPublicHtmlArtifacts() { + return []; + }, + }, + }), + /registerWorkspaceFileArtifact/, + ); +}); + +test('publish_page returns a canonical logical URL and delivers only the selected page', async (t) => { + const setup = createSetup(); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + }); + fs.writeFileSync( + path.join( + setup.workspaceRoot, + 'public', + 'report.html', + ), + 'Report', + ); + + const result = + await setup.service.publishPage({ + ...setup.scope, + path: 'public/report.html', + }); + + assert.equal( + result.canonicalUrl, + 'https://example.com/MindSpace/user-1/public/report.html', + ); + assert.equal( + setup.calls.at(-1).kind, + 'deliver', + ); + assert.deepEqual( + setup.calls.at(-1).options + .pageDataRelativePaths, + ['public/report.html'], + ); +}); + +test('workspace tool service rejects path, package, browser-storage, and symlink escapes', async (t) => { + const setup = createSetup(); + const outsideRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'mindspace-outside-'), + ); + t.after(() => { + fs.rmSync(setup.workspaceRoot, { + recursive: true, + force: true, + }); + fs.rmSync(outsideRoot, { + recursive: true, + force: true, + }); + }); + fs.symlinkSync( + outsideRoot, + path.join(setup.workspaceRoot, 'escape'), + ); + + await assert.rejects( + setup.service.writeFile({ + ...setup.scope, + path: '../outside.html', + content: 'bad', + }), + (error) => + error?.code === 'invalid_workspace_path', + ); + await assert.rejects( + setup.service.writeFile({ + ...setup.scope, + packageId: 'cp_other-session', + path: 'public/report.html', + content: 'bad', + }), + (error) => + error?.code === + 'invalid_workspace_package_scope', + ); + await assert.rejects( + setup.service.writeFile({ + ...setup.scope, + path: 'public/report.html', + content: + '', + }), + /localStorage/, + ); + await assert.rejects( + setup.service.writeFile({ + ...setup.scope, + path: 'escape/report.html', + content: 'bad', + }), + (error) => + error?.code === 'invalid_workspace_path', + ); + await assert.rejects( + setup.service.writeBinaryFile({ + ...setup.scope, + path: 'public/report.docx', + bodyBase64: 'not-base64', + }), + (error) => + error?.code === + 'invalid_workspace_binary', + ); +}); + +test('workspace refs and logical paths normalize without exposing physical roots', () => { + assert.deepEqual( + parseMindSpaceWorkspaceRef( + 'mindspace://users/user-1/workspace', + ), + { + workspaceRef: + 'mindspace://users/user-1/workspace', + userId: 'user-1', + }, + ); + assert.equal( + normalizeMindSpaceWorkspacePath( + 'public\\report.html', + ), + 'public/report.html', + ); + assert.throws( + () => + normalizeMindSpaceWorkspacePath( + '/srv/h5/MindSpace/user-1/public/report.html', + ), + (error) => + error?.code === 'invalid_workspace_path', + ); + assert.throws( + () => + parseMindSpaceWorkspaceRef( + 'mindspace://users/user%2Fother/workspace', + ), + (error) => + error?.code === 'invalid_workspace_ref', + ); +}); diff --git a/package.json b/package.json index 7dcf554..2f6a8fc 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "smoke:memory-v2-pgvector": "node scripts/smoke-memory-v2-pgvector.mjs", "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", + "smoke:mindspace-split-service": "node scripts/smoke-mindspace-split-service.mjs", "smoke:orchestrator-shadow": "node scripts/smoke-orchestrator-shadow.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs", "test:memind": "node scripts/run-memind-tests.mjs", @@ -74,13 +75,15 @@ "ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs", "migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-rybbit.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", + "verify:mindspace-authority-boundary": "node scripts/verify-mindspace-authority-boundary.mjs", + "verify:mindspace-authority-boundary:full": "node scripts/verify-mindspace-authority-boundary.mjs --with-runtime", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", diff --git a/scripts/build-mindspace-service-runtime.mjs b/scripts/build-mindspace-service-runtime.mjs index e273ed7..35be28e 100644 --- a/scripts/build-mindspace-service-runtime.mjs +++ b/scripts/build-mindspace-service-runtime.mjs @@ -33,8 +33,50 @@ async function exists(targetPath) { } } +async function waitForFile(targetPath, { timeoutMs = 5000 } = {}) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() <= deadline) { + try { + const stat = await fs.stat(targetPath); + if (stat.isFile()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `runtime file was not copied in time: ${targetPath}${ + lastError?.message ? ` (${lastError.message})` : '' + }`, + ); +} + async function remove(targetPath) { - await fs.rm(targetPath, { recursive: true, force: true }); + try { + await fs.rm(targetPath, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + return; + } catch (error) { + if (!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(error?.code)) throw error; + const tombstone = `${targetPath}.delete-${process.pid}-${Date.now()}`; + try { + await fs.rename(targetPath, tombstone); + } catch (renameError) { + if (renameError?.code === 'ENOENT') return; + throw error; + } + await fs.rm(tombstone, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } } async function copyDir(source, target) { @@ -48,6 +90,7 @@ function shouldCopyMemindSource(source) { const parts = relative.split(path.sep); const top = parts[0]; const last = parts[parts.length - 1]; + if (parts.includes('node_modules')) return false; if (excludeTopLevel.has(top)) return false; if (last.endsWith('.pid')) return false; if (last === '.DS_Store' || last === '.env' || last === '.env.local') return false; @@ -95,6 +138,7 @@ async function copyServiceSource() { async function copyMemindSourceSnapshot() { console.log('==> 拷贝 Memind 受控源码快照'); const targetRoot = path.join(runtimeRoot, 'memind-source'); + await remove(targetRoot); await fs.mkdir(targetRoot, { recursive: true }); const entries = await fs.readdir(root); for (const entry of entries) { @@ -165,7 +209,9 @@ async function main() { await copyMemindSourceSnapshot(); await copyNodeModules(); await writeMetadata(); - await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-mindspace-prod.sh'), 0o755); + const runnerPath = path.join(runtimeRoot, 'scripts', 'run-mindspace-prod.sh'); + await waitForFile(runnerPath); + await fs.chmod(runnerPath, 0o755); console.log(''); console.log(`MindSpace service runtime 已生成: ${runtimeRoot}`); } diff --git a/scripts/smoke-mindspace-split-service.mjs b/scripts/smoke-mindspace-split-service.mjs new file mode 100644 index 0000000..080ba51 --- /dev/null +++ b/scripts/smoke-mindspace-split-service.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createMindSpaceRemoteServerAdapter } from '../mindspace-remote-server-adapter.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const runtimeRoot = path.join(root, '.runtime', 'mindspace-service'); +const localToken = 'local-split-service-smoke-token'; + +function loadDotenvFile(targetEnv, filepath) { + if (!fs.existsSync(filepath) || !fs.statSync(filepath).isFile()) return; + const content = fs.readFileSync(filepath, 'utf8'); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || !line.includes('=')) continue; + const [rawKey, ...rawValueParts] = line.split('='); + const key = rawKey.trim(); + if (!key || key in targetEnv) continue; + const rawValue = rawValueParts.join('=').trim(); + const unwrapped = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ? rawValue.slice(1, -1) + : rawValue; + targetEnv[key] = unwrapped; + } +} + +function runBuild() { + const result = spawnSync( + process.execPath, + ['scripts/build-mindspace-service-runtime.mjs', '--skip-node-modules'], + { + cwd: root, + stdio: 'inherit', + env: process.env, + }, + ); + if (result.status !== 0) { + throw new Error(`MindSpace runtime build failed with status ${result.status}`); + } +} + +async function freePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const port = server.address().port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function waitForHealth(endpoint, childLog, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const response = await fetch(`${endpoint}/health`); + if (response.ok) return response.json(); + lastError = new Error(`health returned ${response.status}`); + } catch (error) { + lastError = error; + } + if (childLog.exited) break; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + const detail = childLog.lines.slice(-30).join('\n'); + throw new Error( + `MindSpace split-service smoke could not reach /health: ${lastError?.message ?? 'unknown'}${ + detail ? `\n--- service log ---\n${detail}` : '' + }`, + ); +} + +async function stopChild(child) { + if (!child || child.exitCode != null) return; + const waitForExit = () => + new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await Promise.race([ + waitForExit(), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (child.exitCode != null) return; + child.kill('SIGKILL'); + await Promise.race([ + waitForExit(), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); +} + +function pageHtml({ title = 'Split Smoke', cover = 'hero.png' } = {}) { + return [ + '', + `${title}`, + '', + ``, + '
', + 'Split service smoke page ', + 'x'.repeat(600), + '
', + ].join(''); +} + +async function resolveSmokeUserId() { + loadDotenvFile(process.env, path.join(root, '.env')); + loadDotenvFile(process.env, path.join(root, '.env.local')); + const db = await import('../db.mjs'); + if (!db.isDatabaseConfigured()) { + throw new Error('split-service smoke requires DATABASE_URL or MYSQL_* configuration'); + } + const pool = db.createDbPool(); + try { + const supplied = String( + process.env.MINDSPACE_SPLIT_SMOKE_USER_ID ?? + process.env.MINDSPACE_LOOPBACK_USER_ID ?? + '', + ).trim(); + if (supplied) return { userId: supplied, pool }; + const [rows] = await pool.query( + `SELECT id FROM h5_users WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`, + ); + const userId = String(rows?.[0]?.id ?? '').trim(); + if (!userId) throw new Error('split-service smoke could not find an active local H5 user'); + return { userId, pool }; + } catch (error) { + await pool.end().catch(() => {}); + throw error; + } +} + +async function cleanupSmokeRows(pool, { userId, sessionId }) { + const packageId = `cp_${sessionId}`; + const now = Date.now(); + await pool.query(`DELETE FROM h5_conversation_artifacts WHERE package_id = ?`, [packageId]).catch(() => {}); + await pool.query( + `DELETE FROM h5_conversation_packages WHERE id = ? OR (user_id = ? AND session_id = ?)`, + [packageId, userId, sessionId], + ).catch(() => {}); + await pool.query( + `UPDATE h5_assets + SET status = 'deleted', deleted_at = ?, updated_at = ? + WHERE user_id = ? AND source_session_id = ?`, + [now, now, userId, sessionId], + ).catch(() => {}); +} + +async function main() { + const skipBuild = process.argv.includes('--skip-build'); + if (!skipBuild) runBuild(); + + const { userId, pool } = await resolveSmokeUserId(); + const sessionId = `split-service-smoke-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; + const packageId = `cp_${sessionId}`; + const runtimeDataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-split-service-')); + fs.mkdirSync(path.join(runtimeDataRoot, 'MindSpace', userId), { recursive: true }); + const port = Number(process.env.MINDSPACE_SPLIT_SMOKE_PORT ?? '') || await freePort(); + const endpoint = `http://127.0.0.1:${port}`; + const childLog = { lines: [], exited: false }; + const child = spawn(process.execPath, ['server.mjs'], { + cwd: runtimeRoot, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + MINDSPACE_MEMIND_ROOT: path.join(runtimeRoot, 'memind-source'), + MINDSPACE_SERVICE_H5_ROOT: runtimeDataRoot, + MINDSPACE_STORAGE_ROOT: path.join(runtimeDataRoot, 'data', 'mindspace'), + H5_USERS_ROOT: path.join(runtimeDataRoot, 'users'), + MINDSPACE_REMOTE_AUTH_TOKEN: localToken, + MINDSPACE_SERVICE_PORT: String(port), + MINDSPACE_SERVICE_AGENT_WORKER_ENABLED: 'false', + MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE: 'false', + }, + }); + const capture = (chunk) => { + for (const line of String(chunk).split(/\r?\n/).filter(Boolean)) { + childLog.lines.push(line); + if (childLog.lines.length > 200) childLog.lines.shift(); + } + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + child.once('exit', () => { + childLog.exited = true; + }); + + try { + const health = await waitForHealth(endpoint, childLog); + assert.equal(health.ok, true); + assert.equal(health.service, 'mindspace-service'); + + const contractResponse = await fetch(`${endpoint}/mindspace/v1/contract`); + assert.equal(contractResponse.ok, true); + const contract = await contractResponse.json(); + assert.ok(contract.bindings.publicFinishService.includes('prepareWechatHtmlDelivery')); + assert.ok(contract.bindings.publicFinishService.includes('ensureWechatFreshPageThumbnails')); + assert.ok(contract.bindings.workspaceToolService.includes('writeFile')); + + const remote = createMindSpaceRemoteServerAdapter({ + endpoint, + authToken: localToken, + timeoutMs: 20_000, + }); + remote.assertReady(); + + const workspaceRef = `mindspace://users/${userId}/workspace`; + const html = pageHtml(); + const write = await remote.workspaceToolService.writeFile({ + workspaceRef, + sessionId, + packageId, + path: 'public/split-service-smoke.html', + content: html, + messageId: 'message-workspace-write', + }); + assert.equal(write.relativePath, 'public/split-service-smoke.html'); + + const read = await remote.workspaceToolService.readFile({ + workspaceRef, + sessionId, + packageId, + path: 'public/split-service-smoke.html', + }); + assert.match(read.content, /Split service smoke page/); + + const delivery = await remote.publicFinishService.prepareWechatHtmlDelivery({ + userId, + sessionId, + reply: { + text: '页面完成:https://wrong.example/MindSpace/user/public/split-service-smoke.html', + messages: [ + { + id: 'message-wechat-write', + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: 'public/split-service-smoke.html', + content: html, + }, + }, + }, + }, + ], + }, + ], + }, + intent: { + agentText: '生成 public/split-service-smoke.html', + }, + requestStartedAt: Date.now() - 1_000, + allowRecentArtifacts: false, + }); + assert.equal(delivery.confirmedArtifacts.length, 1); + assert.equal(delivery.confirmedArtifacts[0].relativePath, 'public/split-service-smoke.html'); + assert.equal(Object.hasOwn(delivery.confirmedArtifacts[0], 'localPath'), false); + + const thumbnail = await remote.publicFinishService.ensureWechatFreshPageThumbnails({ + userId, + sessionId, + artifacts: delivery.confirmedArtifacts, + images: [ + { + jobId: 'split-service-smoke-image', + purpose: 'hero', + mimeType: 'image/png', + htmlSrc: 'hero.png', + }, + ], + }); + assert.equal(thumbnail.ok, true); + assert.deepEqual(thumbnail.matchRelativePaths, ['public/split-service-smoke.html']); + assert.ok(thumbnail.thumbnailRelativePaths.includes('public/split-service-smoke.thumbnail.svg')); + assert.equal(JSON.stringify(thumbnail).includes(runtimeDataRoot), false); + + console.log(JSON.stringify({ + ok: true, + endpoint, + userId, + sessionId, + checks: [ + 'health', + 'contract', + 'workspace-write-read', + 'wechat-html-delivery', + 'wechat-fresh-thumbnail', + ], + }, null, 2)); + } finally { + await cleanupSmokeRows(pool, { userId, sessionId }).catch(() => {}); + await pool.end().catch(() => {}); + await stopChild(child); + fs.rmSync(runtimeDataRoot, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/verify-mindspace-authority-boundary.mjs b/scripts/verify-mindspace-authority-boundary.mjs new file mode 100644 index 0000000..de5a637 --- /dev/null +++ b/scripts/verify-mindspace-authority-boundary.mjs @@ -0,0 +1,757 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); + +function read(relativePath) { + const targetPath = path.join(root, relativePath); + if (!fs.existsSync(targetPath)) { + throw new Error( + `Missing MindSpace authority guard input: ${relativePath}`, + ); + } + return fs.readFileSync(targetPath, 'utf8'); +} + +function requireSource(source, pattern, label) { + if (!pattern.test(source)) { + throw new Error(`Missing MindSpace authority guard: ${label}`); + } +} + +function forbidSource(source, pattern, label) { + if (pattern.test(source)) { + throw new Error(`MindSpace authority leaked back into Portal: ${label}`); + } +} + +const portalSource = read('server.mjs'); +const contractSource = read( + 'mindspace-server-adapter-contract.mjs', +); +const localRuntimeSource = read( + 'mindspace-local-runtime-services.mjs', +); +const publicFinishServiceSource = read( + 'mindspace-public-finish-service.mjs', +); +const wechatHtmlDeliverySource = read( + 'mindspace-wechat-html-delivery.mjs', +); +const portalSessionRoutesSource = read( + 'server/portal-session-routes.mjs', +); +const workspaceDeliveryServiceSource = read( + 'mindspace-workspace-publication-delivery-service.mjs', +); +const portalWorkspaceDeliverySource = read( + 'server/portal-workspace-publication-delivery.mjs', +); +const portalPublicationRoutesSource = read( + 'server/portal-publication-routes.mjs', +); +const portalDomainBootstrapSource = read( + 'server/portal-domain-services-bootstrap.mjs', +); +const portalGatewayBootstrapSource = read( + 'server/portal-gateway-services-bootstrap.mjs', +); +const portalAgentJobRoutesSource = read( + 'server/portal-agent-job-routes.mjs', +); +const portalIntegrationBootstrapSource = read( + 'server/portal-integration-services-bootstrap.mjs', +); +const wechatMpSource = read('wechat-mp.mjs'); +const workspaceToolServiceSource = read( + 'mindspace-workspace-tool-service.mjs', +); +const scopedMcpTokenSource = read( + 'mindspace-mcp-scoped-token.mjs', +); +const sandboxMcpSource = read( + 'mindspace-sandbox-mcp.mjs', +); +const capabilitiesSource = read( + 'capabilities.mjs', +); +const userAuthSource = read('user-auth.mjs'); +const mindSpaceRpcServerSource = read( + 'mindspace-service/mindspace-rpc-server.mjs', +); +const publicationServiceSource = read( + 'mindspace-publications.mjs', +); +const withRuntime = + process.argv.includes('--with-runtime') || + process.env.MINDSPACE_VERIFY_RUNTIME === '1'; + +requireSource( + portalSource, + /mindSpaceChatSave\.materializeWorkspaceHtml\(/, + 'Portal delegates chat-save HTML materialization', +); +forbidSource( + portalSource, + /\bfsPromises\b/, + 'server.mjs must not retain the chat-save filesystem writer', +); +forbidSource( + portalSource, + /\bmaterializePrivateAssetsInWorkspaceHtml\b/, + 'server.mjs must not materialize private assets directly', +); +forbidSource( + portalSource, + /\brepairMissingHtmlAssetReferences\b/, + 'server.mjs must not repair stored HTML directly', +); +forbidSource( + portalSource, + /\bresolveMindSpaceUserPublishDir\b/, + 'server.mjs must not resolve MindSpace workspace directories', +); +forbidSource( + portalSource, + /\blistRecentlyModifiedPublicHtmlRelativePaths\b/, + 'server.mjs must not scan workspace HTML directly', +); +requireSource( + portalSource, + /\.listRecentlyModifiedPublicHtml\(\{/, + 'Portal delegates recent workspace discovery to MindSpace', +); + +const chatSaveRoutesSource = read( + 'server/portal-mindspace-chat-save-routes.mjs', +); +const chatShareRoutesSource = read( + 'server/portal-mindspace-chat-share-routes.mjs', +); +const portalAssetRoutesSource = read( + 'server/portal-mindspace-asset-routes.mjs', +); +forbidSource( + chatSaveRoutesSource, + /\bresolveMindSpaceUserPublishDir\b/, + 'chat-save routes must not resolve workspace publish directories', +); +forbidSource( + chatSaveRoutesSource, + /\bensureWorkspaceHtmlThumbnail\b/, + 'chat-save routes must not write workspace thumbnails directly', +); +requireSource( + chatSaveRoutesSource, + /getMindSpaceChatSave/, + 'chat-save routes delegate preview thumbnails to MindSpace', +); +requireSource( + chatSaveRoutesSource, + /chatSave\.ensurePreviewThumbnail\(/, + 'chat-save routes use MindSpace preview thumbnail authority', +); +requireSource( + chatSaveRoutesSource, + /chatSave\.renderPreviewThumbnailSvg\(/, + 'chat-save routes render preview thumbnails through MindSpace', +); +requireSource( + chatShareRoutesSource, + /chatSave\.createSharedHtml\(\{/, + 'quick share delegates shared HTML creation to MindSpace', +); +forbidSource( + chatShareRoutesSource, + /\bwriteFile\b/, + 'chat share routes must not write shared HTML directly', +); +forbidSource( + chatShareRoutesSource, + /\bbuildMindSpacePublicUrlForUser\b/, + 'chat share routes must not build public URLs', +); +forbidSource( + chatShareRoutesSource, + /\bresolveMindSpaceRuntimeConfig\b/, + 'chat share routes must not resolve storage roots', +); +forbidSource( + chatShareRoutesSource, + /\bresolveMindSpaceUserPublishDir\b/, + 'chat share routes must not resolve workspace publish directories', +); +forbidSource( + chatShareRoutesSource, + /\binlinePrivateAssetsInHtml\b/, + 'chat share routes must not localize private assets directly', +); +requireSource( + contractSource, + /chatSaveService:\s*Object\.freeze\(\[/, + 'local and remote adapters expose chatSaveService', +); +requireSource( + contractSource, + /'materializeWorkspaceHtml'/, + 'chat save contract includes workspace HTML authority', +); +requireSource( + contractSource, + /'ensurePreviewThumbnail'/, + 'chat save contract includes preview thumbnail authority', +); +requireSource( + contractSource, + /'renderPreviewThumbnailSvg'/, + 'chat save contract includes preview thumbnail rendering', +); +requireSource( + contractSource, + /'createSharedHtml'/, + 'chat save contract includes shared HTML authority', +); +requireSource( + contractSource, + /'readWorkspaceHtml'/, + 'chat save contract includes workspace HTML read authority', +); +requireSource( + localRuntimeSource, + /createMindSpaceChatSaveService\(\{/, + 'MindSpace local runtime owns chatSaveService', +); +requireSource( + portalAssetRoutesSource, + /assets\.readAssetContent\(/, + 'Portal asset download consumes logical MindSpace content', +); +requireSource( + portalAssetRoutesSource, + /\.readPublicAssetContent\(/, + 'Portal public asset download consumes logical MindSpace content', +); +forbidSource( + portalAssetRoutesSource, + /(?:node:fs|sendFile\(|\breadFile\()/, + 'Portal asset routes must not read physical asset paths', +); +forbidSource( + contractSource, + /'read(?:Public)?Asset'/, + 'remote asset contract must not expose physical readAsset paths', +); +requireSource( + contractSource, + /'readAssetContent'/, + 'remote asset contract exposes logical asset content', +); +requireSource( + portalSource, + /registerPublishedLongImageArtifact\(\{/, + 'Portal delegates published long image registration', +); +forbidSource( + portalSource, + /mindSpaceConversationPackageRegistry\.(?:putObjectForSession|recordArtifact|writeManifestForSession)\(/, + 'server.mjs must not mutate conversation packages directly', +); +forbidSource( + portalSessionRoutesSource, + /\bmaterializePublicHtmlWritesFromSessionEvent\b/, + 'Portal session routes must not materialize public HTML directly', +); +forbidSource( + portalSessionRoutesSource, + /\bsyncPublicHtmlAfterFinish\b/, + 'Portal session routes must not perform final public HTML sync directly', +); +forbidSource( + portalSessionRoutesSource, + /\bPUBLISH_ROOT_DIR\b/, + 'Portal session routes must not build public page URLs', +); +forbidSource( + portalSessionRoutesSource, + /(?:resolveMindSpaceUserPublishDir|resolveMindSpaceRuntimeConfig|\bpublishDir\b|\bstorageRoot\b)/, + 'Portal session routes must not resolve MindSpace storage paths', +); +requireSource( + portalSessionRoutesSource, + /publicFinish\.materializeSessionEvent\(\{/, + 'streamed public HTML writes delegate to MindSpace', +); +requireSource( + portalSessionRoutesSource, + /publicFinish\.syncAfterFinish\(\{/, + 'Finish public HTML sync delegates to MindSpace', +); +requireSource( + portalSessionRoutesSource, + /evaluation:\s*[\s\S]{0,80}syncResult[\s\S]{0,80}deliveryEvaluation/, + 'Portal consumes MindSpace HTML delivery evaluation', +); +requireSource( + portalSessionRoutesSource, + /publicFinish\.preparePageDataAfterFinish\(\{/, + 'Portal requests Page Data preparation from MindSpace after page sync', +); +requireSource( + chatSaveRoutesSource, + /conversationArtifacts\.registerChatDocxArtifact\(\{/, + 'chat DOCX artifact registration delegates to MindSpace', +); +forbidSource( + chatSaveRoutesSource, + /getConversationPackageRegistry/, + 'chat save routes must not access the package registry directly', +); +requireSource( + contractSource, + /conversationArtifactService:\s*Object\.freeze\(\[/, + 'local and remote adapters expose conversationArtifactService', +); +requireSource( + contractSource, + /'registerPublicHtmlArtifacts'/, + 'conversation artifact contract includes public HTML registration', +); +forbidSource( + contractSource, + /conversationPackageRegistry:\s*Object\.freeze\(\[[\s\S]{0,240}'(?:ensurePackage|putObjectForSession|recordArtifact|writeManifestForSession)'/, + 'remote package registry contract must stay read-only', +); +requireSource( + localRuntimeSource, + /createMindSpaceConversationArtifactService\(\{/, + 'MindSpace local runtime owns conversationArtifactService', +); +requireSource( + contractSource, + /publicFinishService:\s*Object\.freeze\(\[/, + 'local and remote adapters expose publicFinishService', +); +requireSource( + contractSource, + /'preparePageDataAfterFinish'/, + 'public Finish contract includes Page Data preparation', +); +requireSource( + contractSource, + /'prepareWechatPageDataDelivery'/, + 'public Finish contract includes WeChat Page Data delivery preparation', +); +requireSource( + contractSource, + /'prepareWechatHtmlDelivery'/, + 'public Finish contract includes WeChat HTML delivery preparation', +); +requireSource( + contractSource, + /'ensureWechatFreshPageThumbnails'/, + 'public Finish contract includes WeChat thumbnail authority', +); +requireSource( + localRuntimeSource, + /createMindSpacePublicFinishService\(\{/, + 'MindSpace local runtime owns publicFinishService', +); +requireSource( + publicFinishServiceSource, + /conversationArtifactService[\s\S]{0,160}\.registerPublicHtmlArtifacts\(\{/, + 'MindSpace Finish service owns public HTML artifact registration', +); +requireSource( + publicFinishServiceSource, + /evaluateH5HtmlFinishGuardFn\(\{/, + 'MindSpace Finish service owns HTML delivery evaluation', +); +requireSource( + publicFinishServiceSource, + /preparePageDataAfterFinishFn\(\{/, + 'MindSpace Finish service owns Page Data delivery preparation', +); +requireSource( + publicFinishServiceSource, + /resolvePageDataCollectOutcomeAsyncFn\(\{/, + 'MindSpace Finish service owns WeChat Page Data delivery evaluation', +); +requireSource( + publicFinishServiceSource, + /ensurePageDataDeliveryReadyFn\(\{/, + 'MindSpace Finish service owns WeChat Page Data backing delivery checks', +); +forbidSource( + portalIntegrationBootstrapSource, + /resolveMindSpaceRuntimeConfig|\bstorageRoot\b|\bauthPool\b/, + 'Portal integration bootstrap must not configure WeChat with MindSpace storage paths', +); +requireSource( + portalIntegrationBootstrapSource, + /pageDataFinishGuard:[\s\S]{0,180}mindSpacePublicFinish/, + 'Portal gives WeChat the logical MindSpace Finish authority', +); +requireSource( + portalIntegrationBootstrapSource, + /htmlDeliveryAuthority:[\s\S]{0,260}mindSpacePublicFinish/, + 'Portal gives WeChat the logical MindSpace HTML delivery authority', +); +forbidSource( + wechatMpSource, + /pageDataFinishGuard\?\.(?:pool|h5Root|storageRoot)/, + 'WeChat delivery must not receive MindSpace storage internals', +); +requireSource( + wechatMpSource, + /prepareWechatPageDataDelivery/, + 'WeChat delegates Page Data delivery preparation to MindSpace', +); +requireSource( + wechatMpSource, + /prepareWechatHtmlDelivery/, + 'WeChat delegates HTML delivery preparation to MindSpace', +); +requireSource( + wechatMpSource, + /ensureWechatFreshPageThumbnails/, + 'WeChat delegates fresh thumbnail delivery to MindSpace', +); +forbidSource( + wechatMpSource, + /materializeMissingPublicHtmlWrites|ensureWorkspaceHtmlThumbnail|collectRecentPublishedHtmlArtifacts|resolveLinkExistsForWorkingDir/, + 'WeChat must not perform physical HTML delivery work', +); +requireSource( + publicFinishServiceSource, + /prepareWechatHtmlDeliveryAtWorkspaceFn\(\{/, + 'MindSpace Finish service owns WeChat HTML backing-file preparation', +); +requireSource( + publicFinishServiceSource, + /ensureWechatFreshPageThumbnailsAtWorkspaceFn/, + 'MindSpace Finish service owns WeChat fresh thumbnail work', +); +requireSource( + wechatHtmlDeliverySource, + /materializeMissingPublicHtmlWrites\(\{/, + 'MindSpace WeChat delivery materializes HTML inside the service boundary', +); +requireSource( + wechatHtmlDeliverySource, + /ensureWorkspaceHtmlThumbnail\(/, + 'MindSpace WeChat delivery renders thumbnails inside the service boundary', +); +requireSource( + contractSource, + /workspacePublicationDeliveryService:\s*[\s\S]{0,80}Object\.freeze\(\[/, + 'local and remote adapters expose workspacePublicationDeliveryService', +); +requireSource( + contractSource, + /'resolveWorkspaceRequest'/, + 'workspace delivery contract includes logical request resolution', +); +requireSource( + contractSource, + /'listRecentlyModifiedPublicHtml'/, + 'workspace delivery contract includes recent HTML discovery', +); +requireSource( + localRuntimeSource, + /createMindSpaceWorkspacePublicationDeliveryService\(\{/, + 'MindSpace local runtime owns workspace publication delivery', +); +requireSource( + contractSource, + /workspacePageDeliveryService:\s*[\s\S]{0,80}Object\.freeze\(\['syncAndDeliver'\]\)/, + 'local and remote adapters expose workspace page delivery', +); +requireSource( + localRuntimeSource, + /createWorkspacePageDeliverService\(\{/, + 'MindSpace local runtime owns workspace page sync and delivery', +); +requireSource( + portalDomainBootstrapSource, + /\.workspacePageDeliveryService/, + 'Portal domain bootstrap consumes the MindSpace workspace page delivery binding', +); +forbidSource( + portalDomainBootstrapSource, + /(?:createWorkspacePageDeliverService|resolveMindSpaceRuntimeConfig|ensurePageDataHtmlPagesBound)/, + 'Portal domain bootstrap must not construct storage-aware workspace delivery', +); +requireSource( + portalGatewayBootstrapSource, + /service\.validateRunDeliverables\(/, + 'Portal run validation delegates to MindSpace', +); +forbidSource( + portalGatewayBootstrapSource, + /(?:resolveMindSpaceUserPublishDir|evaluatePageDataHtmlContent|readPageAccessPolicy|scanWorkspaceFilesForProhibitedBrowserStorage)/, + 'Portal gateway must not read workspace files for delivery validation', +); +forbidSource( + portalGatewayBootstrapSource, + /(?:mindSpaceAssets\.readAsset\(|readAssetFileFn)/, + 'Portal gateway must not read physical MindSpace asset paths', +); +forbidSource( + portalAgentJobRoutesSource, + /(?:node:fs|\breadFile\(|asset\.path)/, + 'Portal agent job routes must not read physical asset paths', +); +requireSource( + portalAgentJobRoutesSource, + /asset\.bodyBase64/, + 'Portal agent job delivery consumes MindSpace binary content', +); +requireSource( + workspaceDeliveryServiceSource, + /resolvePublicRequestFn\(\{/, + 'MindSpace workspace delivery resolves public backing files', +); +requireSource( + workspaceDeliveryServiceSource, + /getDeliveryContractFn\(\{/, + 'MindSpace workspace delivery validates delivery readiness', +); +requireSource( + workspaceDeliveryServiceSource, + /materializePrivateAssetsFn\(\{/, + 'MindSpace workspace delivery owns private asset materialization', +); +requireSource( + workspaceDeliveryServiceSource, + /async validateRunDeliverables\(\{/, + 'MindSpace workspace delivery owns run deliverable validation', +); +requireSource( + portalWorkspaceDeliverySource, + /\.resolveWorkspaceRequest\(\{/, + 'Portal workspace route delegates logical delivery to MindSpace', +); +requireSource( + portalWorkspaceDeliverySource, + /\.renderLongImage\(\{/, + 'Portal delegates workspace long-image rendering to MindSpace', +); +forbidSource( + portalWorkspaceDeliverySource, + /(?:node:fs|node:path|resolveMindSpaceUserPublishDir|resolveMindSpaceRuntimeConfig|resolveMindSpacePublicRequest|readFileSync|sendFile\()/, + 'Portal workspace delivery must not resolve or read physical backing files', +); +forbidSource( + portalWorkspaceDeliverySource, + /materializePrivateAssetsInWorkspaceHtml/, + 'Portal workspace delivery must not materialize private assets', +); +requireSource( + portalPublicationRoutesSource, + /\.readOwnerPublicAsset\(\{/, + 'owner public asset route delegates to MindSpace', +); +forbidSource( + portalPublicationRoutesSource, + /(?:node:fs|node:path|resolveMindSpaceUserPublishDir|sendFile\()/, + 'Portal publication routes must not resolve or serve physical files', +); +forbidSource( + portalPublicationRoutesSource, + /\bbuildMindSpacePublicRoutePath\b/, + 'Portal publication routes must not construct workspace canonical URLs', +); +requireSource( + portalPublicationRoutesSource, + /result\.workspacePublicUrl/, + 'Portal publication redirects use the MindSpace canonical URL', +); +requireSource( + publicationServiceSource, + /workspacePublicUrl:\s*[\s\S]{0,80}resolveWorkspaceMindSpacePublicUrl\(\{/, + 'MindSpace publication resolve result owns the workspace canonical URL', +); +requireSource( + contractSource, + /workspaceToolService:\s*Object\.freeze\(\[/, + 'local and remote adapters expose logical workspace tools', +); +requireSource( + contractSource, + /workspaceToolService:[\s\S]{0,240}'generateLongImage'[\s\S]{0,240}'writeBinaryFile'/, + 'workspace tool contract includes long-image generation and binary writes', +); +requireSource( + contractSource, + /conversationArtifactService:[\s\S]{0,240}'registerWorkspaceFileArtifact'/, + 'conversation artifact contract includes generic workspace write registration', +); +requireSource( + localRuntimeSource, + /createMindSpaceWorkspaceToolService\(\{/, + 'MindSpace local runtime owns logical workspace tools', +); +requireSource( + workspaceToolServiceSource, + /parseMindSpaceWorkspaceRef\(workspaceRef\)/, + 'workspace tools resolve a logical workspaceRef', +); +requireSource( + workspaceToolServiceSource, + /syncWorkspaceAssets\(/, + 'workspace writes synchronize logical assets', +); +requireSource( + workspaceToolServiceSource, + /\.registerWorkspaceFileArtifact\(\{/, + 'non-public workspace writes register package artifacts directly', +); +requireSource( + workspaceToolServiceSource, + /\.registerPublicHtmlArtifacts\(\{/, + 'public HTML workspace writes register package artifacts immediately', +); +requireSource( + workspaceToolServiceSource, + /async writeBinaryFile\([\s\S]{0,900}registerWrite\(\{/, + 'binary workspace writes persist and register through MindSpace', +); +requireSource( + workspaceToolServiceSource, + /async generateLongImage\([\s\S]{0,1800}renderLongImageFn\(\{[\s\S]{0,900}registerWrite\(\{/, + 'long-image generation and artifact registration run inside MindSpace', +); +forbidSource( + workspaceToolServiceSource, + /\babsolutePath\s*:/, + 'logical workspace tool results must not expose absolute paths', +); +requireSource( + scopedMcpTokenSource, + /sessionId:[\s\S]{0,180}packageId:[\s\S]{0,180}workspaceRef:[\s\S]{0,180}tools:/, + 'MCP token binds session, package, workspace, and tool allowlist', +); +requireSource( + mindSpaceRpcServerSource, + /verifyMindSpaceMcpScopedToken\(\{/, + 'MindSpace MCP RPC verifies scoped tokens', +); +requireSource( + mindSpaceRpcServerSource, + /workspaceToolService[\s\S]{0,240}claims\.workspaceRef/, + 'MindSpace MCP RPC injects token workspace claims into tool calls', +); +requireSource( + mindSpaceRpcServerSource, + /generate_long_image:\s*'generateLongImage'[\s\S]{0,160}write_binary_file:\s*'writeBinaryFile'/, + 'MindSpace MCP RPC exposes scoped long-image and binary operations', +); +requireSource( + sandboxMcpSource, + /callLogicalWorkspaceTool\(/, + 'sandbox MCP delegates logical workspace operations over RPC', +); +requireSource( + sandboxMcpSource, + /MINDSPACE_MCP_SCOPED_TOKEN/, + 'sandbox MCP accepts only a scoped service token', +); +requireSource( + sandboxMcpSource, + /case 'generate_docx':[\s\S]{0,1200}callLogicalWorkspaceTool\([\s\S]{0,120}'write_binary_file'/, + 'scoped DOCX generation uploads through MindSpace instead of the workspace path', +); +requireSource( + sandboxMcpSource, + /LOGICAL_WORKSPACE_TOOLS[\s\S]{0,240}'generate_long_image'/, + 'scoped long-image generation delegates to MindSpace', +); +requireSource( + capabilitiesSource, + /mintMindSpaceMcpScopedToken\(\{/, + 'Portal policy assembly mints a scoped MCP token', +); +requireSource( + capabilitiesSource, + /mcpTools\.includes\('generate_docx'\)[\s\S]{0,100}'write_binary_file'/, + 'DOCX capability grants only the internal scoped binary write operation', +); +forbidSource( + capabilitiesSource, + /envs\.MINDSPACE_MCP_TOKEN_SECRET/, + 'the MCP signing secret must never enter the Goose extension environment', +); +requireSource( + userAuthSource, + /workspaceRef:[\s\S]{0,180}sessionId:[\s\S]{0,180}packageId:/, + 'agent session policy supplies logical workspace/session/package scope', +); +requireSource( + userAuthSource, + /mcpBaseUrl:[\s\S]{0,160}mcpTokenSecret:/, + 'agent session policy supplies the MCP endpoint and server-side signing secret', +); + +if (withRuntime) { + const portalRuntimeSource = read('.runtime/portal/server.mjs'); + const mindSpaceRuntimeContract = read( + '.runtime/mindspace-service/memind-source/mindspace-server-adapter-contract.mjs', + ); + const mindSpaceRuntimeService = read( + '.runtime/mindspace-service/memind-source/mindspace-chat-save-service.mjs', + ); + const mindSpaceRuntimeArtifactService = read( + '.runtime/mindspace-service/memind-source/mindspace-conversation-package-artifact-service.mjs', + ); + const mindSpaceRuntimePublicFinishService = read( + '.runtime/mindspace-service/memind-source/mindspace-public-finish-service.mjs', + ); + const mindSpaceRuntimeWorkspaceDeliveryService = read( + '.runtime/mindspace-service/memind-source/mindspace-workspace-publication-delivery-service.mjs', + ); + requireSource( + portalRuntimeSource, + /materializeWorkspaceHtml/, + 'Portal runtime delegates chat-save materialization', + ); + requireSource( + mindSpaceRuntimeContract, + /chatSaveService/, + 'MindSpace runtime exposes the chat-save RPC contract', + ); + requireSource( + mindSpaceRuntimeService, + /writeFileFn\(absolutePath,\s*materializedHtml,\s*'utf8'\)/, + 'MindSpace runtime owns the chat-save workspace write', + ); + requireSource( + mindSpaceRuntimeService, + /async createSharedHtml\(\{/, + 'MindSpace runtime owns shared HTML creation', + ); + requireSource( + mindSpaceRuntimeArtifactService, + /registerPublicHtmlArtifacts/, + 'MindSpace runtime owns conversation artifact registration', + ); + requireSource( + mindSpaceRuntimePublicFinishService, + /syncAfterFinish/, + 'MindSpace runtime owns public Finish synchronization', + ); + requireSource( + mindSpaceRuntimeWorkspaceDeliveryService, + /resolveWorkspaceRequest/, + 'MindSpace runtime owns workspace publication delivery', + ); +} + +console.log( + withRuntime + ? 'mindspace read/write authority boundary ok (with runtimes)' + : 'mindspace read/write authority boundary ok', +); diff --git a/scripts/verify-mindspace-publish-guards.mjs b/scripts/verify-mindspace-publish-guards.mjs index db0b02f..90b1d5e 100644 --- a/scripts/verify-mindspace-publish-guards.mjs +++ b/scripts/verify-mindspace-publish-guards.mjs @@ -19,11 +19,32 @@ run('MindSpace publish + chat finish unit tests', [ '--test', 'mindspace-public-finish-sync.test.mjs', 'mindspace-h5-html-finish-guard.test.mjs', + 'mindspace-chat-save-service.test.mjs', + 'mindspace-conversation-package-artifact-service.test.mjs', + 'mindspace-workspace-publication-delivery-service.test.mjs', + 'mindspace-workspace-tool-service.test.mjs', + 'mindspace-mcp-scoped-token.test.mjs', + 'mindspace-sandbox-mcp.test.mjs', + 'capabilities.test.mjs', + 'server/portal-workspace-publication-delivery.test.mjs', + 'server/portal-publication-routes.test.mjs', + 'server/portal-mindspace-asset-routes.test.mjs', + 'server/portal-agent-job-routes.test.mjs', + 'server/portal-gateway-services-bootstrap.test.mjs', 'conversation-display.test.mjs', 'chat-finish-sync.test.mjs', ]); run('chat finish sync source guards', ['scripts/verify-chat-finish-sync.mjs']); +run( + withRuntime + ? 'MindSpace authority source and runtime guards' + : 'MindSpace authority source guards', + [ + 'scripts/verify-mindspace-authority-boundary.mjs', + ...(withRuntime ? ['--with-runtime'] : []), + ], +); if (withRuntime) { run('public finish sync runtime guard', ['scripts/verify-public-finish-sync-runtime.mjs']); diff --git a/scripts/verify-public-finish-sync-runtime.mjs b/scripts/verify-public-finish-sync-runtime.mjs index 6d842cb..17bef61 100644 --- a/scripts/verify-public-finish-sync-runtime.mjs +++ b/scripts/verify-public-finish-sync-runtime.mjs @@ -62,19 +62,22 @@ if (!fs.existsSync(runtimeServer)) { } const runtimeSource = fs.readFileSync(runtimeServer, 'utf8'); -const requiredRuntimeSnippets = [ - 'normalizedName === "write"', - 'normalizedName.endsWith("__write")', - 'normalizedName === "edit_file"', - 'applyPublicHtmlEdit', - 'recentCount = 80', - 'if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) {', +const requiredRuntimePatterns = [ + [/normalizedName === "write"/, 'plain write tool request detection'], + [/normalizedName\.endsWith\("__write"\)/, 'namespaced write tool request detection'], + [/normalizedName === "edit_file"/, 'edit_file tool request detection'], + [/applyPublicHtmlEdit/, 'public HTML edit materialization'], + [/recentCount = 80/, 'wide recent message scan window'], + [ + /if \(extractPublicHtmlWriteArtifacts\(\[message\], \{ publishDir(?:: \w+)? \}\)\.length > 0\) \{/, + 'textless session-event HTML write prefilter', + ], ]; -for (const snippet of requiredRuntimeSnippets) { +for (const [pattern, label] of requiredRuntimePatterns) { assert.ok( - runtimeSource.includes(snippet), - `runtime server bundle is missing public finish sync guard: ${snippet}`, + pattern.test(runtimeSource), + `runtime server bundle is missing public finish sync guard: ${label}`, ); } diff --git a/server.mjs b/server.mjs index 15c5b39..a59fe90 100644 --- a/server.mjs +++ b/server.mjs @@ -1,7 +1,5 @@ import express from 'express'; -import crypto from 'node:crypto'; import fs from 'node:fs'; -import fsPromises from 'node:fs/promises'; import { createProxyMiddleware } from 'http-proxy-middleware'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -73,13 +71,10 @@ import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs'; import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; -import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs'; import { buildMindSpacePublicUrlForUser, - resolveMindSpaceRuntimeConfig, resolveMindSpacePublishRoot, resolveMindSpaceServerRuntimeOptions, - resolveMindSpaceUserPublishDir, resolvePortalH5Root, } from './mindspace-runtime-config.mjs'; import { @@ -92,15 +87,12 @@ import { buildChatSaveThumbnailUrl, buildWorkspaceAssetUrl, buildWorkspaceThumbnailUrl, - materializePrivateAssetsInWorkspaceHtml, - repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, } from './mindspace-chat-save.mjs'; import { normalizePublicHtmlRelativePath, } from './mindspace-public-finish-sync.mjs'; import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs'; -import { listRecentlyModifiedPublicHtmlRelativePaths } from './mindspace-run-public-html-scope.mjs'; import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; import { @@ -290,6 +282,11 @@ let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpaceServiceFacade = null; +let mindSpaceChatSave = null; +let mindSpaceConversationArtifacts = null; +let mindSpacePublicFinish = null; +let mindSpaceWorkspacePublicationDelivery = + null; let mindSpaceConversationPackageRegistry = null; let mindSpacePages = null; let mindSpacePageSync = null; @@ -342,7 +339,6 @@ async function bootstrapUserAuth() { onMindSearchSchemaReady: () => { authPool = pool; }, - registerPublicHtmlArtifactsForConversation, workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED, logger: console, @@ -362,6 +358,15 @@ async function bootstrapUserAuth() { mindSpace = domainServices.mindSpace; mindSpaceServiceFacade = domainServices.mindSpaceServiceFacade; + mindSpaceChatSave = + domainServices.mindSpaceChatSave; + mindSpaceConversationArtifacts = + domainServices.mindSpaceConversationArtifacts; + mindSpacePublicFinish = + domainServices.mindSpacePublicFinish; + mindSpaceWorkspacePublicationDelivery = + domainServices + .mindSpaceWorkspacePublicationDelivery; mindSpaceConversationPackageRegistry = domainServices.mindSpaceConversationPackageRegistry; mindSpaceAssets = domainServices.mindSpaceAssets; @@ -485,6 +490,8 @@ async function bootstrapUserAuth() { memoryV2, systemDisclosurePolicyService, mindSpaceAssets, + getWorkspacePublicationDelivery: () => + mindSpaceWorkspacePublicationDelivery, directChatService, chatIntentRouter, syncUserGeneratedPages, @@ -501,7 +508,7 @@ async function bootstrapUserAuth() { env: process.env, usersRoot: USERS_ROOT, wechatMpConfig: WECHAT_MP_CONFIG, - authPool, + mindSpacePublicFinish, userAuth, sessionAccess, tkmindProxy, @@ -1115,12 +1122,18 @@ async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null // no conversation artifact. Discover only files written around this run; // `null` would rescan every historical page and let stale Page Data pages // block an unrelated delivery. - const recentWorkspaceRelativePaths = sessionId && !discoveredRelativePaths?.length - ? listRecentlyModifiedPublicHtmlRelativePaths( - resolveMindSpaceUserPublishDir(H5_ROOT, { id: userId }), - { sinceMs }, - ) - : []; + const recentWorkspaceRelativePaths = + sessionId && + !discoveredRelativePaths?.length && + mindSpaceWorkspacePublicationDelivery + ? ( + await mindSpaceWorkspacePublicationDelivery + .listRecentlyModifiedPublicHtml({ + userId, + sinceMs, + }) + )?.relativePaths ?? [] + : []; const pageDataRelativePaths = sessionId ? (discoveredRelativePaths?.length ? discoveredRelativePaths : recentWorkspaceRelativePaths) : null; @@ -1147,42 +1160,23 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { selectedLinkIndex, }); - if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { - const { storageRoot } = resolveMindSpaceRuntimeConfig(h5Root, process.env); - let htmlContent = resolvedHtml.content; - - const repaired = await repairMissingHtmlAssetReferences({ - pool: authPool, - userId: user.id, - sourceContent: source.content, - html: htmlContent, - }).catch(() => ({ html: htmlContent, changed: false })); - if (repaired.changed) { - htmlContent = repaired.html; - } - - const materialized = await materializePrivateAssetsInWorkspaceHtml({ - pool: authPool, - storageRoot, - h5Root, - userId: user.id, - html: htmlContent, - htmlRelativePath: resolvedHtml.relativePath, - writeBack: false, - }).catch(() => ({ html: htmlContent, count: 0, changed: false })); - if (materialized.changed) { - htmlContent = materialized.html; - } - - if (htmlContent !== resolvedHtml.content) { - const publishDir = resolveMindSpaceUserPublishDir(h5Root, { id: user.id }); - await fsPromises.writeFile( - path.join(publishDir, resolvedHtml.relativePath), - htmlContent, - 'utf8', - ); - resolvedHtml = { ...resolvedHtml, content: htmlContent }; - } + if ( + resolvedHtml?.content && + resolvedHtml.relativePath && + mindSpaceChatSave + ) { + const materialized = + await mindSpaceChatSave.materializeWorkspaceHtml({ + userId: user.id, + sourceContent: source.content, + html: resolvedHtml.content, + relativePath: resolvedHtml.relativePath, + }); + resolvedHtml = { + ...resolvedHtml, + relativePath: materialized.relativePath, + content: materialized.html, + }; } return { @@ -1228,7 +1222,7 @@ async function registerPublishedLongImageArtifactForConversation({ const messageId = String(pageSource.sourceMessageId ?? '').trim(); const publicationId = String(result?.publication?.id ?? '').trim(); if ( - !mindSpaceConversationPackageRegistry || + !mindSpaceConversationArtifacts || !ownerId || !sessionId || !publicationId || @@ -1237,77 +1231,34 @@ async function registerPublishedLongImageArtifactForConversation({ return; } try { - const hash = crypto - .createHash('sha256') - .update(`${ownerId}:${sessionId}:${publicationId}:long-image`) - .digest('hex') - .slice(0, 16); - const artifactId = `ca_long_image_${hash}`; - const filename = `${publicationId || 'published-page'}.long.png`; - const relativePath = `artifacts/${artifactId}/${filename}`; - const now = Date.now(); - const { packageRecord, writeResult } = - await mindSpaceConversationPackageRegistry.putObjectForSession({ - userId: ownerId, + await mindSpaceConversationArtifacts + .registerPublishedLongImageArtifact({ + ownerId, sessionId, + messageId: messageId || null, + publicationId, + pageId: + pageSource.pageId ?? + result?.publication?.pageId ?? + null, title: pageSource.title ?? null, - relativePath, body: image, + canonicalUrl, }); - await mindSpaceConversationPackageRegistry.recordArtifact({ - id: artifactId, - packageId: packageRecord.id, - artifactKind: 'long_image', - role: 'assistant', - pageId: pageSource.pageId ?? result?.publication?.pageId ?? null, - publicationId, - messageId: messageId || null, - displayName: filename, - mimeType: 'image/png', - sizeBytes: image.length, - storageKey: writeResult.key, - canonicalUrl, - sortOrder: now, - now, - }); - await mindSpaceConversationPackageRegistry.writeManifestForSession({ - userId: ownerId, - sessionId, - }); } catch (error) { console.warn('[MindSpace] failed to record published long image artifact:', error?.message ?? error); } } -async function registerPublicHtmlArtifactsForConversation({ - user, - publishDir, - sessionId, - title = null, - relativePaths = [], - artifactRefs = [], -}) { - return registerPublicHtmlArtifactsForConversationPackage({ - conversationPackageRegistry: mindSpaceConversationPackageRegistry, - h5Root: H5_ROOT, - env: process.env, - user, - publishDir, - sessionId, - title, - relativePaths, - artifactRefs, - }); -} - attachPortalMindSpaceChatSaveRoutes(api, { h5Root: H5_ROOT, env: process.env, getMindSpacePages: () => mindSpacePages, getMindSpaceAssets: () => mindSpaceAssets, + getMindSpaceChatSave: () => mindSpaceChatSave, + getConversationArtifactService: () => + mindSpaceConversationArtifacts, getAuthPool: () => authPool, - getConversationPackageRegistry: () => - mindSpaceConversationPackageRegistry, resolveChatSaveBundle, resolveExistingSavedPage, resolveOwnedAssistantMessage, @@ -1317,11 +1268,10 @@ attachPortalMindSpaceChatSaveRoutes(api, { attachPortalMindSpaceChatShareRoutes(api, { h5Root: H5_ROOT, - env: process.env, getMindSpacePages: () => mindSpacePages, + getMindSpaceChatSave: () => mindSpaceChatSave, getMindSpacePublications: () => mindSpacePublications, getPlazaPosts: () => plazaPosts, - getAuthPool: () => authPool, resolveChatSaveBundle, resolvePlazaPostUrl: resolvePlazaPostUrlForRequest, mapPlazaError, @@ -1406,25 +1356,21 @@ attachPortalAgentRuntimeRoutes(api, { }); attachPortalSessionRoutes(api, { - h5Root: H5_ROOT, - env: process.env, waitForUserAuthReady: () => userAuthReady, getUserAuth: () => userAuth, getTkmindProxy: () => tkmindProxy, getSessionSnapshotService: () => sessionSnapshotService, getAuthPool: () => authPool, - getMindSpaceAssets: () => mindSpaceAssets, + getMindSpacePublicFinish: () => + mindSpacePublicFinish, getMemoryV2: () => memoryV2, getMindSpaceAnalyticsConfig: () => mindSpaceAnalyticsConfig, getMindSpaceRybbitConfig: () => mindSpaceRybbitConfig, - isWorkspaceMaintenanceEnabled: () => - WORKSPACE_MAINTENANCE_ENABLED, ownsAgentSession, unregisterAgentSessionForUser, beginSessionPageDelivery, endSessionPageDelivery, syncUserGeneratedPages, - registerPublicHtmlArtifactsForConversation, logger: console, }); @@ -1517,7 +1463,6 @@ app.use('/rybbit', createProxyMiddleware({ })); const { serveUserPublishFile } = createPortalWorkspacePublicationDelivery({ - h5Root: H5_ROOT, internalAgentSecret: INTERNAL_AGENT_SECRET, analyticsConfig: @@ -1527,6 +1472,8 @@ const { serveUserPublishFile } = getAuthPool: () => authPool, getUserAuth: () => userAuth, getMindSpacePages: () => mindSpacePages, + getWorkspacePublicationDelivery: () => + mindSpaceWorkspacePublicationDelivery, resolveRequestOrigin, removeQueryParam, logger: console, @@ -1557,6 +1504,8 @@ attachPortalPublicationRoutes({ getMindSpacePages: () => mindSpacePages, getMindSpacePublications: () => mindSpacePublications, + getWorkspacePublicationDelivery: () => + mindSpaceWorkspacePublicationDelivery, getUserAuth: () => userAuth, sendPublishedPage, }); diff --git a/server/portal-agent-job-routes.mjs b/server/portal-agent-job-routes.mjs index 5211e95..20370b2 100644 --- a/server/portal-agent-job-routes.mjs +++ b/server/portal-agent-job-routes.mjs @@ -1,5 +1,3 @@ -import fs from 'node:fs'; - const TERMINAL_AGENT_JOB_STATUSES = new Set([ 'completed', 'failed', @@ -33,7 +31,6 @@ export function attachPortalAgentJobRoutes( handleMindSpaceError = (_res, _req, error) => { throw error; }, - readFile = fs.promises.readFile, getSsePollMs = () => 1_000, setIntervalFn = setInterval, clearIntervalFn = clearInterval, @@ -254,7 +251,12 @@ export function attachPortalAgentJobRoutes( ); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); - return res.send(await readFile(asset.path)); + return res.send( + Buffer.from( + asset.bodyBase64, + 'base64', + ), + ); } catch (error) { return handleMindSpaceError(res, req, error); } diff --git a/server/portal-agent-job-routes.test.mjs b/server/portal-agent-job-routes.test.mjs index 4cc6e90..07cea14 100644 --- a/server/portal-agent-job-routes.test.mjs +++ b/server/portal-agent-job-routes.test.mjs @@ -448,15 +448,12 @@ test('internal asset preserves token, headers, file read, and binary response', return { mimeType: 'text/plain', displayName: 'report one.txt', - path: '/tmp/report-one.txt', + bodyBase64: + body.toString('base64'), }; }, }), bearerToken: () => 'worker-token', - readFile: async (path) => { - assert.equal(path, '/tmp/report-one.txt'); - return body; - }, }); attachPortalAgentJobRoutes(api, deps.dependencies); const res = createResponseRecorder(); @@ -474,7 +471,7 @@ test('internal asset preserves token, headers, file read, and binary response', 'Cache-Control': 'private, no-store', 'X-Request-Id': 'request-asset', }); - assert.equal(res.body, body); + assert.deepEqual(res.body, body); }); test('heartbeat and complete preserve worker token and payload mapping', async () => { diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs index d438257..630c10c 100644 --- a/server/portal-domain-services-bootstrap.mjs +++ b/server/portal-domain-services-bootstrap.mjs @@ -5,11 +5,6 @@ import { assertMindSpaceServerAdapterContract, createMindSpaceServerAdapter, } from '../mindspace-server-adapter.mjs'; -import { - resolveMindSpaceRuntimeConfig, -} from '../mindspace-runtime-config.mjs'; -import { createWorkspacePageDeliverService } from '../mindspace-workspace-page-deliver.mjs'; -import { ensurePageDataHtmlPagesBound } from '../page-data-workspace-ensure.mjs'; import { createPageDataPublicService } from '../page-data-public-service.mjs'; import { createPageDataService } from '../page-data-service.mjs'; import { @@ -43,7 +38,6 @@ export async function bootstrapPortalDomainServices({ getUserAuth = () => null, getSessionSnapshotService = () => null, onMindSearchSchemaReady = () => {}, - registerPublicHtmlArtifactsForConversation, workspaceMaintenanceEnabled = true, logger = console, initSchemaFn = initSchema, @@ -61,12 +55,6 @@ export async function bootstrapPortalDomainServices({ createMindSpaceServerAdapter, assertMindSpaceServerAdapterContractFn = assertMindSpaceServerAdapterContract, - createWorkspacePageDeliverServiceFn = - createWorkspacePageDeliverService, - ensurePageDataHtmlPagesBoundFn = - ensurePageDataHtmlPagesBound, - resolveMindSpaceRuntimeConfigFn = - resolveMindSpaceRuntimeConfig, ensureAlgorithmConfigFn = ensureAlgorithmConfig, loadAlgorithmConfigFn = loadAlgorithmConfig, createPlazaRedisFn = createPlazaRedis, @@ -89,15 +77,6 @@ export async function bootstrapPortalDomainServices({ 'bootstrapPortalDomainServices requires pool and runtime', ); } - if ( - typeof registerPublicHtmlArtifactsForConversation !== - 'function' - ) { - throw new Error( - 'bootstrapPortalDomainServices requires artifact registration', - ); - } - const mindSearchConfigService = createMindSearchConfigServiceFn(pool); await mindSearchConfigService.ensureSchema(); @@ -175,7 +154,6 @@ export async function bootstrapPortalDomainServices({ getUserAuth().resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => getSessionSnapshotService()?.get?.(sessionId), - registerPublicHtmlArtifactsForConversation, syncWorkspaceAssetsEnabled: workspaceMaintenanceEnabled, remote: runtime.remote, @@ -186,6 +164,15 @@ export async function bootstrapPortalDomainServices({ const mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade; + const mindSpaceChatSave = + mindSpaceRuntimeAdapter.chatSaveService; + const mindSpaceConversationArtifacts = + mindSpaceRuntimeAdapter.conversationArtifactService; + const mindSpacePublicFinish = + mindSpaceRuntimeAdapter.publicFinishService; + const mindSpaceWorkspacePublicationDelivery = + mindSpaceRuntimeAdapter + .workspacePublicationDeliveryService; const mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; const mindSpaceAssets = @@ -200,25 +187,8 @@ export async function bootstrapPortalDomainServices({ const mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; const workspacePageDeliver = - createWorkspacePageDeliverServiceFn({ - pool, - pageService: mindSpacePages, - publicationService: mindSpacePublications, - pageSyncService: mindSpacePageSync, - pageDataEnsure: { - ensurePageDataHtmlPagesBound: - ensurePageDataHtmlPagesBoundFn, - }, - h5Root, - storageRoot: - resolveMindSpaceRuntimeConfigFn(h5Root, env) - .storageRoot, - findPageByRelativePath: - mindSpacePages.findPageByRelativePath.bind( - mindSpacePages, - ), - logger, - }); + mindSpaceRuntimeAdapter + .workspacePageDeliveryService; const resolveUserIdByDirKey = async (dirKey) => { let userId = dirKey; @@ -309,6 +279,10 @@ export async function bootstrapPortalDomainServices({ mindSpace, mindSpaceRuntimeAdapter, mindSpaceServiceFacade, + mindSpaceChatSave, + mindSpaceConversationArtifacts, + mindSpacePublicFinish, + mindSpaceWorkspacePublicationDelivery, mindSpaceConversationPackageRegistry, mindSpaceAssets, mindSpacePages, diff --git a/server/portal-domain-services-bootstrap.test.mjs b/server/portal-domain-services-bootstrap.test.mjs index 35c872c..8e39196 100644 --- a/server/portal-domain-services-bootstrap.test.mjs +++ b/server/portal-domain-services-bootstrap.test.mjs @@ -21,6 +21,17 @@ function createBootstrapSetup(overrides = {}) { }; const adapter = { serviceFacade: { id: 'facade' }, + chatSaveService: { id: 'chat-save' }, + conversationArtifactService: { + id: 'conversation-artifacts', + }, + publicFinishService: { id: 'public-finish' }, + workspacePublicationDeliveryService: { + id: 'workspace-publication-delivery', + }, + workspacePageDeliveryService: { + id: 'workspace-deliver', + }, conversationPackageRegistry: { id: 'registry' }, assetService: { id: 'assets' }, pageService, @@ -42,7 +53,6 @@ function createBootstrapSetup(overrides = {}) { let adapterOptions = null; let pageDataOptions = null; let pageDataPublicOptions = null; - let workspaceDeliverOptions = null; let plazaPostOptions = null; let plazaOpsOptions = null; const dependencies = { @@ -73,7 +83,6 @@ function createBootstrapSetup(overrides = {}) { assert.equal(receivedPool, pool); calls.push({ kind: 'auth-pool-ready' }); }, - registerPublicHtmlArtifactsForConversation() {}, workspaceMaintenanceEnabled: true, logger: { log(message) { @@ -133,14 +142,6 @@ function createBootstrapSetup(overrides = {}) { calls.push({ kind: 'adapter-contract' }); return received; }, - createWorkspacePageDeliverServiceFn(options) { - workspaceDeliverOptions = options; - return { id: 'workspace-deliver' }; - }, - ensurePageDataHtmlPagesBoundFn() {}, - resolveMindSpaceRuntimeConfigFn() { - return { storageRoot: '/storage' }; - }, async ensureAlgorithmConfigFn() { calls.push({ kind: 'algorithm-schema' }); }, @@ -221,9 +222,6 @@ function createBootstrapSetup(overrides = {}) { get pageDataPublicOptions() { return pageDataPublicOptions; }, - get workspaceDeliverOptions() { - return workspaceDeliverOptions; - }, get plazaPostOptions() { return plazaPostOptions; }, @@ -234,19 +232,11 @@ function createBootstrapSetup(overrides = {}) { }; } -test('domain bootstrap requires pool, runtime, and artifact registration', async () => { +test('domain bootstrap requires pool and runtime', async () => { await assert.rejects( () => bootstrapPortalDomainServices(), /requires pool and runtime/, ); - await assert.rejects( - () => - bootstrapPortalDomainServices({ - pool: {}, - runtime: {}, - }), - /requires artifact registration/, - ); }); test('domain bootstrap preserves schema and service assembly order', async () => { @@ -288,6 +278,21 @@ test('domain bootstrap preserves schema and service assembly order', async () => ); assert.equal(result.mindSpace.id, 'mindspace'); assert.equal(result.mindSpaceAssets.id, 'assets'); + assert.equal(result.mindSpaceChatSave.id, 'chat-save'); + assert.equal( + result.mindSpaceConversationArtifacts.id, + 'conversation-artifacts', + ); + assert.equal( + result.mindSpacePublicFinish.id, + 'public-finish', + ); + assert.equal( + result + .mindSpaceWorkspacePublicationDelivery + .id, + 'workspace-publication-delivery', + ); assert.equal(result.mindSpacePages, setup.pageService); assert.equal(result.workspacePageDeliver.id, 'workspace-deliver'); assert.equal(result.mindSpaceCleanup.id, 'cleanup'); @@ -364,10 +369,6 @@ test('domain bootstrap preserves delayed user, snapshot, and Page Data dependenc setup.captured.adapterOptions.syncWorkspaceAssetsEnabled, true, ); - assert.equal( - setup.captured.workspaceDeliverOptions.storageRoot, - '/storage', - ); }); test('domain bootstrap preserves directory and Plaza cross-service callbacks', async () => { diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index a80c9c8..4381fc6 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -3,13 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { createAgentRunGateway } from '../agent-run-gateway.mjs'; import { isDirectChatSessionId } from '../direct-chat-service.mjs'; -import { scanWorkspaceFilesForProhibitedBrowserStorage } from '../mindspace-browser-storage-policy.mjs'; -import { evaluatePageDataHtmlContent } from '../mindspace-page-data-finish-guard.mjs'; -import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; -import { resolveMindSpaceUserPublishDir } from '../mindspace-runtime-config.mjs'; -import { policyAllowsAction } from '../page-access-policy.mjs'; -import { detectPageDataDatasetUsageFromHtml } from '../page-data-html-detect.mjs'; -import { readPageAccessPolicy } from '../page-data-policy-store.mjs'; import { cancelSessionActiveRequest, quiesceSessionStdioExtensions, @@ -91,115 +84,35 @@ export function startPortalAgentRunRecoveryLoop( } export function createPortalRunDeliverablesValidator({ - h5Root, - resolveMindSpaceUserPublishDirFn = - resolveMindSpaceUserPublishDir, - normalizeWorkspaceRelativePathFn = - normalizeWorkspaceRelativePath, - evaluatePageDataHtmlContentFn = - evaluatePageDataHtmlContent, - readPageAccessPolicyFn = readPageAccessPolicy, - detectPageDataDatasetUsageFromHtmlFn = - detectPageDataDatasetUsageFromHtml, - policyAllowsActionFn = policyAllowsAction, - scanWorkspaceFilesForProhibitedBrowserStorageFn = - scanWorkspaceFilesForProhibitedBrowserStorage, - resolvePathFn = path.resolve, - pathSeparator = path.sep, - existsSyncFn = fs.existsSync, - readFileSyncFn = fs.readFileSync, + getWorkspacePublicationDelivery = () => + null, } = {}) { - if (!h5Root) { + if ( + typeof getWorkspacePublicationDelivery !== + 'function' + ) { throw new Error( - 'createPortalRunDeliverablesValidator requires h5Root', + 'createPortalRunDeliverablesValidator requires MindSpace delivery access', ); } - return async function validateRunDeliverables({ - userId, - deliverables, - }) { - const publishDir = - resolveMindSpaceUserPublishDirFn(h5Root, { - id: userId, - }); - const resolvedPublishDir = - resolvePathFn(publishDir); - const pageDataErrors = []; - - for (const page of deliverables?.pages ?? []) { - const relativePath = - normalizeWorkspaceRelativePathFn( - page.workspaceRelativePath, - ); - if (!relativePath?.startsWith('public/')) continue; - const filePath = resolvePathFn( - publishDir, - relativePath, + return async function validateRunDeliverables( + input, + ) { + const service = + getWorkspacePublicationDelivery(); + if ( + !service || + typeof service.validateRunDeliverables !== + 'function' + ) { + throw new Error( + 'MindSpace 交付验证服务未启用', ); - if ( - !filePath.startsWith( - `${resolvedPublishDir}${pathSeparator}`, - ) || - !existsSyncFn(filePath) - ) { - continue; - } - const html = readFileSyncFn(filePath, 'utf8'); - const evaluation = - evaluatePageDataHtmlContentFn(html, { - relativePath, - }); - if (!evaluation.usesPageDataApi) continue; - - for (const issue of evaluation.issues) { - pageDataErrors.push({ - code: issue, - message: `${relativePath} Page Data HTML 不可交付:${issue}`, - }); - } - const policy = page.pageId - ? readPageAccessPolicyFn( - publishDir, - page.pageId, - ) - : null; - for (const [dataset, actions] of - detectPageDataDatasetUsageFromHtmlFn(html)) { - for (const action of ['read', 'insert']) { - if ( - actions?.[action] && - !policyAllowsActionFn( - policy, - dataset, - action, - ) - ) { - pageDataErrors.push({ - code: 'page_data_policy_action_missing', - message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, - }); - } - } - } } - - const violations = - scanWorkspaceFilesForProhibitedBrowserStorageFn({ - publishDir, - relativePaths: (deliverables?.pages ?? []) - .map((page) => page.workspaceRelativePath) - .filter(Boolean), - }); - return { - errors: [ - ...pageDataErrors, - ...violations.map((violation) => ({ - code: 'browser_storage_forbidden', - message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, - })), - ], - }; + return service.validateRunDeliverables( + input, + ); }; } @@ -220,6 +133,8 @@ export function bootstrapPortalGatewayServices({ memoryV2, systemDisclosurePolicyService, mindSpaceAssets, + getWorkspacePublicationDelivery = () => + null, directChatService, chatIntentRouter, syncUserGeneratedPages, @@ -235,7 +150,6 @@ export function bootstrapPortalGatewayServices({ createPortalRunDeliverablesValidator, startAgentRunRecoveryLoopFn = startPortalAgentRunRecoveryLoop, - readAssetFileFn = fs.promises.readFile, } = {}) { if ( !pool || @@ -267,15 +181,17 @@ export function bootstrapPortalGatewayServices({ systemDisclosurePolicyService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { - const { asset, path: assetPath } = - await mindSpaceAssets.readAsset( - userId, - assetId, - ); - const buffer = - await readAssetFileFn(assetPath); + const { asset, bodyBase64 } = + await mindSpaceAssets + .readAssetContent( + userId, + assetId, + ); return { - buffer, + buffer: Buffer.from( + bodyBase64, + 'base64', + ), mimeType: asset.mimeType, }; } @@ -285,7 +201,9 @@ export function bootstrapPortalGatewayServices({ llmProviderService, }); const validateRunDeliverables = - createRunDeliverablesValidatorFn({ h5Root }); + createRunDeliverablesValidatorFn({ + getWorkspacePublicationDelivery, + }); const workflowShadowEnabled = isEnabledFlag( env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED, ); diff --git a/server/portal-gateway-services-bootstrap.test.mjs b/server/portal-gateway-services-bootstrap.test.mjs index 157e6e1..c02e50e 100644 --- a/server/portal-gateway-services-bootstrap.test.mjs +++ b/server/portal-gateway-services-bootstrap.test.mjs @@ -19,6 +19,13 @@ function createSetup(overrides = {}) { const tkmindProxy = { id: 'proxy' }; const toolGateway = { id: 'tool-gateway' }; const agentRunGateway = { id: 'agent-run-gateway' }; + const workspacePublicationDelivery = { + async validateRunDeliverables() { + return { errors: [] }; + }, + }; + const getWorkspacePublicationDelivery = + () => workspacePublicationDelivery; let proxyOptions; let toolOptions; let agentOptions; @@ -48,14 +55,21 @@ function createSetup(overrides = {}) { id: 'system-disclosure-policy', }, mindSpaceAssets: { - async readAsset(userId, assetId) { + async readAssetContent( + userId, + assetId, + ) { calls.push(['read-asset', userId, assetId]); return { asset: { mimeType: 'image/png' }, - path: '/assets/image.png', + bodyBase64: + Buffer.from('image').toString( + 'base64', + ), }; }, }, + getWorkspacePublicationDelivery, directChatService: { id: 'direct-chat' }, chatIntentRouter: { id: 'intent-router' }, async syncUserGeneratedPages(userId, options) { @@ -86,10 +100,6 @@ function createSetup(overrides = {}) { validatorOptions = receivedOptions; return async () => ({ errors: [] }); }, - async readAssetFileFn(assetPath) { - calls.push(['read-asset-file', assetPath]); - return Buffer.from('image'); - }, ...overrides, }; return { @@ -103,6 +113,8 @@ function createSetup(overrides = {}) { tkmindProxy, toolGateway, agentRunGateway, + workspacePublicationDelivery, + getWorkspacePublicationDelivery, getCaptured() { return { proxyOptions, @@ -155,7 +167,8 @@ test('preserves Proxy, Tool, and Agent gateway wiring', () => { llmProviderService: setup.llmProviderService, }); assert.deepEqual(captured.validatorOptions, { - h5Root: '/app', + getWorkspacePublicationDelivery: + setup.getWorkspacePublicationDelivery, }); assert.equal(captured.agentOptions.tkmindProxy, setup.tkmindProxy); assert.equal(captured.agentOptions.toolGateway, setup.toolGateway); @@ -235,11 +248,8 @@ test('preserves local asset reads and optional asset absence', async () => { }, ); assert.deepEqual( - setup.calls.slice(-2), - [ - ['read-asset', 'user-1', 'asset-1'], - ['read-asset-file', '/assets/image.png'], - ], + setup.calls.slice(-1), + [['read-asset', 'user-1', 'asset-1']], ); const withoutAssets = createSetup({ @@ -327,48 +337,28 @@ test('keeps memory observation optional and parses disabled dispatch', async () assert.equal(agentOptions.runTimeoutMs, 15 * 60 * 1000); }); -test('validates Page Data issues, policy grants, and browser storage', async () => { - const policyChecks = []; +test('delegates run deliverable validation to MindSpace', async () => { + const calls = []; const validator = createPortalRunDeliverablesValidator({ - h5Root: '/app', - resolveMindSpaceUserPublishDirFn: () => - '/publish/user-1', - normalizeWorkspaceRelativePathFn: (value) => value, - resolvePathFn: (...parts) => parts.join('/').replaceAll('//', '/'), - pathSeparator: '/', - existsSyncFn: () => true, - readFileSyncFn: () => 'page data', - evaluatePageDataHtmlContentFn: () => ({ - usesPageDataApi: true, - issues: ['dataset_binding_missing'], - }), - readPageAccessPolicyFn: () => ({ id: 'policy' }), - detectPageDataDatasetUsageFromHtmlFn: () => [ - [ - 'orders', - { read: true, insert: true }, - ], - ], - policyAllowsActionFn(policy, dataset, action) { - policyChecks.push([policy, dataset, action]); - return action === 'read'; - }, - scanWorkspaceFilesForProhibitedBrowserStorageFn: - (options) => { - assert.deepEqual(options, { - publishDir: '/publish/user-1', - relativePaths: ['public/orders.html'], - }); - return [ - { - relativePath: 'public/orders.html', - apis: ['localStorage', 'indexedDB'], - }, - ]; - }, + getWorkspacePublicationDelivery: + () => ({ + async validateRunDeliverables( + input, + ) { + calls.push(input); + return { + errors: [ + { + code: + 'browser_storage_forbidden', + }, + ], + }; + }, + }), }); - const result = await validator({ + const input = { userId: 'user-1', deliverables: { pages: [ @@ -378,63 +368,28 @@ test('validates Page Data issues, policy grants, and browser storage', async () }, ], }, - }); + }; + const result = await validator(input); - assert.deepEqual(policyChecks, [ - [{ id: 'policy' }, 'orders', 'read'], - [{ id: 'policy' }, 'orders', 'insert'], - ]); - assert.deepEqual( - result.errors.map(({ code }) => code), - [ - 'dataset_binding_missing', - 'page_data_policy_action_missing', - 'browser_storage_forbidden', - ], + assert.deepEqual(calls, [input]); + assert.equal( + result.errors[0].code, + 'browser_storage_forbidden', ); }); -test('skips non-public, missing, and path-escaping Page Data files', async () => { - const readPaths = []; +test('fails closed when MindSpace validation is unavailable', async () => { const validator = createPortalRunDeliverablesValidator({ - h5Root: '/app', - resolveMindSpaceUserPublishDirFn: () => - '/publish/user-1', - normalizeWorkspaceRelativePathFn: (value) => value, - resolvePathFn: (...parts) => { - const joined = parts.join('/'); - if (joined.includes('escape')) return '/outside/file.html'; - return joined.replaceAll('//', '/'); - }, - pathSeparator: '/', - existsSyncFn: (filePath) => - !filePath.includes('missing'), - readFileSyncFn(filePath) { - readPaths.push(filePath); - return ''; - }, - evaluatePageDataHtmlContentFn: () => ({ - usesPageDataApi: false, - issues: [], - }), - scanWorkspaceFilesForProhibitedBrowserStorageFn: - () => [], + getWorkspacePublicationDelivery: + () => null, }); - const result = await validator({ - userId: 'user-1', - deliverables: { - pages: [ - { workspaceRelativePath: 'private/a.html' }, - { workspaceRelativePath: 'public/missing.html' }, - { workspaceRelativePath: 'public/escape.html' }, - { workspaceRelativePath: 'public/valid.html' }, - ], - }, - }); - - assert.deepEqual(readPaths, [ - '/publish/user-1/public/valid.html', - ]); - assert.deepEqual(result, { errors: [] }); + await assert.rejects( + () => + validator({ + userId: 'user-1', + deliverables: { pages: [] }, + }), + /交付验证服务未启用/, + ); }); diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 0d97001..de27bc7 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -8,7 +8,6 @@ import { sendMindSpaceRybbitEvent, } from '../mindspace-rybbit.mjs'; import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs'; -import { resolveMindSpaceRuntimeConfig } from '../mindspace-runtime-config.mjs'; import { createNotificationDispatcher } from '../notification-dispatcher.mjs'; import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs'; import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs'; @@ -20,7 +19,7 @@ export async function bootstrapPortalIntegrationServices({ env = process.env, usersRoot, wechatMpConfig, - authPool, + mindSpacePublicFinish, userAuth, sessionAccess, tkmindProxy, @@ -39,8 +38,6 @@ export async function bootstrapPortalIntegrationServices({ mindSpacePageLiveEdit, logger = console, loadWechatMpModuleFn = loadWechatMpModule, - resolveMindSpaceRuntimeConfigFn = - resolveMindSpaceRuntimeConfig, resolveAnalyticsOwnerSegmentFn = resolveAnalyticsOwnerSegment, resolveAnalyticsOwnerLabelFn = @@ -78,17 +75,21 @@ export async function bootstrapPortalIntegrationServices({ config: wechatMpConfig, userAuth, sessionAccess, - pageDataFinishGuard: authPool - ? { - pool: authPool, - h5Root, - storageRoot: - resolveMindSpaceRuntimeConfigFn( - h5Root, - env, - ).storageRoot, - } - : null, + htmlDeliveryAuthority: + typeof mindSpacePublicFinish + ?.prepareWechatHtmlDelivery === + 'function' && + typeof mindSpacePublicFinish + ?.ensureWechatFreshPageThumbnails === + 'function' + ? mindSpacePublicFinish + : null, + pageDataFinishGuard: + typeof mindSpacePublicFinish + ?.prepareWechatPageDataDelivery === + 'function' + ? mindSpacePublicFinish + : null, apiFetch: tkmindProxy.apiFetch, startAgentSession: ({ userId, diff --git a/server/portal-integration-services-bootstrap.test.mjs b/server/portal-integration-services-bootstrap.test.mjs index cbd28b9..93dfa73 100644 --- a/server/portal-integration-services-bootstrap.test.mjs +++ b/server/portal-integration-services-bootstrap.test.mjs @@ -57,6 +57,17 @@ function createSetup(overrides = {}) { }, }; const scheduleService = { id: 'schedule' }; + const mindSpacePublicFinish = { + async prepareWechatHtmlDelivery() { + return { confirmedArtifacts: [] }; + }, + async ensureWechatFreshPageThumbnails() { + return { ok: true }; + }, + async prepareWechatPageDataDelivery() { + return { outcome: { action: 'skip' } }; + }, + }; const sessionSnapshotService = { isEnabled() { return true; @@ -92,7 +103,7 @@ function createSetup(overrides = {}) { }, usersRoot: '/users', wechatMpConfig: { enabled: true }, - authPool: pool, + mindSpacePublicFinish, userAuth, sessionAccess, tkmindProxy, @@ -136,10 +147,6 @@ function createSetup(overrides = {}) { }, }; }, - resolveMindSpaceRuntimeConfigFn(h5Root, env) { - calls.push(['runtime-config', h5Root, env]); - return { storageRoot: '/storage' }; - }, resolveAnalyticsOwnerSegmentFn(user) { calls.push(['owner-segment', user]); return 'segment'; @@ -229,11 +236,14 @@ test('preserves WeChat service configuration and proxy callbacks', async () => { ); const { wechatOptions } = setup.getCaptured(); - assert.deepEqual(wechatOptions.pageDataFinishGuard, { - pool: setup.pool, - h5Root: '/app', - storageRoot: '/storage', - }); + assert.equal( + wechatOptions.pageDataFinishGuard, + setup.options.mindSpacePublicFinish, + ); + assert.equal( + wechatOptions.htmlDeliveryAuthority, + setup.options.mindSpacePublicFinish, + ); assert.equal( wechatOptions.apiFetch, setup.tkmindProxy.apiFetch, @@ -450,7 +460,7 @@ test('preserves subscription timer work, logs, and unref', async () => { test('keeps optional integrations disabled and contains timer failures', async () => { const setup = createSetup({ env: {}, - authPool: null, + mindSpacePublicFinish: null, scheduleService: null, subscriptionService: { async processAutoRenewals() { @@ -484,6 +494,11 @@ test('keeps optional integrations disabled and contains timer failures', async ( setup.disabledWechatOptions.pageDataFinishGuard, null, ); + assert.equal( + setup.disabledWechatOptions + .htmlDeliveryAuthority, + null, + ); assert.equal( setup.disabledWechatOptions.scheduleService, null, diff --git a/server/portal-mindspace-asset-routes.mjs b/server/portal-mindspace-asset-routes.mjs index 12919d9..e3052c1 100644 --- a/server/portal-mindspace-asset-routes.mjs +++ b/server/portal-mindspace-asset-routes.mjs @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import { renderImageAssetViewerHtml, wantsInlineImageViewer, @@ -39,7 +38,6 @@ export function attachPortalMindSpaceAssetRoutes( verifyAssetToken = verifyPublicAssetToken, wantsImageViewer = wantsInlineImageViewer, renderImageViewer = renderImageAssetViewerHtml, - readFile = fs.promises.readFile, now = Date.now, logger = console, } = {}, @@ -168,9 +166,14 @@ export function attachPortalMindSpaceAssetRoutes( return res.status(401).json({ message: '未授权,请重新登录' }); } } - const { asset, path: assetPath } = currentUserId - ? await assets.readAsset(currentUserId, assetId) - : await assets.readPublicAsset(assetId); + const { asset, bodyBase64 } = + currentUserId + ? await assets.readAssetContent( + currentUserId, + assetId, + ) + : await assets + .readPublicAssetContent(assetId); await getMindSpaceAudit()?.write({ userId: currentUserId, action: 'asset.download', @@ -208,7 +211,9 @@ export function attachPortalMindSpaceAssetRoutes( : `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`, ); res.setHeader('X-Request-Id', req.requestId); - return res.sendFile(assetPath); + return res.send( + Buffer.from(bodyBase64, 'base64'), + ); } catch (error) { await getMindSpaceAudit()?.write({ userId: req.currentUser?.id ?? null, @@ -277,10 +282,11 @@ export function attachPortalMindSpaceAssetRoutes( page: existingPage, }); } - const { asset, path: assetPath } = await assets.readAsset( - req.currentUser.id, - assetId, - ); + const { asset, bodyBase64 } = + await assets.readAssetContent( + req.currentUser.id, + assetId, + ); if (asset.mimeType === 'text/html') { const relativePath = normalizeWorkspacePath( String( @@ -302,7 +308,10 @@ export function attachPortalMindSpaceAssetRoutes( }); } } - const content = await readFile(assetPath, 'utf8'); + const content = Buffer.from( + bodyBase64, + 'base64', + ).toString('utf8'); const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown'; const htmlRelativePath = diff --git a/server/portal-mindspace-asset-routes.test.mjs b/server/portal-mindspace-asset-routes.test.mjs index aa6114b..70b4534 100644 --- a/server/portal-mindspace-asset-routes.test.mjs +++ b/server/portal-mindspace-asset-routes.test.mjs @@ -211,7 +211,10 @@ test('authenticated download preserves audit, headers, and file response', async const api = createRouterRecorder(); const dependencies = createDependencies({ getMindSpaceAssets: () => ({ - async readAsset(userId, assetId) { + async readAssetContent( + userId, + assetId, + ) { assert.equal(userId, 'user-1'); assert.equal(assetId, 'asset-1'); return { @@ -220,7 +223,10 @@ test('authenticated download preserves audit, headers, and file response', async mimeType: 'application/pdf', riskLevel: 'low', }, - path: '/tmp/report.pdf', + bodyBase64: + Buffer.from('pdf-body').toString( + 'base64', + ), }; }, }), @@ -241,7 +247,10 @@ test('authenticated download preserves audit, headers, and file response', async res, ); assert.equal(res.mimeType, 'application/pdf'); - assert.equal(res.filePath, '/tmp/report.pdf'); + assert.equal( + res.body.toString(), + 'pdf-body', + ); assert.equal( res.headers['Content-Disposition'], "attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A%201.pdf", @@ -264,7 +273,7 @@ test('image download preserves inline viewer response', async () => { attachPortalMindSpaceAssetRoutes(api, { ensureMindSpaceEnabled: () => true, getMindSpaceAssets: () => ({ - async readAsset() { + async readAssetContent() { return { asset: { filename: 'cover.png', @@ -272,7 +281,7 @@ test('image download preserves inline viewer response', async () => { mimeType: 'image/png', riskLevel: 'none', }, - path: '/tmp/cover.png', + bodyBase64: '', }; }, }), @@ -312,14 +321,17 @@ test('anonymous download preserves publication, signed-token, referrer, and deni const api = createRouterRecorder(); const dependencies = createDependencies({ getMindSpaceAssets: () => ({ - async readPublicAsset(assetId) { + async readPublicAssetContent(assetId) { return { asset: { filename: `${assetId}.png`, mimeType: 'image/png', riskLevel: 'none', }, - path: `/tmp/${assetId}.png`, + bodyBase64: + Buffer.from(assetId).toString( + 'base64', + ), }; }, }), @@ -499,7 +511,10 @@ test('from-asset preserves existing-page reuse and HTML page creation', async () const createApi = createRouterRecorder(); const createDependenciesResult = createDependencies({ getMindSpaceAssets: () => ({ - async readAsset(userId, assetId) { + async readAssetContent( + userId, + assetId, + ) { calls.push({ kind: 'read-asset', userId, assetId }); return { asset: { @@ -509,7 +524,9 @@ test('from-asset preserves existing-page reuse and HTML page creation', async () displayName: 'Landing', categoryCode: 'document', }, - path: '/tmp/landing.html', + bodyBase64: Buffer.from( + 'Landing', + ).toString('base64'), }; }, }), @@ -530,10 +547,6 @@ test('from-asset preserves existing-page reuse and HTML page creation', async () calls.push({ kind: 'normalize', value }); return value; }, - async readFile(path, encoding) { - calls.push({ kind: 'read-file', path, encoding }); - return 'Landing'; - }, }); attachPortalMindSpaceAssetRoutes( createApi, @@ -590,7 +603,7 @@ test('asset route errors preserve denied audit and shared mapping', async () => const api = createRouterRecorder(); const dependencies = createDependencies({ getMindSpaceAssets: () => ({ - async readAsset() { + async readAssetContent() { throw failure; }, async renderAssetThumbnail() { diff --git a/server/portal-mindspace-chat-save-routes.mjs b/server/portal-mindspace-chat-save-routes.mjs index c3a9151..7488311 100644 --- a/server/portal-mindspace-chat-save-routes.mjs +++ b/server/portal-mindspace-chat-save-routes.mjs @@ -1,27 +1,13 @@ -import path from 'node:path'; import { analyzeChatMessageForSave, buildWorkspaceBaseHref, - createAssetDataUriResolver, injectHtmlBaseHref, resolveStaticHtmlContent, } from '../mindspace-chat-save.mjs'; -import { - DOCX_MIME_TYPE, - registerChatDocxArtifactForConversation, -} from '../mindspace-chat-docx-package.mjs'; +import { DOCX_MIME_TYPE } from '../mindspace-chat-docx-package.mjs'; import { scanContent } from '../mindspace-content-scan.mjs'; import { generateDocxBuffer } from '../mindspace-docx-export.mjs'; import { normalizeWorkspaceRelativePath } from '../mindspace-pages.mjs'; -import { - resolveMindSpaceRuntimeConfig, - resolveMindSpaceUserPublishDir, -} from '../mindspace-runtime-config.mjs'; -import { generateHtmlThumbnail } from '../mindspace-thumbnails.mjs'; -import { - ensureWorkspaceHtmlThumbnail, - workspaceThumbnailRelativePath, -} from '../mindspace-workspace-thumbnails.mjs'; const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); @@ -44,8 +30,9 @@ export function attachPortalMindSpaceChatSaveRoutes( env = process.env, getMindSpacePages = () => null, getMindSpaceAssets = () => null, + getMindSpaceChatSave = () => null, + getConversationArtifactService = () => null, getAuthPool = () => null, - getConversationPackageRegistry = () => null, resolveChatSaveBundle, resolveExistingSavedPage, resolveOwnedAssistantMessage, @@ -56,18 +43,11 @@ export function attachPortalMindSpaceChatSaveRoutes( }, buildWorkspaceBaseHrefFn = buildWorkspaceBaseHref, injectHtmlBaseHrefFn = injectHtmlBaseHref, - resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, - workspaceThumbnailRelativePathFn = workspaceThumbnailRelativePath, - ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, - resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, - createAssetDataUriResolverFn = createAssetDataUriResolver, - generateHtmlThumbnailFn = generateHtmlThumbnail, analyzeChatMessageForSaveFn = analyzeChatMessageForSave, resolveStaticHtmlContentFn = resolveStaticHtmlContent, normalizeWorkspaceRelativePathFn = normalizeWorkspaceRelativePath, scanContentFn = scanContent, generateDocxBufferFn = generateDocxBuffer, - registerChatDocxArtifactFn = registerChatDocxArtifactForConversation, logger = console, } = {}, ) { @@ -115,7 +95,8 @@ export function attachPortalMindSpaceChatSaveRoutes( }); api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { - if (!getMindSpacePages()) { + const chatSave = getMindSpaceChatSave(); + if (!getMindSpacePages() || !chatSave) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { @@ -129,13 +110,6 @@ export function attachPortalMindSpaceChatSaveRoutes( code: 'static_page_not_found', }); } - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); - const thumbRel = workspaceThumbnailRelativePathFn( - bundle.resolvedHtml.relativePath, - ); const title = bundle.previewTitle || bundle.resolvedHtml.suggestedTitle || @@ -144,34 +118,18 @@ export function attachPortalMindSpaceChatSaveRoutes( bundle.previewSummary || bundle.resolvedHtml.suggestedSummary || bundle.analysis.suggestedSummary; - await ensureWorkspaceHtmlThumbnailFn( - publishDir, - bundle.resolvedHtml.relativePath, - bundle.resolvedHtml.content, - { title, subtitle, force: true }, - ).catch(() => {}); - const { storageRoot } = resolveMindSpaceRuntimeConfigFn(h5Root, env); - const resolveAssetDataUri = createAssetDataUriResolverFn( - getAuthPool(), - storageRoot, - req.currentUser.id, - ); - const svg = await generateHtmlThumbnailFn( - publishDir, - thumbRel, - bundle.resolvedHtml.content, - { - title, - subtitle, - contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), - resolveAssetDataUri, - force: true, - }, - ); - res.set('Content-Type', 'image/svg+xml; charset=utf-8'); + const rendered = await chatSave.renderPreviewThumbnailSvg({ + userId: req.currentUser.id, + relativePath: bundle.resolvedHtml.relativePath, + html: bundle.resolvedHtml.content, + title, + subtitle, + force: true, + }); + res.set('Content-Type', rendered.contentType); res.set('Cache-Control', 'private, no-store'); res.setHeader('X-Request-Id', req.requestId); - return res.send(svg); + return res.send(rendered.svg); } catch (error) { return handleMindSpaceError(res, req, error); } @@ -179,7 +137,8 @@ export function attachPortalMindSpaceChatSaveRoutes( api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { const pages = getMindSpacePages(); - if (!pages) { + const chatSave = getMindSpaceChatSave(); + if (!pages || !chatSave) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { @@ -203,21 +162,15 @@ export function attachPortalMindSpaceChatSaveRoutes( } = bundle; let thumbnailReady = false; if (resolvedHtml?.content && resolvedHtml.relativePath) { - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); try { - await ensureWorkspaceHtmlThumbnailFn( - publishDir, - resolvedHtml.relativePath, - resolvedHtml.content, - { - title: previewTitle || resolvedHtml.suggestedTitle, - subtitle: previewSummary || resolvedHtml.suggestedSummary, - force: Boolean(previewTitle || previewSummary), - }, - ); + await chatSave.ensurePreviewThumbnail({ + userId: req.currentUser.id, + relativePath: resolvedHtml.relativePath, + html: resolvedHtml.content, + title: previewTitle || resolvedHtml.suggestedTitle, + subtitle: previewSummary || resolvedHtml.suggestedSummary, + force: Boolean(previewTitle || previewSummary), + }); thumbnailReady = true; } catch { thumbnailReady = false; @@ -272,7 +225,9 @@ export function attachPortalMindSpaceChatSaveRoutes( }); const handleChatSaveDocx = async (req, res) => { - if (!getMindSpacePages()) { + const conversationArtifacts = + getConversationArtifactService(); + if (!getMindSpacePages() || !conversationArtifacts) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { @@ -303,13 +258,19 @@ export function attachPortalMindSpaceChatSaveRoutes( .replace(/^-+|-+$/g, '') .slice(0, 60) || 'mindspace-document'; const filename = `${filenameBase}.docx`; - await registerChatDocxArtifactFn({ - registry: getConversationPackageRegistry(), - user: req.currentUser, - bundle, - requestBody: req.body, + await conversationArtifacts.registerChatDocxArtifact({ + userId: req.currentUser.id, + sessionId: + req.body?.session_id ?? req.body?.sessionId, + messageId: + req.body?.message_id ?? req.body?.messageId, + selectedLinkIndex: + req.body?.selected_link_index ?? + req.body?.selectedLinkIndex ?? + 0, + title: bundle?.source?.session?.name ?? title, filename, - buffer, + body: buffer, }).catch((error) => { logger.warn( '[MindSpace] failed to record chat docx artifact:', @@ -339,7 +300,8 @@ export function attachPortalMindSpaceChatSaveRoutes( api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { const pages = getMindSpacePages(); const assets = getMindSpaceAssets(); - if (!pages || !assets) { + const chatSave = getMindSpaceChatSave(); + if (!pages || !assets || !chatSave) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { @@ -461,19 +423,15 @@ export function attachPortalMindSpaceChatSaveRoutes( resolvedHtml?.content && analysis.relativePath ) { - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); - await ensureWorkspaceHtmlThumbnailFn( - publishDir, - analysis.relativePath, - resolvedHtml.content, - { + await chatSave + .ensurePreviewThumbnail({ + userId: req.currentUser.id, + relativePath: analysis.relativePath, + html: resolvedHtml.content, title: pageInput.title, subtitle: pageInput.summary, - }, - ).catch(() => {}); + }) + .catch(() => {}); } const saveAsNew = Boolean(req.body?.save_as_new); diff --git a/server/portal-mindspace-chat-save-routes.test.mjs b/server/portal-mindspace-chat-save-routes.test.mjs index 47b6d3c..29044ab 100644 --- a/server/portal-mindspace-chat-save-routes.test.mjs +++ b/server/portal-mindspace-chat-save-routes.test.mjs @@ -91,18 +91,55 @@ function createBundle(overrides = {}) { }; } +function createChatSaveStub(overrides = {}) { + const calls = []; + return { + calls, + service: { + async ensurePreviewThumbnail(input) { + calls.push({ kind: 'ensure', input }); + return { relativePath: input.relativePath, ready: true }; + }, + async renderPreviewThumbnailSvg(input) { + calls.push({ kind: 'render', input }); + return { + svg: 'thumb', + contentType: 'image/svg+xml; charset=utf-8', + relativePath: input.relativePath, + }; + }, + ...overrides, + }, + }; +} + function createDependencies(overrides = {}) { const calls = { data: [], errors: [], }; + const chatSaveStub = createChatSaveStub( + overrides.chatSaveOverrides ?? {}, + ); + const conversationArtifactCalls = []; + const conversationArtifactService = { + async registerChatDocxArtifact(input) { + conversationArtifactCalls.push(input); + return { id: 'docx-1' }; + }, + }; return { calls, + chatSaveStub, + conversationArtifactCalls, dependencies: { h5Root: '/workspace', env: {}, getMindSpacePages: () => ({}), getMindSpaceAssets: () => ({}), + getMindSpaceChatSave: () => chatSaveStub.service, + getConversationArtifactService: () => + conversationArtifactService, resolveChatSaveBundle: async () => createBundle(), resolveExistingSavedPage: async () => null, resolveOwnedAssistantMessage: async () => createBundle().source, @@ -169,68 +206,49 @@ test('preview preserves base injection and response headers', async () => { }); }); -test('thumbnail preserves title selection, generation options, and response', async () => { - const calls = []; +test('thumbnail delegates preview rendering to MindSpace chat save authority', async () => { const api = createRouterRecorder(); - const dependencies = createDependencies({ - getAuthPool: () => ({ id: 'pool' }), + const { dependencies, chatSaveStub } = createDependencies({ resolveChatSaveBundle: async () => createBundle({ previewTitle: 'Preview title', previewSummary: 'Preview summary', }), - resolveMindSpaceUserPublishDirFn(root, user) { - calls.push({ kind: 'publish-dir', root, user }); - return '/workspace/user'; - }, - workspaceThumbnailRelativePathFn(relativePath) { - calls.push({ kind: 'thumb-path', relativePath }); - return 'public/page.thumbnail.svg'; - }, - async ensureWorkspaceHtmlThumbnailFn(...args) { - calls.push({ kind: 'ensure', args }); - }, - resolveMindSpaceRuntimeConfigFn() { - return { storageRoot: '/storage' }; - }, - createAssetDataUriResolverFn(pool, storageRoot, userId) { - calls.push({ kind: 'resolver', pool, storageRoot, userId }); - return async () => null; - }, - async generateHtmlThumbnailFn(...args) { - calls.push({ kind: 'generate', args }); - return 'thumb'; - }, }); - attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + attachPortalMindSpaceChatSaveRoutes(api, dependencies); const res = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/pages/chat-save-thumbnail')( createRequest(), res, ); assert.equal(res.body, 'thumb'); - assert.equal(res.headers['Content-Type'], 'image/svg+xml; charset=utf-8'); - assert.deepEqual(calls.find((call) => call.kind === 'resolver'), { - kind: 'resolver', - pool: { id: 'pool' }, - storageRoot: '/storage', - userId: 'user-1', + assert.equal( + res.headers['Content-Type'], + 'image/svg+xml; charset=utf-8', + ); + assert.deepEqual(chatSaveStub.calls[0], { + kind: 'render', + input: { + userId: 'user-1', + relativePath: 'public/page.html', + html: 'Page', + title: 'Preview title', + subtitle: 'Preview summary', + force: true, + }, }); - const generateOptions = calls.find((call) => call.kind === 'generate').args[3]; - assert.equal(generateOptions.title, 'Preview title'); - assert.equal(generateOptions.subtitle, 'Preview summary'); - assert.equal(generateOptions.contentBaseDir, '/workspace/public'); - assert.equal(generateOptions.force, true); }); test('analyze preserves projection, thumbnail state, scan, and existing page', async () => { const calls = []; const api = createRouterRecorder(); - const dependencies = createDependencies({ - async ensureWorkspaceHtmlThumbnailFn() { - calls.push('thumbnail'); + const { dependencies, calls: routeCalls } = createDependencies({ + chatSaveOverrides: { + async ensurePreviewThumbnail(input) { + calls.push({ kind: 'ensure', input }); + return { ready: true }; + }, }, - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', async resolveExistingSavedPage(userId, input) { calls.push({ userId, input }); return { @@ -245,7 +263,7 @@ test('analyze preserves projection, thumbnail state, scan, and existing page', a return { blocked: false }; }, }); - attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + attachPortalMindSpaceChatSaveRoutes(api, dependencies); const req = createRequest({ body: { session_id: 'session-1', message_id: 'message-1' }, }); @@ -253,7 +271,7 @@ test('analyze preserves projection, thumbnail state, scan, and existing page', a req, createResponseRecorder(), ); - const data = dependencies.calls.data[0].data; + const data = routeCalls.data[0].data; assert.equal(data.contentMode, 'static_html'); assert.equal(data.selectedLinkIndex, 0); assert.equal(data.suggestedTitle, 'Resolved title'); @@ -265,6 +283,8 @@ test('analyze preserves projection, thumbnail state, scan, and existing page', a title: 'Saved', categoryCode: 'draft', }); + assert.equal(calls[0].kind, 'ensure'); + assert.equal(calls[0].input.relativePath, 'public/page.html'); }); test('DOCX route preserves artifact registration, headers, and non-fatal registry errors', async () => { @@ -273,15 +293,16 @@ test('DOCX route preserves artifact registration, headers, and non-fatal registr const api = createRouterRecorder(); const buffer = Buffer.from('docx'); const dependencies = createDependencies({ - getConversationPackageRegistry: () => ({ id: 'registry' }), generateDocxBufferFn(input) { calls.push({ kind: 'generate', input }); return buffer; }, - async registerChatDocxArtifactFn(input) { - calls.push({ kind: 'register', input }); - throw new Error('registry unavailable'); - }, + getConversationArtifactService: () => ({ + async registerChatDocxArtifact(input) { + calls.push({ kind: 'register', input }); + throw new Error('registry unavailable'); + }, + }), logger: { warn(...args) { warnings.push(args); @@ -298,6 +319,9 @@ test('DOCX route preserves artifact registration, headers, and non-fatal registr 'Resolved title', ); assert.equal(calls[0].input.contentFormat, 'html'); + assert.equal(calls[1].input.userId, 'user-1'); + assert.equal(calls[1].input.sessionId, 'session-1'); + assert.equal(calls[1].input.body, buffer); assert.equal( res.headers['Content-Type'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -419,7 +443,7 @@ test('save-from-chat preserves HTML replacement and error mapping', async () => }, }; const api = createRouterRecorder(); - const dependencies = createDependencies({ + const { dependencies, chatSaveStub, calls: routeCalls } = createDependencies({ getMindSpacePages: () => pages, resolveOwnedAssistantMessage: async () => createBundle().source, analyzeChatMessageForSaveFn: () => ({ @@ -430,10 +454,8 @@ test('save-from-chat preserves HTML replacement and error mapping', async () => resolveStaticHtmlContentFn: async () => createBundle().resolvedHtml, normalizeWorkspaceRelativePathFn: (value) => value, scanContentFn: () => ({}), - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', - ensureWorkspaceHtmlThumbnailFn: async () => {}, }); - attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); + attachPortalMindSpaceChatSaveRoutes(api, dependencies); const res = createResponseRecorder(); await api.routes.get('POST /mindspace/v1/pages/save-from-chat')( createRequest({ @@ -450,6 +472,7 @@ test('save-from-chat preserves HTML replacement and error mapping', async () => calls.find((call) => call.kind === 'update').input.expectedVersion, 4, ); + assert.equal(chatSaveStub.calls[0].kind, 'ensure'); const invalidRes = createResponseRecorder(); await api.routes.get('POST /mindspace/v1/pages/save-from-chat')( @@ -457,7 +480,7 @@ test('save-from-chat preserves HTML replacement and error mapping', async () => invalidRes, ); assert.equal(invalidRes.statusCode, 418); - assert.equal(dependencies.calls.errors.at(-1).error.code, 'invalid_category_code'); + assert.equal(routeCalls.errors.at(-1).error.code, 'invalid_category_code'); }); test('chat save routes preserve unavailable and missing HTML errors', async () => { @@ -465,6 +488,8 @@ test('chat save routes preserve unavailable and missing HTML errors', async () = const unavailableDependencies = createDependencies({ getMindSpacePages: () => null, getMindSpaceAssets: () => null, + getMindSpaceChatSave: () => null, + getConversationArtifactService: () => null, }); attachPortalMindSpaceChatSaveRoutes( unavailableApi, diff --git a/server/portal-mindspace-chat-share-routes.mjs b/server/portal-mindspace-chat-share-routes.mjs index f2b27c4..3c7cb9e 100644 --- a/server/portal-mindspace-chat-share-routes.mjs +++ b/server/portal-mindspace-chat-share-routes.mjs @@ -1,19 +1,8 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { inlinePrivateAssetsInHtml } from '../mindspace-pages.mjs'; import { getQuickPlazaFromPublicHtmlStatus, quickPlazaFromChat, quickPlazaFromPublicHtml, } from '../mindspace-chat-plaza.mjs'; -import { rewriteWorkspacePublicAssetReferences } from '../mindspace-publications.mjs'; -import { - buildMindSpacePublicUrlForUser, - resolveMindSpaceRuntimeConfig, - resolveMindSpaceUserPublishDir, -} from '../mindspace-runtime-config.mjs'; -import { PUBLIC_ZONE_DIR } from '../user-publish.mjs'; function assertRouter(api) { if ( @@ -31,11 +20,10 @@ export function attachPortalMindSpaceChatShareRoutes( api, { h5Root = '', - env = process.env, getMindSpacePages = () => null, + getMindSpaceChatSave = () => null, getMindSpacePublications = () => null, getPlazaPosts = () => null, - getAuthPool = () => null, resolveChatSaveBundle, resolvePlazaPostUrl, mapPlazaError = () => 500, @@ -49,20 +37,10 @@ export function attachPortalMindSpaceChatShareRoutes( handleMindSpaceError = (_res, _req, error) => { throw error; }, - resolveMindSpaceRuntimeConfigFn = resolveMindSpaceRuntimeConfig, - inlinePrivateAssetsInHtmlFn = inlinePrivateAssetsInHtml, - resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, - rewriteWorkspacePublicAssetReferencesFn = - rewriteWorkspacePublicAssetReferences, - buildMindSpacePublicUrlForUserFn = - buildMindSpacePublicUrlForUser, quickPlazaFromChatFn = quickPlazaFromChat, getQuickPlazaFromPublicHtmlStatusFn = getQuickPlazaFromPublicHtmlStatus, quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml, - mkdir = fs.mkdir, - writeFile = fs.writeFile, - randomUUID = crypto.randomUUID, now = Date.now, logger = console, } = {}, @@ -78,7 +56,8 @@ export function attachPortalMindSpaceChatShareRoutes( } api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { - if (!getMindSpacePages()) { + const chatSave = getMindSpaceChatSave(); + if (!getMindSpacePages() || !chatSave) { return res.status(503).json({ message: 'MindSpace 未启用' }); } try { @@ -97,56 +76,16 @@ export function attachPortalMindSpaceChatShareRoutes( }); } - const { storageRoot } = resolveMindSpaceRuntimeConfigFn( - h5Root, - env, - ); - const { html: localizedHtml } = - await inlinePrivateAssetsInHtmlFn( - getAuthPool(), - storageRoot, - req.currentUser.id, - bundle.resolvedHtml.content, - ); - - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); - const sharedDir = path.join( - publishDir, - PUBLIC_ZONE_DIR, - 'shared', - ); - await mkdir(sharedDir, { recursive: true }); - - const basename = bundle.resolvedHtml.filename.replace( - /\.html$/i, - '', - ); - const filename = - `${basename}-${randomUUID().slice(0, 8)}.html`; - const sharedRelativePath = - `${PUBLIC_ZONE_DIR}/shared/${filename}`; - const sharedHtml = - rewriteWorkspacePublicAssetReferencesFn( - localizedHtml, - sharedRelativePath, - ); - const destPath = path.join(sharedDir, filename); - await writeFile(destPath, sharedHtml, 'utf8'); - - const publicUrl = buildMindSpacePublicUrlForUserFn({ - h5Root, - env, - user: req.currentUser.id, - relativePath: sharedRelativePath, + const shared = await chatSave.createSharedHtml({ + userId: req.currentUser.id, + html: bundle.resolvedHtml.content, + sourceFilename: bundle.resolvedHtml.filename, }); return res.status(201).json({ data: { - publicUrl, - filename, + publicUrl: shared.publicUrl, + filename: shared.filename, }, }); } catch (error) { @@ -175,19 +114,13 @@ export function attachPortalMindSpaceChatShareRoutes( h5Root, req.body, ); - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); const result = await quickPlazaFromChatFn({ user: req.currentUser, - h5Root, bundle, body: req.body ?? {}, mindSpacePages: pages, mindSpacePublications: publications, plazaPosts, - publishDir, }); logger.info('[quick-plaza] ok', { ms: now() - startedAt, @@ -257,9 +190,15 @@ export function attachPortalMindSpaceChatShareRoutes( '/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => { const pages = getMindSpacePages(); + const chatSave = getMindSpaceChatSave(); const publications = getMindSpacePublications(); const plazaPosts = getPlazaPosts(); - if (!pages || !publications || !plazaPosts) { + if ( + !pages || + !chatSave || + !publications || + !plazaPosts + ) { return sendError( res, req, @@ -275,17 +214,14 @@ export function attachPortalMindSpaceChatShareRoutes( req.body?.relativePath ?? '', ).trim(); - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - req.currentUser, - ); const result = await quickPlazaFromPublicHtmlFn({ user: req.currentUser, relativePath, mindSpacePages: pages, mindSpacePublications: publications, plazaPosts, - publishDir, + readWorkspaceHtml: (input) => + chatSave.readWorkspaceHtml(input), }); const plazaUrl = resolvePlazaPostUrl( result.post.id, diff --git a/server/portal-mindspace-chat-share-routes.test.mjs b/server/portal-mindspace-chat-share-routes.test.mjs index 4ee1515..9c5c88d 100644 --- a/server/portal-mindspace-chat-share-routes.test.mjs +++ b/server/portal-mindspace-chat-share-routes.test.mjs @@ -51,6 +51,23 @@ function createBundle() { function createDependencies(overrides = {}) { const services = { pages: { id: 'pages' }, + chatSave: { + async createSharedHtml() { + return { + publicUrl: + 'https://example.com/MindSpace/user-1/public/shared/page-default.html', + filename: 'page-default.html', + relativePath: + 'public/shared/page-default.html', + }; + }, + async readWorkspaceHtml(input) { + return { + html: 'Page', + relativePath: input.relativePath, + }; + }, + }, publications: { id: 'publications' }, plazaPosts: { id: 'plaza-posts' }, }; @@ -64,8 +81,8 @@ function createDependencies(overrides = {}) { services, dependencies: { h5Root: '/workspace', - env: {}, getMindSpacePages: () => services.pages, + getMindSpaceChatSave: () => services.chatSave, getMindSpacePublications: () => services.publications, getPlazaPosts: () => services.plazaPosts, resolveChatSaveBundle: async () => createBundle(), @@ -107,39 +124,22 @@ test('chat share module preserves route inventory and order', () => { ]); }); -test('quick share preserves localization, path, write, and 201 response', async () => { +test('quick share delegates shared HTML write and URL authority to MindSpace', async () => { const calls = []; const api = createRouterRecorder(); const dependencies = createDependencies({ - getAuthPool: () => ({ id: 'pool' }), - resolveMindSpaceRuntimeConfigFn: () => ({ - storageRoot: '/storage', + getMindSpaceChatSave: () => ({ + async createSharedHtml(input) { + calls.push(input); + return { + publicUrl: + 'https://example.com/MindSpace/user/public/shared/page-12345678.html', + filename: 'page-12345678.html', + relativePath: + 'public/shared/page-12345678.html', + }; + }, }), - async inlinePrivateAssetsInHtmlFn( - pool, - storageRoot, - userId, - html, - ) { - calls.push({ kind: 'inline', pool, storageRoot, userId, html }); - return { html: 'Localized' }; - }, - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', - async mkdir(dir, options) { - calls.push({ kind: 'mkdir', dir, options }); - }, - rewriteWorkspacePublicAssetReferencesFn(html, relativePath) { - calls.push({ kind: 'rewrite', html, relativePath }); - return 'Rewritten'; - }, - async writeFile(filePath, content, encoding) { - calls.push({ kind: 'write', filePath, content, encoding }); - }, - randomUUID: () => '12345678-aaaa-bbbb-cccc-dddddddddddd', - buildMindSpacePublicUrlForUserFn(input) { - calls.push({ kind: 'url', input }); - return 'https://example.com/MindSpace/user/public/shared/page-12345678.html'; - }, }); attachPortalMindSpaceChatShareRoutes(api, dependencies.dependencies); const res = createResponseRecorder(); @@ -155,23 +155,10 @@ test('quick share preserves localization, path, write, and 201 response', async filename: 'page-12345678.html', }, }); - assert.deepEqual(calls.find((call) => call.kind === 'inline'), { - kind: 'inline', - pool: { id: 'pool' }, - storageRoot: '/storage', + assert.deepEqual(calls[0], { userId: 'user-1', html: 'Page', - }); - assert.deepEqual(calls.find((call) => call.kind === 'rewrite'), { - kind: 'rewrite', - html: 'Localized', - relativePath: 'public/shared/page-12345678.html', - }); - assert.deepEqual(calls.find((call) => call.kind === 'write'), { - kind: 'write', - filePath: '/workspace/user/public/shared/page-12345678.html', - content: 'Rewritten', - encoding: 'utf8', + sourceFilename: 'page.html', }); }); @@ -180,7 +167,6 @@ test('quick Plaza from chat preserves dependencies, timing, result, and errors', let clock = 1_000; const api = createRouterRecorder(); const dependencies = createDependencies({ - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', now: () => { clock += 25; return clock; @@ -203,13 +189,11 @@ test('quick Plaza from chat preserves dependencies, timing, result, and errors', assert.equal(dependencies.calls.data[0].status, 201); assert.deepEqual(forwarded[0], { user: req.currentUser, - h5Root: '/workspace', bundle: createBundle(), body: { title: 'Post title' }, mindSpacePages: dependencies.services.pages, mindSpacePublications: dependencies.services.publications, plazaPosts: dependencies.services.plazaPosts, - publishDir: '/workspace/user', }); const failureApi = createRouterRecorder(); @@ -220,7 +204,6 @@ test('quick Plaza from chat preserves dependencies, timing, result, and errors', quickPlazaFromChatFn: async () => { throw plazaFailure; }, - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', mapPlazaError: () => 409, }); attachPortalMindSpaceChatShareRoutes( @@ -272,7 +255,6 @@ test('quick Plaza public HTML preserves publish input, URL, and 201 response', a const forwarded = []; const api = createRouterRecorder(); const dependencies = createDependencies({ - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', async quickPlazaFromPublicHtmlFn(input) { forwarded.push(input); return { @@ -292,7 +274,20 @@ test('quick Plaza public HTML preserves publish input, URL, and 201 response', a createResponseRecorder(), ); assert.equal(forwarded[0].relativePath, 'public/page.html'); - assert.equal(forwarded[0].publishDir, '/workspace/user'); + assert.equal( + typeof forwarded[0].readWorkspaceHtml, + 'function', + ); + assert.deepEqual( + await forwarded[0].readWorkspaceHtml({ + userId: 'user-1', + relativePath: 'public/page.html', + }), + { + html: 'Page', + relativePath: 'public/page.html', + }, + ); assert.equal(dependencies.calls.data[0].status, 201); assert.deepEqual(dependencies.calls.data[0].data, { pageId: 'page-1', @@ -309,7 +304,6 @@ test('quick Plaza public HTML enriches already-published errors with URL', async }); const api = createRouterRecorder(); const dependencies = createDependencies({ - resolveMindSpaceUserPublishDirFn: () => '/workspace/user', quickPlazaFromPublicHtmlFn: async () => { throw failure; }, @@ -330,6 +324,7 @@ test('chat share routes preserve availability gates and MindSpace fallback error const unavailableApi = createRouterRecorder(); const unavailable = createDependencies({ getMindSpacePages: () => null, + getMindSpaceChatSave: () => null, getMindSpacePublications: () => null, getPlazaPosts: () => null, }); diff --git a/server/portal-publication-routes.mjs b/server/portal-publication-routes.mjs index b3e1dee..d158e55 100644 --- a/server/portal-publication-routes.mjs +++ b/server/portal-publication-routes.mjs @@ -1,15 +1,10 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { - buildMindSpacePublicRoutePath, isPublicWorkspaceHtmlRelativePath, - resolveMindSpaceUserPublishDir, } from '../mindspace-runtime-config.mjs'; import { isPlazaEmbedRequest } from '../plaza-embed.mjs'; import { rasterizeThumbnailSvgToPng, } from '../mindspace-thumbnail-png.mjs'; -import { PUBLIC_ZONE_DIR } from '../user-publish.mjs'; function escapePublicHtml(value) { return String(value ?? '') @@ -137,24 +132,18 @@ export function attachPortalPublicationRoutes({ getAuthPool = () => null, getMindSpacePages = () => null, getMindSpacePublications = () => null, + getWorkspacePublicationDelivery = () => + null, getUserAuth = () => null, sendPublishedPage, - fsApi = fs, - pathApi = path, - publicZoneDir = PUBLIC_ZONE_DIR, - resolveUserPublishDir = - resolveMindSpaceUserPublishDir, isEmbedRequest = isPlazaEmbedRequest, isPublicWorkspaceHtml = isPublicWorkspaceHtmlRelativePath, - buildPublicRoutePath = - buildMindSpacePublicRoutePath, rasterizeThumbnail = rasterizeThumbnailSvgToPng, } = {}) { if ( !app || - !h5Root || typeof urlencodedBody !== 'function' || typeof sendPublishedPage !== 'function' ) { @@ -205,16 +194,13 @@ export function attachPortalPublicationRoutes({ 'public' && isPublicWorkspaceHtml( result.workspaceRelativePath, - ) + ) && + result.workspacePublicUrl ) { - const redirectPath = - buildPublicRoutePath( - result.ownerId, - String( - result.workspaceRelativePath, - ).split('/'), - ); - return res.redirect(301, redirectPath); + return res.redirect( + 301, + result.workspacePublicUrl, + ); } return await sendPublishedPage( req, @@ -338,86 +324,60 @@ export function attachPortalPublicationRoutes({ .status(404) .json({ message: '用户不存在' }); } - const authPool = getAuthPool(); - if (!authPool) { + const deliveryService = + getWorkspacePublicationDelivery(); + if ( + !deliveryService || + typeof deliveryService + .readOwnerPublicAsset !== 'function' + ) { return res .status(503) .send('MindSpace 未启用'); } try { - const [rows] = await authPool.query( - `SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`, - [ownerSlug], - ); - const userId = rows[0]?.id - ? String(rows[0].id) - : null; - if (!userId) { - return res - .status(404) - .json({ message: '用户不存在' }); - } - - const relativePath = String( - req.path ?? '', - ) - .replace(/^\/+/, '') - .split('/') - .map((segment) => - decodeURIComponent(segment), - ) - .filter(Boolean); - if (relativePath.length === 0) { - return res - .status(404) - .json({ message: '文件不存在' }); - } - - const publishDir = - resolveUserPublishDir(h5Root, { - id: userId, - }); - const resolvedRoot = - pathApi.resolve(publishDir); - const filePath = pathApi.resolve( - publishDir, - publicZoneDir, - ...relativePath, - ); + const delivery = + await deliveryService + .readOwnerPublicAsset({ + ownerSlug, + requestPath: req.path, + }); if ( - filePath !== resolvedRoot && - !filePath.startsWith( - `${resolvedRoot}${pathApi.sep}`, - ) + delivery.action === 'forbidden' ) { return res .status(403) .json({ message: '禁止访问' }); } if ( - !fsApi.existsSync(filePath) || - !fsApi.statSync(filePath).isFile() + delivery.action !== 'deliver' ) { return res .status(404) - .json({ message: '文件不存在' }); + .json({ + message: + delivery.reason === + 'missing_owner' + ? '用户不存在' + : '文件不存在', + }); } - - return res.sendFile( - filePath, - (error) => { - if ( - error && - !res.headersSent - ) { - res - .status(404) - .json({ - message: '文件不存在', - }); - } - }, + res.type?.( + delivery.servedName || + 'application/octet-stream', + ); + if (delivery.cacheControl) { + res.set( + 'Cache-Control', + delivery.cacheControl, + ); + } + return res.send( + Buffer.from( + delivery.bodyBase64 || '', + 'base64', + ), ); } catch { return res diff --git a/server/portal-publication-routes.test.mjs b/server/portal-publication-routes.test.mjs index b1b7485..0d4156d 100644 --- a/server/portal-publication-routes.test.mjs +++ b/server/portal-publication-routes.test.mjs @@ -18,6 +18,10 @@ function createResponse() { this.statusCode = code; return this; }, + type(value) { + this.headers['Content-Type'] = value; + return this; + }, set(name, value) { this.headers[name] = value; return this; @@ -92,6 +96,8 @@ function createSetup(overrides = {}) { ownerId: 'user-1', workspaceRelativePath: 'public/page.html', + workspacePublicUrl: + '/MindSpace/user-1/public/page.html', publication: { accessMode: 'public', }, @@ -171,6 +177,27 @@ function createSetup(overrides = {}) { getMindSpacePages: () => mindSpacePages, getMindSpacePublications: () => mindSpacePublications, + getWorkspacePublicationDelivery: + () => ({ + async readOwnerPublicAsset({ + ownerSlug, + requestPath, + }) { + calls.push([ + 'read-owner-public-asset', + ownerSlug, + requestPath, + ]); + return { + action: 'deliver', + kind: 'binary', + servedName: 'file.txt', + bodyBase64: Buffer.from( + 'asset-body', + ).toString('base64'), + }; + }, + }), getUserAuth: () => userAuth, async sendPublishedPage( req, @@ -185,28 +212,12 @@ function createSetup(overrides = {}) { ]); return res.send('published-page'); }, - fsApi: { - existsSync() { - return true; - }, - statSync() { - return { - isFile: () => true, - }; - }, - }, - resolveUserPublishDir() { - return '/workspace/user-1'; - }, isEmbedRequest(query) { return query.embed === '1'; }, isPublicWorkspaceHtml(value) { return value?.startsWith('public/'); }, - buildPublicRoutePath(ownerId, segments) { - return `/MindSpace/${ownerId}/${segments.join('/')}`; - }, rasterizeThumbnail(svg) { calls.push(['rasterize', svg]); return Buffer.from('png'); @@ -357,6 +368,8 @@ test('raw, embed, and password access preserve direct page delivery', async () = ownerId: 'user-1', workspaceRelativePath: 'public/page.html', + workspacePublicUrl: + '/MindSpace/user-1/public/page.html', publication: { accessMode: 'public', }, @@ -525,7 +538,7 @@ test('thumbnail middleware preserves pass-through, lookup, headers, and errors', ); }); -test('public asset route preserves owner lookup, path, and file responses', async () => { +test('public asset route proxies logical MindSpace delivery results', async () => { const setup = createSetup(); const success = await invoke( setup, @@ -537,21 +550,35 @@ test('public asset route preserves owner lookup, path, and file responses', asyn }), ); assert.equal( - success.response.sentFile, - '/workspace/user-1/public/assets/file.txt', + success.response.body.toString(), + 'asset-body', ); - const queryCall = setup.calls.find( - (call) => call[0] === 'query', + assert.deepEqual( + setup.calls.find( + (call) => + call[0] === + 'read-owner-public-asset', + ), + [ + 'read-owner-public-asset', + 'alice', + '/assets/file.txt', + ], ); - assert.deepEqual(queryCall[2], ['alice']); - setup.setAuthPool({ - async query() { - return [[]]; - }, + const missingSetup = createSetup({ + getWorkspacePublicationDelivery: + () => ({ + async readOwnerPublicAsset() { + return { + action: 'not_found', + reason: 'missing_owner', + }; + }, + }), }); const missing = await invoke( - setup, + missingSetup, 'use', '/u/:ownerSlug/public', createRequest({ diff --git a/server/portal-session-routes.mjs b/server/portal-session-routes.mjs index 0a3065f..828651b 100644 --- a/server/portal-session-routes.mjs +++ b/server/portal-session-routes.mjs @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { isDirectChatSessionId, isPortalDirectChatSnapshot, @@ -21,21 +19,12 @@ import { } from '../mindspace-delivery-contract.mjs'; import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs'; import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs'; -import { - collectPublicHtmlWritePathsFromSessionEvent, - materializePublicHtmlWritesFromSessionEvent, - syncPublicHtmlAfterFinish, -} from '../mindspace-public-finish-sync.mjs'; -import { - resolveMindSpaceRuntimeConfig, - resolveMindSpaceUserPublishDir, -} from '../mindspace-runtime-config.mjs'; +import { hasPublicHtmlWriteRequestInSessionEvent } from '../mindspace-public-finish-sync.mjs'; import { repairSessionConversationFromDb, } from '../conversation-repair.mjs'; import { filterNonemptyUserVisibleMessages } from '../conversation-transcript-persist.mjs'; import { sanitizeSessionConversationPublicHtmlLinks } from '../tkmind-proxy.mjs'; -import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; function assertRouter(api) { if ( @@ -52,24 +41,20 @@ function assertRouter(api) { export function attachPortalSessionRoutes( api, { - h5Root = '', - env = process.env, waitForUserAuthReady = async () => {}, getUserAuth = () => null, getTkmindProxy = () => null, getSessionSnapshotService = () => null, getAuthPool = () => null, - getMindSpaceAssets = () => null, + getMindSpacePublicFinish = () => null, getMemoryV2 = () => null, getMindSpaceAnalyticsConfig = () => null, getMindSpaceRybbitConfig = () => null, - isWorkspaceMaintenanceEnabled = () => false, ownsAgentSession, unregisterAgentSessionForUser, beginSessionPageDelivery, endSessionPageDelivery, syncUserGeneratedPages, - registerPublicHtmlArtifactsForConversation, isDirectChatSessionIdFn = isDirectChatSessionId, isPortalDirectChatSnapshotFn = isPortalDirectChatSnapshot, sendDirectChatSessionEventsFn = sendDirectChatSessionEvents, @@ -81,14 +66,10 @@ export function attachPortalSessionRoutes( repairSessionConversationFromDb, filterNonemptyUserVisibleMessagesFn = filterNonemptyUserVisibleMessages, - resolveMindSpaceUserPublishDirFn = - resolveMindSpaceUserPublishDir, - collectPublicHtmlWritePathsFn = - collectPublicHtmlWritePathsFromSessionEvent, preparePageDeliveryContractFn = preparePageDeliveryContract, - materializePublicHtmlWritesFn = - materializePublicHtmlWritesFromSessionEvent, + hasPublicHtmlWriteRequestInSessionEventFn = + hasPublicHtmlWriteRequestInSessionEvent, sendMindSpaceAnalyticsEventFn = sendMindSpaceAnalyticsEvent, sendMindSpaceRybbitEventFn = @@ -98,18 +79,12 @@ export function attachPortalSessionRoutes( resolveAnalyticsOwnerLabelFn = resolveAnalyticsOwnerLabel, resolveAnalyticsPlanFn = resolveAnalyticsPlan, - syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish, - resolveMindSpaceRuntimeConfigFn = - resolveMindSpaceRuntimeConfig, maybeRepairH5HtmlAfterFinishFn = maybeRepairH5HtmlAfterFinish, maybeRepairPageDataAfterFinishFn = maybeRepairPageDataAfterFinish, markPageDeliveryContractReadyFn = markPageDeliveryContractReady, - fileExists = fs.existsSync, - resolvePath = path.resolve, - publishRootDir = PUBLISH_ROOT_DIR, logger = console, } = {}, ) { @@ -119,8 +94,7 @@ export function attachPortalSessionRoutes( typeof unregisterAgentSessionForUser !== 'function' || typeof beginSessionPageDelivery !== 'function' || typeof endSessionPageDelivery !== 'function' || - typeof syncUserGeneratedPages !== 'function' || - typeof registerPublicHtmlArtifactsForConversation !== 'function' + typeof syncUserGeneratedPages !== 'function' ) { throw new Error( 'attachPortalSessionRoutes requires session route dependencies', @@ -372,20 +346,19 @@ export function attachPortalSessionRoutes( portalDirectSnapshot, ); } - const publishDir = resolveMindSpaceUserPublishDirFn( - h5Root, - { id: req.currentUser.id }, - ); // `proxySessionEvents` deliberately invokes `onEvent` synchronously so an // async callback here would leave rejected database writes unhandled. // Retain every in-flight contract write and await it before marking files // deliverable after Finish. const deliveryContractWrites = new Map(); + const streamPublicHtmlWrites = new Set(); const generationAnalyticsEvents = new Set(); const syncPublicHtmlDuringStream = (event) => { - const paths = collectPublicHtmlWritePathsFn(event, { - publishDir, - }); + if ( + !hasPublicHtmlWriteRequestInSessionEventFn(event) + ) { + return; + } const eventMessages = event?.type === 'Message' && event.message ? [event.message] @@ -398,60 +371,112 @@ export function attachPortalSessionRoutes( message?.role === 'user' && message?.metadata?.memindRun?.pgRequired === true, ); - for (const relativePath of paths) { - if (deliveryContractWrites.has(relativePath)) continue; - const write = preparePageDeliveryContractFn({ - pool: authPool, - userId: req.currentUser.id, - requestId: sessionId, - relativePath, - pgRequired, - }).catch((error) => { - logger.warn( - `[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`, + const write = Promise.resolve() + .then(async () => { + const publicFinish = + getMindSpacePublicFinish(); + if (!publicFinish) { + throw new Error( + 'MindSpace public finish service unavailable', + ); + } + const result = + await publicFinish.materializeSessionEvent({ + userId: req.currentUser.id, + event, + }); + const artifactsByPath = new Map( + ( + Array.isArray(result?.publicHtmlArtifacts) + ? result.publicHtmlArtifacts + : [] + ).map((artifact) => [ + artifact.relativePath, + artifact, + ]), + ); + const paths = Array.isArray( + result?.publicHtmlRelativePaths, + ) + ? result.publicHtmlRelativePaths + : []; + for (const relativePath of paths) { + if ( + !deliveryContractWrites.has(relativePath) + ) { + const contractWrite = + preparePageDeliveryContractFn({ + pool: authPool, + userId: req.currentUser.id, + requestId: sessionId, + relativePath, + pgRequired, + }).catch((error) => { + logger.warn( + `[MindSpace] failed to prepare delivery contract for ${relativePath}: ${error?.message || error}`, + ); + return null; + }); + deliveryContractWrites.set( + relativePath, + contractWrite, + ); + } + const canonicalUrl = + artifactsByPath.get(relativePath) + ?.canonicalUrl; + if ( + generationAnalyticsEvents.has( + relativePath, + ) || + !canonicalUrl + ) { + continue; + } + generationAnalyticsEvents.add(relativePath); + const analyticsPayload = { + eventName: 'page_generated', + ownerId: req.currentUser.id, + ownerSegment: + resolveAnalyticsOwnerSegmentFn( + req.currentUser, + ), + ownerLabel: + resolveAnalyticsOwnerLabelFn( + req.currentUser, + ), + planType: + resolveAnalyticsPlanFn( + req.currentUser, + ), + generatedAt: new Date().toISOString(), + pageId: relativePath, + publicationId: sessionId, + agentRunId: sessionId, + channel: 'h5', + url: canonicalUrl, + }; + void sendMindSpaceAnalyticsEventFn({ + config: + getMindSpaceAnalyticsConfig(), + ...analyticsPayload, + }); + void sendMindSpaceRybbitEventFn({ + config: + getMindSpaceRybbitConfig(), + ...analyticsPayload, + }); + } + }) + .catch((error) => { + logger.warn( + `[MindSpace] failed to materialize streamed public HTML for ${sessionId}: ${error?.message || error}`, ); - return null; }); - deliveryContractWrites.set(relativePath, write); - } - materializePublicHtmlWritesFn(event, { publishDir }); - for (const relativePath of paths) { - const absolutePath = resolvePath( - publishDir, - relativePath, - ); - if ( - generationAnalyticsEvents.has(relativePath) || - !fileExists(absolutePath) - ) { - continue; - } - generationAnalyticsEvents.add(relativePath); - const analyticsPayload = { - eventName: 'page_generated', - ownerId: req.currentUser.id, - ownerSegment: - resolveAnalyticsOwnerSegmentFn(req.currentUser), - ownerLabel: - resolveAnalyticsOwnerLabelFn(req.currentUser), - planType: resolveAnalyticsPlanFn(req.currentUser), - generatedAt: new Date().toISOString(), - pageId: relativePath, - publicationId: sessionId, - agentRunId: sessionId, - channel: 'h5', - url: - `/${publishRootDir}/${req.currentUser.id}/${relativePath}`, - }; - void sendMindSpaceAnalyticsEventFn({ - config: getMindSpaceAnalyticsConfig(), - ...analyticsPayload, - }); - void sendMindSpaceRybbitEventFn({ - config: getMindSpaceRybbitConfig(), - ...analyticsPayload, - }); - } + streamPublicHtmlWrites.add(write); + void write.finally(() => { + streamPublicHtmlWrites.delete(write); + }); }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the @@ -497,34 +522,53 @@ export function attachPortalSessionRoutes( messages = null; } } - const { storageRoot } = - resolveMindSpaceRuntimeConfigFn(h5Root, env); - const syncResult = await syncPublicHtmlAfterFinishFn({ - messages, - currentUser: req.currentUser, - publishDir, - sessionId: sid, - pool: authPool, - storageRoot, - h5Root, - syncWorkspaceAssets: - isWorkspaceMaintenanceEnabled() && - getMindSpaceAssets() - ? (userId, options) => - getMindSpaceAssets().syncWorkspaceAssets( - userId, - options, + await Promise.all([...streamPublicHtmlWrites]); + const publicFinish = + getMindSpacePublicFinish(); + if (!publicFinish) { + throw new Error( + 'MindSpace public finish service unavailable', + ); + } + const lastUserMessage = [ + ...(Array.isArray(messages) ? messages : []), + ] + .reverse() + .find( + (message) => + message?.role === 'user', + ); + const lastUserText = + typeof lastUserMessage?.content === + 'string' + ? lastUserMessage.content + : Array.isArray( + lastUserMessage?.content, + ) + ? lastUserMessage.content + .filter( + (item) => + item?.type === 'text', ) - : null, - registerPublicHtmlArtifacts: (_userId, options) => - registerPublicHtmlArtifactsForConversation({ - user: req.currentUser, - publishDir, - sessionId: options?.sessionId, - relativePaths: options?.relativePaths, - artifactRefs: options?.artifactRefs, - }), - }); + .map((item) => + String( + item.text ?? '', + ).trim(), + ) + .filter(Boolean) + .join('\n') + : ''; + const syncResult = + await publicFinish.syncAfterFinish({ + userId: uid, + sessionId: sid, + messages, + currentUser: { + id: req.currentUser.id, + username: + req.currentUser.username, + }, + }); if ( Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0 @@ -539,41 +583,29 @@ export function attachPortalSessionRoutes( userId: uid, currentUser: req.currentUser, messages, - publishDir, syncResult, + evaluation: + syncResult + ?.deliveryEvaluation, tkmindProxy, }); - const lastUserMessage = [ - ...(Array.isArray(messages) ? messages : []), - ] - .reverse() - .find((message) => message?.role === 'user'); - const lastUserText = - typeof lastUserMessage?.content === 'string' - ? lastUserMessage.content - : Array.isArray(lastUserMessage?.content) - ? lastUserMessage.content - .filter((item) => item?.type === 'text') - .map((item) => - String(item.text ?? '').trim(), - ) - .filter(Boolean) - .join('\n') - : ''; await syncUserGeneratedPages(uid, { sessionId: sid, }); + const pageDataPreparation = + await publicFinish.preparePageDataAfterFinish({ + userId: uid, + messages, + userText: lastUserText, + }); const pageDataDelivery = await maybeRepairPageDataAfterFinishFn({ sessionId: sid, userId: uid, - publishDir, messages, - pool: authPool, - h5Root, - storageRoot, tkmindProxy, userText: lastUserText, + preparation: pageDataPreparation, }); const htmlReady = htmlDelivery?.skipped === 'ok'; const pageDataReady = [ diff --git a/server/portal-session-routes.test.mjs b/server/portal-session-routes.test.mjs index 42e8063..221713e 100644 --- a/server/portal-session-routes.test.mjs +++ b/server/portal-session-routes.test.mjs @@ -125,10 +125,38 @@ function createDependencies(overrides = {}) { getTkmindProxy: () => proxy, getSessionSnapshotService: () => null, getAuthPool: () => null, - getMindSpaceAssets: () => null, + getMindSpacePublicFinish: () => ({ + async materializeSessionEvent() { + return { + materialized: [], + skipped: [], + publicHtmlRelativePaths: [], + publicHtmlArtifacts: [], + }; + }, + async syncAfterFinish() { + return { + materialized: [], + skipped: [], + publicHtmlRelativePaths: [], + }; + }, + async preparePageDataAfterFinish() { + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [], + }, + }; + }, + }), getMemoryV2: () => null, getMindSpaceAnalyticsConfig: () => ({}), - isWorkspaceMaintenanceEnabled: () => false, async ownsAgentSession(userId, sessionId) { calls.owns.push({ userId, sessionId }); return true; @@ -145,9 +173,6 @@ function createDependencies(overrides = {}) { async syncUserGeneratedPages(userId, options) { calls.syncPages.push({ userId, options }); }, - async registerPublicHtmlArtifactsForConversation(input) { - calls.artifacts.push(input); - }, isDirectChatSessionIdFn: () => false, isPortalDirectChatSnapshotFn: () => false, shouldExpirePortalDirectChatSnapshotFn: async () => false, @@ -157,21 +182,11 @@ function createDependencies(overrides = {}) { session, ) => session, filterNonemptyUserVisibleMessagesFn: (messages) => messages, - resolveMindSpaceUserPublishDirFn: () => - '/workspace/user-1', - collectPublicHtmlWritePathsFn: () => [], preparePageDeliveryContractFn: async () => ({}), - materializePublicHtmlWritesFn: () => {}, sendMindSpaceAnalyticsEventFn: async () => {}, resolveAnalyticsOwnerSegmentFn: () => 'segment', resolveAnalyticsOwnerLabelFn: () => 'label', resolveAnalyticsPlanFn: () => 'pro', - syncPublicHtmlAfterFinishFn: async () => ({ - publicHtmlRelativePaths: [], - }), - resolveMindSpaceRuntimeConfigFn: () => ({ - storageRoot: '/storage', - }), maybeRepairH5HtmlAfterFinishFn: async () => ({ skipped: 'ok', }), @@ -485,16 +500,46 @@ test('stream hook uses current session id for contracts and analytics', async () const api = createRouterRecorder(); const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), - collectPublicHtmlWritePathsFn: () => [ - 'public/page.html', - ], + hasPublicHtmlWriteRequestInSessionEventFn: () => + true, + getMindSpacePublicFinish: () => ({ + async materializeSessionEvent(input) { + calls.push({ kind: 'materialize', input }); + return { + materialized: ['public/page.html'], + publicHtmlRelativePaths: [ + 'public/page.html', + ], + publicHtmlArtifacts: [ + { + relativePath: 'public/page.html', + canonicalUrl: + 'https://example.com/MindSpace/user-1/public/page.html', + }, + ], + }; + }, + async syncAfterFinish() { + return { publicHtmlRelativePaths: [] }; + }, + async preparePageDataAfterFinish() { + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [], + }, + }; + }, + }), preparePageDeliveryContractFn(input) { calls.push({ kind: 'contract', input }); return Promise.resolve({}); }, - materializePublicHtmlWritesFn(event, options) { - calls.push({ kind: 'materialize', event, options }); - }, sendMindSpaceAnalyticsEventFn(input) { calls.push({ kind: 'analytics', input }); return Promise.resolve(); @@ -519,10 +564,11 @@ test('stream hook uses current session id for contracts and analytics', async () metadata: { memindRun: { pgRequired: true } }, }, }); - await Promise.resolve(); - assert.equal(calls[0].input.requestId, 'session-1'); - assert.equal(calls[0].input.pgRequired, true); - assert.equal(calls[1].kind, 'materialize'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls[0].kind, 'materialize'); + assert.equal(calls[0].input.userId, 'user-1'); + assert.equal(calls[1].input.requestId, 'session-1'); + assert.equal(calls[1].input.pgRequired, true); assert.equal(calls[2].input.publicationId, 'session-1'); assert.equal(calls[2].input.agentRunId, 'session-1'); assert.equal(calls[2].input.planType, 'pro'); @@ -579,17 +625,35 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock endSessionPageDelivery(sessionId) { calls.push({ kind: 'end', sessionId }); }, - async syncPublicHtmlAfterFinishFn(input) { - calls.push({ kind: 'sync-html', input }); - await input.registerPublicHtmlArtifacts('user-1', { - sessionId: 'session-1', - relativePaths: ['public/page.html'], - }); - return { - publicHtmlRelativePaths: ['public/page.html'], - docxSync: { missing: [] }, - }; - }, + getMindSpacePublicFinish: () => ({ + async materializeSessionEvent() { + return { publicHtmlRelativePaths: [] }; + }, + async syncAfterFinish(input) { + calls.push({ kind: 'sync-html', input }); + return { + publicHtmlRelativePaths: ['public/page.html'], + docxSync: { missing: [] }, + }; + }, + async preparePageDataAfterFinish(input) { + calls.push({ + kind: 'prepare-page-data', + input, + }); + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [], + }, + }; + }, + }), async maybeRepairH5HtmlAfterFinishFn(input) { calls.push({ kind: 'repair-html', input }); return { skipped: 'ok' }; @@ -609,9 +673,6 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock calls.push({ kind: 'ready', input }); return true; }, - async registerPublicHtmlArtifactsForConversation(input) { - calls.push({ kind: 'artifacts', input }); - }, }); const api = createRouterRecorder(); attachPortalSessionRoutes(api, setup.dependencies); @@ -630,9 +691,9 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock [ 'refresh', 'sync-html', - 'artifacts', 'repair-html', 'sync-pages', + 'prepare-page-data', 'repair-page-data', 'prepare-contract', 'ready', @@ -643,9 +704,9 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock [ 'refresh', 'sync-html', - 'artifacts', 'repair-html', 'sync-pages', + 'prepare-page-data', 'repair-page-data', 'prepare-contract', 'ready', @@ -665,6 +726,8 @@ test('Finish marks streamed HTML contracts ready when final sync omits one path' const preparedPaths = []; const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), + hasPublicHtmlWriteRequestInSessionEventFn: () => + true, getTkmindProxy: () => ({ async resolveTarget(sessionId) { return `target:${sessionId}`; @@ -676,19 +739,45 @@ test('Finish marks streamed HTML contracts ready when final sync omits one path' hooks = receivedHooks; }, }), - collectPublicHtmlWritePathsFn: () => [ - 'public/shop-admin.html', - ], + getMindSpacePublicFinish: () => ({ + async materializeSessionEvent() { + return { + publicHtmlRelativePaths: [ + 'public/shop-admin.html', + ], + publicHtmlArtifacts: [ + { + relativePath: 'public/shop-admin.html', + canonicalUrl: + 'https://example.com/MindSpace/user-1/public/shop-admin.html', + }, + ], + }; + }, + async syncAfterFinish() { + return { + publicHtmlRelativePaths: ['public/shop.html'], + docxSync: { missing: [] }, + }; + }, + async preparePageDataAfterFinish() { + return { + autoBind: { + bound: [], + skipped: [], + errors: [], + }, + evaluation: { + structuralPageData: false, + relevantFiles: [], + }, + }; + }, + }), async preparePageDeliveryContractFn(input) { preparedPaths.push(input.relativePath); return {}; }, - async syncPublicHtmlAfterFinishFn() { - return { - publicHtmlRelativePaths: ['public/shop.html'], - docxSync: { missing: [] }, - }; - }, async markPageDeliveryContractReadyFn(input) { readyPaths.push(input.relativePath); return true; diff --git a/server/portal-workspace-publication-delivery.mjs b/server/portal-workspace-publication-delivery.mjs index b02aa52..bec01de 100644 --- a/server/portal-workspace-publication-delivery.mjs +++ b/server/portal-workspace-publication-delivery.mjs @@ -1,8 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { - ensureWorkspaceHtmlThumbnail, -} from '../mindspace-workspace-thumbnails.mjs'; import { injectMindSpaceAnalytics, resolveAnalyticsOwnerLabel, @@ -12,12 +7,8 @@ import { import { injectMindSpaceRybbit, } from '../mindspace-rybbit.mjs'; -import { - resolveMindSpacePublicRequest, -} from '../mindspace-public-route.mjs'; import { buildPublishedHtmlViewContext, - parseMindSpacePublishFilePath, resolveMindSpacePageDataContext, } from '../mindspace-public-page-context.mjs'; import { @@ -25,24 +16,7 @@ import { } from '../mindspace-public-asset-token.mjs'; import { decorateMindSpacePublishedHtml, - handleMindSpaceLongImageDownload, } from '../mindspace-public-delivery.mjs'; -import { - PUBLIC_ZONE_DIR, - PUBLISH_ROOT_DIR, -} from '../user-publish.mjs'; -import { - resolveMindSpaceRuntimeConfig, - resolveMindSpaceUserPublishDir, -} from '../mindspace-runtime-config.mjs'; -import { - htmlReferencesPrivateAssets, - materializePrivateAssetsInWorkspaceHtml, - resolveClosestHtmlRelativePath, -} from '../mindspace-chat-save.mjs'; -import { - getPageDeliveryContract, -} from '../mindspace-delivery-contract.mjs'; import { injectPublicFileShareButton, } from '../mindspace-public-share-widget.mjs'; @@ -61,50 +35,39 @@ import { injectOgTags, injectWechatShareBridge, } from '../mindspace-og-tags.mjs'; -import { - ensureThumbnailPng, - thumbnailPngPathForSvg, -} from '../mindspace-thumbnail-png.mjs'; import { isLongImageDownloadRequest, - longImagePathForHtml, - renderLongImage, } from '../mindspace-long-image.mjs'; import { isWechatUserAgent } from '../wechat-oauth.mjs'; +function deliveryNotFoundMessage(reason) { + if (reason === 'missing_owner_dir' || reason === 'missing_owner') { + return '用户不存在'; + } + if (reason === 'missing_directory_index') { + return '目录中没有 index.html'; + } + return '文件不存在'; +} + export function createPortalWorkspacePublicationDelivery({ - h5Root, internalAgentSecret, analyticsConfig, rybbitConfig, getAuthPool = () => null, getUserAuth = () => null, getMindSpacePages = () => null, + getWorkspacePublicationDelivery = () => + null, resolveRequestOrigin, removeQueryParam, - processEnv = process.env, logger = console, - fsApi = fs, - pathApi = path, - resolvePublicRequest = - resolveMindSpacePublicRequest, - resolveUserPublishDir = - resolveMindSpaceUserPublishDir, - resolveRuntimeConfig = - resolveMindSpaceRuntimeConfig, - resolveClosestHtml = - resolveClosestHtmlRelativePath, - ensureWorkspaceThumbnail = - ensureWorkspaceHtmlThumbnail, resolvePageDataContext = resolveMindSpacePageDataContext, - getDeliveryContract = - getPageDeliveryContract, - ensureThumbnail = ensureThumbnailPng, } = {}) { if ( - !h5Root || - typeof resolveRequestOrigin !== 'function' || + typeof resolveRequestOrigin !== + 'function' || typeof removeQueryParam !== 'function' ) { throw new Error( @@ -112,141 +75,120 @@ export function createPortalWorkspacePublicationDelivery({ ); } + function requireDeliveryService() { + const service = + getWorkspacePublicationDelivery(); + if ( + !service || + typeof service.resolveWorkspaceRequest !== + 'function' + ) { + throw Object.assign( + new Error('MindSpace 交付服务未启用'), + { code: 'delivery_service_unavailable' }, + ); + } + return service; + } + async function sendLongImageDownloadIfRequested( req, res, - filePath, ) { + if ( + !isLongImageDownloadRequest(req.query) + ) { + return false; + } const origin = resolveRequestOrigin(req); const currentUrl = origin ? `${origin}${ req.originalUrl || req.url || '' }` : null; - return handleMindSpaceLongImageDownload({ - query: req.query, - filePath, - pageUrl: currentUrl - ? removeQueryParam( - removeQueryParam( - currentUrl, - 'download', - ), - 'export', - ) - : null, - res, - isLongImageDownloadRequest, - longImagePathForHtml, - renderLongImage, - }); + const pageUrl = currentUrl + ? removeQueryParam( + removeQueryParam( + currentUrl, + 'download', + ), + 'export', + ) + : null; + try { + const rendered = + await requireDeliveryService() + .renderLongImage({ pageUrl }); + const image = Buffer.from( + rendered.bodyBase64, + 'base64', + ); + res.set('Content-Type', 'image/png'); + res.set( + 'Content-Disposition', + `attachment; filename="${ + rendered.servedName || + 'mindspace-public-page.long.png' + }"`, + ); + res.set('Cache-Control', 'no-store'); + res.send(image); + } catch (error) { + res + .status(500) + .type('text/plain; charset=utf-8') + .send( + `长图生成失败:${ + error?.message || '未知错误' + }`, + ); + } + return true; } - async function ensurePublicHtmlPrivateAssetsMaterialized( - filePath, - html, - ) { - const authPool = getAuthPool(); - if ( - !authPool || - !htmlReferencesPrivateAssets(html) - ) { - return html; - } - const normalized = pathApi.resolve( - String(filePath ?? ''), - ); - const ownerMatch = normalized.match( - new RegExp( - `[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, - 'i', - ), - ); - const userId = ownerMatch?.[1] ?? null; - if (!userId) return html; - const publishDir = resolveUserPublishDir( - h5Root, - { id: userId }, - ); - const htmlRelativePath = pathApi - .relative(publishDir, normalized) - .replace(/\\/g, '/'); - if ( - !htmlRelativePath.startsWith( - `${PUBLIC_ZONE_DIR}/`, - ) - ) { - return html; - } - const { storageRoot } = resolveRuntimeConfig( - h5Root, - processEnv, - ); - const result = - await materializePrivateAssetsInWorkspaceHtml({ - pool: authPool, - storageRoot, - h5Root, - userId, - html, - htmlRelativePath, - writeBack: true, - }).catch(() => ({ - html, - changed: false, - })); - return result.changed ? result.html : html; - } - - async function sendPublishFile( + async function sendPublishDelivery( req, res, - filePath, + delivery, { isOwner = true } = {}, ) { - if (!filePath.toLowerCase().endsWith('.html')) { - res.sendFile(filePath, (error) => { - if (error && !res.headersSent) { - res - .status(404) - .json({ message: '文件不存在' }); - } - }); - return; + if (delivery.kind !== 'html') { + if (delivery.cacheControl) { + res.set( + 'Cache-Control', + delivery.cacheControl, + ); + } + res.type?.( + delivery.servedName || + 'application/octet-stream', + ); + return res.send( + Buffer.from( + delivery.bodyBase64 || '', + 'base64', + ), + ); } if ( await sendLongImageDownloadIfRequested( req, res, - filePath, ) ) { return; } - let html; - try { - html = fsApi.readFileSync( - filePath, - 'utf8', - ); - } catch { - res - .status(404) - .json({ message: '文件不存在' }); - return; - } - html = - await ensurePublicHtmlPrivateAssetsMaterialized( - filePath, - html, - ); - html = - rewriteKnownCdnScriptSources(html).html; + + let html = + rewriteKnownCdnScriptSources( + delivery.html, + ).html; html = preparePublicHtmlAssetDelivery( html, internalAgentSecret, ); - const embed = isPlazaEmbedRequest(req.query); + const embed = + isPlazaEmbedRequest(req.query); if (embed) { html = preparePublicationHtmlForEmbed(html); @@ -261,42 +203,36 @@ export function createPortalWorkspacePublicationDelivery({ origin: resolveRequestOrigin(req), requestPath: req.originalUrl || req.url || '', - filePath, - thumbnailPngPathForSvg, + servedName: delivery.servedName, + fallbackImageName: + delivery.thumbnailName, }); - const parsedPublishPath = - parseMindSpacePublishFilePath( - filePath, - h5Root, - ); const userAuth = getUserAuth(); const pageOwner = - parsedPublishPath?.userId && userAuth + delivery.ownerId && userAuth ? await userAuth - .getUserById( - parsedPublishPath.userId, - ) + .getUserById(delivery.ownerId) .catch(() => null) : null; let pageDataContext = null; - const mindSpacePages = getMindSpacePages(); + const mindSpacePages = + getMindSpacePages(); if ( - parsedPublishPath?.userId && - parsedPublishPath.relativePath + delivery.ownerId && + delivery.relativePath ) { pageDataContext = await resolvePageDataContext({ pool: getAuthPool(), pageService: mindSpacePages, - userId: parsedPublishPath.userId, + userId: delivery.ownerId, relativePath: - parsedPublishPath.relativePath, + delivery.relativePath, logger, }); } html = injectMindSpaceAnalytics(html, { - ownerId: - parsedPublishPath?.userId ?? '', + ownerId: delivery.ownerId ?? '', ownerSegment: resolveAnalyticsOwnerSegment( pageOwner ?? {}, @@ -304,10 +240,12 @@ export function createPortalWorkspacePublicationDelivery({ ownerLabel: resolveAnalyticsOwnerLabel( pageOwner ?? {}, ), - planType: resolveAnalyticsPlan(pageOwner ?? {}), - generatedAt: pageDataContext?.generatedAt ?? '', - pageId: - pageDataContext?.pageId ?? '', + planType: resolveAnalyticsPlan( + pageOwner ?? {}, + ), + generatedAt: + pageDataContext?.generatedAt ?? '', + pageId: pageDataContext?.pageId ?? '', publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? @@ -315,8 +253,7 @@ export function createPortalWorkspacePublicationDelivery({ config: analyticsConfig, }); html = injectMindSpaceRybbit(html, { - ownerId: - parsedPublishPath?.userId ?? '', + ownerId: delivery.ownerId ?? '', ownerSegment: resolveAnalyticsOwnerSegment( pageOwner ?? {}, @@ -324,8 +261,7 @@ export function createPortalWorkspacePublicationDelivery({ ownerLabel: resolveAnalyticsOwnerLabel( pageOwner ?? {}, ), - pageId: - pageDataContext?.pageId ?? '', + pageId: pageDataContext?.pageId ?? '', publicationId: pageDataContext?.publicationId ?? pageDataContext?.publication_id ?? @@ -339,7 +275,6 @@ export function createPortalWorkspacePublicationDelivery({ isOwner, pageDataContext, context, - htmlFilePath: filePath, userAgent: req.get('user-agent') || '', preparePublicationHtmlForEmbed, @@ -366,7 +301,7 @@ export function createPortalWorkspacePublicationDelivery({ 'Cache-Control', 'private, no-store', ); - res.send(html); + return res.send(html); } async function serveUserPublishFile( @@ -374,132 +309,53 @@ export function createPortalWorkspacePublicationDelivery({ res, next, ) { - const result = await resolvePublicRequest({ - h5Root, - requestPath: req.path, - resolveUsernameToUserId: async ( - username, - ) => { - const authPool = getAuthPool(); - if (!authPool) return null; - const [rows] = await authPool.query( - `SELECT id FROM h5_users WHERE username = ? LIMIT 1`, - [username], - ); - return rows[0]?.id - ? String(rows[0].id) - : null; - }, - resolveClosestHtmlRelativePath: - resolveClosestHtml, - ensureThumbnail: async ( - publishDir, - relativePath, - ) => { - await ensureWorkspaceThumbnail( - publishDir, - relativePath, - ).catch(() => {}); - }, - logger, - }); + let result; + try { + result = + await requireDeliveryService() + .resolveWorkspaceRequest({ + requestPath: req.path, + }); + } catch (error) { + logger?.error?.( + '[MindSpace] workspace delivery failed', + error, + ); + return res + .status(503) + .json({ + message: 'MindSpace 交付服务不可用', + }); + } if (result.action === 'redirect') { - res.redirect( + return res.redirect( result.status ?? 301, result.location, ); - return; } if (result.action === 'forbidden') { - res + return res .status(403) .json({ message: '禁止访问' }); - return; } if (result.action === 'not_found') { - if ( - result.reason === 'missing_owner_dir' - ) { - res - .status(404) - .json({ message: '用户不存在' }); - return; - } - if ( - result.reason === - 'missing_directory_index' - ) { - res - .status(404) - .json({ - message: - '目录中没有 index.html', - }); - return; - } - res + return res .status(404) - .json({ message: '文件不存在' }); - return; - } - - const resolvedPath = result.filePath; - const authPool = getAuthPool(); - if ( - authPool && - result.ownerKey && - /\.html$/i.test(resolvedPath) - ) { - const publishDir = - resolveUserPublishDir(h5Root, { - id: result.ownerKey, + .json({ + message: + deliveryNotFoundMessage( + result.reason, + ), }); - const relativePath = pathApi - .relative(publishDir, resolvedPath) - .replace(/\\/g, '/'); - const contract = - await getDeliveryContract({ - pool: authPool, - userId: result.ownerKey, - relativePath, - }).catch(() => null); - if ( - contract && - contract.status !== 'ready' - ) { - return res - .status(409) - .type('text/plain; charset=utf-8') - .send( - '页面已生成,正在完成发布验证,请稍后重试。', - ); - } } - if ( - /\.thumbnail\.png$/i.test( - resolvedPath, - ) - ) { - const svgSibling = resolvedPath.replace( - /\.png$/i, - '.svg', - ); - if (fsApi.existsSync(svgSibling)) { - const pngPath = - ensureThumbnail(svgSibling); - if ( - pngPath && - fsApi.existsSync(pngPath) - ) { - res.set( - 'Cache-Control', - 'public, max-age=300', - ); - res.sendFile(pngPath); - return; - } - } + if (result.action === 'not_ready') { + return res + .status(409) + .type('text/plain; charset=utf-8') + .send( + '页面已生成,正在完成发布验证,请稍后重试。', + ); } const userAuth = getUserAuth(); @@ -511,23 +367,20 @@ export function createPortalWorkspacePublicationDelivery({ : null; const isOwner = Boolean( viewer?.id && - result.ownerKey && + result.ownerId && String(viewer.id).toLowerCase() === - String( - result.ownerKey, - ).toLowerCase(), + String(result.ownerId).toLowerCase(), ); - - await sendPublishFile( + return sendPublishDelivery( req, res, - resolvedPath, + result, { isOwner }, ); } return { - sendPublishFile, + sendPublishDelivery, serveUserPublishFile, }; } diff --git a/server/portal-workspace-publication-delivery.test.mjs b/server/portal-workspace-publication-delivery.test.mjs index c13849a..845cac8 100644 --- a/server/portal-workspace-publication-delivery.test.mjs +++ b/server/portal-workspace-publication-delivery.test.mjs @@ -9,8 +9,6 @@ function createResponse() { statusCode: 200, body: undefined, headers: {}, - headersSent: false, - sentFile: null, redirectValue: null, status(code) { this.statusCode = code; @@ -37,25 +35,16 @@ function createResponse() { this.redirectValue = location; return this; }, - sendFile(filePath, callback) { - this.sentFile = filePath; - callback?.(null); - return this; - }, }; } function createRequest(overrides = {}) { return { - path: '/user/public/page.html', + path: '/user-1/public/page.html', originalUrl: - '/MindSpace/user/public/page.html', - url: '/user/public/page.html', + '/MindSpace/user-1/public/page.html', + url: '/user-1/public/page.html', query: {}, - headers: { - host: 'portal.example', - }, - protocol: 'https', userSession: null, userToken: null, get(name) { @@ -67,33 +56,55 @@ function createRequest(overrides = {}) { }; } -function createSetup(overrides = {}) { - let publicResult = { - action: 'serve', - filePath: '/workspace/file.txt', - ownerKey: 'user-1', +function htmlDelivery() { + return { + action: 'deliver', + kind: 'html', + ownerId: 'user-1', + relativePath: 'public/page.html', + servedName: 'page.html', + canonicalPath: + '/MindSpace/user-1/public/page.html', + thumbnailName: 'page.thumbnail.png', + html: + 'Demo
Demo
', }; - let authPool = null; +} + +function createSetup(overrides = {}) { + let deliveryResult = htmlDelivery(); let userAuth = null; const calls = []; - const fsApi = { - readFileSync() { - return 'Demo
Demo
'; + const deliveryService = { + async resolveWorkspaceRequest(input) { + calls.push([ + 'resolve-workspace', + input, + ]); + return deliveryResult; }, - existsSync() { - return false; + async renderLongImage(input) { + calls.push(['long-image', input]); + return { + servedName: + 'mindspace-public-page.long.png', + bodyBase64: + Buffer.from('png-body').toString( + 'base64', + ), + }; }, }; const service = createPortalWorkspacePublicationDelivery({ - h5Root: '/project', internalAgentSecret: 'test-secret', - analyticsConfig: { - enabled: false, - }, - getAuthPool: () => authPool, + analyticsConfig: { enabled: false }, + rybbitConfig: { enabled: false }, + getAuthPool: () => null, getUserAuth: () => userAuth, getMindSpacePages: () => null, + getWorkspacePublicationDelivery: () => + deliveryService, resolveRequestOrigin: () => 'https://portal.example', removeQueryParam(url, key) { @@ -102,40 +113,24 @@ function createSetup(overrides = {}) { url, key, ]); - return url; - }, - fsApi, - async resolvePublicRequest(options) { - calls.push([ - 'resolve-public-request', - options, - ]); - return publicResult; - }, - resolveUserPublishDir( - _h5Root, - user, - ) { - return `/project/MindSpace/${user.id}`; - }, - async getDeliveryContract() { - return null; - }, - ensureThumbnail(svgPath) { - calls.push(['thumbnail', svgPath]); - return svgPath.replace(/\.svg$/, '.png'); + return new URL(url) + .toString() + .replace( + new RegExp( + `([?&])${key}=[^&]*&?`, + ), + '$1', + ) + .replace(/[?&]$/, ''); }, ...overrides, }); return { service, calls, - fsApi, - setPublicResult(value) { - publicResult = value; - }, - setAuthPool(value) { - authPool = value; + deliveryService, + setDeliveryResult(value) { + deliveryResult = value; }, setUserAuth(value) { userAuth = value; @@ -153,9 +148,9 @@ async function serve(setup, request) { return response; } -test('maps redirects, forbidden results, and all not-found reasons', async () => { +test('maps redirects, forbidden, not-ready, and not-found delivery results', async () => { const setup = createSetup(); - setup.setPublicResult({ + setup.setDeliveryResult({ action: 'redirect', status: 302, location: '/canonical', @@ -167,14 +162,21 @@ test('maps redirects, forbidden results, and all not-found reasons', async () => '/canonical', ); - setup.setPublicResult({ + setup.setDeliveryResult({ action: 'forbidden', }); const forbidden = await serve(setup); assert.equal(forbidden.statusCode, 403); - assert.deepEqual(forbidden.body, { - message: '禁止访问', + + setup.setDeliveryResult({ + action: 'not_ready', }); + const notReady = await serve(setup); + assert.equal(notReady.statusCode, 409); + assert.match( + notReady.body, + /正在完成发布验证/, + ); for (const [reason, message] of [ ['missing_owner_dir', '用户不存在'], @@ -184,7 +186,7 @@ test('maps redirects, forbidden results, and all not-found reasons', async () => ], ['missing_file', '文件不存在'], ]) { - setup.setPublicResult({ + setup.setDeliveryResult({ action: 'not_found', reason, }); @@ -196,148 +198,102 @@ test('maps redirects, forbidden results, and all not-found reasons', async () => } }); -test('preserves username database resolution passed to public request resolver', async () => { - let resolvedUserId = null; - const authPool = { - async query(sql, params) { - assert.match(sql, /h5_users/); - assert.deepEqual(params, ['alice']); - return [[{ id: 'user-alice' }]]; - }, - }; - const setup = createSetup({ - getAuthPool: () => authPool, - async resolvePublicRequest(options) { - resolvedUserId = - await options.resolveUsernameToUserId( - 'alice', - ); - return { - action: 'redirect', - location: '/canonical', - }; - }, - }); +test('passes only the logical request path to MindSpace', async () => { + const setup = createSetup(); await serve(setup); - assert.equal( - resolvedUserId, - 'user-alice', - ); + assert.deepEqual(setup.calls[0], [ + 'resolve-workspace', + { + requestPath: + '/user-1/public/page.html', + }, + ]); }); -test('blocks HTML delivery while its delivery contract is not ready', async () => { - const authPool = { - async query() { - return [[]]; - }, - }; - const setup = createSetup({ - getAuthPool: () => authPool, - async resolvePublicRequest() { - return { - action: 'serve', - filePath: - '/project/MindSpace/user-1/public/page.html', - ownerKey: 'user-1', - }; - }, - async getDeliveryContract(options) { - assert.equal(options.pool, authPool); - assert.equal( - options.userId, - 'user-1', - ); - assert.equal( - options.relativePath, - 'public/page.html', - ); - return { status: 'validating' }; - }, - }); - const response = await serve(setup); - assert.equal(response.statusCode, 409); - assert.match( - response.body, - /正在完成发布验证/, - ); -}); - -test('materializes thumbnail PNG sidecars before normal file delivery', async () => { - const setup = createSetup({ - fsApi: { - readFileSync() { - throw new Error('not used'); - }, - existsSync() { - return true; - }, - }, - async resolvePublicRequest() { - return { - action: 'serve', - filePath: - '/workspace/page.thumbnail.png', - ownerKey: 'user-1', - }; - }, - ensureThumbnail(svgPath) { - assert.equal( - svgPath, - '/workspace/page.thumbnail.svg', - ); - return '/workspace/page.thumbnail.png'; - }, +test('proxies binary bodies without receiving a physical path', async () => { + const setup = createSetup(); + setup.setDeliveryResult({ + action: 'deliver', + kind: 'binary', + ownerId: 'user-1', + relativePath: + 'public/page.thumbnail.png', + servedName: 'page.thumbnail.png', + bodyBase64: + Buffer.from('png-body').toString( + 'base64', + ), + cacheControl: 'public, max-age=300', }); const response = await serve(setup); assert.equal( - response.sentFile, - '/workspace/page.thumbnail.png', + response.body.toString(), + 'png-body', ); assert.equal( response.headers['Cache-Control'], 'public, max-age=300', ); + assert.equal( + Object.hasOwn( + setup.calls[0][1], + 'filePath', + ), + false, + ); }); -test('serves non-HTML files unchanged and decorates HTML as private no-store', async () => { +test('decorates HTML at the edge and preserves viewer-specific no-store delivery', async () => { const setup = createSetup(); - const fileResponse = createResponse(); - await setup.service.sendPublishFile( - createRequest(), - fileResponse, - '/workspace/file.txt', - ); + setup.setUserAuth({ + async getMe() { + return { id: 'viewer-1' }; + }, + async getUserById() { + return { id: 'user-1' }; + }, + }); + const response = await serve(setup); + assert.equal(response.statusCode, 200); assert.equal( - fileResponse.sentFile, - '/workspace/file.txt', - ); - - const htmlResponse = createResponse(); - await setup.service.sendPublishFile( - createRequest({ - path: - '/00000000-0000-0000-0000-000000000001/public/page.html', - originalUrl: - '/MindSpace/00000000-0000-0000-0000-000000000001/public/page.html', - }), - htmlResponse, - '/project/MindSpace/00000000-0000-0000-0000-000000000001/public/page.html', - { isOwner: false }, - ); - assert.equal( - htmlResponse.statusCode, - 200, - ); - assert.equal( - htmlResponse.headers['Cache-Control'], + response.headers['Cache-Control'], 'private, no-store', ); assert.equal( - htmlResponse.headers['Content-Type'], + response.headers['Content-Type'], 'text/html; charset=utf-8', ); + assert.match(String(response.body), /Demo/); assert.match( - String(htmlResponse.body), - /Demo/, + String(response.body), + /page\.thumbnail\.png/, + ); +}); + +test('delegates long-image rendering to MindSpace', async () => { + const setup = createSetup(); + const response = await serve( + setup, + createRequest({ + originalUrl: + '/MindSpace/user-1/public/page.html?download=long-image', + query: { + download: 'long-image', + }, + }), + ); + assert.equal( + response.headers['Content-Type'], + 'image/png', + ); + assert.equal( + response.body.toString(), + 'png-body', + ); + assert.equal( + setup.calls.some( + ([kind]) => kind === 'long-image', + ), + true, ); }); diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 1ca6cfd..0b3e6ac 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1452,12 +1452,27 @@ export function createTkmindProxy({ } await rememberSessionTarget(session.id, startTarget); await sessionStore.registerAgentSession(userId, session.id, startTarget); - if (resolvedSessionPolicy?.gooseMode) { + const sessionScopedPolicy = + typeof userAuth.getAgentSessionPolicy === + 'function' + ? await userAuth.getAgentSessionPolicy( + userId, + { + toolMode: + resolvedSessionPolicy + ?.toolMode ?? 'chat', + sessionId: session.id, + packageId: `cp_${session.id}`, + }, + ) + : resolvedSessionPolicy; + if (sessionScopedPolicy?.gooseMode) { const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', { method: 'POST', body: JSON.stringify({ session_id: session.id, - goose_mode: resolvedSessionPolicy.gooseMode, + goose_mode: + sessionScopedPolicy.gooseMode, }), }); if (!modeRes.ok) { @@ -1472,7 +1487,7 @@ export function createTkmindProxy({ session.id, { workingDir: resolvedWorkingDir, - sessionPolicy: resolvedSessionPolicy, + sessionPolicy: sessionScopedPolicy, sandboxConstraints: publishLayout?.constraints ?? null, userMemories, userContext: publishLayout @@ -1578,11 +1593,30 @@ export function createTkmindProxy({ }); } - async function getSessionPolicyForToolMode(userId, toolMode = 'chat') { + async function getSessionPolicyForToolMode( + userId, + toolMode = 'chat', + sessionId = null, + ) { + const scope = sessionId + ? { + sessionId, + packageId: `cp_${sessionId}`, + } + : {}; if (toolMode === 'code' && userAuth.getCodeAgentSessionPolicy) { - return userAuth.getCodeAgentSessionPolicy(userId); + return userAuth.getCodeAgentSessionPolicy( + userId, + scope, + ); } - return userAuth.getAgentSessionPolicy(userId); + return userAuth.getAgentSessionPolicy( + userId, + { + ...scope, + toolMode, + }, + ); } async function reconcileSessionPolicyForUser( @@ -1593,7 +1627,12 @@ export function createTkmindProxy({ if (!userId || !sessionId) return; const target = await resolveTarget(sessionId); const workingDir = await userAuth.resolveWorkingDir(userId); - const sessionPolicy = await getSessionPolicyForToolMode(userId, toolMode); + const sessionPolicy = + await getSessionPolicyForToolMode( + userId, + toolMode, + sessionId, + ); const publishLayout = await userAuth.getUserPublishLayout(userId); const userMemories = await resolveUserMemories(userId, { sessionId, @@ -1820,7 +1859,13 @@ export function createTkmindProxy({ if (!policyState.unrestricted) { body = injectTaskRoutingHint( body, - await userAuth.getAgentSessionPolicy(userId), + await userAuth.getAgentSessionPolicy( + userId, + { + sessionId, + packageId: `cp_${sessionId}`, + }, + ), ); } body = { @@ -2017,12 +2062,27 @@ export function createTkmindProxy({ session.id, startTarget, ); - if (sessionPolicy.gooseMode) { + const sessionScopedPolicy = + await userAuth + .getAgentSessionPolicy( + req.currentUser.id, + { + toolMode: + sessionPolicy.toolMode ?? + 'chat', + sessionId: session.id, + packageId: + `cp_${session.id}`, + }, + ); + if (sessionScopedPolicy.gooseMode) { const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', { method: 'POST', body: JSON.stringify({ session_id: session.id, - goose_mode: sessionPolicy.gooseMode, + goose_mode: + sessionScopedPolicy + .gooseMode, }), }); if (!modeRes.ok) { @@ -2039,7 +2099,8 @@ export function createTkmindProxy({ try { await reconcileAgentSession(api, session.id, { workingDir, - sessionPolicy, + sessionPolicy: + sessionScopedPolicy, sandboxConstraints: publishLayout?.constraints ?? null, userMemories, userContext: publishLayout @@ -2100,7 +2161,16 @@ export function createTkmindProxy({ const skipReconcile = req.body?.skip_reconcile === true; if (!skipReconcile) { const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id); - const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id); + const sessionPolicy = + await userAuth + .getAgentSessionPolicy( + req.currentUser.id, + { + sessionId, + packageId: + `cp_${sessionId}`, + }, + ); const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id); const userMemories = await resolveUserMemories(req.currentUser.id, { sessionId, diff --git a/user-auth.mjs b/user-auth.mjs index 5a56eca..be98ddc 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1859,7 +1859,14 @@ export function createUserAuth(pool, options = {}) { }; }; - const getAgentSessionPolicy = async (userId, { toolMode = 'chat' } = {}) => { + const getAgentSessionPolicy = async ( + userId, + { + toolMode = 'chat', + sessionId = null, + packageId = null, + } = {}, + ) => { const user = await getUserById(userId); if (!user) throw new Error('用户不存在'); const capabilityState = await resolveUserCapabilities(user); @@ -1915,6 +1922,21 @@ export function createUserAuth(pool, options = {}) { sandboxRoot: workspaceCapability.sandboxRoot, workspaceRoot: workspaceCapability.workspaceRoot, workspaceRef: workspaceCapability.workspaceRef, + sessionId: + String(sessionId ?? '').trim() || + null, + packageId: + String(packageId ?? '').trim() || + (String(sessionId ?? '').trim() + ? `cp_${String( + sessionId, + ).trim()}` + : null), + mcpBaseUrl: + env.MINDSPACE_MCP_BASE_URL ?? + env.MINDSPACE_REMOTE_BASE_URL, + mcpTokenSecret: + env.MINDSPACE_MCP_TOKEN_SECRET, userId: user.id, nodeExecPath: env.GOOSED_MCP_NODE_PATH, containerized, @@ -1952,8 +1974,14 @@ export function createUserAuth(pool, options = {}) { }; }; - const getCodeAgentSessionPolicy = async (userId) => - getAgentSessionPolicy(userId, { toolMode: 'code' }); + const getCodeAgentSessionPolicy = async ( + userId, + scope = {}, + ) => + getAgentSessionPolicy(userId, { + ...scope, + toolMode: 'code', + }); const getRoleCapabilities = async (role = 'user') => { const roleDefaults = await listCapabilityGrants('role', role); diff --git a/user-auth.test.mjs b/user-auth.test.mjs index 4a2c3fb..bc3aca5 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -5,6 +5,9 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; +import { + verifyMindSpaceMcpScopedToken, +} from './mindspace-mcp-scoped-token.mjs'; import { createUserAuth, clearUserLogoutCookies, userLoginCookies } from './user-auth.mjs'; test('ensureAllUserDataSpaces provisions every active non-admin user', async () => { @@ -164,7 +167,19 @@ function createAgentPolicyPool(userRow) { if (sql.includes('FROM h5_assets a') && sql.includes('JOIN h5_space_categories')) { return [[]]; } - if (sql.includes('FROM h5_user_capability_grants')) return [[]]; + if ( + sql.includes( + 'FROM h5_capability_grants', + ) + ) { + return [[ + { + capability_key: + 'static_publish', + allowed: 1, + }, + ]]; + } if (sql.includes('FROM h5_user_policy_grants')) return [[]]; if (sql.includes('FROM h5_user_skill_grants')) return [[]]; if (sql.includes('UPDATE h5_users SET workspace_root = ?')) { @@ -410,6 +425,10 @@ test('agent session policy preserves sandbox root and exposes workspace ref meta H5_PORTAL_BASE_URL: 'http://127.0.0.1:18131', H5_PORT: '18131', MEMIND_PAGE_DATA_DELIVERY_BASE_URL: 'http://127.0.0.1:18131', + MINDSPACE_MCP_BASE_URL: + 'http://mindspace.internal:8082', + MINDSPACE_MCP_TOKEN_SECRET: + 'mindspace-user-auth-secret-1234', }, }); @@ -429,6 +448,38 @@ test('agent session policy preserves sandbox root and exposes workspace ref meta sandboxFs.envs.MEMIND_PAGE_DATA_DELIVERY_BASE_URL, 'http://127.0.0.1:18131', ); + assert.equal( + sandboxFs.envs.MINDSPACE_MCP_SCOPED_TOKEN, + undefined, + ); + + const scopedPolicy = + await auth.getAgentSessionPolicy( + userRow.id, + { sessionId: 'session-1' }, + ); + const scopedSandbox = + scopedPolicy.extensionOverrides.find( + (item) => item.name === 'sandbox-fs', + ); + const claims = + verifyMindSpaceMcpScopedToken({ + token: + scopedSandbox.envs + .MINDSPACE_MCP_SCOPED_TOKEN, + secret: + 'mindspace-user-auth-secret-1234', + tool: 'write_file', + }); + assert.equal( + scopedSandbox.envs.MINDSPACE_PACKAGE_ID, + 'cp_session-1', + ); + assert.equal(claims.sessionId, 'session-1'); + assert.equal( + claims.workspaceRef, + 'mindspace://users/user-1/workspace', + ); }); test('admin capabilities include granted platform skills', async () => { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 05800df..969a992 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1,18 +1,11 @@ import crypto from 'node:crypto'; -import fs from 'node:fs'; import path from 'node:path'; import { fetch as undiciFetch } from 'undici'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { resolveSessionAccess } from './session-broker.mjs'; -import { - extractPublicHtmlWriteArtifacts, - isStubPublicHtmlContent, - materializeMissingPublicHtmlWrites, -} from './mindspace-public-finish-sync.mjs'; import { loadWechatMpConfig } from './wechat-mp-config.mjs'; -import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { downloadTemporaryMedia, persistWechatAttachment, @@ -46,22 +39,17 @@ import { buildPageGenerateAgentPrompt, buildPagePublishFailureText, } from './wechat/prompts/page-generate.mjs'; -import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; +import { + artifactFileExists, + isStubPublicHtmlArtifact, + selectSendableHtmlArtifacts, +} from './wechat/verify/page-artifact.mjs'; import { collectWechatGeneratedImages, - repairUnambiguousFreshWechatPageThumbnail, - summarizeFreshWechatThumbnailVerification, - verifyFreshWechatPageThumbnails, } from './wechat/verify/generated-thumbnail.mjs'; import { resolveBillingTokenState } from './billing-token-state.mjs'; -import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs'; import { buildPageDataCollectFailureText, - buildPageDataDeliveryArtifactsFromBindResult, - ensurePageDataDeliveryReady, - maybeAutoBindPageDataHtmlPages, - resolvePageDataCollectOutcomeAsync, - rewritePageDataDeliveryLinks, } from './mindspace-page-data-finish-guard.mjs'; export { buildWechatAgentPrompt }; @@ -538,71 +526,6 @@ function isMissingRequiredPublishSkill(reply, intent) { return !usedStaticPagePublishSkill(replyRequestMessages(reply)); } -function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { - const owner = path.basename(path.resolve(workingDir)); - const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/'); - return buildPublicUrl(publicBaseUrl, owner, normalized); -} - -function ensurePublicHtmlArtifact(htmlPath, workingDir) { - const workspaceRoot = path.resolve(workingDir); - const source = path.isAbsolute(String(htmlPath ?? '')) - ? path.resolve(String(htmlPath)) - : path.resolve(workspaceRoot, String(htmlPath ?? '')); - if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null; - if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null; - - const publicRoot = path.join(workspaceRoot, 'public'); - let publishedPath = source; - if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) { - fs.mkdirSync(publicRoot, { recursive: true }); - publishedPath = path.join(publicRoot, path.basename(source)); - if (publishedPath !== source) fs.copyFileSync(source, publishedPath); - } - - const relativePath = path.relative(workspaceRoot, publishedPath); - if (!relativePath || relativePath.startsWith('..')) return null; - return { - localPath: publishedPath, - relativePath, - }; -} - -function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) { - const artifacts = []; - const htmlTargets = extractHtmlWriteTargets(replyRequestMessages(reply)); - for (const target of htmlTargets) { - const artifact = ensurePublicHtmlArtifact(target, workingDir); - if (!artifact) continue; - artifacts.push({ - ...artifact, - url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), - }); - } - return artifacts; -} - -function extractRequestedHtmlTarget(text) { - const value = String(text ?? ''); - if (!value) return ''; - const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i); - if (explicitPublicMatch?.[1]) return explicitPublicMatch[1]; - const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i); - if (bareMatch?.[1]) return `public/${bareMatch[1]}`; - return ''; -} - -function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) { - const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); - if (!target) return []; - const artifact = ensurePublicHtmlArtifact(target, workingDir); - if (!artifact) return []; - return [{ - ...artifact, - url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), - }]; -} - function sanitizeFilenameSlug(value) { const normalized = String(value ?? '') .toLowerCase() @@ -629,66 +552,6 @@ function collectPublicHtmlLinkFilenames(text) { return filenames; } -function collectRecentPublishedHtmlArtifacts( - intent, - { workingDir, publicBaseUrl, replyText = '', sinceMs = 0, limit = 20 } = {}, -) { - const publicRoot = path.join(path.resolve(workingDir), 'public'); - if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return []; - - const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); - const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : ''; - const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); - const artifacts = []; - const stack = [publicRoot]; - while (stack.length > 0 && artifacts.length < limit) { - const current = stack.pop(); - let entries = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const absolutePath = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(absolutePath); - continue; - } - if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue; - let stat; - try { - stat = fs.statSync(absolutePath); - } catch { - continue; - } - if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue; - const relativePath = path.relative(path.resolve(workingDir), absolutePath); - if (!relativePath || relativePath.startsWith('..')) continue; - const normalizedRelativePath = relativePath.replace(/\\/g, '/'); - const filename = path.posix.basename(normalizedRelativePath); - if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue; - artifacts.push({ - localPath: absolutePath, - relativePath: normalizedRelativePath, - url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl), - mtimeMs: Number(stat.mtimeMs ?? 0), - matchesExpected: expectedRelativePath - ? normalizedRelativePath === expectedRelativePath - : false, - }); - if (artifacts.length >= limit) break; - } - } - - return artifacts - .sort((left, right) => { - if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1; - return right.mtimeMs - left.mtimeMs; - }) - .map(({ mtimeMs, matchesExpected, ...artifact }) => artifact); -} - function rewritePublishedHtmlLinks(text, artifacts = []) { const value = String(text ?? ''); if (!value || artifacts.length === 0) return value; @@ -719,16 +582,14 @@ function rewritePublishedHtmlLinks(text, artifacts = []) { async function maybeAttachPublishedHtmlLink( reply, { - workingDir, - publicBaseUrl, - artifacts: providedArtifacts = null, + artifacts: providedArtifacts = [], allowAttachment = true, }, ) { if (!allowAttachment) return String(reply?.text ?? '').trim(); const artifacts = Array.isArray(providedArtifacts) ? providedArtifacts - : collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); + : []; const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim(); for (const artifact of artifacts) { const url = artifact.url; @@ -826,65 +687,73 @@ function formatWechatOutboundText(text, user = null) { } function defaultPublicHtmlLinkExists(urlText) { - let url; try { - url = new URL(urlText); + const url = new URL(urlText); + return !PUBLIC_HTML_LINK_PATTERN.test( + url.toString(), + ); } catch { return true; + } finally { + PUBLIC_HTML_LINK_PATTERN.lastIndex = 0; } - const parts = url.pathname.split('/').filter(Boolean).map((part) => { - try { - return decodeURIComponent(part); - } catch { - return part; - } - }); - if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true; - const owner = parts[1]; - const rest = parts.slice(3); - if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false; - const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, 'public'); - const target = path.resolve(root, ...rest); - if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; - return fs.existsSync(target) && fs.statSync(target).isFile(); } -export function createPublicHtmlLinkExists(workingDir) { - const workspaceRoot = path.resolve(String(workingDir ?? '')); - const workspaceKey = path.basename(workspaceRoot); - return (urlText) => { - let url; - try { - url = new URL(urlText); - } catch { - return true; +function publicHtmlLinkOwner(urlText) { + try { + const url = new URL(urlText); + const parts = url.pathname + .split('/') + .filter(Boolean) + .map((part) => { + try { + return decodeURIComponent(part); + } catch { + return part; + } + }); + if ( + parts.length < 4 || + parts[0] !== 'MindSpace' || + parts[2] !== 'public' + ) { + return null; } - const parts = url.pathname.split('/').filter(Boolean).map((part) => { - try { - return decodeURIComponent(part); - } catch { - return part; - } - }); - if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true; - const owner = parts[1]; - const rest = parts.slice(3); - if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false; - if (owner === workspaceKey) { - const root = path.join(workspaceRoot, 'public'); - const target = path.resolve(root, ...rest); - if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; - return fs.existsSync(target) && fs.statSync(target).isFile(); + return parts[1] || null; + } catch { + return null; + } +} + +function createPreparedPublicHtmlLinkExists({ + userId, + prepared, + fallback = defaultPublicHtmlLinkExists, +}) { + const validUrls = new Set([ + ...(prepared?.validReplyUrls ?? []), + ...(prepared?.confirmedArtifacts ?? []) + .flatMap((artifact) => [ + artifact?.url, + artifact?.canonicalUrl, + ]), + ].map((value) => String(value ?? '').trim()) + .filter(Boolean)); + const normalizedUserId = + String(userId ?? '').trim(); + return async (urlText) => { + const normalized = + String(urlText ?? '').trim(); + if (validUrls.has(normalized)) return true; + const owner = + publicHtmlLinkOwner(normalized); + if (owner && owner === normalizedUserId) { + return false; } - return defaultPublicHtmlLinkExists(urlText); + return fallback(normalized); }; } -function resolveLinkExistsForWorkingDir(workingDir, linkExists = defaultPublicHtmlLinkExists) { - if (linkExists !== defaultPublicHtmlLinkExists) return linkExists; - return createPublicHtmlLinkExists(workingDir); -} - export async function guardMissingPublicHtmlLinks( text, { linkExists = defaultPublicHtmlLinkExists } = {}, @@ -937,16 +806,6 @@ export function isHtmlPublishFailureMessage(message) { export { maybeAttachPublishedHtmlLink }; -function artifactFileExists(artifact) { - const localPath = String(artifact?.localPath ?? '').trim(); - if (!localPath) return false; - try { - return fs.existsSync(localPath) && fs.statSync(localPath).isFile(); - } catch { - return false; - } -} - function uniqueArtifactsByUrl(artifacts = []) { const byUrl = new Map(); for (const artifact of artifacts) { @@ -956,74 +815,96 @@ function uniqueArtifactsByUrl(artifacts = []) { return [...byUrl.values()]; } -function allExistingHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [] } = {}) { - return uniqueArtifactsByUrl([ - ...publishedArtifacts, - ...expectedArtifacts, - ...recentArtifacts, - ]).filter((artifact) => artifactFileExists(artifact)); -} - -function buildVerifiedHtmlArtifacts({ - publishedArtifacts = [], - expectedArtifacts = [], - recentArtifacts = [], - replyText = '', -} = {}) { - const existing = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts }); - const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); - if (linkedFilenames.size === 0) return existing; - const matched = existing.filter((artifact) => { - const filename = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/')); - return filename && linkedFilenames.has(filename); - }); - return matched; -} - -function resolveHtmlPublishArtifacts({ +async function resolveHtmlPublishArtifacts({ reply, intent, - workingDir, - publicBaseUrl, requestStartedAt, userId = '', sessionId = '', onPageGenerated = null, allowRecentArtifacts = true, + htmlDeliveryAuthority = null, }) { - const artifactMessages = reply?.requestMessages ?? reply?.messages ?? []; - const artifactReply = { ...reply, messages: artifactMessages }; - materializeMissingPublicHtmlWrites({ - messages: artifactMessages, - publishDir: workingDir, + const prepareDelivery = + htmlDeliveryAuthority + ?.prepareWechatHtmlDelivery; + const deliveryRequired = + allowRecentArtifacts === true || + looksLikeHtmlGenerationIntent( + intent?.agentText, + ) || + isPageDataIntent(intent?.agentText) || + hasAnyPublicHtmlLink(reply?.text) || + extractHtmlWriteTargets( + replyRequestMessages(reply), + ).length > 0; + if ( + typeof prepareDelivery !== 'function' + ) { + if (!deliveryRequired) { + return { + publishedArtifacts: [], + expectedArtifacts: [], + recentArtifacts: [], + confirmedArtifacts: [], + verifiedArtifacts: [], + validReplyUrls: [], + hasValidReplyLink: false, + }; + } + throw Object.assign( + new Error( + 'MindSpace WeChat HTML delivery authority is unavailable', + ), + { + code: + 'MINDSPACE_WECHAT_HTML_AUTHORITY_UNAVAILABLE', + }, + ); + } + const prepared = await prepareDelivery({ + userId, + sessionId, + reply: { + text: String(reply?.text ?? ''), + messages: replyRequestMessages(reply), + }, + intent: { + agentText: String( + intent?.agentText ?? '', + ), + displayText: String( + intent?.displayText ?? '', + ), + }, + requestStartedAt, + allowRecentArtifacts, }); - const publishedArtifacts = collectPublishedHtmlArtifacts(artifactReply, { - workingDir, - publicBaseUrl, - }); - const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { - workingDir, - publicBaseUrl, - }); - const recentArtifacts = allowRecentArtifacts - ? collectRecentPublishedHtmlArtifacts(intent, { - workingDir, - publicBaseUrl, - replyText: reply?.text, - sinceMs: requestStartedAt, - }) + const publishedArtifacts = Array.isArray( + prepared?.publishedArtifacts, + ) + ? prepared.publishedArtifacts + : []; + const expectedArtifacts = Array.isArray( + prepared?.expectedArtifacts, + ) + ? prepared.expectedArtifacts + : []; + const recentArtifacts = Array.isArray( + prepared?.recentArtifacts, + ) + ? prepared.recentArtifacts + : []; + const confirmedArtifacts = Array.isArray( + prepared?.confirmedArtifacts, + ) + ? prepared.confirmedArtifacts + : []; + const verifiedArtifacts = Array.isArray( + prepared?.verifiedArtifacts, + ) + ? prepared.verifiedArtifacts : []; - const confirmedArtifacts = allExistingHtmlArtifacts({ - publishedArtifacts, - expectedArtifacts, - recentArtifacts, - }); - const verifiedArtifacts = buildVerifiedHtmlArtifacts({ - publishedArtifacts, - expectedArtifacts, - recentArtifacts, - replyText: reply?.text, - }); if ( typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0 @@ -1037,23 +918,16 @@ function resolveHtmlPublishArtifacts({ recentArtifacts, confirmedArtifacts, verifiedArtifacts, + validReplyUrls: Array.isArray( + prepared?.validReplyUrls, + ) + ? prepared.validReplyUrls + : [], + hasValidReplyLink: + prepared?.hasValidReplyLink === true, }; } -function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) { - return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); -} - -function isStubPublicHtmlArtifact(artifact) { - const localPath = String(artifact?.localPath ?? '').trim(); - if (!localPath) return false; - try { - return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8')); - } catch { - return false; - } -} - function nonStubHtmlArtifacts(artifacts = []) { return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact)); } @@ -1197,52 +1071,55 @@ function markWechatUserNotified(err) { async function enforcePageDataCollectDelivery({ reply, intent, - workingDir, userId, pageDataFinishGuard, publicBaseUrl, requestStartedAt = 0, notifyFailure, }) { - let outcome = await resolvePageDataCollectOutcomeAsync({ - reply, - intent, - publishDir: workingDir, - requestStartedAt, - pool: pageDataFinishGuard?.pool ?? null, - userId, - findPageByRelativePath: null, - apiBase: publicBaseUrl, - }); - let autoBind = null; - if (outcome.action === 'skip') return outcome; - - if (pageDataFinishGuard?.pool) { - const { createPageService } = await import('./mindspace-pages.mjs'); - const pageService = createPageService(pageDataFinishGuard.pool, { - h5Root: pageDataFinishGuard.h5Root, - storageRoot: pageDataFinishGuard.storageRoot, - }); - autoBind = await maybeAutoBindPageDataHtmlPages({ - pool: pageDataFinishGuard.pool, - userId, - publishDir: workingDir, - h5Root: pageDataFinishGuard.h5Root, - storageRoot: pageDataFinishGuard.storageRoot, - findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), - }); - outcome = await resolvePageDataCollectOutcomeAsync({ - reply, - intent, - publishDir: workingDir, - requestStartedAt, - pool: pageDataFinishGuard.pool, - userId, - findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService), - apiBase: publicBaseUrl, - }); + const prepareDelivery = + pageDataFinishGuard + ?.prepareWechatPageDataDelivery; + if (typeof prepareDelivery !== 'function') { + const agentText = String( + intent?.agentText ?? + intent?.displayText ?? + '', + ); + if (!isPageDataIntent(agentText)) { + return { action: 'skip' }; + } + const text = buildPageDataCollectFailureText(); + if (typeof notifyFailure === 'function') { + await notifyFailure(text); + } + throw markWechatUserNotified(new Error(text)); } + const prepared = await prepareDelivery({ + userId, + reply: { + text: String(reply?.text ?? ''), + messages: Array.isArray(reply?.messages) + ? reply.messages + : [], + }, + intent: { + agentText: String(intent?.agentText ?? ''), + displayText: String( + intent?.displayText ?? '', + ), + }, + publicBaseUrl, + requestStartedAt, + }); + const outcome = prepared?.outcome ?? { + action: 'fail', + failureText: + buildPageDataCollectFailureText(), + }; + const autoBind = prepared?.autoBind ?? null; + if (outcome.action === 'skip') return outcome; if (outcome.action === 'retry') { throw new Error('stale_session_poisoned_completion'); } @@ -1254,34 +1131,27 @@ async function enforcePageDataCollectDelivery({ throw markWechatUserNotified(new Error(text)); } - const deliveryArtifacts = buildPageDataDeliveryArtifactsFromBindResult(autoBind, workingDir, { - publicBaseUrl, - }); - if (deliveryArtifacts.length > 0 && pageDataFinishGuard?.pool) { - const { createPageService } = await import('./mindspace-pages.mjs'); - const pageService = createPageService(pageDataFinishGuard.pool, { - h5Root: pageDataFinishGuard.h5Root, - storageRoot: pageDataFinishGuard.storageRoot, - }); - const deliveryCheck = await ensurePageDataDeliveryReady({ - publishDir: workingDir, - userId, - pool: pageDataFinishGuard.pool, - h5Root: pageDataFinishGuard.h5Root, - storageRoot: pageDataFinishGuard.storageRoot, - apiBase: publicBaseUrl, - artifacts: deliveryArtifacts, - }); - if (!deliveryCheck.ok) { + const deliveryArtifacts = Array.isArray( + prepared?.deliveryArtifacts, + ) + ? prepared.deliveryArtifacts + : []; + if ( + prepared?.deliveryCheck && + !prepared.deliveryCheck.ok + ) { const text = buildPageDataCollectFailureText(); if (typeof notifyFailure === 'function') { await notifyFailure(text); } throw markWechatUserNotified(new Error(text)); - } } - if (deliveryArtifacts.length > 0 && reply && typeof reply.text === 'string') { - reply.text = rewritePageDataDeliveryLinks(reply.text, deliveryArtifacts); + if ( + deliveryArtifacts.length > 0 && + reply && + typeof prepared?.rewrittenText === 'string' + ) { + reply.text = prepared.rewrittenText; } return { ...outcome, deliveryArtifacts, autoBind }; } @@ -1585,6 +1455,7 @@ export function createWechatMpService({ applySessionLlmProvider = null, refreshSessionSnapshot = null, sessionIntentClassifier = null, + htmlDeliveryAuthority = null, pageDataFinishGuard = null, wechatFetch = undiciFetch, linkExists = defaultPublicHtmlLinkExists, @@ -1932,62 +1803,74 @@ export function createWechatMpService({ reply, openid, user, + userId, + sessionId, imagePolicy, - publishDir, notifyFailure = true, }) => { if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; const images = collectWechatGeneratedImages(replyRequestMessages(reply)); - let verification = verifyFreshWechatPageThumbnails(artifacts, images); - if (!verification.ok) { - logger.warn?.( - 'WeChat MP fresh thumbnail verification failed:', - summarizeFreshWechatThumbnailVerification(verification, images), - ); - if (config.repairFreshPageThumbnail) { - const repair = repairUnambiguousFreshWechatPageThumbnail({ + const ensureFresh = + htmlDeliveryAuthority + ?.ensureWechatFreshPageThumbnails; + let verification; + if (typeof ensureFresh !== 'function') { + verification = { + ok: false, + reason: + 'mindspace_thumbnail_authority_unavailable', + }; + } else { + try { + verification = await ensureFresh({ + userId, + sessionId, artifacts, images, - currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(replyRequestMessages(reply), { - publishDir, - }), - verificationReason: verification.reason, + messages: + replyRequestMessages(reply), + repairEnabled: + config.repairFreshPageThumbnail, }); - if (repair.ok) { - verification = verifyFreshWechatPageThumbnails(artifacts, images); - logger.info?.('WeChat MP fresh thumbnail repaired:', { - relativePath: String(repair.artifact?.relativePath ?? ''), - jobId: String(repair.image?.jobId ?? ''), - cover: repair.cover, - verified: verification.ok, - }); - } else { - logger.warn?.('WeChat MP fresh thumbnail repair skipped:', { - reason: repair.reason, - artifactCount: repair.artifactCount, - imageCount: repair.imageCount, - }); - } - } - } - if (verification.ok) { - try { - for (const match of verification.matches) { - const htmlRelativePath = path.relative(publishDir, match.artifact.localPath); - if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) { - throw new Error('缩略图页面路径不在用户发布目录内'); - } - await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath); - } - return; } catch (error) { verification = { - ...verification, ok: false, - reason: `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`, + reason: + `mindspace_thumbnail_authority_failed:${ + error instanceof Error + ? error.message + : String(error) + }`, }; } } + if (verification?.repair?.ok) { + logger.info?.( + 'WeChat MP fresh thumbnail repaired by MindSpace:', + { + relativePath: + verification.repair.relativePath, + cover: verification.repair.cover, + }, + ); + } + if (verification?.ok) { + return; + } + logger.warn?.( + 'WeChat MP fresh thumbnail verification failed:', + { + reason: String( + verification?.reason ?? 'unknown', + ), + artifact: + verification?.artifact ?? null, + images: images.map((image) => ({ + jobId: image.jobId, + purpose: image.purpose, + })), + }, + ); const text = buildPagePublishFailureText({ missingFreshThumbnail: true }); if (notifyFailure) { try { @@ -1996,7 +1879,11 @@ export function createWechatMpService({ logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr); } } - const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`); + const error = new Error( + `wechat_page_fresh_thumbnail_required:${ + verification?.reason ?? 'unknown' + }`, + ); error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED'; throw notifyFailure ? markWechatUserNotified(error) : error; }; @@ -2050,7 +1937,8 @@ export function createWechatMpService({ await userAuth.clearWechatAgentRoute(config.appId, openid); } const workingDir = await userAuth.resolveWorkingDir(userId); - const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); + let sessionPolicy = + await userAuth.getAgentSessionPolicy(userId); const publishLayout = await userAuth.getUserPublishLayout(userId); const addressName = resolveWechatAddressName(userContext); const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); @@ -2100,6 +1988,18 @@ export function createWechatMpService({ await userAuth.clearWechatAgentRoute(config.appId, openid); rememberedWechatContexts.delete(existingRoute.agentSessionId); } else { + sessionPolicy = + await userAuth + .getAgentSessionPolicy( + userId, + { + sessionId: + existingRoute + .agentSessionId, + packageId: + `cp_${existingRoute.agentSessionId}`, + }, + ); await reconcileAgentSession( (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), existingRoute.agentSessionId, @@ -2160,6 +2060,14 @@ export function createWechatMpService({ if (!sessionId) { throw new Error('公众号专属 Agent 会话创建失败'); } + sessionPolicy = + await userAuth.getAgentSessionPolicy( + userId, + { + sessionId, + packageId: `cp_${sessionId}`, + }, + ); // `/agent/start` already persists the owning user and goosed node via the // portal proxy. Re-registering here without the node can overwrite the @@ -2385,7 +2293,6 @@ export function createWechatMpService({ forceNew, }); let sessionId = route.sessionId; - const publishLayout = await userAuth.getUserPublishLayout(user.userId); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); let finished = false; @@ -2449,28 +2356,40 @@ export function createWechatMpService({ error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; throw error; } - const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); - const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { publishedArtifacts, verifiedArtifacts, expectedArtifacts, recentArtifacts, confirmedArtifacts, - } = resolveHtmlPublishArtifacts({ + validReplyUrls, + hasValidReplyLink, + } = await resolveHtmlPublishArtifacts({ reply, intent, - workingDir, - publicBaseUrl: config.publicBaseUrl, requestStartedAt, userId: user.userId, sessionId, onPageGenerated, allowRecentArtifacts: htmlArtifactDeliveryExpected, + htmlDeliveryAuthority, }); - const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { - confirmedArtifacts, - }); + const linkExistsForRequest = + createPreparedPublicHtmlLinkExists({ + userId: user.userId, + prepared: { + validReplyUrls, + confirmedArtifacts, + }, + fallback: linkExists, + }); + const hasValidLinkInReply = + hasValidReplyLink || + await hasAnyValidPublishedHtmlLink( + reply?.text, + linkExistsForRequest, + { confirmedArtifacts }, + ); const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); const suspiciousPublishClaim = expectedArtifacts.length === 0 && @@ -2537,15 +2456,15 @@ export function createWechatMpService({ reply, openid: inbound.fromUserName, user, + userId: user.userId, + sessionId, imagePolicy, - publishDir: workingDir, notifyFailure: false, }); } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, intent, - workingDir, userId: user.userId, pageDataFinishGuard, publicBaseUrl: config.publicBaseUrl, @@ -2569,8 +2488,6 @@ export function createWechatMpService({ await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId); } let finalizedReply = await maybeAttachPublishedHtmlLink(reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, allowAttachment: publishArtifacts.length > 0, }); @@ -2670,28 +2587,40 @@ export function createWechatMpService({ error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; throw error; } - const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); - const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { publishedArtifacts, verifiedArtifacts, expectedArtifacts, recentArtifacts, confirmedArtifacts, - } = resolveHtmlPublishArtifacts({ + validReplyUrls, + hasValidReplyLink, + } = await resolveHtmlPublishArtifacts({ reply, intent, - workingDir, - publicBaseUrl: config.publicBaseUrl, requestStartedAt: retryStartedAt, userId: user.userId, sessionId, onPageGenerated, allowRecentArtifacts: htmlArtifactDeliveryExpected, + htmlDeliveryAuthority, }); - const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { - confirmedArtifacts, - }); + const linkExistsForRequest = + createPreparedPublicHtmlLinkExists({ + userId: user.userId, + prepared: { + validReplyUrls, + confirmedArtifacts, + }, + fallback: linkExists, + }); + const hasValidLinkInReply = + hasValidReplyLink || + await hasAnyValidPublishedHtmlLink( + reply?.text, + linkExistsForRequest, + { confirmedArtifacts }, + ); const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); const suspiciousPublishClaim = expectedArtifacts.length === 0 && @@ -2755,14 +2684,14 @@ export function createWechatMpService({ reply, openid: inbound.fromUserName, user, + userId: user.userId, + sessionId, imagePolicy, - publishDir: workingDir, }); } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, intent, - workingDir, userId: user.userId, pageDataFinishGuard, publicBaseUrl: config.publicBaseUrl, @@ -2786,8 +2715,6 @@ export function createWechatMpService({ await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId); } let finalizedReply = await maybeAttachPublishedHtmlLink(reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, allowAttachment: publishArtifacts.length > 0, }); diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index e5a6c62..6143413 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -7,7 +7,7 @@ import sharp from 'sharp'; import { buildWechatAgentPrompt, buildWechatTextReply, - createWechatMpService, + createWechatMpService as createProductionWechatMpService, guardMissingPublicHtmlLinks, assertWechatAgentReplyIsSendable, findRecoverableWechatAgentErrorInReply, @@ -26,6 +26,10 @@ import { WECHAT_CUSTOMER_TEXT_MAX_BYTES, } from './wechat-mp.mjs'; import { createChatIntentRouter } from './chat-intent-router.mjs'; +import { + ensureWechatFreshPageThumbnailsAtWorkspace, + prepareWechatHtmlDeliveryAtWorkspace, +} from './mindspace-wechat-html-delivery.mjs'; function signatureFor(token, timestamp, nonce) { return crypto @@ -70,6 +74,82 @@ function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover return `${title}
${'x'.repeat(600)}

TKMind · 智趣

`; } +function createWechatMpService(options) { + const resolvePublishDir = async (userId) => { + const layout = + await options.userAuth + ?.getUserPublishLayout?.(userId); + return ( + layout?.publishDir ?? + await options.userAuth + ?.resolveWorkingDir?.(userId) + ); + }; + const buildCanonicalUrl = ( + userId, + relativePath, + ) => { + const base = String( + options.config?.publicBaseUrl ?? + 'https://example.com', + ).replace(/\/+$/, ''); + const normalized = String(relativePath) + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + return `${base}/MindSpace/${encodeURIComponent(userId)}/${normalized}`; + }; + const htmlDeliveryAuthority = + options.htmlDeliveryAuthority ?? { + async prepareWechatHtmlDelivery(input) { + const publishDir = + await resolvePublishDir(input.userId); + return prepareWechatHtmlDeliveryAtWorkspace({ + reply: input.reply, + intent: input.intent, + publishDir, + requestStartedAt: + input.requestStartedAt, + allowRecentArtifacts: + input.allowRecentArtifacts, + buildCanonicalUrl: (relativePath) => + buildCanonicalUrl( + input.userId, + relativePath, + ), + }); + }, + async ensureWechatFreshPageThumbnails( + input, + ) { + const publishDir = + await resolvePublishDir(input.userId); + return ensureWechatFreshPageThumbnailsAtWorkspace( + { + artifacts: input.artifacts, + images: input.images, + messages: input.messages, + repairEnabled: + input.repairEnabled, + publishDir, + buildCanonicalUrl: ( + relativePath, + ) => + buildCanonicalUrl( + input.userId, + relativePath, + ), + }, + ); + }, + }; + return createProductionWechatMpService({ + ...options, + htmlDeliveryAuthority, + }); +} + test('executeSessionReply converts a hanging event stream into a bounded timeout', async () => { let cancelled = false; const body = new ReadableStream({ @@ -453,34 +533,39 @@ test('maybeAttachPublishedHtmlLink copies generated root html into public and re const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-'); const htmlPath = `${workspaceRoot}/hello.html`; fs.writeFileSync(htmlPath, 'Hello'); - - const text = await maybeAttachPublishedHtmlLink( - { - text: '已完成', - messages: [ - { - role: 'assistant', - content: [ - { - type: 'toolRequest', - toolCall: { - value: { - name: 'developer', - arguments: { - action: 'write', - path: htmlPath, - content: 'Hello', - }, + const reply = { + text: '已完成', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'developer', + arguments: { + action: 'write', + path: htmlPath, + content: 'Hello', }, }, }, - ], - }, - ], - }, + }, + ], + }, + ], + }; + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply, + publishDir: workspaceRoot, + buildCanonicalUrl: (relativePath) => + `https://m.tkmind.cn/MindSpace/user-1/${relativePath}`, + }); + const text = await maybeAttachPublishedHtmlLink( + reply, { - workingDir: workspaceRoot, - publicBaseUrl: 'https://m.tkmind.cn', + artifacts: prepared.confirmedArtifacts, }, ); @@ -496,33 +581,38 @@ test('maybeAttachPublishedHtmlLink recognizes sandbox-fs write_file html outputs const htmlPath = `${workspaceRoot}/public/codex-verify.html`; fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true }); fs.writeFileSync(htmlPath, 'Codex Verify'); - - const text = await maybeAttachPublishedHtmlLink( - { - text: '页面已生成 ✅', - messages: [ - { - role: 'assistant', - content: [ - { - type: 'toolRequest', - toolCall: { - value: { - name: 'sandbox-fs__write_file', - arguments: { - path: 'public/codex-verify.html', - content: 'Codex Verify', - }, + const reply = { + text: '页面已生成 ✅', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/codex-verify.html', + content: 'Codex Verify', }, }, }, - ], - }, - ], - }, + }, + ], + }, + ], + }; + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply, + publishDir: workspaceRoot, + buildCanonicalUrl: (relativePath) => + `https://m.tkmind.cn/MindSpace/user-1/${relativePath}`, + }); + const text = await maybeAttachPublishedHtmlLink( + reply, { - workingDir: workspaceRoot, - publicBaseUrl: 'https://m.tkmind.cn', + artifacts: prepared.confirmedArtifacts, }, ); @@ -537,33 +627,38 @@ test('maybeAttachPublishedHtmlLink rewrites wrong public html links to the canon const htmlPath = `${workspaceRoot}/public/summer-breeze-journal.html`; fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true }); fs.writeFileSync(htmlPath, 'Summer'); - - const text = await maybeAttachPublishedHtmlLink( - { - text: '页面已发布:https://m.tkmind.cn/public/summer-breeze-journal.html', - messages: [ - { - role: 'assistant', - content: [ - { - type: 'toolRequest', - toolCall: { - value: { - name: 'sandbox-fs__write_file', - arguments: { - path: 'public/summer-breeze-journal.html', - content: 'Summer', - }, + const reply = { + text: '页面已发布:https://m.tkmind.cn/public/summer-breeze-journal.html', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/summer-breeze-journal.html', + content: 'Summer', }, }, }, - ], - }, - ], - }, + }, + ], + }, + ], + }; + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply, + publishDir: workspaceRoot, + buildCanonicalUrl: (relativePath) => + `https://m.tkmind.cn/MindSpace/user-1/${relativePath}`, + }); + const text = await maybeAttachPublishedHtmlLink( + reply, { - workingDir: workspaceRoot, - publicBaseUrl: 'https://m.tkmind.cn', + artifacts: prepared.confirmedArtifacts, }, ); @@ -579,33 +674,38 @@ test('maybeAttachPublishedHtmlLink rewrites tkmind domain variants to the canoni const htmlPath = `${workspaceRoot}/public/codex-prod-check-v6.html`; fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true }); fs.writeFileSync(htmlPath, 'Prod Check'); - - const text = await maybeAttachPublishedHtmlLink( - { - text: '唯一可访问链接:https://mindspace.tkmind.cn/a70ff537-8908-486e-9b6c-042e07cc25db/codex-prod-check-v6.html', - messages: [ - { - role: 'assistant', - content: [ - { - type: 'toolRequest', - toolCall: { - value: { - name: 'sandbox-fs__write_file', - arguments: { - path: 'public/codex-prod-check-v6.html', - content: 'Prod Check', - }, + const reply = { + text: '唯一可访问链接:https://mindspace.tkmind.cn/a70ff537-8908-486e-9b6c-042e07cc25db/codex-prod-check-v6.html', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/codex-prod-check-v6.html', + content: 'Prod Check', }, }, }, - ], - }, - ], - }, + }, + ], + }, + ], + }; + const prepared = prepareWechatHtmlDeliveryAtWorkspace({ + reply, + publishDir: workspaceRoot, + buildCanonicalUrl: (relativePath) => + `https://m.tkmind.cn/MindSpace/user-1/${relativePath}`, + }); + const text = await maybeAttachPublishedHtmlLink( + reply, { - workingDir: workspaceRoot, - publicBaseUrl: 'https://m.tkmind.cn', + artifacts: prepared.confirmedArtifacts, }, ); diff --git a/wechat/verify/page-artifact.mjs b/wechat/verify/page-artifact.mjs index c92d9d8..9db1f97 100644 --- a/wechat/verify/page-artifact.mjs +++ b/wechat/verify/page-artifact.mjs @@ -8,6 +8,9 @@ export function isStubPublicHtmlContent(content) { } export function artifactFileExists(artifact) { + if (typeof artifact?.exists === 'boolean') { + return artifact.exists; + } const localPath = String(artifact?.localPath ?? '').trim(); if (!localPath) return false; try { @@ -18,6 +21,9 @@ export function artifactFileExists(artifact) { } export function isStubPublicHtmlArtifact(artifact) { + if (typeof artifact?.isStub === 'boolean') { + return artifact.isStub; + } const localPath = String(artifact?.localPath ?? '').trim(); if (!localPath) return false; try { @@ -44,6 +50,24 @@ export function verifyPageArtifactContent(artifact, { minBytes = 512 } = {}) { if (isStubPublicHtmlArtifact(artifact)) { return { ok: false, reason: 'stub_placeholder' }; } + if (!String(artifact?.localPath ?? '').trim()) { + if ( + Number(artifact?.sizeBytes ?? 0) < + minBytes + ) { + return { + ok: false, + reason: 'too_small', + }; + } + if (artifact?.isHtmlDocument !== true) { + return { + ok: false, + reason: 'not_html_document', + }; + } + return { ok: true, reason: null }; + } const size = fs.statSync(artifact.localPath).size; if (size < minBytes) { return { ok: false, reason: 'too_small' }; diff --git a/wechat/verify/share-preview.mjs b/wechat/verify/share-preview.mjs index faefe79..d13872a 100644 --- a/wechat/verify/share-preview.mjs +++ b/wechat/verify/share-preview.mjs @@ -61,6 +61,18 @@ export function verifyArtifactSharePreview(artifact) { if (!artifactFileExists(artifact)) { return { ok: false, reason: 'missing_file' }; } + if (!String(artifact?.localPath ?? '').trim()) { + if ( + typeof artifact?.sharePreview?.ok === + 'boolean' + ) { + return artifact.sharePreview; + } + return { + ok: false, + reason: 'missing_share_preview_evaluation', + }; + } const content = fs.readFileSync(artifact.localPath, 'utf8'); return verifySharePreviewMeta(content); }