diff --git a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json index 5d63d87..139dac0 100644 --- a/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json +++ b/plugins/hanzijie/cq-viewer/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "cq-viewer", "displayName": "CadQuery Viewer", - "version": "1.1.0", + "version": "1.1.1", "description": "本地 3D 模型查看器:浏览项目文件夹里的 STL / STEP(保留装配零件名称、颜色和真实边线),接收 CadQuery 脚本推送的模型,在模型上标注位置并一键发给 AI。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;内置 flowful-ai 的 CadQuery 参数化建模 skill。", "author": "HanZijie, flowful-ai", "icon": "icon.png", diff --git a/plugins/hanzijie/cq-viewer/README.md b/plugins/hanzijie/cq-viewer/README.md index 676ee76..0e00e69 100644 --- a/plugins/hanzijie/cq-viewer/README.md +++ b/plugins/hanzijie/cq-viewer/README.md @@ -9,7 +9,7 @@ A local 3D viewer for MiniMax Code. Open STL and STEP models from your project folder — STEP assemblies keep their part names, colours, and real CAD edges — watch models pushed live from CadQuery scripts, then mark positions on the model and send them to the conversation in one click, so the AI knows exactly which face, hole, or edge you mean. The AI can register model folders and open models in the viewer through MCP tools, and the package bundles flowful-ai's CadQuery parametric-design skill. -Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) (bundled CadQuery skill) · Version: `1.1.0` +Authors: [HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai) (bundled CadQuery skill) · Version: `1.1.1` ![CadQuery Viewer: a STEP assembly with three annotations ready to send](docs/preview.jpg) @@ -106,24 +106,26 @@ The page first performs the standard MCP Apps handshake (`ui/initialize` over `p - `~/.cqview/scene/` — models pushed by `python/cqview.py` or `copy_to_scene` (`.stl`, `.step`, `.stp`, `scene.json`). - `~/.cqview/roots.json` and `~/.cqview/selection.json` (its own files, see below). -**What it writes.** Outside `dataDir` it writes only its own exchange folder `~/.cqview/`, always atomically (temp file in the same folder, then rename), one write at a time: +**What it writes.** Outside `dataDir` it writes only its own exchange folder `~/.cqview/`, always atomically (uniquely named temp file in the same folder, then rename), one write at a time, keeping the existing file's permissions: - `~/.cqview/roots.json` — the registered folders (when you add one, the page is opened with `?root=`, or the AI calls `add_model_folder` / `add_model_file`). - `~/.cqview/selection.json` — the current annotations: points, notes, instruction, and the absolute path of each annotated model. Rewritten on every change; the last write wins. - `~/.cqview/scene/.` and `~/.cqview/scene/scene.json` — only for `add_model_file` with `copy_to_scene`: the copied model, its colour/opacity, and removal of the same name in the other model formats (as `cqview.py` does). - It creates `~/.cqview/` and `~/.cqview/scene/` if missing. It never writes into your project folders. -Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file path, size, and modification time (deleting it is safe), and `open-requests.json`, the last 20 AI requests from the past 24 hours (folder ID, path relative to that folder, or scene name). +Inside `dataDir` it keeps `step-cache/`, the converted STEP meshes keyed by file path, size, and modification time (deleting it is safe; on the first conversion after each start, entries unused for 30 days, entries beyond 1 GB (least recently used first), and temp files older than a day are removed), and `open-requests.json`, the last 20 AI requests from the past 24 hours (folder ID, path relative to that folder, or scene name). **Processes.** Python is started only when a STEP file is opened, with arguments passed as an array (no shell), stdio ignored, and every child killed on shutdown: - Probe, until one candidate works: ` -c "import cadquery"` (60 s timeout). - Convert: ` /miniapp/node/step_mesh.py /step-cache/.cqvm..tmp` (300 s timeout). -- Fallback if that fails: ` -c /step-cache/.stl..tmp`. +- Fallback if that fails: ` -c /step-cache/.stl..tmp`. Not used when the conversion was stopped on purpose (time limit, shutdown) or killed outright (`SIGKILL`/`SIGTERM`); a native crash such as `SIGSEGV` still falls back. Once the Mini App is shutting down, no Python process is started. The model path is validated first (registered folder, allowed extension, no symbolic link) and passed as a single argument. -**MCP endpoint.** `POST /mcp/cq-viewer` on the Mini App's loopback listener. It rejects requests whose `Host` or `Origin` is not loopback, and accepts JSON bodies only. The tools apply the same folder rules as the page. +**Request checks.** Every route, the page included, refuses a request whose `Host` is not this machine (`127.0.0.1`, `localhost`, `::1`) or whose `Origin`, when present, is not this listener itself, so a web page reached through DNS rebinding or another origin cannot register folders or read models. Writes accept JSON bodies only. + +**MCP endpoint.** `POST /mcp/cq-viewer` on the Mini App's loopback listener, under the same checks. The tools apply the same folder rules as the page. **Bundled skill.** The Mini App never runs it. When the agent follows the skill it runs the skill's Python scripts itself, through its normal shell tool and permission prompts: `run_cadquery_model.py` executes the model script in a child Python process and collects the files it wrote; `preview.py` renders PNGs offscreen. The scripts make no network requests; the skill asks the agent to look up real-world dimensions with web search, and its setup section installs the Python packages above with `pip` (or `conda`) if they are missing. @@ -138,8 +140,8 @@ The model path is validated first (registered folder, allowed extension, no symb ## Tested environment - MiniMax Code desktop `3.1.1-preview.4597` (Electron 42), macOS 26 (Apple Silicon); Python 3.9 with CadQuery 2.5.2 / OCP 7.7.2, trimesh 4.12, pyrender 0.1.45. -- In MiniMax Code (1.1.0): local install; a new conversation lists `mcp__cq-viewer__add_model_folder`, `mcp__cq-viewer__add_model_file`, `mcp__cq-viewer__list_model_folders` and the skill `cq-viewer:parametric-3d-printing`; `list_model_folders` and `add_model_folder` (`select: false`) called through the Host. Earlier (1.0.0): open, folder registration via `?root=`, and Host bridge detection (the `ui/initialize` handshake is not answered in this build, so the fallback path is used). -- `tests/mcp.test.mjs` (`node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`): the three tools, folder rules, copy to scene with colour, request log across a restart, loopback/JSON checks — 10 tests against the real Node entry with a temporary home directory. +- In MiniMax Code (1.1.1): local install; the page opened with `?root=` reads and registers folders under the new Host/Origin checks, and `list_model_folders` reports `viewer_connected: true` while the panel is open. (1.1.0): local install; a new conversation lists `mcp__cq-viewer__add_model_folder`, `mcp__cq-viewer__add_model_file`, `mcp__cq-viewer__list_model_folders` and the skill `cq-viewer:parametric-3d-printing`; `list_model_folders` and `add_model_folder` (`select: false`) called through the Host. Earlier (1.0.0): open, folder registration via `?root=`, and Host bridge detection (the `ui/initialize` handshake is not answered in this build, so the fallback path is used). +- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs` — 19 tests against the real Node entry with a temporary home directory. `tests/mcp.test.mjs`: the three tools, folder rules, copy to scene with colour, request log across a restart, loopback/JSON checks. `tests/server.test.mjs`: 40 concurrent annotation saves and 8 concurrent folder registrations (page and MCP mixed) without errors or lost entries; with a fake Python, dispose during a STEP conversion kills it and starts no fallback, a killed conversion does not fall back, a `SIGSEGV` crash does; step-cache pruning; Host/Origin checks on every route; versions in sync. - End-to-end in headless Chrome against the same Node entry: the page switches folder and opens the file when the AI calls the tools; `copy_to_scene` shows the copied part in its colour; a file requested while the page is closed opens on the next visit, is not replayed after that, and stays closed once you untick it; `viewer_connected` turns false when the page goes away. From 1.0.0: STL and STEP opening (multi-level assemblies with names, colours, instances), live reload, pick → confirm → notes, the `ui/message` path against a simulated MCP Apps host, the fallback path against a simulated page bridge, and copy mode without a Host. - Bundled skill: `run_cadquery_model.py --preview --strict` run from the installed plugin directory (model → STL → watertight check → preview PNG). - Not verified: Windows; `add_model_file` through the Host with the viewer panel open (the page side was verified in headless Chrome only); that the Send fallback actually fills the conversation input in this build; STEP files with face-level colours from other CAD tools; the skill's Gridfinity generator with CadQuery 2.5 (the skill asks for 2.6.1 or later). diff --git a/plugins/hanzijie/cq-viewer/README.zh-CN.md b/plugins/hanzijie/cq-viewer/README.zh-CN.md index d9de401..84f815f 100644 --- a/plugins/hanzijie/cq-viewer/README.zh-CN.md +++ b/plugins/hanzijie/cq-viewer/README.zh-CN.md @@ -9,7 +9,7 @@ MiniMax Code 里的本地 3D 模型查看器。打开项目文件夹里的 STL 和 STEP(STEP 装配保留零件名称、颜色和真实 CAD 边线),实时显示 CadQuery 脚本推送的模型,在模型上标出位置后一键发到对话里,让 AI 准确知道你说的是哪个面、哪个孔、哪条边。AI 可以通过 MCP 工具登记模型文件夹、在查看器里打开模型;包里还内置了 flowful-ai 的 CadQuery 参数化建模 skill。 -作者:[HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai)(内置的 CadQuery skill) · 版本:`1.1.0` +作者:[HanZijie](https://github.com/HanZijie) · [flowful-ai](https://github.com/flowful-ai)(内置的 CadQuery skill) · 版本:`1.1.1` ![CadQuery Viewer:一个 STEP 装配,已标注三个位置,准备发送](docs/preview.jpg) @@ -106,24 +106,26 @@ clear() # 清空推送的模型 - `~/.cqview/scene/`:`python/cqview.py` 或 `copy_to_scene` 推送的模型(`.stl`、`.step`、`.stp`、`scene.json`)。 - `~/.cqview/roots.json` 和 `~/.cqview/selection.json`(它自己的文件,见下文)。 -**写入**:在 `dataDir` 之外只写自己的交换目录 `~/.cqview/`,都是原子写入(同目录临时文件再改名),并且逐个执行: +**写入**:在 `dataDir` 之外只写自己的交换目录 `~/.cqview/`,都是原子写入(同目录下名字唯一的临时文件,再改名),逐个执行,并保留原文件的权限: - `~/.cqview/roots.json`:登记过的文件夹(添加文件夹、页面带 `?root=` 打开,或 AI 调用 `add_model_folder` / `add_model_file` 时)。 - `~/.cqview/selection.json`:当前标注,包括点、备注、说明和每个标注所在模型的绝对路径。每次修改都会重写,以最后一次写入为准。 - `~/.cqview/scene/.<扩展名>` 和 `~/.cqview/scene/scene.json`:只在 `add_model_file` 带 `copy_to_scene` 时写,包括复制的模型、它的颜色/不透明度,并删除同名的其他格式模型(和 `cqview.py` 一样)。 - 如果 `~/.cqview/` 和 `~/.cqview/scene/` 不存在会创建。它从不写入你的项目文件夹。 -在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除;以及 `open-requests.json`,最近 24 小时内最多 20 条 AI 请求(文件夹 ID、相对该文件夹的路径,或场景模型名)。 +在 `dataDir` 内保存 `step-cache/`,即 STEP 转换结果,按文件路径、大小和修改时间区分,可以随时删除(每次启动后第一次转换时,清理 30 天没用过的条目、超出 1 GB 的部分(最久没用的先删),以及超过一天的临时文件);以及 `open-requests.json`,最近 24 小时内最多 20 条 AI 请求(文件夹 ID、相对该文件夹的路径,或场景模型名)。 **子进程**:只有打开 STEP 时才启动 Python。参数以数组传递(不经过 shell),忽略标准输入输出,Viewer 关闭时结束所有子进程: - 探测,直到找到可用的候选:` -c "import cadquery"`(超时 60 秒)。 - 转换:` <插件目录>/miniapp/node/step_mesh.py <模型.step> /step-cache/.cqvm..tmp`(超时 300 秒)。 -- 转换失败时退回:` -c <内置 STEP→STL 脚本> <模型.step> /step-cache/.stl..tmp`。 +- 转换失败时退回:` -c <内置 STEP→STL 脚本> <模型.step> /step-cache/.stl..tmp`。如果转换是被主动停止的(超时、Mini App 关闭)或被直接结束(`SIGKILL`/`SIGTERM`),不再退回;`SIGSEGV` 这类原生崩溃仍会退回。Mini App 开始关闭后不再启动任何 Python 进程。 模型路径会先校验(在登记的文件夹内、扩展名允许、不是符号链接),再作为单个参数传入。 -**MCP 端点**:Mini App 回环端口上的 `POST /mcp/cq-viewer`。`Host` 或 `Origin` 不是本机的请求一律拒绝,只接受 JSON 请求体。工具遵守和页面相同的文件夹规则。 +**请求校验**:所有路由(包括页面本身)都拒绝 `Host` 不是本机(`127.0.0.1`、`localhost`、`::1`)的请求,也拒绝带了 `Origin` 但不是这个端口本身的请求,所以通过 DNS rebinding 或其他来源的网页无法登记文件夹、读取模型。写请求只接受 JSON 请求体。 + +**MCP 端点**:Mini App 回环端口上的 `POST /mcp/cq-viewer`,同样经过上述校验。工具遵守和页面相同的文件夹规则。 **内置 skill**:Mini App 本身从不运行它。Agent 按 skill 操作时,会通过自己的终端工具(照常经过权限确认)运行其中的 Python 脚本:`run_cadquery_model.py` 在子 Python 进程里执行模型脚本并收集它写出的文件,`preview.py` 离屏渲染 PNG。脚本不发起网络请求;skill 会让 Agent 用网页搜索查真实尺寸,缺少依赖时它的安装说明会用 `pip`(或 `conda`)安装上面列出的 Python 包。 @@ -138,8 +140,8 @@ clear() # 清空推送的模型 ## 测试环境 - MiniMax Code 桌面版 `3.1.1-preview.4597`(Electron 42),macOS 26(Apple Silicon);Python 3.9 + CadQuery 2.5.2 / OCP 7.7.2、trimesh 4.12、pyrender 0.1.45。 -- 在 MiniMax Code 中验证(1.1.0):本机安装;新开的对话里能看到 `mcp__cq-viewer__add_model_folder`、`mcp__cq-viewer__add_model_file`、`mcp__cq-viewer__list_model_folders` 和 skill `cq-viewer:parametric-3d-printing`;经宿主调用了 `list_model_folders` 和 `add_model_folder`(`select: false`)。1.0.0 时验证过:打开、通过 `?root=` 登记文件夹、宿主桥接探测(这个版本不响应 `ui/initialize` 握手,因此走降级路径)。 -- `tests/mcp.test.mjs`(`node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`):三个工具、文件夹规则、带颜色复制到场景、重启后的请求记录、回环与 JSON 检查,共 10 个测试,用临时用户目录跑真实的 Node 入口。 +- 在 MiniMax Code 中验证(1.1.1):本机安装;带 `?root=` 打开的页面在新的 Host/Origin 校验下能正常读取和登记文件夹,面板开着时 `list_model_folders` 返回 `viewer_connected: true`。(1.1.0):本机安装;新开的对话里能看到 `mcp__cq-viewer__add_model_folder`、`mcp__cq-viewer__add_model_file`、`mcp__cq-viewer__list_model_folders` 和 skill `cq-viewer:parametric-3d-printing`;经宿主调用了 `list_model_folders` 和 `add_model_folder`(`select: false`)。1.0.0 时验证过:打开、通过 `?root=` 登记文件夹、宿主桥接探测(这个版本不响应 `ui/initialize` 握手,因此走降级路径)。 +- `node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs`:共 19 个测试,用临时用户目录跑真实的 Node 入口。`tests/mcp.test.mjs`:三个工具、文件夹规则、带颜色复制到场景、重启后的请求记录、回环与 JSON 检查。`tests/server.test.mjs`:40 个并发保存标注、8 个并发登记文件夹(页面和 MCP 混合)没有报错也没有丢条目;用假 Python 验证转换中 dispose 会结束进程且不启动退回转换、被结束的转换不退回、`SIGSEGV` 崩溃仍会退回;缓存清理;所有路由的 Host/Origin 校验;版本号一致。 - 在无头 Chrome 中对同一个 Node 入口做了端到端测试:AI 调用工具后页面切换文件夹并打开文件;`copy_to_scene` 复制的零件按指定颜色显示;页面关着时请求的文件在下次打开时出现,之后不会重复执行,用户取消勾选后保持关闭;页面离开后 `viewer_connected` 变为 false。1.0.0 时测过:打开 STL 和 STEP(多层装配,含名称、颜色、复用实例)、自动重新加载、选点 → 确认 → 备注、模拟 MCP Apps 宿主下的 `ui/message` 路径、模拟页面桥接下的降级路径,以及没有宿主时按钮切换为复制。 - 内置 skill:在已安装的插件目录里运行 `run_cadquery_model.py --preview --strict`(建模 → STL → watertight 校验 → 预览图)。 - 未验证:Windows;查看器面板开着时经宿主调用 `add_model_file`(页面这一侧只在无头 Chrome 里验证过);这个版本里发送的降级路径是否真的填进了对话输入框;其他 CAD 软件导出的带面级颜色的 STEP;skill 的 Gridfinity 生成器在 CadQuery 2.5 下的表现(skill 要求 2.6.1 及以上)。 diff --git a/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js b/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js index 28c56a6..f3aef3a 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js +++ b/plugins/hanzijie/cq-viewer/miniapp/client/assets/host-bridge.js @@ -9,7 +9,7 @@ // delivered to our listener; it is recognised by the "cqv-" id prefix and ignored. const PROTOCOL_VERSION = '2026-01-26'; -const APP_INFO = { name: 'cq-viewer', version: '1.3.0' }; +const APP_INFO = { name: 'cq-viewer', version: '1.1.1' }; // keep equal to plugin.json (tests check) const OWN_NOTIFICATIONS = new Set(['ui/notifications/initialized', 'ui/notifications/size-changed']); /** diff --git a/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs b/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs index 7aabb02..c222256 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs +++ b/plugins/hanzijie/cq-viewer/miniapp/node/mcp.mjs @@ -6,7 +6,7 @@ export const MCP_PATH = '/mcp/cq-viewer'; -const SERVER_INFO = { name: 'cq-viewer', version: '1.1.0' }; +const SERVER_INFO = { name: 'cq-viewer', version: '1.1.1' }; // keep equal to plugin.json (tests check) const INSTRUCTIONS = [ 'CadQuery Viewer 是本机的 STL / STEP 查看器,页面在 MiniMax Code 的 Mini App 面板里打开。', diff --git a/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs b/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs index 2304cdf..b9d11b7 100644 --- a/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs +++ b/plugins/hanzijie/cq-viewer/miniapp/node/server.mjs @@ -10,14 +10,16 @@ // // Folder browsing is scoped: only folders registered in roots.json, only inside the // user's home directory, only .stl/.step/.stp files, no symlinks, no hidden folders. -// The MCP endpoint (mcp.mjs) only answers loopback requests and reuses the same rules; -// its one extra power is copy_to_scene, which copies a model file into ~/.cqview/scene. +// Every route answers only loopback requests (Host is this machine; Origin, if sent, is this listener). +// The MCP endpoint (mcp.mjs) reuses the folder rules; its one extra power is copy_to_scene, which +// copies a model file into ~/.cqview/scene. Writes to ~/.cqview run one at a time (serial()). import { spawn } from 'node:child_process'; -import { createHash, randomBytes } from 'node:crypto'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream, watch } from 'node:fs'; import { access, + chmod, copyFile, lstat, mkdir, @@ -27,6 +29,7 @@ import { rename, stat, unlink, + utimes, writeFile, } from 'node:fs/promises'; import { createServer } from 'node:http'; @@ -67,6 +70,9 @@ const PY_PROBE_TIMEOUT_MS = 60_000; const STEP_TIMEOUT_MS = 300_000; const STEP_MESH_VERSION = 'cqvm-1'; const CQVM_TYPE = 'application/x-cqview-mesh'; +const STEP_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const STEP_CACHE_MAX_BYTES = 1024 * 1024 * 1024; +const STEP_TMP_MAX_AGE_MS = 24 * 60 * 60 * 1000; const REQUEST_TTL_MS = 24 * 60 * 60 * 1000; const MAX_REQUESTS = 20; const REQUEST_TYPES = new Set(['folder', 'file', 'scene']); @@ -118,6 +124,8 @@ export async function start(context) { const stepCacheDir = join(context.dataDir, 'step-cache'); const requestsFile = join(context.dataDir, 'open-requests.json'); const log = context.logger; + /** Set by dispose(): from then on nothing new is spawned. */ + let disposed = false; /** Read-modify-write of our JSON files runs one at a time (page and AI can write together). */ let writeChain = Promise.resolve(); @@ -174,8 +182,9 @@ export async function start(context) { }; const ensureSceneWatcher = async () => { - if (sceneWatcher) return; + if (sceneWatcher || disposed) return; await mkdir(SCENE_DIR, { recursive: true }); + if (sceneWatcher || disposed) return; // a request raced us, or dispose ran meanwhile sceneWatcher = watch(SCENE_DIR, { persistent: false }, () => debounced('scene', () => { sceneVersion += 1; @@ -191,6 +200,7 @@ export async function start(context) { const syncRootWatchers = async () => { const roots = await readRoots(); + if (disposed) return; const wanted = new Set(); for (const r of roots) { const id = rootId(r.path); @@ -416,7 +426,7 @@ export async function start(context) { const dst = join(SCENE_DIR, `${name}${src.ext}`); await serial(async () => { await mkdir(SCENE_DIR, { recursive: true }); - const tmp = join(SCENE_DIR, `.${name}.${process.pid}.${Date.now()}.tmp`); + const tmp = join(SCENE_DIR, `.${name}.${process.pid}.${randomUUID()}.tmp`); try { await copyFile(src.real, tmp); await rename(tmp, dst); @@ -511,12 +521,15 @@ export async function start(context) { /** @param {string} file @param {import('node:fs').Stats} st @returns {Promise<{path: string, type: 'cqvm'|'stl'}>} */ const stepToMesh = async (file, st) => { + if (disposed) throw new HttpError(503, 'shutting_down', '查看器正在关闭'); const key = createHash('sha1').update(`${STEP_MESH_VERSION}|${file}|${st.size}|${st.mtimeMs}`).digest('hex'); const outMesh = join(stepCacheDir, `${key}.cqvm`); const outStl = join(stepCacheDir, `${key}.stl`); for (const [path, type] of /** @type {const} */ ([[outMesh, 'cqvm'], [outStl, 'stl']])) { try { await access(path); + const now = new Date(); + await utimes(path, now, now).catch(() => {}); // last use, for pruning return { path, type }; } catch { /* not cached yet */ @@ -526,8 +539,12 @@ export async function start(context) { if (running) return running; const job = (async () => { const python = await findPython(); - if (!python) throw new HttpError(503, 'step_unavailable', '打开 STEP 需要本机的 Python 和 CadQuery'); + if (!python) { + if (disposed) throw new HttpError(503, 'shutting_down', '查看器正在关闭'); + throw new HttpError(503, 'step_unavailable', '打开 STEP 需要本机的 Python 和 CadQuery'); + } await mkdir(stepCacheDir, { recursive: true }); + pruneOnce(); // 1) full fidelity: assembly names, colours, real edges const tmpMesh = `${outMesh}.${process.pid}.tmp`; try { @@ -540,7 +557,17 @@ export async function start(context) { if (code === 'exit_3' || code === 'exit_4') { throw new HttpError(422, 'step_convert_failed', code === 'exit_3' ? '这个 STEP 文件读不出来,可能已损坏' : '这个 STEP 文件里没有实体'); } - log.warn('cqviewer.step.mesh_failed', { code: code ?? 'unknown' }); + // Stopped on purpose (shutdown, time limit) or killed outright: running the fallback would + // only start another Python nobody waits for. A native crash (SIGSEGV, SIGABRT …) is what + // the simpler fallback is for, so that one still falls through. + if (code === 'disposed') throw new HttpError(503, 'shutting_down', '查看器正在关闭'); + if (code === 'timeout') { + throw new HttpError(422, 'step_convert_failed', 'STEP 转换超过 5 分钟,已停止;文件可能太大或太复杂'); + } + if (code === 'signal_SIGKILL' || code === 'signal_SIGTERM') { + throw new HttpError(422, 'step_convert_failed', 'STEP 转换进程被系统结束了,可能是内存不足'); + } + log.warn(`cqviewer.step.mesh_failed ${code ?? 'unknown'}`, { code: code ?? 'unknown' }); } // 2) fallback: single merged mesh const tmpStl = `${outStl}.${process.pid}.tmp`; @@ -550,7 +577,9 @@ export async function start(context) { return { path: outStl, type: /** @type {const} */ ('stl') }; } catch (error) { await unlink(tmpStl).catch(() => {}); - log.warn('cqviewer.step.convert_failed', { code: /** @type {any} */ (error).code ?? 'unknown' }); + const code = /** @type {any} */ (error).code; + if (code === 'disposed') throw new HttpError(503, 'shutting_down', '查看器正在关闭'); + log.warn(`cqviewer.step.convert_failed ${code ?? 'unknown'}`, { code: code ?? 'unknown' }); throw new HttpError(422, 'step_convert_failed', 'STEP 没有打开成功,文件可能已损坏或不含实体'); } })().finally(() => stepJobs.delete(key)); @@ -558,6 +587,16 @@ export async function start(context) { return job; }; + /** Drop cache entries unused for STEP_CACHE_MAX_AGE_MS, then the oldest until under STEP_CACHE_MAX_BYTES. */ + let pruned = false; + const pruneOnce = () => { + if (pruned) return; + pruned = true; + void pruneStepCache(stepCacheDir).catch((error) => + log.warn(`cqviewer.step.prune_failed ${error?.code ?? 'unknown'}`, { code: error?.code ?? 'unknown' }), + ); + }; + /** Stream a model file, converting STEP on the way. */ const serveModel = async (response, full, st, kind) => { if (kind === 'stl') return streamFile(response, full, st.size, 'model/stl'); @@ -579,6 +618,7 @@ export async function start(context) { '/usr/local/bin/python3', ].filter((c) => typeof c === 'string' && c.length > 0); for (const c of /** @type {string[]} */ (candidates)) { + if (disposed) return null; try { await runChild(c, ['-c', 'import cadquery'], PY_PROBE_TIMEOUT_MS); return c; @@ -595,13 +635,25 @@ export async function start(context) { return pythonProbe; }; - /** @param {string} cmd @param {string[]} args @param {number} timeoutMs */ + /** + * Rejects with code 'disposed' once dispose() has run (and when dispose kills the child), + * 'timeout' when the time limit kills it, 'signal_' / 'exit_' otherwise. + * @param {string} cmd @param {string[]} args @param {number} timeoutMs + */ const runChild = (cmd, args, timeoutMs) => new Promise((resolve, reject) => { + if (disposed) { + reject(Object.assign(new Error('disposed'), { code: 'disposed' })); + return; + } // stdout is reserved for the Host protocol: never inherit it. const child = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'ignore'], env: process.env }); children.add(child); - const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutMs); child.on('error', (error) => { clearTimeout(timer); children.delete(child); @@ -611,7 +663,10 @@ export async function start(context) { clearTimeout(timer); children.delete(child); if (code === 0) resolve(undefined); - else reject(Object.assign(new Error('child failed'), { code: signal ? `signal_${signal}` : `exit_${code}` })); + else { + const why = disposed ? 'disposed' : timedOut ? 'timeout' : signal ? `signal_${signal}` : `exit_${code}`; + reject(Object.assign(new Error('child failed'), { code: why })); + } }); }); @@ -654,7 +709,7 @@ export async function start(context) { const { d, dx, dy, dz } = /** @type {any[]} */ (doc.segments)[0]; doc.distance = { d, dx, dy, dz }; } - await writeJsonAtomic(SELECTION_FILE, doc); // commit first … + await serial(() => writeJsonAtomic(SELECTION_FILE, doc)); // commit first … const client = /** @type {any} */ (body).client; broadcast({ type: 'selection', client: typeof client === 'string' && CLIENT_ID_RE.test(client) ? client : null }); // … then notify return doc; @@ -707,10 +762,13 @@ export async function start(context) { const path = url.pathname; const method = request.method ?? 'GET'; - // MCP: the Host's MCP client calls this over loopback. Refuse anything a browser page on another - // origin (or a DNS-rebound hostname) could send. + // Every route: the Host header must name this machine (a DNS-rebound hostname is refused) and a + // browser Origin, when sent, must be this listener itself. The page is same-origin; the Host's + // MCP client is a Node process and sends no Origin. + if (!isLoopbackRequest(request, context.listen.port)) return sendJson(response, 403, { error: 'forbidden' }); + + // MCP: the Host's MCP client calls this over loopback. if (path === MCP_PATH) { - if (!isLoopbackRequest(request)) return sendJson(response, 403, { error: 'forbidden' }); if (method !== 'POST') { response.writeHead(405, { allow: 'POST', 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify({ error: 'method_not_allowed' })); @@ -821,10 +879,9 @@ export async function start(context) { await listen(server, context.listen.host, context.listen.port); log.info('cqviewer.runtime.listening'); - let disposed = false; const dispose = async () => { if (disposed) return; - disposed = true; + disposed = true; // runChild refuses from here on, so no fallback can start after this context.signal.removeEventListener('abort', onAbort); clearInterval(heartbeat); for (const t of timers.values()) clearTimeout(t); @@ -839,6 +896,7 @@ export async function start(context) { clients.clear(); server.closeAllConnections?.(); await close(server); + await writeChain; // let a write already in progress finish its rename }; const onAbort = () => { void dispose(); @@ -1029,17 +1087,24 @@ function sceneNameFrom(/** @type {string} */ file) { } /** - * Host must be loopback (blocks DNS rebinding); Origin, when a browser sends one, too. - * The Host's MCP client is a Node process and sends no Origin. + * Host must be loopback (blocks DNS rebinding). Origin, when a browser sends one, must be this + * listener: loopback host and our port. The Host's MCP client is a Node process and sends no Origin. * @param {import('node:http').IncomingMessage} request + * @param {number} port */ -function isLoopbackRequest(request) { +function isLoopbackRequest(request, port) { const host = request.headers.host; if (host !== undefined && !LOOPBACK_HOSTS.has(hostnameOf(host))) return false; const origin = request.headers.origin; if (origin === undefined) return true; - const m = /^[a-z][a-z0-9+.-]*:\/\/(\[[^\]]*\]|[^/:?#]+)/i.exec(origin); - return !!m && LOOPBACK_HOSTS.has(hostnameOf(m[1])); + let url; + try { + url = new URL(origin); + } catch { + return false; // includes "null" (sandboxed iframes, file://) + } + const originPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80)); + return url.protocol === 'http:' && LOOPBACK_HOSTS.has(hostnameOf(url.host)) && originPort === port; } /** @param {string} hostHeader */ @@ -1130,12 +1195,53 @@ function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } -/** @param {string} file @param {unknown} data */ +/** + * Temp file in the same folder, then rename. The random suffix keeps two writes in the same + * millisecond apart; callers that read-modify-write go through serial(). The file keeps its mode. + * @param {string} file @param {unknown} data + */ async function writeJsonAtomic(file, data) { await mkdir(dirname(file), { recursive: true }); - const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); - await rename(tmp, file); + const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + const prev = await stat(file).catch(() => null); + if (prev) await chmod(tmp, prev.mode & 0o777); + await rename(tmp, file); + } finally { + await unlink(tmp).catch(() => {}); + } +} + +/** + * Remove step-cache entries not used for STEP_CACHE_MAX_AGE_MS (a cache hit refreshes the + * modification time), then the least recently used until the folder is under STEP_CACHE_MAX_BYTES. + * Also clears temp files left by a crash. Only touches the files this Mini App writes there. + * @param {string} dir + */ +async function pruneStepCache(dir) { + const now = Date.now(); + /** @type {{path: string, size: number, mtime: number}[]} */ + const kept = []; + for (const name of await readdir(dir)) { + if (!/^[a-f0-9]{40}\.(cqvm|stl)(\.\d+\.tmp)?$/.test(name)) continue; + const path = join(dir, name); + const st = await lstat(path).catch(() => null); + if (!st || !st.isFile()) continue; + const age = now - st.mtimeMs; + if (name.endsWith('.tmp') ? age > STEP_TMP_MAX_AGE_MS : age > STEP_CACHE_MAX_AGE_MS) { + await unlink(path).catch(() => {}); + } else if (!name.endsWith('.tmp')) { + kept.push({ path, size: st.size, mtime: st.mtimeMs }); + } + } + let total = kept.reduce((sum, f) => sum + f.size, 0); + kept.sort((a, b) => a.mtime - b.mtime); + for (const f of kept) { + if (total <= STEP_CACHE_MAX_BYTES) break; + await unlink(f.path).catch(() => {}); + total -= f.size; + } } /** diff --git a/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs b/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs index 945e46e..030392a 100644 --- a/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs +++ b/plugins/hanzijie/cq-viewer/tests/mcp.test.mjs @@ -173,7 +173,8 @@ test('the MCP endpoint refuses non-loopback Host/Origin, GET, and non-JSON bodie const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }); assert.equal((await raw('POST', '/mcp/cq-viewer', body, { host: 'evil.example.com' })).status, 403); assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: 'https://evil.example.com' })).status, 403); - assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: 'http://127.0.0.1:1234' })).status, 200); + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: 'http://127.0.0.1:1' })).status, 403); // other local app + assert.equal((await raw('POST', '/mcp/cq-viewer', body, { origin: `http://127.0.0.1:${port}` })).status, 200); assert.equal((await raw('GET', '/mcp/cq-viewer', undefined, {})).status, 405); assert.equal((await raw('POST', '/mcp/cq-viewer', body, { 'content-type': 'text/plain' })).status, 415); }); diff --git a/plugins/hanzijie/cq-viewer/tests/server.test.mjs b/plugins/hanzijie/cq-viewer/tests/server.test.mjs new file mode 100644 index 0000000..cff059b --- /dev/null +++ b/plugins/hanzijie/cq-viewer/tests/server.test.mjs @@ -0,0 +1,272 @@ +// Run: node --test plugins/hanzijie/cq-viewer/tests/*.test.mjs +// Concurrency of writes to ~/.cqview, child-process cleanup on dispose, and the Host/Origin guard. +// Starts the real Node entry against a throw-away HOME; STEP conversion uses a fake "python". + +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, utimes, writeFile } from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const unix = process.platform !== 'win32'; + +let home = ''; +let fakeLog = ''; +/** @type {{port: number, lifecycle: {dispose(): Promise|void}}[]} */ +const running = []; + +before(async () => { + home = await realpath(await mkdtemp(join(tmpdir(), 'cqv-home-'))); + process.env.HOME = home; // read by os.homedir() when server.mjs loads + process.env.USERPROFILE = home; + await mkdir(join(home, 'proj'), { recursive: true }); + await writeFile(join(home, 'proj', 'part.step'), 'ISO-10303-21;\n'); + await writeFile(join(home, 'proj', 'crash.step'), 'ISO-10303-21;\n'); + await writeFile(join(home, 'proj', 'segv.step'), 'ISO-10303-21;\n'); + for (let i = 0; i < 8; i++) await mkdir(join(home, `folder${i}`), { recursive: true }); + if (unix) { + // Fake python: the probe succeeds; step_mesh.py logs its pid and hangs (or kills itself for + // crash.step); the inline STL fallback only logs that it ran. + fakeLog = join(home, 'fake-python.log'); + const fake = join(home, 'fake-python'); + await writeFile( + fake, + [ + '#!/bin/sh', + 'case "$1" in', + ' -c)', + ' if [ "$2" = "import cadquery" ]; then exit 0; fi', + ' echo "fallback" >> "$CQV_FAKE_LOG"; exit 0 ;;', + ' *step_mesh.py)', + ' echo "mesh $$" >> "$CQV_FAKE_LOG"', + ' case "$2" in *crash.step) kill -9 $$ ;; *segv.step) kill -SEGV $$ ;; esac', + ' exec sleep 30 ;;', + 'esac', + 'exit 9', + '', + ].join('\n'), + ); + await chmod(fake, 0o755); + process.env.CQVIEW_PYTHON = fake; + process.env.CQV_FAKE_LOG = fakeLog; + } +}); + +after(async () => { + for (const r of running) await r.lifecycle.dispose(); + await rm(home, { recursive: true, force: true }); +}); + +test('40 concurrent selection saves all succeed and leave valid JSON', async () => { + const { port } = await boot(); + const replies = await Promise.all( + Array.from({ length: 40 }, (_, i) => + raw(port, 'POST', '/viewer/api/selection', JSON.stringify({ points: [{ x: i, y: 0, z: 0 }] })), + ), + ); + assert.deepEqual( + replies.map((r) => r.status).filter((s) => s !== 200), + [], + ); + const doc = JSON.parse(await readFile(join(home, '.cqview', 'selection.json'), 'utf8')); + assert.equal(doc.count, 1); +}); + +test('8 concurrent folder registrations all succeed and all land in roots.json', async () => { + const { port } = await boot(); + const replies = await Promise.all( + Array.from({ length: 8 }, (_, i) => + raw(port, 'POST', '/viewer/api/roots', JSON.stringify({ path: join(home, `folder${i}`) })), + ), + ); + assert.deepEqual( + replies.map((r) => r.status), + Array(8).fill(200), + ); + const { roots } = JSON.parse(await readFile(join(home, '.cqview', 'roots.json'), 'utf8')); + for (let i = 0; i < 8; i++) assert.ok(roots.some((r) => r.path === join(home, `folder${i}`)), `folder${i} kept`); +}); + +test('page and MCP registering at the same time keep every folder', async () => { + const { port } = await boot(); + await rm(join(home, '.cqview', 'roots.json'), { force: true }); + const jobs = []; + for (let i = 0; i < 8; i++) { + const path = join(home, `folder${i}`); + jobs.push( + i % 2 + ? raw(port, 'POST', '/viewer/api/roots', JSON.stringify({ path })) + : raw( + port, + 'POST', + '/mcp/cq-viewer', + JSON.stringify({ jsonrpc: '2.0', id: i, method: 'tools/call', params: { name: 'add_model_folder', arguments: { path, select: false } } }), + ), + ); + } + const replies = await Promise.all(jobs); + assert.deepEqual( + replies.map((r) => r.status), + Array(8).fill(200), + ); + const { roots } = JSON.parse(await readFile(join(home, '.cqview', 'roots.json'), 'utf8')); + assert.equal(roots.length, 8); +}); + +test('dispose during a STEP conversion kills Python and starts no fallback', { skip: !unix && 'needs a POSIX shell' }, async () => { + await writeFile(fakeLog, ''); + const { port, lifecycle } = await boot(); + const rootId = await register(port, join(home, 'proj')); + const pending = raw(port, 'GET', `/viewer/api/file?root=${rootId}&rel=part.step`).catch(() => null); + const pid = await waitForMeshPid(); + await lifecycle.dispose(); + await sleep(1500); + const log = await readFile(fakeLog, 'utf8'); + assert.ok(!log.includes('fallback'), `no fallback after dispose; log:\n${log}`); + assert.throws(() => process.kill(pid, 0), /ESRCH/, 'step_mesh.py was killed'); + await pending; +}); + +test('a conversion killed by a signal fails without starting the fallback', { skip: !unix && 'needs a POSIX shell' }, async () => { + await writeFile(fakeLog, ''); + const { port } = await boot(); + const rootId = await register(port, join(home, 'proj')); + const res = await raw(port, 'GET', `/viewer/api/file?root=${rootId}&rel=crash.step`); + assert.equal(res.status, 422); + assert.equal(JSON.parse(res.text).error, 'step_convert_failed'); + const log = await readFile(fakeLog, 'utf8'); + assert.ok(log.includes('mesh'), 'step_mesh.py ran'); + assert.ok(!log.includes('fallback'), `no fallback after a signal; log:\n${log}`); +}); + +test('a native crash of step_mesh.py (SIGSEGV) still tries the simpler fallback', { skip: !unix && 'needs a POSIX shell' }, async () => { + await writeFile(fakeLog, ''); + const { port } = await boot(); + const rootId = await register(port, join(home, 'proj')); + const res = await raw(port, 'GET', `/viewer/api/file?root=${rootId}&rel=segv.step`); + assert.equal(res.status, 422); // the fake fallback writes no mesh + assert.ok((await readFile(fakeLog, 'utf8')).includes('fallback')); +}); + +test('step-cache pruning removes stale entries and leftovers, nothing else', { skip: !unix && 'needs a POSIX shell' }, async () => { + const dataDir = await mkdtemp(join(home, 'data-')); + const cache = join(dataDir, 'step-cache'); + await mkdir(cache, { recursive: true }); + const old = new Date(Date.now() - 40 * 24 * 3600 * 1000); + const files = { + stale: `${'a'.repeat(40)}.cqvm`, + fresh: `${'b'.repeat(40)}.stl`, + leftover: `${'c'.repeat(40)}.cqvm.123.tmp`, + other: 'notes.txt', + }; + for (const name of Object.values(files)) await writeFile(join(cache, name), 'x'); + for (const name of [files.stale, files.leftover, files.other]) await utimes(join(cache, name), old, old); + const { port } = await boot(dataDir); + const rootId = await register(port, join(home, 'proj')); + await raw(port, 'GET', `/viewer/api/file?root=${rootId}&rel=crash.step`); // first conversion triggers pruning + await sleep(300); + const left = await readdir(cache); + assert.ok(!left.includes(files.stale), 'stale entry removed'); + assert.ok(!left.includes(files.leftover), 'old temp file removed'); + assert.ok(left.includes(files.fresh), 'recent entry kept'); + assert.ok(left.includes(files.other), 'unknown files untouched'); +}); + +test('the version in plugin.json, the MCP server info, and the page bridge agree', async () => { + const { version } = JSON.parse(await readFile(join(PLUGIN_ROOT, '.minimax-plugin', 'plugin.json'), 'utf8')); + const mcp = await readFile(join(PLUGIN_ROOT, 'miniapp', 'node', 'mcp.mjs'), 'utf8'); + const bridge = await readFile(join(PLUGIN_ROOT, 'miniapp', 'client', 'assets', 'host-bridge.js'), 'utf8'); + assert.match(mcp, new RegExp(`SERVER_INFO = \\{ name: 'cq-viewer', version: '${version.replace(/\./g, '\\.')}' \\}`)); + assert.match(bridge, new RegExp(`APP_INFO = \\{ name: 'cq-viewer', version: '${version.replace(/\./g, '\\.')}' \\}`)); +}); + +test('non-loopback Host or cross-origin requests are refused on every route', async () => { + const { port } = await boot(); + const body = JSON.stringify({ path: join(home, 'proj') }); + const evilHost = await raw(port, 'POST', '/viewer/api/roots', body, { host: 'evil.example' }); + assert.equal(evilHost.status, 403); + const evilOrigin = await raw(port, 'POST', '/viewer/api/roots', body, { origin: 'https://evil.example' }); + assert.equal(evilOrigin.status, 403); + const page = await raw(port, 'GET', '/viewer', undefined, { host: 'evil.example' }); + assert.equal(page.status, 403); + const file = await raw(port, 'GET', '/viewer/api/roots', undefined, { host: `evil.example:${port}` }); + assert.equal(file.status, 403); + // the page itself: loopback Host, same-origin Origin + const own = await raw(port, 'POST', '/viewer/api/roots', body, { origin: `http://127.0.0.1:${port}` }); + assert.equal(own.status, 200); + const localhost = await raw(port, 'GET', '/viewer', undefined, { host: `localhost:${port}` }); + assert.equal(localhost.status, 200); +}); + +// ---------------------------------------------------------------- helpers + +async function boot(dataDir) { + const port = await freePort(); + const { start } = await import(new URL('../miniapp/node/server.mjs', import.meta.url).href); + const noop = () => {}; + const lifecycle = await start({ + pluginId: 'cq-viewer', + pluginRoot: PLUGIN_ROOT, + dataDir: dataDir ?? (await mkdtemp(join(home, 'data-'))), + listen: { host: '127.0.0.1', port }, + signal: new AbortController().signal, + logger: { debug: noop, info: noop, warn: noop, error: noop }, + }); + const entry = { port, lifecycle }; + running.push(entry); + return entry; +} + +async function register(port, path) { + const res = await raw(port, 'POST', '/viewer/api/roots', JSON.stringify({ path })); + assert.equal(res.status, 200, res.text); + return JSON.parse(res.text).root.id; +} + +async function waitForMeshPid() { + for (let t = 0; t < 10_000; t += 50) { + const m = /mesh (\d+)/.exec(await readFile(fakeLog, 'utf8').catch(() => '')); + if (m) return Number(m[1]); + await sleep(50); + } + throw new Error('step_mesh.py never started'); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** @returns {Promise<{status: number, text: string}>} */ +function raw(port, method, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: '127.0.0.1', + port, + method, + path, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8') })); + }, + ); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +function freePort() { + return new Promise((resolve, reject) => { + const s = createServer(); + s.once('error', reject); + s.listen(0, '127.0.0.1', () => { + const { port: p } = /** @type {import('node:net').AddressInfo} */ (s.address()); + s.close(() => resolve(p)); + }); + }); +}