diff --git a/docs/schemas/hub-control.md b/docs/schemas/hub-control.md index c875275..708a297 100644 --- a/docs/schemas/hub-control.md +++ b/docs/schemas/hub-control.md @@ -6,6 +6,7 @@ > 本文档描述**已实现的真实契约**,不是设想稿。发现的实现问题一律显式标注(§4.2 / §4.10 / §9 已知问题 #1),不隐去。 > **阶段 5(写入路径)修订(2026-10-05)**:已知问题 #1(hub 运行期 pull / diff 必 500)**已修复**——`pullFromGame` / `diffWorkspace` 增加 `server?: EditorServer` 注入,hub 路由传 `daemon.server` 复用已绑定的 39998(§4.2 / §4.10);`POST /v1/push` 契约扩展为完整写入流水线(§4.11)。 > **阶段 7(测试运行器 + 发布链路)修订(2026-10-06)**:新增 `POST /v1/test/run`(§4.13)与 `POST /v1/pack/build`(§4.14)——JSON 路由 **12 → 14 条**;`HubClient` 补 `testRun` / `packBuild`(§6.1);MCP 工具 10 → 12 个(新增 `tts_test_run` / `tts_pack_build`,§7)。 +> **v0.8.0(桌面 UI 联通)修订(2026-10-07,UI-1b 窗口)**:新增 `POST /v1/files/read`(§4.15)与 `PUT /v1/files/write`(§4.16)——JSON 路由 **14 → 16 条**;`/v1/status` 响应加 `hub.version` / `hub.appMode`(§4.1);新旗标 `tts hub --app-mode`(app 模式错误体 `details` 含 `userAction` 提示键,§3.2);loopback CORS Origin 回显(§2)。**红线:`/v1/files/*` 不暴露为 MCP 工具**(防 MCP 越权读宿主机文件,§7)。 --- @@ -37,8 +38,9 @@ hub 控制通道是 `tts hub` 常驻进程暴露的**本机 HTTP+JSON 控制面* | 端口 | 缺省 39995;`tts hub --port ` 可改;与 39998 / 39997 / 39996 相互独立 | `lifecycle.ts:27-28`、`cli/commands/hub.ts:93-100` | | 请求体编码 | UTF-8 JSON 对象(顶层必须是键值对象,数组 / 标量 → 400) | `control.ts:726-737` | | 响应编码 | `application/json; charset=utf-8` + `Content-Length` | `control.ts:278-292` | -| 请求媒体类型 | 读请求体的 POST 路由要求 `Content-Type: application/json`(允许 `; charset=...` 等参数,大小写不敏感);否则 415。三个 GET 路由与 `/v1/hub/shutdown` **不读请求体**,不校验媒体类型 | `control.ts:213-218`、`:710-717`、`:1049-1057` | +| 请求媒体类型 | 读请求体的路由(各 POST 路由与 `PUT /v1/files/write`)要求 `Content-Type: application/json`(允许 `; charset=...` 等参数,大小写不敏感);否则 415。三个 GET 路由与 `/v1/hub/shutdown` **不读请求体**,不校验媒体类型 | `control.ts:213-218`、`:710-717`、`:1049-1057` | | 路径匹配 | 精确匹配 pathname;查询串不参与路由(仅 `/v1/packs` 读取 `packsRoot` 参数) | `control.ts:630-665`、`:797-801` | +| CORS | loopback-only Origin 回显(`null` / `localhost` / `127.0.0.1` / `[::1]`;仅这些回环 Origin 的请求回显 `Access-Control-Allow-Origin`,其余 Origin 不回显) | v0.8.0(UI-1b)新增;桌面 UI(Vite dev server / Tauri webview)跨端口访问 hub 所需,依据 `control.ts`(CORS 处理) | | 鉴权 | **无 token / 无鉴权**:唯一访问边界是本机回环。本机任意进程都可调用,切勿把控制通道暴露到局域网 | `control.ts` 全模块无鉴权代码路径;`:225-231` 的地址约束 | --- @@ -47,7 +49,7 @@ hub 控制通道是 `tts hub` 常驻进程暴露的**本机 HTTP+JSON 控制面* ### 3.1 请求体上限与请求侧错误 -- 读请求体的 POST 路由:请求体上限 **1MB = 1 048 576 字节**,超限 → **413 `HUB_PAYLOAD_TOO_LARGE`**(超限后不再缓冲,`control.ts:86`、`:239-269`、`:722-725`)。 +- 读请求体的路由(各 POST 路由与 `PUT /v1/files/write`):请求体上限 **1MB = 1 048 576 字节**,超限 → **413 `HUB_PAYLOAD_TOO_LARGE`**(超限后不再缓冲,`control.ts:86`、`:239-269`、`:722-725`)。 - 请求体不是合法 JSON、或顶层不是键值对象 → **400 `HUB_BAD_REQUEST`**。 - 必填字段缺失 / 类型不符 / 全空白字符串 / 非正有限数字 → **400 `HUB_BAD_REQUEST`**,message 形如 `missing or invalid field: root (non-empty string required)`(协议层英文短句)。 - 请求 URL 本身解析失败(防御路径)→ **400 `HUB_BAD_REQUEST`** `malformed request URL`(`control.ts:630-637`)。 @@ -64,6 +66,8 @@ hub 控制通道是 `tts hub` 常驻进程暴露的**本机 HTTP+JSON 控制面* - `message`:协议层英文短句;业务错误(`PackError` / `LuaError`)的 message 由底层模块经 `t()` 生成后**原样透传**(可能是中文)。消费方按 `code` 分支,不解析 message(`control.ts:47-50`)。 - `details`:可选;无细节时该字段不出现。 +**app 模式的错误增强(v0.8.0,UI-1b)**:hub 以 app 模式启动时(`tts hub --app-mode app`,或 `tts-hub` bin 经环境变量 `TTS_HUB_APP_MODE=app`),错误响应的 `details` 可能携带 `userAction` 字段——值是面向用户的**提示键**(i18n 键),桌面 UI 拿到后直接渲染对应的本地化文案,不需要自己按 `code` 硬编码提示语;standalone 模式(缺省)下不注入该字段。`userAction` 只是**附加**提示,`code` / `message` 的语义与取值不变,旧客户端可忽略。 + `control.ts` 自身的业务异常映射顺序(`respondError`,`control.ts:681-701`): | 抛出类型 | HTTP | code | details | @@ -79,11 +83,14 @@ hub 控制通道是 `tts hub` 常驻进程暴露的**本机 HTTP+JSON 控制面* | code | HTTP | 触发 | | --- | --- | --- | | `HUB_BAD_REQUEST` | 400 | 请求体非法 JSON / 非对象、必填字段缺失或类型不符、URL 解析失败 | -| `HUB_NOT_FOUND` | 404 | 路径不在 12 条 JSON 路由中(SSE 路径另计,见 §5) | -| `HUB_METHOD_NOT_ALLOWED` | 405 | 路径存在但方法不对;响应带 `Allow: GET` / `Allow: POST` | +| `HUB_NOT_FOUND` | 404 | 路径不在 16 条 JSON 路由中(SSE 路径另计,见 §5) | +| `HUB_METHOD_NOT_ALLOWED` | 405 | 路径存在但方法不对;响应带 `Allow: GET` / `Allow: POST` / `Allow: PUT`(`/v1/files/write`) | | `HUB_PAYLOAD_TOO_LARGE` | 413 | 请求体超过 1MB | -| `HUB_UNSUPPORTED_MEDIA_TYPE` | 415 | 读体 POST 路由的 `Content-Type` 不是 `application/json` | +| `HUB_UNSUPPORTED_MEDIA_TYPE` | 415 | 读体路由(POST 与 `PUT /v1/files/write`)的 `Content-Type` 不是 `application/json` | | `HUB_CONFIRM_REQUIRED` | 400 | `POST /v1/push` 的 `confirm` 不严格等于 `true` | +| `HUB_PATH_ESCAPE` | 400 | `/v1/files/read` / `/v1/files/write` 的 `path` 解析后逃逸出请求 `root`(resolve 前缀校验或 symlink 真实路径校验失败,§4.15 / §4.16) | +| `HUB_CONFLICT` | 409 | `PUT /v1/files/write` 的 `baseSha256` 与目标文件当前内容的实际 sha256 不符(乐观锁冲突,§4.16) | +| `HUB_FILE_TOO_LARGE` | 413 | `/v1/files/read` 的目标文件超过 20MB 上限(§4.15) | | `HUB_PACK_ERROR` | 400 | 底层 `PackError` 透传;`details.packCode` 为业务码(`PACK_*` / `PULL_FAILED` / `SLICE_*` / `PLAN_*` / `IMPORT_*` / `REGISTRY_*` / `PUSH_FAILED` / `PUSH_ASSET_CHANGES_DETECTED` / `BASELINE_CONFLICT` / `PUSH_VERIFY_FAILED` / `BASELINE_*` / `BACKUP_*` 等) | | `HUB_LUA_ERROR` | 400 | Lua 运行时错误透传 | | `HUB_INTERNAL_ERROR` | 500 | 其余异常(含会话层超时 / 未连接 TTS 的普通 Error、端口占用 `PortInUseError`) | @@ -108,7 +115,7 @@ hub 控制通道是 `tts hub` 常驻进程暴露的**本机 HTTP+JSON 控制面* ### 4.0 总览 -S2 控制面共 **12 条 JSON 路由**(下表)+ **1 条 SSE 事件流**(`GET /v1/events`,见 §5),共 13 个路径;阶段 7(窗口 G)追加 2 条 JSON 路由(§4.13 / §4.14,下表 13 / 14 行),**JSON 路由合计 14 条**。 +S2 控制面共 **12 条 JSON 路由**(下表 1–12 行)+ **1 条 SSE 事件流**(`GET /v1/events`,见 §5);阶段 7(窗口 G)追加 2 条 JSON 路由(§4.13 / §4.14,下表 13 / 14 行);v0.8.0(UI-1b 窗口)追加 2 条 JSON 路由(§4.15 / §4.16,下表 15 / 16 行,桌面 UI 文件读写专用),**JSON 路由合计 16 条**。 | # | 方法 | 路径 | 作用 | 副作用 | 请求体 | 成功响应 | | --- | --- | --- | --- | --- | --- | --- | @@ -126,6 +133,8 @@ S2 控制面共 **12 条 JSON 路由**(下表)+ **1 条 SSE 事件流**(`G | 12 | POST | `/v1/hub/shutdown` | 优雅关闭 hub | 进程退出 | 不读 | `{ok:true}` | | 13 | POST | `/v1/test/run` | 在 TTS 中跑 Lua 测试(阶段 7) | 可能改游戏状态(测试脚本在 TTS 内执行) | `{root, targetGuid?, timeoutMs?, bail?, bundle?, include?}` | `RunReport`(§4.13) | | 14 | POST | `/v1/pack/build` | 工作区 → 存档 JSON → BSON 载荷(阶段 7) | 写本地文件(`dryRun` 时无) | `{root, outPath?, dryRun?}` | `BsonBuildResult` + 诊断字段(§4.14) | +| 15 | POST | `/v1/files/read` | 读取图包工作区内文件(base64,v0.8.0) | 无(只读) | `{root, path}` | `{base64, mime, size}`(§4.15) | +| 16 | PUT | `/v1/files/write` | 写图包工作区内文本文件(乐观锁,v0.8.0) | 写本地文件 | `{root, path, content, baseSha256?}` | `{sha256, size}`(§4.16) | 所有 POST 路由通用的请求侧错误(400 / 404 / 405 / 413 / 415)见 §3;各小节只列该路由特有的错误。curl 示例假定 hub 在缺省端口 39995。 @@ -137,7 +146,7 @@ S2 控制面共 **12 条 JSON 路由**(下表)+ **1 条 SSE 事件流**(`G ```json { "ok": true, - "hub": { "editor": true, "tcpClients": 0, "wsClients": 1, "inprocClients": 2, "startedAt": 1759638000000, "uptimeMs": 123456 }, + "hub": { "editor": true, "tcpClients": 0, "wsClients": 1, "inprocClients": 2, "startedAt": 1759638000000, "uptimeMs": 123456, "version": "0.8.0", "appMode": "standalone" }, "tts": { "connected": true, "version": "MoonSharp 3.0.0.0", "objects": 42 } } ``` @@ -148,11 +157,14 @@ S2 控制面共 **12 条 JSON 路由**(下表)+ **1 条 SSE 事件流**(`G | `hub.tcpClients` / `hub.wsClients` / `hub.inprocClients` | number | 当前三路扇出下游数 | | `hub.startedAt` | number | hub 启动时刻(epoch 毫秒) | | `hub.uptimeMs` | number | 已运行毫秒数,**响应时现算**(负值截为 0) | +| `hub.version` | string | hub 版本号(v0.8.0 起:取自 tts-toolkit `package.json` 的 `version`);**v0.7.x 旧 hub 无此字段**,消费方以缺失处理 | +| `hub.appMode` | string | 应用模式:`"standalone"`(独立运行,缺省)或 `"app"`(桌面 UI 作 sidecar 启动;v0.8.0 起,由 `--app-mode` / `TTS_HUB_APP_MODE` 决定) | | `tts.connected` | boolean | TTS 是否已连上 39998:经执行 Lua `return _VERSION` 探测(单次 2s 超时)成功即为 true | | `tts.version` | string? | 版本探测成功且返回字符串时存在(实测 MoonSharp 版本串) | | `tts.objects` | number? | 对象数探测(`return #getObjects()`,2s 超时)成功时存在;失败只影响该可选字段 | - **TTS 未连接 / 探活失败不是路由错误**:仍 200、`connected:false`(S2 约定)。 +- **版本语义(v0.8.0,UI 兼容策略)**:MINOR bump(0.7.x → 0.8.0)= 新增字段 + 新增路由,**向前兼容**(旧 UI 连新 hub 正常,不读新字段即可);新 UI 必须按 `hub.version` 判断能力——缺失字段视为旧版(< 0.7.0);`0.7.0`/`0.7.1` 为降级模式(`/v1/files/*` 不存在,依赖它的功能禁用);`>= 0.8.0` 全功能。 - 可能的错误码:404 `HUB_NOT_FOUND`、405 `HUB_METHOD_NOT_ALLOWED`;500 `HUB_INTERNAL_ERROR` 仅为防御路径。 - curl: @@ -534,6 +546,104 @@ curl -s -X POST http://127.0.0.1:39995/v1/pack/build \ -d '{"root":"D:/packs/第七大陆","outPath":"D:/packs/第七大陆/dist/pack.bson","dryRun":true}' ``` +### 4.15 POST /v1/files/read + +v0.8.0(UI-1b 窗口)新增:读取图包工作区内的单个文件,返回 base64 内容与 MIME 类型(桌面 UI 的卡面预览 / 素材展示专用;`handleFilesRead`,`src/hub/control.ts`)。**纯本地文件操作,不依赖 `daemon.server`**(不碰 39998 / 39999)。 + +> **红线 15**:本路由(与 §4.16)**不暴露为 MCP 工具**——MCP 工具层若能经 hub 读宿主机任意文件即构成越权;`HubClient` 提供 `filesRead`(§6.1),桌面 UI 侧用自己的 fetch 客户端直连。若无 hub,独立模式的 CLI / MCP **没有**等价入口(这是有意设计)。 + +- 请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `root` | string | ✅ | 图包工作区根目录(非空字符串) | +| `path` | string | ✅ | 目标文件路径(`root` 内的相对路径或绝对路径) | + +- 行为(路径防护 + 大小 / 类型闸门,逐道校验): + - **已注册图包限定**:`root` 须为已注册图包的工作区根(注册表之外的目录一律拒绝,防把 hub 当通用文件读口); + - **resolve 前缀校验**:`path` 经 `path.resolve` 归一后必须仍位于 `root` 之内(`../` 穿越逃逸 → 400 `HUB_PATH_ESCAPE`); + - **symlink 真实路径校验**:目标文件的真实路径(realpath,含符号链接展开)必须仍在 `root` 之内(软链逃逸 → 400 `HUB_PATH_ESCAPE`); + - **MIME 白名单**(按扩展名):`png` / `jpg` / `jpeg` / `webp` / `gif` / `pdf` / `txt` / `lua` / `xml` / `json` / `csv` / `md`;白名单之外的扩展名 → 400 `HUB_BAD_REQUEST`; + - **大小上限 20MB**:目标文件超过 20 MB → 413 `HUB_FILE_TOO_LARGE`(先 stat 后读,不做无谓整读)。 +- 响应 200: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `base64` | string | 文件内容的 base64 编码 | +| `mime` | string | 按扩展名映射的 MIME 类型(白名单内) | +| `size` | number | 文件原始字节数(非 base64 编码后长度) | + +- 可能的错误码: + +| HTTP | code | 触发 | +| --- | --- | --- | +| 400 | `HUB_BAD_REQUEST` | `root` / `path` 缺失或非非空字符串;扩展名不在 MIME 白名单 | +| 400 | `HUB_PATH_ESCAPE` | resolve 前缀校验或 symlink 真实路径校验失败(逃逸出 `root`) | +| 413 | `HUB_FILE_TOO_LARGE` | 目标文件超过 20MB | +| 405 | `HUB_METHOD_NOT_ALLOWED` | GET 本路由(响应带 `Allow: POST`) | +| 500 | `HUB_INTERNAL_ERROR` | 文件读取 IO 失败等普通 Error | + +- curl(读一张卡面图,响应为 base64): + +```bash +curl -s -X POST http://127.0.0.1:39995/v1/files/read \ + -H "Content-Type: application/json" \ + -d '{"root":"D:/packs/第七大陆","path":"decks/冒险/atlas.png"}' +``` + +### 4.16 PUT /v1/files/write + +v0.8.0(UI-1b 窗口)新增:向图包工作区内写入**文本**文件(桌面 UI 工作台保存脚本 / 笔记等;`handleFilesWrite`,`src/hub/control.ts`)。**方法为 PUT 而非 POST**(v0.8.0 已确认决策:写语义 + 幂等表达,dispatch 相应扩为 GET / POST / PUT 三路)。**纯本地文件操作,不依赖 `daemon.server`**。 + +> **红线 15**:同 §4.15——本路由不暴露为 MCP 工具;`HubClient` 提供 `filesWrite`(§6.1)。 + +- 请求体: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `root` | string | ✅ | 图包工作区根目录(非空字符串) | +| `path` | string | ✅ | 目标文件路径(`root` 内的相对路径或绝对路径) | +| `content` | string | ✅ | 写入的文本内容(UTF-8;**仅文本**,不收二进制) | +| `baseSha256` | string | 可省略 | **乐观锁基线**:调用方最后一次读到的文件 sha256(与 §4.15 响应配套,hex 形式) | + +- 路径防护与 §4.15 相同:已注册图包限定 + resolve 前缀校验 + symlink 真实路径校验(失败 → 400 `HUB_PATH_ESCAPE`);写入大小上限 **1MB**(`content` 是 JSON 请求体的一部分,由 §3.1 的请求体上限兜底:body 超 1MB → 413 `HUB_PAYLOAD_TOO_LARGE`)。 +- **baseSha256 乐观锁语义**(并发保护,UI 多窗口 / 用户手动改文件的冲突防线): + - 未提供 `baseSha256`:直接覆盖写入(last-write-wins); + - 提供 `baseSha256`:写入前先计算目标文件**当前内容**的 sha256,与 `baseSha256` **不一致 → 409 `HUB_CONFLICT`,不写入**——调用方应重新读取文件(拿新 baseSha256)、合并改动后重试,而不是盲目覆盖。 +- 响应 200: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `sha256` | string | 写入内容(`content` 的 UTF-8 字节)的 sha256,小写十六进制(与 `baseSha256` 同格式,可作下一次写入的乐观锁基线) | +| `size` | number | 写入的字节数(UTF-8 编码后) | + +- 可能的错误码: + +| HTTP | code | 触发 | +| --- | --- | --- | +| 400 | `HUB_BAD_REQUEST` | `root` / `path` / `content` 缺失或类型不符;`baseSha256` 非非空字符串 | +| 400 | `HUB_PATH_ESCAPE` | resolve 前缀校验或 symlink 真实路径校验失败(逃逸出 `root`) | +| 409 | `HUB_CONFLICT` | `baseSha256` 与目标文件当前内容的实际 sha256 不符(乐观锁冲突) | +| 413 | `HUB_PAYLOAD_TOO_LARGE` | 请求体(含 `content`)超过 1MB | +| 405 | `HUB_METHOD_NOT_ALLOWED` | GET / POST 本路由(响应带 `Allow: PUT`) | +| 500 | `HUB_INTERNAL_ERROR` | 文件写入 IO 失败等普通 Error | + +- curl(带乐观锁保存文本文件): + +```bash +curl -s -X PUT http://127.0.0.1:39995/v1/files/write \ + -H "Content-Type: application/json" \ + -d '{"root":"D:/packs/第七大陆","path":"decks/冒险/notes.md","content":"# 笔记\n","baseSha256":"<当前文件的 sha256>"}' +``` + +- curl(新建 / 无条件覆盖:不带 baseSha256,last-write-wins): + +```bash +curl -s -X PUT http://127.0.0.1:39995/v1/files/write \ + -H "Content-Type: application/json" \ + -d '{"root":"D:/packs/第七大陆","path":"decks/冒险/notes.md","content":"# 新建\n"}' +``` + --- ## 5. SSE 事件流 @@ -594,6 +704,8 @@ curl -N http://127.0.0.1:39995/v1/events | `shutdown()` | POST `/v1/hub/shutdown` | `HubOkResult` `{ok:true}` | | `testRun({root, targetGuid?, timeoutMs?, bail?, bundle?})` | POST `/v1/test/run` | `unknown`(`RunReport`,§4.13) | | `packBuild({root, outPath?, dryRun?})` | POST `/v1/pack/build` | `unknown`(`BsonBuildResult` + 诊断字段,§4.14) | +| `filesRead(root, path)` | POST `/v1/files/read` | `unknown`(`{base64, mime, size}`,§4.15;v0.8.0) | +| `filesWrite(root, path, content, baseSha256?)` | PUT `/v1/files/write` | `unknown`(`{sha256, size}`,§4.16;v0.8.0;409 `HUB_CONFLICT` 走 `HubError` 透传) | - 缺省选项(`HubClientOptions`,`client.ts:49-76`):`host=127.0.0.1`、`port=39995`、单请求 `timeoutMs=30000`(覆盖"发起请求 + 读取响应体"全程);host 为 IPv6 字面量时自动加 `[]`。 - 请求体由 `JSON.stringify` 序列化,仅在有 body 时带 `Content-Type: application/json`;`push` 的 `confirm` 参数类型是字面量 `true`——调用方传 `false` / 漏传**编译期**即报错,与服务端 `HUB_CONFIRM_REQUIRED` 门双保险(`client.ts:566`)。`push` 第 3 参 `PushOptions`(`dryRun` / `forceScriptsOnly` / `skipBackup` / `skipBaselineCheck` / `backupRetention`,全部可选)逐字段透传,未给的字段**不出现在请求体**里、由 hub 侧套用缺省值(§4.11)。 @@ -630,7 +742,9 @@ S2 明确控制通道**不暴露**以下能力(`control.ts:5-6`、`施工流 | `sync-upstream` | 上游仓库同步,不触碰游戏 | 对应本地命令 | | `review` | 评审流程(本地 diff / 门禁) | `tts review`(本地直接执行) | -推论:MCP 的 12 个工具也不包含这些能力(工具清单见 `src/mcp/server.ts:29-40`);控制通道内也**没有**通用文件读写 / 任意命令执行入口(`/v1/exec` 的 Lua 在 TTS 进程内执行,不是宿主机 shell)。阶段 7 新增的 `/v1/pack/build`(§4.14)虽然"纯本地",但它是**离线回路的构建步骤**(工作区 → 可加载存档 → 上传载荷),不是通用文件写入入口:路径由 `root` 推导、内容由 pack 布局决定,且不提供覆盖任意文件的能力。 +推论:MCP 的 12 个工具也不包含这些能力(工具清单见 `src/mcp/server.ts:29-40`);控制通道内也**没有**任意命令执行入口(`/v1/exec` 的 Lua 在 TTS 进程内执行,不是宿主机 shell)。 + +**通用文件读写的边界(v0.8.0 修订)**:v0.8.0 之前控制通道内"没有通用文件读写";v0.8.0 起新增的 `/v1/files/read` / `/v1/files/write`(§4.15 / §4.16)是**有边界的例外**——仅服务已注册图包工作区(resolve 前缀校验 + symlink 真实路径校验 + 大小上限 + MIME 白名单),**桌面 UI 经 `/v1/files/*` 直连 hub,MCP 不暴露这两个能力(红线 15:防止 MCP 越权读 / 写宿主机文件)**,MCP 工具清单不变(仍 12 个)。阶段 7 新增的 `/v1/pack/build`(§4.14)虽然"纯本地",但它是**离线回路的构建步骤**(工作区 → 可加载存档 → 上传载荷),不是通用文件写入入口:路径由 `root` 推导、内容由 pack 布局决定,且不提供覆盖任意文件的能力。 --- @@ -657,3 +771,4 @@ S2 明确控制通道**不暴露**以下能力(`control.ts:5-6`、`施工流 | 2026-10-05 | 初版(窗口 D / 阶段 4)。契约依据 `src/hub/control.ts`(12 条 JSON 路由 + `GET /v1/events` SSE)、`src/hub/lifecycle.ts`、`src/mcp/client.ts`、`src/cli/_shared.ts`;路由表与 `方案设计.md` §14.5.1 S2 / `施工流程.md` 阶段 4 一致。**已知问题 #1**:`/v1/scripts/pull` 与 `/v1/diff` 在 hub 运行期因 39998 端口冲突返回 500(待修,详见 §4.2 / §4.10)。**(阶段 5 已修复,见下行)** | | 2026-10-05 | 阶段 5(写入路径)Run 2 修订。① **已知问题 #1 修复落档**:`PullOptions.server` / `DiffOptions.server` 注入(`src/pack/pull.ts:93-99` / `src/pack/diff.ts:161-167`),hub 路由传 `daemon.server`(`control.ts:887` / `:1036`)——§4.2 / §4.10 的"待修"块改为"已修复"并补测试现状。② **§4.11 `/v1/push` 契约扩展**:请求体加 `dryRun` / `forceScriptsOnly` / `skipBackup` / `skipBaselineCheck` / `backupRetention` 五个可选字段,响应体改为 `{ok:true, dryRun, pushed, skipped, items(=pushed+skipped 兼容别名), backupDir?, baselineConflicts?, assetChanges?}`,错误码补 `PUSH_ASSET_CHANGES_DETECTED` / `BASELINE_CONFLICT` 等(经 `HUB_PACK_ERROR` + `details.packCode` 透传)。③ 依据行补 `src/pack/push.ts` / `src/safety/baseline.ts`;§3.3 / §3.4 / §6.1 / §6.2 同步 push 的新形状与注入语义;新增契约文档 `docs/schemas/baseline.json.md` 交叉引用。 | | 2026-10-06 | 阶段 7(窗口 G:测试运行器 + 发布链路)修订(子代理 C2)。① **新增 §4.13 `POST /v1/test/run`**:`discoverTests` + `TestRunner`(`server: daemon.server` 注入,坑 17),请求体 `{root, targetGuid?, timeoutMs?, bail?, bundle?, include?}`(`include` 为附加可选字段),响应 = `RunReport`;`details.packCode` 补 `TEST_RUN_*`。② **新增 §4.14 `POST /v1/pack/build`**:`buildSave` + `buildBson`(纯本地、不依赖 daemon.server),请求体 `{root, outPath?, dryRun?}`,响应 = `BsonBuildResult`(`{outPath, byteLength, headerLength}`)+ 诊断字段(`dryRun` / `jsonPath` / `warnings` / 四个替换计数),`dryRun` 下字节数恒 0。③ §4.0 总览表补 13 / 14 行、计数 12 → 14;§6.1 补 `testRun` / `packBuild`;§1 与 §7 的"MCP 10 个工具" → 12 个(新增 `tts_test_run` / `tts_pack_build`)。实现依据:`src/hub/control.ts:1193-1290`(两个 handler)、`src/mcp/client.ts`(`testRun` / `packBuild`)、`src/mcp/tools/test-run.ts` / `pack-build.ts`、`src/test/index.ts`(Stage A 桶文件)、`src/pack/build.ts`、`src/publish/bson.ts`。单测:`tests/unit/mcp-test-run.test.ts` / `tests/unit/mcp-pack-build.test.ts`。 | +| 2026-10-07 | v0.8.0(桌面 UI 联通)修订(UI-1b 窗口)。① **新增 2 条文件路由**:§4.15 `POST /v1/files/read`(base64 + MIME 白名单 + 20MB 上限)与 §4.16 `PUT /v1/files/write`(仅文本、1MB 上限、`baseSha256` 乐观锁,不符 → 409;PUT 而非 POST 为已确认决策,dispatch 扩为 GET/POST/PUT 三路)——路径防护均为已注册图包限定 + resolve 前缀校验 + symlink 真实路径校验;新错误码 `HUB_PATH_ESCAPE`(400)/ `HUB_CONFLICT`(409)/ `HUB_FILE_TOO_LARGE`(413)(§3.3);§4.0 总览补 15 / 16 行、计数 **14 → 16**;§6.1 补 `filesRead` / `filesWrite`。**红线 15:`/v1/files/*` 不暴露为 MCP 工具**(§7 同步修订)。② **§4.1 `/v1/status` 增强**:响应体加 `hub.version`(取自 tts-toolkit `package.json`)与 `hub.appMode`,补版本兼容语义(旧 hub 缺字段视为旧版)。③ **app 模式**:新 CLI 旗标 `tts hub --app-mode `(白名单校验)与环境变量 `TTS_HUB_APP_MODE`(`tts-hub` bin 保持零旗标,经 `lifecycle.ts` 的 `runHubProcess` 解析链透传);app 模式下错误体 `details` 可能含 `userAction` 提示键(§3.2),standalone 不注入。④ **§2 CORS**:loopback-only Origin 回显(`null` / `localhost` / `127.0.0.1` / `[::1]`)。⑤ 读写体的 1MB 上限与 `Content-Type` 校验口径从"POST 路由"扩为"POST 路由 + `PUT /v1/files/write`"(§2 / §3.1 / §3.3)。实现依据:`src/hub/control.ts`(files 路由 + CORS + appMode + `hub.version`)、`src/mcp/client.ts`(`filesRead` / `filesWrite`)、`src/hub/lifecycle.ts` + `src/cli/commands/hub.ts`(app-mode)、`locales/*.json`(`cli.hub.option.appMode`)。单测:`tests/unit/hub-control-files.test.ts`(agentD)。 | diff --git a/locales/en-US.json b/locales/en-US.json index fcca82a..6e9ce1d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -757,7 +757,8 @@ "needB": "Pass --b to specify the source B (new render) directory: that semantic choice belongs to the caller, this tool does not guess", "noDeck": "No deck directory found under {root}; create one first with tts import / tts deck", "multipleDecks": "Multiple decks under decks/ ({names}); pass --deck to choose the one to review", - "invalidPort": "Invalid port: {value} (must be an integer in 1..65535)" + "invalidPort": "Invalid port: {value} (must be an integer in 1..65535)", + "invalidOption": "Invalid option value: {value} (allowed: {allowed})" }, "status": { "needApprovalRoot": "Pass --approval-root pointing at the approval tool repository (contains agent.py), or use --server for HTTP" @@ -785,7 +786,8 @@ "editorPort": "TTS editor port (default 39998; TTS connects inbound)", "tcpPort": "TCP fanout port (default 39997)", "wsPort": "WebSocket fanout port (default 39996)", - "logFile": "Log file path (appended; by default logs go to stdout only)" + "logFile": "Log file path (appended; by default logs go to stdout only)", + "appMode": "App mode (standalone=standalone; app=launched as sidecar by desktop UI, errors carry userAction key)" }, "started": "Hub started: control channel http://127.0.0.1:{controlPort}/v1; editor port {editorPort}; TCP fanout {tcpPort}; WS fanout {wsPort} (Ctrl+C to stop)", "stopped": "Hub stopped" diff --git a/locales/zh-CN.json b/locales/zh-CN.json index bcf0fff..7d0ef8f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -757,7 +757,8 @@ "needB": "请用 --b 指定源B(新版渲染)目录:它是使用方的语义决定,本工具不猜", "noDeck": "在 {root} 下没有找到任何 deck 目录,请先用 tts import / tts deck 建卡堆", "multipleDecks": "decks/ 下有多个卡堆({names}),请用 --deck 指定要审批的那个", - "invalidPort": "端口无效:{value}(需为 1..65535 的整数)" + "invalidPort": "端口无效:{value}(需为 1..65535 的整数)", + "invalidOption": "选项值无效:{value}(允许的取值:{allowed})" }, "status": { "needApprovalRoot": "请用 --approval-root 指定审批工具仓库根(含 agent.py),或改用 --server 走 HTTP" @@ -785,7 +786,8 @@ "editorPort": "TTS 编辑器端口(缺省 39998,由 TTS 主动连入)", "tcpPort": "TCP 扇出端口(缺省 39997)", "wsPort": "WebSocket 扇出端口(缺省 39996)", - "logFile": "日志文件路径(追加写入;缺省日志只写 stdout)" + "logFile": "日志文件路径(追加写入;缺省日志只写 stdout)", + "appMode": "应用模式(standalone=独立运行;app=由桌面 UI 作 sidecar 启动,错误体含 userAction 提示键)" }, "started": "hub 已启动:控制通道 http://127.0.0.1:{controlPort}/v1;编辑器端口 {editorPort};TCP 扇出 {tcpPort};WS 扇出 {wsPort}(Ctrl+C 停止)", "stopped": "hub 已停止" diff --git a/src/cli/commands/hub.ts b/src/cli/commands/hub.ts index 76081f1..15e9ff2 100644 --- a/src/cli/commands/hub.ts +++ b/src/cli/commands/hub.ts @@ -23,11 +23,14 @@ * 本模块使用的 i18n 键(locales/*.json 双语镜像;缺键时 t() 原样输出键名): * - 静态:`cli.command.hub.description`、`cli.hub.option.port` / * `cli.hub.option.editorPort` / `cli.hub.option.tcpPort` / - * `cli.hub.option.wsPort` / `cli.hub.option.logFile`、`cli.hub.stopped`; + * `cli.hub.option.wsPort` / `cli.hub.option.logFile` / + * `cli.hub.option.appMode`、`cli.hub.stopped`; * - 插值:`cli.hub.started` {controlPort} {editorPort} {tcpPort} {wsPort}; * - 复用既有键:`cli.review.prepare.invalidPort` {value}(端口非法提示,文案 - * 通用:端口无效:{value}(需为 1..65535 的整数))、`error.generic` {message} - * (非端口占用的启动失败统一出口)。 + * 通用:端口无效:{value}(需为 1..65535 的整数))、 + * `cli.review.prepare.invalidOption` {value} {allowed}(选项值不在白名单的 + * 提示,--app-mode 校验用)、`error.generic` {message}(非端口占用的启动 + * 失败统一出口)。 */ import { Command } from "commander"; @@ -53,12 +56,22 @@ interface HubCliOptions { wsPort?: string; /** 日志文件路径(追加写;缺省只写 stdout) */ logFile?: string; + /** 应用模式(字符串形式,action 内校验白名单 ["standalone", "app"]) */ + appMode?: string; } // --------------------------------------------------------------------------- // 内部工具 // --------------------------------------------------------------------------- +/** + * `--app-mode` 的合法取值白名单(v0.8.0): + * - "standalone":独立运行(CLI 手动启动,缺省); + * - "app":由桌面 UI 作 sidecar 启动(控制通道错误体 details 会附 userAction + * 提示键,供 UI 直接渲染,见 docs/schemas/hub-control.md §3.2)。 + */ +const APP_MODE_WHITELIST: readonly string[] = ["standalone", "app"]; + /** * 解析端口选项:字符串 → 1..65535 的整数;未提供选项时返回 undefined * 表示"用缺省端口"(控制通道 39995 / 编辑器 39998 / TCP 39997 / WS 39996)。 @@ -79,16 +92,46 @@ function parsePortOption(value: string | undefined): number | undefined { return port; } +/** + * 解析 --app-mode 选项:须命中白名单 ["standalone", "app"];未提供选项时返回 + * undefined 表示"用 lifecycle 的解析链"(参数 > 环境变量 TTS_HUB_APP_MODE > + * 缺省 "standalone",见 src/hub/lifecycle.ts)。 + * + * @param value 命令行原始值;undefined 表示未提供该选项 + * @returns 合法的应用模式;未提供选项时 undefined + * @returns 非法值时向 stderr 输出本地化提示并 process.exit(1)(不返回) + */ +function parseAppModeOption(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + if (!APP_MODE_WHITELIST.includes(value)) { + console.error( + t("cli.review.prepare.invalidOption", { + value, + allowed: APP_MODE_WHITELIST.join(" / "), + }), + ); + process.exit(1); + } + return value; +} + // --------------------------------------------------------------------------- // 子命令 // --------------------------------------------------------------------------- /** * `tts hub [--port ] [--editor-port ] [--tcp-port ] [--ws-port ] - * [--log-file <路径>]`:前台常驻的 hub 进程(控制通道 + 协议扇出)。 + * [--log-file <路径>] [--app-mode ]`:前台常驻的 hub 进程 + * (控制通道 + 协议扇出)。 * * 启动成功打印一行 started(含实际端口),随后常驻;SIGINT / SIGTERM / * /v1/hub/shutdown 触发优雅停止后打印 stopped 并以 0 退出。 + * + * --app-mode(v0.8.0):standalone=独立运行(缺省);app=由桌面 UI 作 sidecar + * 启动(控制通道错误体 details 附 userAction 提示键)。`tts-hub` bin 保持零 + * 旗标,同语义经环境变量 TTS_HUB_APP_MODE 透传。 */ export const hubCommand: Command = new Command("hub") .description(t("cli.command.hub.description")) @@ -97,11 +140,13 @@ export const hubCommand: Command = new Command("hub") .option("--tcp-port ", t("cli.hub.option.tcpPort")) .option("--ws-port ", t("cli.hub.option.wsPort")) .option("--log-file ", t("cli.hub.option.logFile")) + .option("--app-mode ", t("cli.hub.option.appMode")) .action(async (opts: HubCliOptions) => { const controlPort = parsePortOption(opts.port); const editorPort = parsePortOption(opts.editorPort) ?? EDITOR_PORT; const tcpPort = parsePortOption(opts.tcpPort) ?? HUB_TCP_PORT; const wsPort = parsePortOption(opts.wsPort) ?? HUB_WS_PORT; + const appMode = parseAppModeOption(opts.appMode); const hub = runHubProcess({ ...(controlPort !== undefined ? { controlPort } : {}), @@ -109,6 +154,7 @@ export const hubCommand: Command = new Command("hub") tcpPort, wsPort, ...(opts.logFile !== undefined ? { logFile: opts.logFile } : {}), + ...(appMode !== undefined ? { appMode } : {}), }); try { diff --git a/src/hub/control.ts b/src/hub/control.ts index 0e44066..60183a0 100644 --- a/src/hub/control.ts +++ b/src/hub/control.ts @@ -2,7 +2,8 @@ /** * hub 控制通道(S2):HTTP+JSON 服务,监听 http://127.0.0.1:39995/v1,零框架(node:http)。 * - * 路由表(与方案设计 §14.5.1 S2 一致;不暴露 pack export / pack import .ttsmod / + * 路由表(S2 12 条 + 阶段 7 的 /v1/test/run、/v1/pack/build + UI-1b 的 + * /v1/files/read、/v1/files/write;不暴露 pack export / pack import .ttsmod / * sync-upstream / review): * * | 方法 | 路由 | 作用 | @@ -22,12 +23,22 @@ * | GET | /v1/events | SSE 事件流 | * | POST | /v1/test/run | 跑 Lua 测试返回 RunReport(阶段 7) | * | POST | /v1/pack/build | 工作区 → 存档 JSON → BSON(阶段 7) | + * | POST | /v1/files/read | 读 root 内文件 → base64(UI-1b) | + * | PUT | /v1/files/write | 写文本文件进 root(UI-1b,乐观锁) | * * 统一约定: * - 只监听回环地址(opts.host 缺省 "127.0.0.1";通配地址 "0.0.0.0" / "::" / * 空串在 {@link createControlServer} 构造时直接抛错,绝不监听所有接口); - * - POST 请求体上限 1MB(超限 413),Content-Type 必须是 application/json - * (否则 415);三条 GET 路由不读请求体; + * - 读体路由(POST / PUT)请求体上限 1MB(超限 413 HUB_PAYLOAD_TOO_LARGE; + * /v1/files/write 单独放宽到 8MB——其 content 业务上限仍是 1MB,放宽只为 + * 容纳 JSON 字符串转义的最坏膨胀,超限映射见 {@link handleFilesWrite}), + * Content-Type 必须是 application/json(否则 415);三条 GET 路由不读请求体; + * - loopback CORS(UI-1b,桌面 UI / Vite dev server 跨源访问):Origin 缺省 + * (同源请求)或非白名单来源时不发任何 CORS 头;Origin 为字面 "null" 或 + * http(s)://localhost / 127.0.0.1 / [::1](任意端口)时回显 + * Access-Control-Allow-Origin;OPTIONS(预检)统一 204 + + * Access-Control-Allow-Methods: GET,POST,PUT,OPTIONS + + * Access-Control-Allow-Headers: Content-Type,不进路由表; * - 响应统一 JSON;错误统一 `{error:{code,message,details?}}` + 4xx/5xx: * - 请求侧:HUB_BAD_REQUEST / HUB_NOT_FOUND / HUB_METHOD_NOT_ALLOWED / * HUB_PAYLOAD_TOO_LARGE / HUB_UNSUPPORTED_MEDIA_TYPE / HUB_CONFIRM_REQUIRED; @@ -64,17 +75,40 @@ * 时不写任何文件,byteLength / headerLength 恒为 0); * 两条路由的业务失败都经 respondError 映射为 400 HUB_PACK_ERROR + * details.packCode(TEST_RUN_* / SKELETON_* / PUBLISH_* 等)。 + * - /v1/files/read 与 /v1/files/write(UI-1b,桌面 UI 专用,**不注册为 MCP 工具** + * ——红线 15,防 MCP 越权读写宿主机文件): + * - /v1/files/read:body `{root, path}`;root 必须是已注册图包根(在 + * `/.registry.yaml` 里有对应条目,否则 400 HUB_BAD_REQUEST), + * path 必须相对 root 且通过 {@link resolveWithinRoot} 的四层路径防护 + * (HUB_PATH_ESCAPE → 400);扩展名白名单 + * png/jpg/jpeg/webp/gif/pdf/txt/lua/xml/json/csv/md 之外 400;文件上限 + * 20MB(413 HUB_FILE_TOO_LARGE);响应 `{base64, mime, size}`; + * - /v1/files/write:body `{root, path, content, baseSha256?}`;同套路径防护 + * (root 不要求已注册——按方案 §8.4,仅 read 校验注册表);仅文本 + * (content 含 null 字节 400)、content 上限 1MB(413 HUB_FILE_TOO_LARGE)、 + * 父目录必须已存在(不隐式建目录);baseSha256(64 位 hex)提供时与当前 + * 文件 sha256 乐观锁比对(文件不存在也视为冲突),不符 409 HUB_CONFLICT; + * 响应 `{sha256, size}`; + * - appMode:opts.appMode("standalone" | "app",缺省 "standalone",由 + * lifecycle 层按启动旗标 / TTS_HUB_APP_MODE 环境变量决定);appMode === + * "app" 时(桌面 sidecar 形态)所有错误响应的 details 附带 userAction + * 字段(i18n 风格提示键,UI 直接渲染;standalone 形态不带)。 * * 本模块不产出面向用户的文案:错误 message 是协议层英文短句(消费方是 MCP 工具 * 层与运维日志,双语呈现由 MCP 层负责),与 src/hub/lifecycle.ts 的日志同一口径 * 不引入 i18n;业务错误(PackError / LuaError)的 message 由底层模块经 t() 生成 - * 后原样透传。 + * 后原样透传。唯一的例外是 details.userAction——它是给桌面 UI 渲染用的 i18n + * 提示键(键名即契约,译文由 UI 侧维护),standalone 形态不出现。 */ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { readFile, stat, writeFile } from "node:fs/promises"; import type { Socket } from "node:net"; import path from "node:path"; +import pkg from "../../package.json" with { type: "json" }; import { checkUrls } from "../assets/check.js"; import { planReplace, type PlanOptions } from "../deck/plan.js"; import { sliceAtlas, type SliceOptions } from "../deck/slice.js"; @@ -84,7 +118,7 @@ import { importAssets } from "../pack/import.js"; import { PackError } from "../pack/packyaml.js"; import { pullFromGame } from "../pack/pull.js"; import { pushSaveAndPlay } from "../pack/push.js"; -import { readRegistry } from "../pack/registry.js"; +import { findPack, readRegistry, registryPath } from "../pack/registry.js"; import { InboundId } from "../protocol/messages.js"; import { buildBson } from "../publish/bson.js"; import { luaGetObjectCount, luaGetVersion } from "../session/lua.js"; @@ -92,6 +126,7 @@ import { LuaError } from "../session/exec.js"; import type { ScriptState } from "../session/scripts.js"; import { discoverTests, TestRunner } from "../test/index.js"; import type { HubDaemon } from "./daemon.js"; +import { PathEscapeError, resolveWithinRoot } from "./paths.js"; // --------------------------------------------------------------------------- // 常量 @@ -115,6 +150,59 @@ const DEFAULT_BACKUP_RETENTION = 20; /** /v1/status 探测 TTS 时单次 exec 的超时毫秒数。 */ const TTS_PROBE_TIMEOUT_MS = 2_000; +/** /v1/files/read 的文件大小上限:20MB。 */ +const MAX_READ_FILE_BYTES = 20 * 1_048_576; + +/** /v1/files/write 的 content 大小上限:1MB(UTF-8 字节数)。 */ +const MAX_WRITE_CONTENT_BYTES = 1_048_576; + +/** + * /v1/files/write 的请求体上限:8MB。content 业务上限是 1MB,但 JSON 字符串 + * 转义最坏逐字符膨胀 6 倍(控制字符 → \uXXXX),放宽请求体上限是为了让 + * "content 超限"落在 413 HUB_FILE_TOO_LARGE 的专用错误码上,而不是先被通用 + * 的 413 HUB_PAYLOAD_TOO_LARGE 截胡(两码都是 413,但语义不同)。 + */ +const MAX_WRITE_BODY_BYTES = 8 * 1_048_576; + +/** + * /v1/files/read 的扩展名 → MIME 白名单(大小写不敏感;白名单之外的扩展名 + * 400 HUB_BAD_REQUEST 拒绝)。lua 无注册 MIME,按纯文本给出。 + */ +const MIME_BY_EXTENSION: Readonly> = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + gif: "image/gif", + pdf: "application/pdf", + txt: "text/plain", + lua: "text/plain", + xml: "application/xml", + json: "application/json", + csv: "text/csv", + md: "text/markdown", +}; + +/** + * CORS 白名单来源:字面 "null"(file:// / 沙箱 iframe 场景)或 http(s) 的 + * loopback 主机(localhost / 127.0.0.1 / [::1],任意端口)。其余来源 + * (含 http://tauri.localhost / 局域网地址 / 公网域名)一律不发 CORS 头。 + */ +const CORS_ALLOWED_ORIGIN_PATTERN = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i; + +/** + * appMode === "app" 时错误码 → details.userAction 提示键的专用映射 + * (i18n 风格键名,译文由桌面 UI 侧维护;未命中的错误码走 + * {@link DEFAULT_USER_ACTION})。 + */ +const ERROR_USER_ACTIONS: Readonly> = { + HUB_LUA_ERROR: "hub.error.checkScript", + HUB_PACK_ERROR: "hub.error.checkWorkspace", +}; + +/** appMode === "app" 时未命中 {@link ERROR_USER_ACTIONS} 的错误码统一回落键。 */ +const DEFAULT_USER_ACTION = "hub.error.restartTts"; + /** stop() 等现有连接结束的宽限期;超时后强关全部连接。 */ const STOP_GRACE_MS = 1_000; @@ -149,6 +237,14 @@ export interface ControlServerOptions { * 响应发出后才触发;回调自身的失败只记日志,绝不产生未处理 rejection。 */ onShutdown?: () => Promise; + /** + * 应用模式(UI-1b):"standalone"(独立运行,缺省)或 "app"(由桌面 UI 作为 + * sidecar 启动)。取值由 lifecycle 层按启动旗标 --app-mode / TTS_HUB_APP_MODE + * 环境变量解析后传入,本模块不校验白名单(仅 "app" 触发行为差异:错误响应的 + * details 附带 userAction 提示键);其余取值一律按 standalone 处理。 + * /v1/status 的 hub.appMode 原样回显该值。 + */ + appMode?: string; } /** hub 控制通道服务器句柄({@link createControlServer} 的返回值)。 */ @@ -554,6 +650,73 @@ function isSseForwarded(msg: unknown): boolean { return typeof id === "number" && SSE_FORWARDED_MESSAGE_IDS.has(id); } +/** + * 解析请求 Origin 头得到允许回显的 Access-Control-Allow-Origin 值(loopback CORS)。 + * 无 Origin(同源请求)与非白名单来源返回 undefined(不发 CORS 头);字面 "null" + * 与 http(s) loopback 主机(localhost / 127.0.0.1 / [::1],任意端口)原样回显。 + * @param origin 请求头 origin(可能 undefined) + * @returns 允许回显时返回 Origin 值本身;否则 undefined + */ +function allowedCorsOrigin(origin: string | undefined): string | undefined { + if (origin === undefined) { + return undefined; + } + if (origin === "null") { + return "null"; + } + return CORS_ALLOWED_ORIGIN_PATTERN.test(origin) ? origin : undefined; +} + +/** + * 从 unknown 错误中取 Node 风格的 code 属性(如 ENOENT / EISDIR),避免 any。 + * @param err 任意抛出值 + * @returns 字符串形式的 code;取不到时返回 undefined + */ +function errCode(err: unknown): string | undefined { + if (err instanceof Error) { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") { + return code; + } + } + return undefined; +} + +/** + * 把 /v1/files/* 文件操作抛出的 fs 错误映射为请求侧 400(ENOENT → 文件不存在、 + * EISDIR → 路径是目录);其余错误原样返回(由 respondError 映射为 500)。 + * @param err fs 操作抛出的任意值 + * @param relPath 请求的相对路径(用于错误消息定位) + * @returns 映射后的错误(应作为 throw 值使用;不可映射时是原错误本身) + */ +function asFileAccessError(err: unknown, relPath: string): unknown { + const code = errCode(err); + if (code === "ENOENT") { + return new BadRequestError(`file not found: ${relPath}`); + } + if (code === "EISDIR") { + return new BadRequestError(`path is a directory: ${relPath}`); + } + return err; +} + +/** + * 取文件扩展名对应的白名单 MIME(大小写不敏感);白名单外抛 400。 + * @param target 已解析的目标文件绝对路径 + * @returns MIME 字符串(如 "image/png") + * @throws BadRequestError 扩展名不在 {@link MIME_BY_EXTENSION} 白名单时 + */ +function mimeForFile(target: string): string { + const ext = path.extname(target).replace(/^\./, "").toLowerCase(); + const mime = MIME_BY_EXTENSION[ext]; + if (mime === undefined) { + throw new BadRequestError( + `unsupported file type: ${ext === "" ? "(no extension)" : `"${ext}"`} (allowed: ${Object.keys(MIME_BY_EXTENSION).join("/")})`, + ); + } + return mime; +} + // --------------------------------------------------------------------------- // 实现 // --------------------------------------------------------------------------- @@ -576,10 +739,14 @@ class ControlServerImpl implements ControlServer { private readonly log: (line: string) => void; /** 优雅退出回调(/v1/hub/shutdown 触发) */ private readonly onShutdown: (() => Promise) | undefined; + /** 应用模式("standalone" | "app";仅 "app" 时错误响应附 userAction) */ + private readonly appMode: string; /** GET 路由表 */ private readonly getRoutes: ReadonlyMap; /** POST 路由表 */ private readonly postRoutes: ReadonlyMap; + /** PUT 路由表 */ + private readonly putRoutes: ReadonlyMap; /** node:http 服务器实例 */ private readonly server: Server; /** 当前打开的连接(stop() 超时强关用) */ @@ -599,6 +766,7 @@ class ControlServerImpl implements ControlServer { assertLoopbackHost(this.host); this.log = opts?.log ?? ((line: string) => console.error(line)); this.onShutdown = opts?.onShutdown; + this.appMode = opts?.appMode ?? "standalone"; this.getRoutes = new Map([ ["/v1/status", (req, res) => this.handleStatus(req, res)], ["/v1/packs", (_req, res, url) => this.handlePacks(res, url)], @@ -616,8 +784,12 @@ class ControlServerImpl implements ControlServer { ["/v1/push", (req, res) => this.handlePush(req, res)], ["/v1/test/run", (req, res) => this.handleTestRun(req, res)], ["/v1/pack/build", (req, res) => this.handlePackBuild(req, res)], + ["/v1/files/read", (req, res) => this.handleFilesRead(req, res)], ["/v1/hub/shutdown", (_req, res) => this.handleShutdown(res)], ]); + this.putRoutes = new Map([ + ["/v1/files/write", (req, res) => this.handleFilesWrite(req, res)], + ]); this.server = createServer((req, res) => { this.onRequest(req, res); }); @@ -708,8 +880,11 @@ class ControlServerImpl implements ControlServer { } /** - * 路由分发:未匹配路径 → 404;路径存在但方法不对 → 405(带 Allow 头); - * 处理函数抛出的异常按统一错误映射响应。 + * 路由分发:入口先做 loopback CORS 中间件(放行来源回显 + * Access-Control-Allow-Origin,非放行来源不发 CORS 头)与 OPTIONS 预检 + * (204 + 允许的方法 / 请求头,不进路由表);随后按方法分流到 GET / POST / + * PUT 三张路由表——未匹配路径 → 404;路径存在但方法不对 → 405(Allow 头聚合 + * 三张表的命中);处理函数抛出的异常按统一错误映射响应。 * @param req 进入的请求 * @param res 目标响应 */ @@ -718,19 +893,47 @@ class ControlServerImpl implements ControlServer { try { url = new URL(req.url ?? "/", "http://127.0.0.1"); } catch { - sendError(res, 400, "HUB_BAD_REQUEST", "malformed request URL"); + this.sendAppError(res, 400, "HUB_BAD_REQUEST", "malformed request URL"); return; } const pathname = url.pathname; try { + // —— loopback CORS 中间件(UI-1b):放行来源回显 ACAO;其余来源不发头 —— + // (用 setHeader 而不是 writeHead:与后续各 handler 的 writeHead 合并发送) + const allowOrigin = allowedCorsOrigin(req.headers.origin); + if (allowOrigin !== undefined) { + res.setHeader("Access-Control-Allow-Origin", allowOrigin); + res.setHeader("Vary", "Origin"); // ACAO 随 Origin 变化,提示缓存按请求区分 + } const method = req.method ?? ""; - const table = method === "GET" ? this.getRoutes : method === "POST" ? this.postRoutes : undefined; + if (method === "OPTIONS") { + // —— 预检:204 + 允许的方法 / 请求头(204 不允许携带响应体)—— + res.writeHead(204, { + "Access-Control-Allow-Methods": "GET,POST,PUT,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Content-Length": "0", + }); + res.end(); + return; + } + const table = + method === "GET" + ? this.getRoutes + : method === "POST" + ? this.postRoutes + : method === "PUT" + ? this.putRoutes + : undefined; const handler = table?.get(pathname); if (handler !== undefined) { await handler(req, res, url); return; } - if (this.getRoutes.has(pathname) || this.postRoutes.has(pathname)) { + if ( + this.getRoutes.has(pathname) || + this.postRoutes.has(pathname) || + this.putRoutes.has(pathname) + ) { const allow: string[] = []; if (this.getRoutes.has(pathname)) { allow.push("GET"); @@ -738,7 +941,10 @@ class ControlServerImpl implements ControlServer { if (this.postRoutes.has(pathname)) { allow.push("POST"); } - sendError( + if (this.putRoutes.has(pathname)) { + allow.push("PUT"); + } + this.sendAppError( res, 405, "HUB_METHOD_NOT_ALLOWED", @@ -748,16 +954,54 @@ class ControlServerImpl implements ControlServer { ); return; } - sendError(res, 404, "HUB_NOT_FOUND", `no route for ${pathname}`); + this.sendAppError(res, 404, "HUB_NOT_FOUND", `no route for ${pathname}`); } catch (err) { + if (err instanceof PathEscapeError) { + this.sendAppError(res, 400, err.code, err.message); + return; + } if (err instanceof BadRequestError) { - sendError(res, 400, "HUB_BAD_REQUEST", err.message); + this.sendAppError(res, 400, "HUB_BAD_REQUEST", err.message); return; } this.respondError(res, err); } } + /** + * 统一错误响应出口(appMode 感知):appMode === "app"(桌面 sidecar 形态)时 + * 在 details 里附带 userAction 提示键(按错误码查 {@link ERROR_USER_ACTIONS}, + * 未命中回落 {@link DEFAULT_USER_ACTION}),UI 拿到后直接渲染可操作提示; + * standalone 形态不带 userAction,details 原样透传。 + * + * 所有类内错误响应(含请求侧 4xx 与 {@link respondError} 的异常映射)都经由 + * 本方法发出,保证 userAction 的有无只取决于 appMode,与触发路径无关。 + * @param res 目标响应 + * @param status HTTP 状态码(4xx / 5xx) + * @param code 机器可读错误码 + * @param message 错误描述 + * @param details 可选结构化细节(undefined 字段不进入 JSON) + * @param headers 可选附加响应头(如 405 的 Allow) + */ + private sendAppError( + res: ServerResponse, + status: number, + code: string, + message: string, + details?: Record, + headers?: Record, + ): void { + if (this.appMode === "app") { + const merged: Record = { + ...(details ?? {}), + userAction: ERROR_USER_ACTIONS[code] ?? DEFAULT_USER_ACTION, + }; + sendError(res, status, code, message, merged, headers); + return; + } + sendError(res, status, code, message, details, headers); + } + /** * 业务异常的统一映射:LuaError → 400 HUB_LUA_ERROR;PackError → 400 * HUB_PACK_ERROR;其余 → 500 HUB_INTERNAL_ERROR。 @@ -776,48 +1020,52 @@ class ControlServerImpl implements ControlServer { if (err.endCol !== undefined) { details.endCol = err.endCol; } - sendError(res, 400, "HUB_LUA_ERROR", err.message, details); + this.sendAppError(res, 400, "HUB_LUA_ERROR", err.message, details); return; } if (err instanceof PackError) { - sendError(res, 400, "HUB_PACK_ERROR", err.message, { packCode: err.code }); + this.sendAppError(res, 400, "HUB_PACK_ERROR", err.message, { packCode: err.code }); return; } - sendError(res, 500, "HUB_INTERNAL_ERROR", describeError(err)); + this.sendAppError(res, 500, "HUB_INTERNAL_ERROR", describeError(err)); } /** - * 读取并把 POST 请求体解析为 JSON 对象;所有请求侧问题(非 JSON 媒体类型 / - * 超限 / 非法 JSON / 非对象)在此直接响应错误并返回 undefined。 + * 读取并把读体路由(POST / PUT)的请求体解析为 JSON 对象;所有请求侧问题 + * (非 JSON 媒体类型 / 超限 / 非法 JSON / 非对象)在此直接响应错误并返回 + * undefined。 * @param req 进入的请求 * @param res 目标响应 + * @param maxBytes 请求体字节上限(缺省 1MB;/v1/files/write 传入放宽值,见 + * {@link MAX_WRITE_BODY_BYTES}) * @returns 解析出的键值对象;请求侧问题已响应(或连接已断)时 undefined */ private async readJsonBody( req: IncomingMessage, res: ServerResponse, + maxBytes: number = MAX_BODY_BYTES, ): Promise | undefined> { if (!isJsonContentType(req.headers["content-type"])) { - sendError(res, 415, "HUB_UNSUPPORTED_MEDIA_TYPE", "content-type must be application/json"); + this.sendAppError(res, 415, "HUB_UNSUPPORTED_MEDIA_TYPE", "content-type must be application/json"); return undefined; } - const raw = await readBody(req, MAX_BODY_BYTES); + const raw = await readBody(req, maxBytes); if (raw.kind === "aborted") { return undefined; // 连接已断,无从响应 } if (raw.kind === "too-large") { - sendError(res, 413, "HUB_PAYLOAD_TOO_LARGE", `request body exceeds ${MAX_BODY_BYTES} bytes`); + this.sendAppError(res, 413, "HUB_PAYLOAD_TOO_LARGE", `request body exceeds ${maxBytes} bytes`); return undefined; } let parsed: unknown; try { parsed = JSON.parse(raw.buffer.toString("utf8")); } catch { - sendError(res, 400, "HUB_BAD_REQUEST", "request body is not valid JSON"); + this.sendAppError(res, 400, "HUB_BAD_REQUEST", "request body is not valid JSON"); return undefined; } if (!isPlainObject(parsed)) { - sendError(res, 400, "HUB_BAD_REQUEST", "request body must be a JSON object"); + this.sendAppError(res, 400, "HUB_BAD_REQUEST", "request body must be a JSON object"); return undefined; } return parsed; @@ -828,9 +1076,12 @@ class ControlServerImpl implements ControlServer { /** * GET /v1/status:hub 健康 + TTS 连接状态。 * - * hub 字段来自 daemon.stats()(uptimeMs 现算,负值截为 0);tts.connected 通过 - * 执行 luaGetVersion 探测(2s 超时),成功时顺带探测对象数(失败只影响可选的 - * objects 字段)。探测失败不是路由错误——仍 200,connected=false。 + * hub 字段来自 daemon.stats()(uptimeMs 现算,负值截为 0)+ 本进程 package.json + * 的 version 与配置的 appMode(UI-1b:桌面 UI 用 hub.version 做"旧版 hub 功能 + * 降级"判断,用 hub.appMode 区分 sidecar 形态;旧版 hub 无这两个字段,UI 侧 + * 按缺失降级处理)。tts.connected 通过执行 luaGetVersion 探测(2s 超时),成功 + * 时顺带探测对象数(失败只影响可选的 objects 字段)。探测失败不是路由错误—— + * 仍 200,connected=false。 * @param _req 进入的请求(未使用) * @param res 目标响应 */ @@ -867,6 +1118,8 @@ class ControlServerImpl implements ControlServer { inprocClients: stats.inprocClients, startedAt: stats.startedAt, uptimeMs: Math.max(0, Date.now() - stats.startedAt), + version: pkg.version, + appMode: this.appMode, }, tts, }); @@ -1133,7 +1386,7 @@ class ControlServerImpl implements ControlServer { } const root = requireString(body, "root"); if (body.confirm !== true) { - sendError(res, 400, "HUB_CONFIRM_REQUIRED", "this operation requires confirm:true in the request body"); + this.sendAppError(res, 400, "HUB_CONFIRM_REQUIRED", "this operation requires confirm:true in the request body"); return; } const dryRun = optionalBoolean(body, "dryRun") ?? false; @@ -1289,6 +1542,174 @@ class ControlServerImpl implements ControlServer { sendJson(res, 200, response); } + // -- UI-1b 文件路由(不注册为 MCP 工具——红线 15)--------------------------- + + /** + * 校验 root 是已注册图包根:`/.registry.yaml`(packsRoot 取 root 的 + * 父目录)中存在 dir = root 基名的条目。注册表模型见 src/pack/registry.ts + * (图包永远是 packs_root 下的一级子目录)。 + * @param root 请求体里的工作区根目录 + * @throws BadRequestError root 不在注册表中(或注册表非法 / IO 失败——后者经 + * respondError 以 400 HUB_PACK_ERROR 透传) + */ + private async assertRegisteredPackRoot(root: string): Promise { + const rootAbs = path.resolve(root); + const packsRoot = path.dirname(rootAbs); + const dir = path.basename(rootAbs); + const entry = await findPack(packsRoot, dir); + if (entry === null) { + throw new BadRequestError( + `root is not a registered pack workspace: ${rootAbs} (no entry "${dir}" in ${registryPath(packsRoot)})`, + ); + } + } + + /** + * POST /v1/files/read:读工作区 root 内的一个文件,返回 base64(UI-1b,卡牌 / + * 素材本地预览用)。**不注册为 MCP 工具**(红线 15:防 MCP 越权读宿主机文件)。 + * + * 流程:字段校验(root / path 非空字符串)→ root 注册表校验(400)→ + * {@link resolveWithinRoot} 四层路径防护(HUB_PATH_ESCAPE → 400)→ 扩展名 + * MIME 白名单(400)→ stat(ENOENT → 400;非普通文件 → 400;>20MB → + * 413 HUB_FILE_TOO_LARGE)→ readFile → `{base64, mime, size}`。 + * @param req 进入的请求 + * @param res 目标响应 + */ + private async handleFilesRead(req: IncomingMessage, res: ServerResponse): Promise { + const body = await this.readJsonBody(req, res); + if (body === undefined) { + return; + } + const root = requireString(body, "root"); + const relPath = requireString(body, "path"); + await this.assertRegisteredPackRoot(root); + const target = await resolveWithinRoot(root, relPath); + const mime = mimeForFile(target); + let info: Stats; + try { + info = await stat(target); + } catch (err) { + throw asFileAccessError(err, relPath); + } + if (!info.isFile()) { + throw new BadRequestError(`not a regular file: ${relPath}`); + } + if (info.size > MAX_READ_FILE_BYTES) { + this.sendAppError( + res, + 413, + "HUB_FILE_TOO_LARGE", + `file exceeds the ${MAX_READ_FILE_BYTES}-byte limit for /v1/files/read`, + ); + return; + } + let content: Buffer; + try { + content = await readFile(target); + } catch (err) { + throw asFileAccessError(err, relPath); + } + sendJson(res, 200, { base64: content.toString("base64"), mime, size: content.length }); + } + + /** + * PUT /v1/files/write:把一个文本文件写进工作区 root(UI-1b,工作台保存脚本 + * 用)。**不注册为 MCP 工具**(红线 15)。方法用 PUT 表达幂等覆写语义。 + * + * 流程与约束: + * 1. 字段校验:root / path 非空字符串;content 必须是字符串(**允许空串**—— + * 清空文件是合法操作);baseSha256 可选、必须 64 位 hex(不区分大小写); + * 2. content 仅文本:含 null 字节 → 400;UTF-8 字节数 > 1MB → + * 413 HUB_FILE_TOO_LARGE(请求体上限放宽到 8MB 只为让超限 content 落到本 + * 专用错误码,见 {@link MAX_WRITE_BODY_BYTES}); + * 3. {@link resolveWithinRoot} 同套四层路径防护(HUB_PATH_ESCAPE → 400;root + * 不要求已注册——按方案 §8.4 仅 read 校验注册表); + * 4. 父目录必须已存在(不隐式 mkdir,防拼写笔误静默建目录树)→ 不存在 400; + * 5. 乐观锁:baseSha256 提供时与当前文件 sha256 比对(目标不存在视为必然 + * 冲突——创建新文件请不要带 baseSha256),不符 409 HUB_CONFLICT, + * details.currentSha256 携带当前值供 UI 重新对齐; + * 6. writeFile 覆写(不存在则创建)→ `{sha256, size}`(对写入后的 UTF-8 + * 字节计算,与下次乐观锁基准一致)。 + * @param req 进入的请求 + * @param res 目标响应 + */ + private async handleFilesWrite(req: IncomingMessage, res: ServerResponse): Promise { + const body = await this.readJsonBody(req, res, MAX_WRITE_BODY_BYTES); + if (body === undefined) { + return; + } + const root = requireString(body, "root"); + const relPath = requireString(body, "path"); + const content = body.content; + if (typeof content !== "string") { + throw new BadRequestError("missing or invalid field: content (string required; empty string allowed)"); + } + if (content.includes("\0")) { + throw new BadRequestError("invalid field: content (plain text without null bytes required)"); + } + const contentBytes = Buffer.byteLength(content, "utf8"); + if (contentBytes > MAX_WRITE_CONTENT_BYTES) { + this.sendAppError( + res, + 413, + "HUB_FILE_TOO_LARGE", + `content exceeds the ${MAX_WRITE_CONTENT_BYTES}-byte limit for /v1/files/write`, + ); + return; + } + const baseSha256 = optionalString(body, "baseSha256"); + if (baseSha256 !== undefined && !/^[0-9a-f]{64}$/i.test(baseSha256)) { + throw new BadRequestError("invalid field: baseSha256 (64-character hex sha256 required)"); + } + const target = await resolveWithinRoot(root, relPath); + const parent = path.dirname(target); + try { + const parentInfo = await stat(parent); + if (!parentInfo.isDirectory()) { + throw new BadRequestError(`parent path is not a directory: ${relPath}`); + } + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + if (errCode(err) === "ENOENT") { + throw new BadRequestError(`parent directory does not exist: ${relPath}`); + } + throw err; + } + if (baseSha256 !== undefined) { + let currentSha: string | undefined; + try { + currentSha = createHash("sha256").update(await readFile(target)).digest("hex"); + } catch (err) { + if (errCode(err) !== "ENOENT") { + throw asFileAccessError(err, relPath); + } + // 目标不存在:currentSha 保持 undefined,与任何 baseSha256 比对都是冲突 + } + if (currentSha !== baseSha256.toLowerCase()) { + this.sendAppError( + res, + 409, + "HUB_CONFLICT", + "baseSha256 does not match the current file content (optimistic-lock conflict)", + currentSha !== undefined ? { currentSha256: currentSha } : undefined, + ); + return; + } + } + const buffer = Buffer.from(content, "utf8"); + try { + await writeFile(target, buffer); + } catch (err) { + throw asFileAccessError(err, relPath); + } + sendJson(res, 200, { + sha256: createHash("sha256").update(buffer).digest("hex"), + size: buffer.length, + }); + } + /** * POST /v1/hub/shutdown:先回 200 `{ok:true}`,响应出站后再触发 onShutdown * (避免退出流程强杀当前连接);回调失败只记日志,绝不产生未处理 rejection。 diff --git a/src/hub/lifecycle.ts b/src/hub/lifecycle.ts index 4059892..801fd9f 100644 --- a/src/hub/lifecycle.ts +++ b/src/hub/lifecycle.ts @@ -11,6 +11,10 @@ * 文件流;幂等(SIGINT 与 /v1/hub/shutdown 同时触发时共享同一次停止流程); * - SIGINT/SIGTERM 用 process.once 注册,各只触发一次 stop(); * /v1/hub/shutdown 路由的 onShutdown 回调走同一 {@link HubProcess.stop}; + * - 应用模式(v0.8.0):opts.appMode 优先,其次环境变量 TTS_HUB_APP_MODE + * (`tts-hub` bin 保持零旗标,桌面 UI sidecar 场景经环境变量透传),都未 + * 提供时缺省 "standalone";解析结果透传给控制通道(app 模式下错误体 + * details 附 userAction 提示键,见 docs/schemas/hub-control.md §3.2); * - 日志:JSON Lines 英文 `{"ts":...,"level":"info|warn|error","msg":"...",...}`; * opts.logFile 提供时同时写 stdout 与文件(追加);日志文件不可用时降级为 * 仅 stdout(日志路径问题不阻塞 hub 启动,降级事件以 warn 记录)。 @@ -45,6 +49,24 @@ function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * 解析应用模式:显式参数优先,其次环境变量 TTS_HUB_APP_MODE(`tts-hub` bin + * 保持零旗标、不经 commander,桌面 UI sidecar 场景经环境变量透传),都未提供 + * 时缺省 "standalone";空白环境变量视为未提供。取值白名单("standalone" / + * "app")的校验在 CLI 层(src/cli/commands/hub.ts 的 parseAppModeOption); + * 这里只做缺省解析,不吞掉显式传入的值。 + * + * @param explicit 调用方显式给出的 appMode(未给时 undefined) + * @returns 生效的应用模式("standalone" | "app" | 调用方显式传入值) + */ +function resolveAppMode(explicit: string | undefined): string { + if (explicit !== undefined) { + return explicit; + } + const env = process.env.TTS_HUB_APP_MODE; + return env !== undefined && env !== "" ? env : "standalone"; +} + /** {@link runHubProcess} 的选项。 */ export interface HubProcessOptions { /** 编辑器侧监听端口(TTS 主动连过来);缺省由 HubDaemon 决定(39998)。 */ @@ -57,6 +79,12 @@ export interface HubProcessOptions { controlPort?: number; /** 日志文件路径(追加写);缺省只写 stdout。 */ logFile?: string; + /** + * 应用模式:"standalone"(独立运行)或 "app"(桌面 UI 作 sidecar 启动, + * 控制通道错误体 details 附 userAction 提示键)。优先级:本参数 > + * 环境变量 TTS_HUB_APP_MODE > "standalone"(见 {@link resolveAppMode})。 + */ + appMode?: string; } /** 一个已组装、可启动的 hub 进程句柄({@link runHubProcess} 的返回值)。 */ @@ -103,6 +131,8 @@ class HubProcessImpl implements HubProcess { private readonly wsPort: number | undefined; private readonly configuredControlPort: number; private readonly logFile: string | undefined; + /** 生效的应用模式(构造时按 参数 > TTS_HUB_APP_MODE > "standalone" 解析)。 */ + private readonly appMode: string; /** stopped() 返回的 promise;构造时创建,停止完成(或 start 失败)时 resolve。 */ private readonly stoppedPromise: Promise; @@ -139,6 +169,7 @@ class HubProcessImpl implements HubProcess { this.wsPort = opts?.wsPort; this.configuredControlPort = opts?.controlPort ?? DEFAULT_CONTROL_PORT; this.logFile = opts?.logFile; + this.appMode = resolveAppMode(opts?.appMode); this.stoppedPromise = new Promise((resolve) => { this.resolveStopped = resolve; }); @@ -177,6 +208,7 @@ class HubProcessImpl implements HubProcess { host: CONTROL_HOST, log: (line: string) => this.writeLine(line), onShutdown: () => this.stop(), + appMode: this.appMode, }); try { await control.start(); diff --git a/src/hub/paths.ts b/src/hub/paths.ts new file mode 100644 index 0000000..c15e0ad --- /dev/null +++ b/src/hub/paths.ts @@ -0,0 +1,164 @@ +// src/hub/paths.ts +/** + * hub 文件路由(UI-1b /v1/files/read、/v1/files/write)共用的路径防护。 + * + * 这两条路由按 UI 请求在"图包工作区 root"内读写任意相对路径的文件,是 hub 全部 + * 路由里唯一直接触碰宿主机文件系统写入面的入口,因此必须在进入 fs 之前把请求 + * 路径钉死在 root 内。防护分四层(参考 src/archive/ttsmod.ts 的 zip-slip 检查 + * 模式,并追加 symlink 真实路径校验): + * + * 1. 字面拒绝:rel 含 null 字节(C 运行时层可截断路径); + * 2. 字面拒绝:rel 是绝对路径 / 盘符前缀(`/etc/passwd`、`C:\Windows`、`C:foo`); + * 3. 字面拒绝:rel 含 ".." 段(按 / 与 \ 两种分隔符切分后逐段比对); + * 4. 归一化后前缀校验:path.resolve(root, rel) 必须落在 path.resolve(root) 的 + * 直接子树内(startsWith(root + path.sep)),双保险拦住 1-3 之外的归一化逃逸; + * 5. symlink 真实路径校验(防逃逸关键层):对 root 与"目标路径最深的已存在祖先" + * 各取 fs.realpath,把不存在尾部拼回后再做一次前缀校验——root 本身是符号链接、 + * 或 rel 的中间段有指向 root 外的符号链接时,这里必然拦截。 + * + * 错误模型:防护失败抛 {@link PathEscapeError}(code = "HUB_PATH_ESCAPE"),由 + * src/hub/control.ts 的 dispatch 统一映射为 400 HUB_PATH_ESCAPE。本模块不引入 + * i18n:message 是协议层英文短句,消费方是 MCP/运维日志与桌面 UI 的错误展示。 + */ + +import { realpath } from "node:fs/promises"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// 错误类型 +// --------------------------------------------------------------------------- + +/** + * 请求路径逃逸工作区 root(/v1/files/* 的路径防护失败)。 + * + * 不继承 src/hub/control.ts 的私有 BadRequestError(避免 hub↔paths 循环依赖), + * 由 control.ts 的 dispatch instanceof 分支映射为 400 HUB_PATH_ESCAPE。 + */ +export class PathEscapeError extends Error { + /** 机器可读错误码(固定 "HUB_PATH_ESCAPE")。 */ + readonly code: string; + + /** + * @param message 协议层英文短句(描述哪一层防护拦下了什么) + */ + constructor(message: string) { + super(message); + this.name = "PathEscapeError"; + this.code = "HUB_PATH_ESCAPE"; + } +} + +// --------------------------------------------------------------------------- +// 内部工具 +// --------------------------------------------------------------------------- + +/** + * 从 unknown 错误中取 Node 风格的 code 属性(如 ENOENT / ELOOP),避免 any。 + * @param err 任意抛出值 + * @returns 字符串形式的 code;取不到时返回 undefined + */ +function errCode(err: unknown): string | undefined { + if (err instanceof Error) { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") { + return code; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// 导出函数 +// --------------------------------------------------------------------------- + +/** + * 把"相对 root 的请求路径"解析为绝对路径,并验证它(及其符号链接真实路径) + * 严格落在 root 子树内。 + * + * 前置条件:root 必须是已存在的目录(realpath 失败视为"无法验证 containment", + * 一律拒绝——两条 /v1/files/* 路由的调用方都先做过 root 级校验,root 不存在时 + * 在这里失败是预期行为);rel 必须是相对路径(绝对路径 / 盘符 / ".." 段 / + * null 字节一律 {@link PathEscapeError})。 + * + * 校验通过的返回值是 path.resolve(root, rel)(保持请求方的路径拼写;符号链接 + * 只是验证对象,不在返回值里改写)。 + * + * @param root 工作区根目录(绝对 / 相对均可,内部先 resolve) + * @param rel 相对 root 的请求路径(不允许绝对路径 / ".." / null 字节) + * @returns 解析出的绝对路径(保证在 root 子树内) + * @throws PathEscapeError rel 含 null 字节 / 绝对路径 / 盘符 / ".." 段、 + * 归一化后逃出 root、root 或路径祖先经 realpath + * 解析后逃出 root,或 root 不可访问(无法验证)时 + */ +export async function resolveWithinRoot(root: string, rel: string): Promise { + // —— 1-3. 字面拒绝(null 字节 / 绝对路径 / 盘符 / ".." 段)—— + if (rel.includes("\0")) { + throw new PathEscapeError("path contains a null byte"); + } + if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) { + throw new PathEscapeError(`path must be relative to the workspace root: ${rel}`); + } + // 按 / 与 \ 两种分隔符切分(Windows 反斜杠在 POSIX 上是文件名字符,但字面 + // 拒绝 ".." 段对两种平台都取更严的一侧) + const segments = rel.split(/[\\/]+/); + if (segments.some((segment) => segment === "..")) { + throw new PathEscapeError(`path must not contain ".." segments: ${rel}`); + } + + // —— 4. 归一化后前缀校验 —— + const rootAbs = path.resolve(root); + const target = path.resolve(rootAbs, rel); + const rootPrefix = rootAbs.endsWith(path.sep) ? rootAbs : rootAbs + path.sep; + if (!target.startsWith(rootPrefix)) { + throw new PathEscapeError(`path escapes the workspace root: ${rel}`); + } + + // —— 5. symlink 真实路径校验:root 与目标的最深已存在祖先各取 realpath —— + let realRoot: string; + try { + realRoot = await realpath(rootAbs); + } catch (err) { + throw new PathEscapeError( + `cannot verify workspace root containment (root missing or inaccessible): ${rootAbs} (${errCode(err) ?? String(err)})`, + ); + } + + // 从 target 逐级向上找第一个真实存在的祖先(write 场景目标文件可以尚不存在; + // ENOENT / ENOTDIR / EINVAL 是"这一级不存在 / 不是目录"的正常信号,继续向上; + // 其余错误——EACCES / ELOOP 等——无法验证 containment,一律拒绝) + let probe = target; + const missing: string[] = []; + let realAncestor: string; + for (;;) { + try { + realAncestor = await realpath(probe); + break; + } catch (err) { + const code = errCode(err); + if (code !== "ENOENT" && code !== "ENOTDIR" && code !== "EINVAL") { + throw new PathEscapeError( + `cannot verify path containment: ${probe} (${code ?? String(err)})`, + ); + } + } + const parent = path.dirname(probe); + if (parent === probe) { + // root 已验证存在,理论上走不到文件系统根;防御兜底 + throw new PathEscapeError(`cannot verify path containment: ${target}`); + } + missing.push(path.basename(probe)); + probe = parent; + } + missing.reverse(); + // 把不存在(未参与 realpath 解析)的尾部段拼回真实祖先,得到目标的真实路径 + const realTarget = + missing.length === 0 ? realAncestor : path.join(realAncestor, ...missing); + const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep; + if (!realTarget.startsWith(realPrefix)) { + throw new PathEscapeError( + `path escapes the workspace root through a symbolic link: ${rel}`, + ); + } + + return target; +} diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 346bdb2..a2fa67c 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -2,7 +2,8 @@ /** * hub 控制通道 HTTP 客户端({@link HubClient} / {@link probeHub}):CLI 与 MCP 工具层 * 共用的唯一 hub 访问入口,与 src/hub/control.ts 的 12 条 S2 路由 + 2 条阶段 7 - * 路由(/v1/test/run、/v1/pack/build)一一对应(本模块 + * 路由(/v1/test/run、/v1/pack/build)+ 2 条 UI-1b 文件路由(/v1/files/read、 + * /v1/files/write)一一对应(本模块 * 不覆盖 GET /v1/events SSE——事件订阅由扇出层 TCP / WS 客户端承担,见 * src/hub/fanout.ts)。 * @@ -12,6 +13,10 @@ * 丢弃——HTTP 场景无交互)、/v1/deck/plan 的 body 就是 {@link PlanOptions} 本身、 * /v1/push 必须 confirm:true(push 的 confirm 参数类型是字面量 `true`,调用方传 * false 编译期即报错,与服务端 HUB_CONFIRM_REQUIRED 门对齐); + * - {@link HubClient.filesRead} / {@link HubClient.filesWrite}(UI-1b)对应 hub 的 + * /v1/files/* 路由:仅供 UI / CLI 内部消费,**不注册为 MCP 工具**(红线 15, + * 防 MCP 越权读写宿主机文件);files/write 走 PUT,baseSha256 可选乐观锁 + * (不符时 hub 返回 409 HUB_CONFLICT,经 {@link HubError} 透传); * - 除 status / saveAndPlay / push / shutdown 按定型返回外,其余方法原样返回控制 * 通道的 JSON 响应体(unknown):/v1/exec 的响应体是 `{result:...}` 包装(见 * src/hub/control.ts 的 handleExec)、/v1/packs 是注册表 JSON、/v1/assets/check @@ -94,12 +99,38 @@ export interface HubTtsStatus { objects?: number; } +/** + * {@link HubClient.status} 返回中 hub 自身状态部分(GET /v1/status 的 hub 字段 + * 定型形状,UI-1b 收窄:新增 version / appMode 两个字段)。 + */ +export interface HubInfoStatus { + /** 编辑器入站端口(39998)当前绑定状态描述(hub 侧 daemon.stats 原样透传)。 */ + editor: number; + /** TCP 扇出端口(39997)当前连接的客户端数。 */ + tcpClients: number; + /** WS 扇出端口(39996)当前连接的客户端数。 */ + wsClients: number; + /** 进程内直连的客户端数。 */ + inprocClients: number; + /** hub 进程启动时刻(epoch 毫秒)。 */ + startedAt: number; + /** hub 进程已运行毫秒数(hub 侧现算,负值截为 0)。 */ + uptimeMs: number; + /** hub 版本号(hub 进程 package.json 的 version;UI 据此做能力降级判断)。 */ + version: string; + /** 应用模式:"standalone"(独立运行)或 "app"(桌面 UI sidecar)。 */ + appMode: string; +} + /** {@link HubClient.status} 的返回类型(GET /v1/status 的定型形状)。 */ export interface HubStatusResult { /** 固定 true(hub 侧 200 约定)。 */ ok: true; - /** hub 自身状态(editor/tcpClients/wsClients/inprocClients/startedAt/uptimeMs)。 */ - hub: unknown; + /** + * hub 自身状态(定型 {@link HubInfoStatus})。运行时为 hub 响应原样透传 + * (不逐字段校验——与旧版本 hub 互通时字段可能缺失,UI / 调用方按需判空)。 + */ + hub: HubInfoStatus; /** TTS 连接状态。 */ tts: HubTtsStatus; } @@ -307,7 +338,8 @@ function buildBaseUrl(host: string, port: number): string { /** * hub 控制通道 HTTP 客户端:方法与 src/hub/control.ts 的 12 条 S2 路由 + 2 条 - * 阶段 7 路由(/v1/test/run、/v1/pack/build)一一对应, + * 阶段 7 路由(/v1/test/run、/v1/pack/build)+ 2 条 UI-1b 文件路由 + * (/v1/files/read、/v1/files/write)一一对应, * CLI 与 MCP 工具层共用。实例无状态(可长期持有),失败按模块头注释的三类语义 * 抛 {@link HubNotRunningError} / {@link HubError},绝不返回半截结果。 */ @@ -334,14 +366,14 @@ export class HubClient { * 超时覆盖"发起请求 + 读取响应体"全程(finally 中 clearTimeout);网络层失败 * (fetch reject / 中止 / 响应体读取中断)→ HubNotRunningError;4xx/5xx 按响应 * 体标准形分类为 HubError;2xx 但 body 非法 JSON → HubError(HUB_UNKNOWN)。 - * @param method HTTP 方法(控制通道只用 GET / POST) + * @param method HTTP 方法(控制通道用 GET / POST / PUT) * @param path 路由路径(相对 /v1,如 "/exec") * @param body 请求体(undefined 表示 GET / 不带体;否则 JSON 序列化) * @returns 已解析的 JSON 响应体 * @throws HubNotRunningError 网络层失败时 * @throws HubError HTTP 4xx/5xx 或响应体不是合法 JSON 时 */ - private async request(method: "GET" | "POST", path: string, body?: unknown): Promise { + private async request(method: "GET" | "POST" | "PUT", path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); @@ -409,7 +441,7 @@ export class HubClient { if (typeof ttsRaw.objects === "number") { tts.objects = ttsRaw.objects; } - return { ok: true, hub: body.hub, tts }; + return { ok: true, hub: body.hub as HubInfoStatus, tts }; } /** @@ -677,6 +709,51 @@ export class HubClient { async packBuild(opts: { root: string; outPath?: string; dryRun?: boolean }): Promise { return this.request("POST", "/pack/build", opts); } + + /** + * POST /v1/files/read:读图包工作区 root 内的一个文件(UI-1b)。 + * + * hub 侧约束:root 必须是已注册图包根;path 相对 root 且过四层路径防护 + * (绝对路径 / ".." / 盘符 / null 字节 / symlink 逃逸 → 400 HUB_PATH_ESCAPE); + * 扩展名白名单 png/jpg/jpeg/webp/gif/pdf/txt/lua/xml/json/csv/md(其余 400); + * 文件上限 20MB(413 HUB_FILE_TOO_LARGE)。**不注册为 MCP 工具**(红线 15), + * 本方法仅供 UI / CLI 内部消费。 + * @param root 图包工作区根目录(绝对路径) + * @param path 相对 root 的文件路径 + * @returns 控制通道 JSON 响应体(`{base64, mime, size}`) + * @throws HubNotRunningError hub 不可达时 + * @throws HubError hub 返回协议错误(HUB_BAD_REQUEST / HUB_PATH_ESCAPE / + * HUB_FILE_TOO_LARGE / HUB_PACK_ERROR——root 未注册时)时 + */ + async filesRead(root: string, path: string): Promise { + return this.request("POST", "/files/read", { root, path }); + } + + /** + * PUT /v1/files/write:把一个文本文件写进图包工作区 root(UI-1b,工作台保存 + * 脚本用)。**不注册为 MCP 工具**(红线 15)。 + * + * hub 侧约束:同套路径防护(root 不要求已注册);仅文本(content 为字符串, + * 允许空串);content 上限 1MB(413 HUB_FILE_TOO_LARGE);父目录必须已存在; + * baseSha256 提供时做乐观锁比对(与当前文件 sha256 不符或目标不存在 → + * 409 HUB_CONFLICT)。 + * @param root 图包工作区根目录(绝对路径) + * @param path 相对 root 的目标文件路径 + * @param content 待写入的文本(UTF-8;允许空串) + * @param baseSha256 可选乐观锁基准:当前文件内容的 sha256(64 位 hex); + * 不带则跳过比对直接覆写 + * @returns 控制通道 JSON 响应体(`{sha256, size}`——对写入字节计算) + * @throws HubNotRunningError hub 不可达时 + * @throws HubError hub 返回协议错误(HUB_BAD_REQUEST / HUB_PATH_ESCAPE / + * HUB_FILE_TOO_LARGE / HUB_CONFLICT)时 + */ + async filesWrite(root: string, path: string, content: string, baseSha256?: string): Promise { + const body: Record = { root, path, content }; + if (baseSha256 !== undefined) { + body.baseSha256 = baseSha256; + } + return this.request("PUT", "/files/write", body); + } } // --------------------------------------------------------------------------- diff --git a/tests/unit/hub-control-files.test.ts b/tests/unit/hub-control-files.test.ts new file mode 100644 index 0000000..5c97236 --- /dev/null +++ b/tests/unit/hub-control-files.test.ts @@ -0,0 +1,525 @@ +// tests/unit/hub-control-files.test.ts +/** + * src/hub/control.ts · /v1/files/read(POST)与 /v1/files/write(PUT)单元测试 + * (UI-1b 文件路由;不注册为 MCP 工具——红线 15)。 + * + * 与 tests/unit/hub-control.test.ts 的分工:那里覆盖通用路由分类(404 / 405 / + * 415 / SSE),这里深挖 UI-1b 两条文件路由与 CORS / appMode 行为: + * - POST /v1/files/read:200 `{base64, mime, size}`(base64 对字节精确往返、 + * mime 按扩展名白名单推断);root 必须是已注册图包根(400 HUB_BAD_REQUEST); + * 路径防护参数化(绝对路径 / ".." 段 / 盘符前缀 / null 字节 / symlink 逃逸 + * → 400 HUB_PATH_ESCAPE);>20MB → 413 HUB_FILE_TOO_LARGE; + * - PUT /v1/files/write:200 `{sha256, size}`(sha256 对写入的 UTF-8 字节计算); + * 乐观锁三态——baseSha256 匹配 → 200 覆写 / 不匹配 → 409 HUB_CONFLICT + * (details.currentSha256 带当前值;目标不存在也是冲突)/ 不提供 → 跳过校验; + * content >1MB → 413 HUB_FILE_TOO_LARGE;root 不要求已注册(§8.4:仅 read + * 校验注册表);GET 打写路由 → 405 且 Allow: PUT(PUT 路由表生效的对照); + * - loopback CORS(UI-1b):http://localhost: Origin → 回显 + * Access-Control-Allow-Origin;非白名单来源 → 完全不发 CORS 头;OPTIONS + * 预检 → 204 + Allow-Methods: GET,POST,PUT,OPTIONS; + * - GET /v1/status:hub.version(= package.json 版本)与 hub.appMode(缺省 + * "standalone");appMode="app" 时错误响应 details 附带 userAction 提示键 + * (standalone 形态不带)。 + * + * mock 策略(真实 HTTP + 真实文件 IO,不 mock 任何模块): + * - daemon 与控制通道用真实现(随机临时端口,绝不绑 39995-39999),请求用真 + * fetch 发出; + * - 测试 root 用 mkdtemp 临时目录 + upsertPack 写 .registry.yaml 构造"已注册 + * 图包根"(findPack 按 root 的父目录与基名查注册表);临时目录在 afterEach + * 全部删除; + * - symlink 逃逸用例用 junction 语义(POSIX 上 type 参数被忽略等价普通符号 + * 链接,Windows 上 junction 无需特权),并带能力探针 skipIf 兜底。 + * + * 错误断言只看 code / details(协议层机器可读键),不断言 message 文案。 + */ +import net, { type AddressInfo } from 'node:net'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import pkg from '../../package.json' with { type: 'json' }; +import { HubDaemon } from '../../src/hub/daemon.js'; +import { createControlServer, type ControlServer } from '../../src/hub/control.js'; +import { upsertPack } from '../../src/pack/registry.js'; + +// --------------------------------------------------------------------------- +// 符号链接能力探针(模块收集期执行一次;个别沙箱环境禁用一切链接创建) +// --------------------------------------------------------------------------- + +/** 当前环境能否创建目录链接(junction 在 Windows 无需特权;POSIX 忽略 type) */ +const dirLinkSupported = await (async () => { + const probe = await mkdtemp(path.join(os.tmpdir(), 'tts-files-link-probe-')); + try { + const dir = path.join(probe, 'dir'); + await mkdir(dir); + try { + await symlink(dir, path.join(probe, 'link'), 'junction'); + return true; + } catch { + return false; + } + } finally { + await rm(probe, { recursive: true, force: true }); + } +})(); + +// --------------------------------------------------------------------------- +// 栈管理(与 hub-control.test.ts 同款:随机临时端口 + afterEach 全关) +// --------------------------------------------------------------------------- + +/** 运行中的 daemon + 控制通道栈 */ +interface RunningStack { + daemon: HubDaemon; + control: ControlServer; + /** 控制通道基础 URL(http://127.0.0.1:/v1) */ + base: string; +} + +const cleanups: Array<() => unknown> = []; + +afterEach(async () => { + for (const fn of cleanups.reverse()) { + await fn(); + } + cleanups.length = 0; +}); + +/** + * 用 listen(0) 向操作系统抢占一个空闲临时端口后立即释放。 + * @returns 空闲端口 + */ +async function grabFreePort(): Promise { + const probe = net.createServer(); + await new Promise((resolve) => { + probe.listen({ port: 0, host: '127.0.0.1' }, () => resolve()); + }); + const addr = probe.address(); + const port = addr !== null && typeof addr === 'object' ? (addr as AddressInfo).port : 0; + await new Promise((resolve) => { + probe.close(() => resolve()); + }); + return port; +} + +/** + * 启动 daemon(三端口 listen(0) 抢占的临时端口)+ 控制通道(port=0 由操作系统 + * 分配,boundPort() 读回)。端口恰被占用时换端口重试(最多 5 次)。 + * @param opts.opts.appMode 传给 ControlServerOptions.appMode(undefined = 缺省 standalone) + * @returns 运行中的栈 + */ +async function startStack(opts: { appMode?: string } = {}): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + const editorPort = await grabFreePort(); + const tcpPort = await grabFreePort(); + const wsPort = await grabFreePort(); + const daemon = new HubDaemon({ editorPort, tcpPort, wsPort, log: () => undefined }); + const control = createControlServer(daemon, { + port: 0, + log: () => undefined, + ...(opts.appMode !== undefined ? { appMode: opts.appMode } : {}), + }); + try { + await daemon.start(); + try { + await control.start(); + } catch (err) { + await daemon.stop(); + throw err; + } + } catch (err) { + lastErr = err; + continue; + } + cleanups.push(() => control.stop()); + cleanups.push(() => daemon.stop()); + return { daemon, control, base: `http://127.0.0.1:${control.boundPort()}/v1` }; + } + throw lastErr; +} + +// --------------------------------------------------------------------------- +// 测试夹具:已注册 / 未注册的图包工作区 root +// --------------------------------------------------------------------------- + +/** 测试用图包工作区 root(含其 packsRoot 与临时基目录) */ +interface TestRoot { + /** 临时基目录(afterEach 整体删除) */ + tmpBase: string; + /** 图包根目录(root 的父目录,持有 .registry.yaml) */ + packsRoot: string; + /** 已注册的图包工作区根目录 */ + root: string; +} + +/** + * 构造"已注册图包根":`/packs/ok-pack` + .registry.yaml 中 dir="ok-pack" + * 的条目,并预建 root/scripts 子目录(write 的父目录必须已存在)。 + * @returns 测试 root 三元组 + */ +async function makeRegisteredRoot(): Promise { + const tmpBase = await mkdtemp(path.join(os.tmpdir(), 'tts-files-root-')); + const packsRoot = path.join(tmpBase, 'packs'); + const root = path.join(packsRoot, 'ok-pack'); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await upsertPack(packsRoot, { + dir: 'ok-pack', + name: 'OK Pack', + kind: 'original', + upstream: null, + branch: 'main', + host: 'custom', + modified: '', // upsert 语义:新增时自动填当天日期 + stats: { decks: 0, cards: 0, scripts: 0 }, + lfs_status: 'disabled', + }); + cleanups.push(() => rm(tmpBase, { recursive: true, force: true })); + return { tmpBase, packsRoot, root }; +} + +/** 构造"未注册"的目录(无 .registry.yaml,findPack 容错返回空表 → 400) */ +async function makeUnregisteredRoot(): Promise { + const tmpBase = await mkdtemp(path.join(os.tmpdir(), 'tts-files-ghost-')); + const root = path.join(tmpBase, 'ghost-pack'); + await mkdir(root, { recursive: true }); + cleanups.push(() => rm(tmpBase, { recursive: true, force: true })); + return root; +} + +/** 在 root 内落一个文件(自动逐级建父目录),返回绝对路径 */ +async function seedFile(root: string, relPath: string, data: string | Buffer): Promise { + const abs = path.join(root, relPath); + await mkdir(path.dirname(abs), { recursive: true }); + await writeFile(abs, data); + return abs; +} + +// --------------------------------------------------------------------------- +// HTTP 辅助 +// --------------------------------------------------------------------------- + +/** 便捷 POST JSON(body = unknown),返回原始 Response */ +const postJson = (base: string, pathname: string, body: unknown): Promise => + fetch(`${base}${pathname}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + +/** 便捷 PUT JSON 到 /v1/files/write,返回原始 Response */ +const putJson = (base: string, body: unknown): Promise => + fetch(`${base}/files/write`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + +/** 解析统一错误响应体并断言 code,返回 details 供进一步断言 */ +async function expectErrorBody(res: Response, status: number, code: string): Promise<{ + message: string; + details?: Record; +}> { + expect(res.status).toBe(status); + const body = (await res.json()) as { + error?: { code?: string; message?: string; details?: Record }; + }; + expect(body.error?.code).toBe(code); + expect(typeof body.error?.message).toBe('string'); + return { message: body.error?.message ?? '', details: body.error?.details }; +} + +/** 文本(UTF-8 字节)的 sha256 hex */ +function sha256Of(text: string): string { + return createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex'); +} + +// --------------------------------------------------------------------------- +// POST /v1/files/read · 200 正常返回 +// --------------------------------------------------------------------------- + +describe('POST /v1/files/read · 200 正常返回', () => { + it('文本文件(.lua,含中文)→ 200 {base64, mime:"text/plain", size},base64 解码与原字节一致', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const text = '-- 引导脚本(UTF-8 多字节)\nlocal seed = 42\n'; + await seedFile(root, 'scripts/bootstrap.lua', text); + + const res = await postJson(base, '/files/read', { root, path: 'scripts/bootstrap.lua' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { base64?: string; mime?: string; size?: number }; + expect(body.mime).toBe('text/plain'); // lua 无注册 MIME,按纯文本给出 + expect(body.size).toBe(Buffer.byteLength(text, 'utf8')); + expect(Buffer.from(body.base64 ?? '', 'base64').toString('utf8')).toBe(text); + }); + + it('二进制文件(.png,PNG 魔数含 0x00)→ 200 mime:"image/png",base64 字节级往返一致', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0xff, 0x80]); + await seedFile(root, 'decks/back.png', bytes); + + const res = await postJson(base, '/files/read', { root, path: 'decks/back.png' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { base64?: string; mime?: string; size?: number }; + expect(body.mime).toBe('image/png'); + expect(body.size).toBe(bytes.length); + const decoded = Buffer.from(body.base64 ?? '', 'base64'); + expect(decoded.equals(bytes)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// POST /v1/files/read · 路径防护(HUB_PATH_ESCAPE) +// --------------------------------------------------------------------------- + +describe('POST /v1/files/read · 路径防护(400 HUB_PATH_ESCAPE)', () => { + const escapeCases: ReadonlyArray<{ name: string; relPath: string }> = [ + { name: '绝对路径 /etc/passwd', relPath: '/etc/passwd' }, + { name: '".." 段 scripts/../../outside.lua', relPath: 'scripts/../../outside.lua' }, + { name: '盘符前缀 C:foo', relPath: 'C:foo' }, + { name: 'null 字节', relPath: 'a\0b' }, + ]; + + for (const { name, relPath } of escapeCases) { + it(`${name} → 400 HUB_PATH_ESCAPE`, async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const res = await postJson(base, '/files/read', { root, path: relPath }); + await expectErrorBody(res, 400, 'HUB_PATH_ESCAPE'); + }); + } + + it('symlink 逃逸(root 内目录链接指向 root 外)→ 400 HUB_PATH_ESCAPE', async (ctx) => { + if (!dirLinkSupported) { + ctx.skip(); // 个别环境禁用链接创建:能力探针兜底跳过 + } + const { base } = await startStack(); + const { packsRoot, root } = await makeRegisteredRoot(); + const outside = path.join(packsRoot, 'outside'); // root 的兄弟目录(在 root 外) + await mkdir(outside, { recursive: true }); + await writeFile(path.join(outside, 'secret.txt'), 'top secret', 'utf8'); + // junction:Windows 无需特权;POSIX 忽略 type,等价普通目录符号链接 + await symlink(outside, path.join(root, 'link'), 'junction'); + + const res = await postJson(base, '/files/read', { root, path: 'link/secret.txt' }); + await expectErrorBody(res, 400, 'HUB_PATH_ESCAPE'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /v1/files/read · 大小上限与 root 注册校验 +// --------------------------------------------------------------------------- + +describe('POST /v1/files/read · 大小上限与 root 注册校验', () => { + it('>20MB(白名单扩展名)→ 413 HUB_FILE_TOO_LARGE', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + await seedFile(root, 'decks/big.png', Buffer.alloc(20 * 1_048_576 + 1)); + + const res = await postJson(base, '/files/read', { root, path: 'decks/big.png' }); + await expectErrorBody(res, 413, 'HUB_FILE_TOO_LARGE'); + }); + + it('root 未注册(注册表无对应条目)→ 400 HUB_BAD_REQUEST', async () => { + const { base } = await startStack(); + const ghostRoot = await makeUnregisteredRoot(); + const res = await postJson(base, '/files/read', { root: ghostRoot, path: 'anything.txt' }); + await expectErrorBody(res, 400, 'HUB_BAD_REQUEST'); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /v1/files/write · 写入与乐观锁 +// --------------------------------------------------------------------------- + +describe('PUT /v1/files/write · 写入与乐观锁', () => { + it('正常写入 → 200 {sha256, size},sha256 对写入的 UTF-8 字节计算,磁盘内容一致', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const content = '-- 由 UI 重写\nlocal ok = true\n'; + + const res = await putJson(base, { root, path: 'scripts/bootstrap.lua', content }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sha256?: string; size?: number }; + expect(body.sha256).toBe(sha256Of(content)); + expect(body.size).toBe(Buffer.byteLength(content, 'utf8')); + expect(await readFile(path.join(root, 'scripts', 'bootstrap.lua'), 'utf8')).toBe(content); + }); + + it('乐观锁:baseSha256 与当前内容匹配 → 200 覆写,返回新内容的 sha256', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const first = 'version one'; + await seedFile(root, 'notes.md', first); + + const res = await putJson(base, { + root, + path: 'notes.md', + content: 'version two', + baseSha256: sha256Of(first), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sha256?: string; size?: number }; + expect(body.sha256).toBe(sha256Of('version two')); + expect(await readFile(path.join(root, 'notes.md'), 'utf8')).toBe('version two'); + }); + + it('乐观锁:baseSha256 不匹配 → 409 HUB_CONFLICT(details.currentSha256 带当前值,文件不变);目标不存在也是冲突', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const current = 'current content'; + await seedFile(root, 'notes.md', current); + const currentSha = sha256Of(current); + + const res = await putJson(base, { + root, + path: 'notes.md', + content: 'stale overwrite', + baseSha256: '0'.repeat(64), // 错误的基准 + }); + const err = await expectErrorBody(res, 409, 'HUB_CONFLICT'); + expect(err.details?.currentSha256).toBe(currentSha); + expect(await readFile(path.join(root, 'notes.md'), 'utf8')).toBe(current); // 未被覆盖 + + // 子场景:目标不存在 + 提供 baseSha256 → 409(创建新文件请不要带 baseSha256) + const resMissing = await putJson(base, { + root, + path: 'scripts/never-created.lua', + content: 'x', + baseSha256: currentSha, + }); + const errMissing = await expectErrorBody(resMissing, 409, 'HUB_CONFLICT'); + expect(errMissing.details?.currentSha256).toBeUndefined(); // 无当前文件,无 currentSha256 + }); + + it('乐观锁:不提供 baseSha256 → 跳过校验,已存在的文件直接覆写 200', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + await seedFile(root, 'notes.md', 'old content'); + + const res = await putJson(base, { root, path: 'notes.md', content: 'new body' }); + expect(res.status).toBe(200); + expect(await readFile(path.join(root, 'notes.md'), 'utf8')).toBe('new body'); + }); + + it('content >1MB(UTF-8 字节)→ 413 HUB_FILE_TOO_LARGE', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + const res = await putJson(base, { + root, + path: 'scripts/big.lua', + content: 'a'.repeat(1_048_577), // 1MB + 1 字节 + }); + await expectErrorBody(res, 413, 'HUB_FILE_TOO_LARGE'); + }); + + it('root 未注册仍可写(§8.4:仅 read 校验注册表)→ 200', async () => { + const { base } = await startStack(); + const ghostRoot = await makeUnregisteredRoot(); + const res = await putJson(base, { root: ghostRoot, path: 'free.md', content: 'no registry needed' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sha256?: string }; + expect(body.sha256).toBe(sha256Of('no registry needed')); + }); +}); + +// --------------------------------------------------------------------------- +// PUT 路由与方法白名单 +// --------------------------------------------------------------------------- + +describe('PUT 路由与方法白名单', () => { + it('PUT /v1/files/write 路由生效;GET 打写路由 → 405 且 Allow: PUT', async () => { + const { base } = await startStack(); + const { root } = await makeRegisteredRoot(); + + // PUT 命中专用路由表(三路 dispatch 之一) + const ok = await putJson(base, { root, path: 'notes.md', content: 'route-check' }); + expect(ok.status).toBe(200); + + // GET /v1/files/write → 405,Allow 聚合三张表后只含 PUT + const res = await fetch(`${base}/files/write`); + expect(res.status).toBe(405); + expect(res.headers.get('allow')).toBe('PUT'); + await expectErrorBody(res, 405, 'HUB_METHOD_NOT_ALLOWED'); + }); +}); + +// --------------------------------------------------------------------------- +// loopback CORS(UI-1b) +// --------------------------------------------------------------------------- + +describe('loopback CORS(UI-1b)', () => { + it('loopback Origin(http://localhost:1420)→ 回显 Access-Control-Allow-Origin + Vary: Origin', async () => { + const { base } = await startStack(); + const res = await fetch(`${base}/status`, { headers: { Origin: 'http://localhost:1420' } }); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:1420'); + expect(res.headers.get('vary')).toBe('Origin'); + }); + + it('非白名单 Origin(http://evil.com)→ 不发任何 CORS 头', async () => { + const { base } = await startStack(); + const res = await fetch(`${base}/status`, { headers: { Origin: 'http://evil.com' } }); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(res.headers.get('vary')).toBeNull(); + }); + + it('OPTIONS 预检 → 204 + Allow-Methods: GET,POST,PUT,OPTIONS + Allow-Headers: Content-Type', async () => { + const { base } = await startStack(); + const res = await fetch(`${base}/files/write`, { + method: 'OPTIONS', + headers: { Origin: 'http://localhost:1420' }, + }); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:1420'); + expect(res.headers.get('access-control-allow-methods')).toBe('GET,POST,PUT,OPTIONS'); + expect(res.headers.get('access-control-allow-headers')).toBe('Content-Type'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /v1/status · UI-1b 字段与 appMode +// --------------------------------------------------------------------------- + +describe('GET /v1/status · UI-1b 字段与 appMode', () => { + it('hub.version 为非空字符串且与 package.json 一致;hub.appMode 缺省 "standalone"', async () => { + const { base } = await startStack(); + const res = await fetch(`${base}/status`); + expect(res.status).toBe(200); + const body = (await res.json()) as { hub?: { version?: string; appMode?: string } }; + expect(typeof body.hub?.version).toBe('string'); + expect(body.hub?.version).toBe(pkg.version); // 顶部 import 本进程 package.json + expect(body.hub?.appMode).toBe('standalone'); // 测试栈未传 appMode → 缺省值 + }); + + it('appMode="app" → 错误响应 details.userAction 提示键;standalone 对照不带', async () => { + const app = await startStack({ appMode: 'app' }); + const tmp = await mkdtemp(path.join(os.tmpdir(), 'tts-files-appmode-')); + cleanups.push(() => rm(tmp, { recursive: true, force: true })); + + // HUB_PACK_ERROR 有专用提示键映射(ERROR_USER_ACTIONS) + const res = await postJson(app.base, '/diff', { root: tmp }); // 无 pack.yaml → PACK_NOT_FOUND + expect(res.status).toBe(400); + const body = (await res.json()) as { + error?: { code?: string; details?: Record }; + }; + expect(body.error?.code).toBe('HUB_PACK_ERROR'); + expect(body.error?.details?.packCode).toBe('PACK_NOT_FOUND'); + expect(body.error?.details?.userAction).toBe('hub.error.checkWorkspace'); + + // 对照:standalone 形态同样的错误,details 只有 packCode,不带 userAction + const standalone = await startStack(); + const resPlain = await postJson(standalone.base, '/diff', { root: tmp }); + expect(resPlain.status).toBe(400); + const bodyPlain = (await resPlain.json()) as { + error?: { code?: string; details?: Record }; + }; + expect(bodyPlain.error?.code).toBe('HUB_PACK_ERROR'); + expect(bodyPlain.error?.details).toHaveProperty('packCode'); + expect(bodyPlain.error?.details).not.toHaveProperty('userAction'); + }); +}); diff --git a/tests/unit/hub-control.test.ts b/tests/unit/hub-control.test.ts index 7206e37..78b5202 100644 --- a/tests/unit/hub-control.test.ts +++ b/tests/unit/hub-control.test.ts @@ -9,7 +9,10 @@ * - PackError 透传:/v1/diff 对无 pack.yaml 的目录 → 400 HUB_PACK_ERROR + * details.packCode === "PACK_NOT_FOUND"(/v1/deck/plan 非法规则 → PLAN_RULE_INVALID); * - GET /v1/events SSE:建立连接 → daemon.fanout 扇出 → 收到 data: 行; - * 不在 SSE 转发集合的消息(ReturnValue)不产生 data: 行。 + * 不在 SSE 转发集合的消息(ReturnValue)不产生 data: 行; + * - UI-1b:GET /v1/status 的 hub 字段携带 version(= package.json 版本)与 + * appMode(缺省 "standalone"),旧版 hub 无这两个字段、UI 侧按缺失降级; + * /v1/files/* 两条文件路由与 CORS 的深挖在 hub-control-files.test.ts。 * * 端口约束:daemon 三端口用 listen(0) 抢占的临时端口(daemon 不暴露实绑端口, * 见 hub-daemon.test.ts 头注释);控制通道用 port=0 由操作系统分配并经 @@ -134,7 +137,7 @@ function expectErrorBody(status: number, body: unknown, code: string): { } describe('GET /v1/status 与 /v1/packs(200 路由)', () => { - it('GET /v1/status:200,hub.editor 为 true,tts.connected 为布尔值', async () => { + it('GET /v1/status:200,hub.editor 为 true,hub.version/appMode(UI-1b),tts.connected 为布尔值', async () => { const { base } = await startStack(); const res = await get(base, '/status'); expect(res.status).toBe(200); @@ -142,12 +145,17 @@ describe('GET /v1/status 与 /v1/packs(200 路由)', () => { const body: unknown = await res.json(); const status = body as { ok?: boolean; - hub?: { editor?: boolean; startedAt?: number }; + hub?: { editor?: boolean; startedAt?: number; version?: string; appMode?: string }; tts?: { connected?: boolean }; }; expect(status.ok).toBe(true); expect(status.hub?.editor).toBe(true); expect(typeof status.hub?.startedAt).toBe('number'); + // UI-1b:hub.version 非空字符串(本进程 package.json 版本)、hub.appMode 为 + // 本测试栈的缺省值 "standalone"(未传 ControlServerOptions.appMode) + expect(typeof status.hub?.version).toBe('string'); + expect((status.hub?.version ?? '').length).toBeGreaterThan(0); + expect(status.hub?.appMode).toBe('standalone'); expect(typeof status.tts?.connected).toBe('boolean'); // 探测失败不是路由错误,仍 200 });