From 54c42b857b867198e9364590474c2cbc17daaa1d Mon Sep 17 00:00:00 2001 From: harveysang Date: Thu, 17 Sep 2026 23:38:53 +0800 Subject: [PATCH] Share viewer source and bundle independent host runtimes --- .github/workflows/opengui-codex-ci.yml | 12 +- .github/workflows/opengui-codex-release.yml | 6 +- .github/workflows/workbuddy-plugin-ci.yml | 2 + ...dex-workbuddy-runtime-convergence.zh-CN.md | 301 ++++++ docs/plans/simple-plugin-install.md | 5 + packages/device-runtime/LICENSE | 21 + packages/device-runtime/README.md | 12 + packages/device-runtime/VIDEO-NOTICE.md | 34 + packages/device-runtime/build.mjs | 114 +++ packages/device-runtime/src/contracts.ts | 9 + packages/device-runtime/src/viewer-page.ts | 41 + packages/device-runtime/src/viewer.ts | 230 +++++ packages/device-runtime/src/websocket.ts | 105 ++ plugins/opengui/SOURCE.md | 6 +- plugins/opengui/package.json | 12 +- plugins/opengui/scripts/stage.mjs | 2 +- plugins/opengui/scripts/validate.mjs | 6 +- plugins/opengui/src/viewer-page.ts | 42 +- plugins/opengui/src/viewer.ts | 231 +---- plugins/opengui/src/websocket.ts | 106 +- plugins/opengui/tsdown.viewer.config.ts | 7 + plugins/opengui/vitest.config.ts | 4 +- workbuddy-plugin/package-lock.json | 911 +++++++++++++++++- workbuddy-plugin/package.json | 27 +- workbuddy-plugin/scripts/validate.mjs | 5 +- workbuddy-plugin/src/viewer-page.ts | 42 +- workbuddy-plugin/src/viewer.ts | 231 +---- workbuddy-plugin/src/websocket.ts | 106 +- workbuddy-plugin/tsconfig.json | 22 +- workbuddy-plugin/tsdown.config.ts | 11 + workbuddy-plugin/vitest.config.ts | 2 + 31 files changed, 1858 insertions(+), 807 deletions(-) create mode 100644 docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md create mode 100644 packages/device-runtime/LICENSE create mode 100644 packages/device-runtime/README.md create mode 100644 packages/device-runtime/VIDEO-NOTICE.md create mode 100644 packages/device-runtime/build.mjs create mode 100644 packages/device-runtime/src/contracts.ts create mode 100644 packages/device-runtime/src/viewer-page.ts create mode 100644 packages/device-runtime/src/viewer.ts create mode 100644 packages/device-runtime/src/websocket.ts create mode 100644 plugins/opengui/tsdown.viewer.config.ts create mode 100644 workbuddy-plugin/tsdown.config.ts create mode 100644 workbuddy-plugin/vitest.config.ts diff --git a/.github/workflows/opengui-codex-ci.yml b/.github/workflows/opengui-codex-ci.yml index 1e2d3df..07301a5 100644 --- a/.github/workflows/opengui-codex-ci.yml +++ b/.github/workflows/opengui-codex-ci.yml @@ -3,10 +3,12 @@ name: Standalone OpenGUI Codex CI on: pull_request: paths: + - 'packages/device-runtime/**' - 'plugins/opengui/**' - '.github/workflows/opengui-codex-*.yml' push: paths: + - 'packages/device-runtime/**' - 'plugins/opengui/**' - '.github/workflows/opengui-codex-*.yml' @@ -33,16 +35,16 @@ jobs: - name: Copy only the standalone source into an isolated build root shell: bash run: | - cp -R plugins/opengui "$RUNNER_TEMP/opengui" + node packages/device-runtime/build.mjs stage codex "$RUNNER_TEMP/opengui-build" test ! -e "$RUNNER_TEMP/deepseek-harness-plugin" - name: Install without lifecycle hooks - working-directory: ${{ runner.temp }}/opengui + working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui run: pnpm install --frozen-lockfile --ignore-scripts - name: Test and validate the isolated package - working-directory: ${{ runner.temp }}/opengui + working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui run: pnpm check - name: Produce the allowlisted upload and archive - working-directory: ${{ runner.temp }}/opengui + working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui run: | pnpm package bash -n scripts/install-macos.command @@ -50,7 +52,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: opengui-codex-${{ matrix.os }} - path: ${{ runner.temp }}/opengui/.artifacts/ + path: ${{ runner.temp }}/opengui-build/plugins/opengui/.artifacts/ include-hidden-files: true if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/opengui-codex-release.yml b/.github/workflows/opengui-codex-release.yml index b0bb2b5..63cc236 100644 --- a/.github/workflows/opengui-codex-release.yml +++ b/.github/workflows/opengui-codex-release.yml @@ -33,9 +33,9 @@ jobs: with: node-version: 22.23.2 - name: Copy the independent package - run: cp -R plugins/opengui "$RUNNER_TEMP/opengui" + run: node packages/device-runtime/build.mjs stage codex "$RUNNER_TEMP/opengui-build" - name: Build and validate without DSH - working-directory: ${{ runner.temp }}/opengui + working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui run: | pnpm install --frozen-lockfile --ignore-scripts pnpm check @@ -43,7 +43,7 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: opengui-codex-release - path: ${{ runner.temp }}/opengui/.artifacts/ + path: ${{ runner.temp }}/opengui-build/plugins/opengui/.artifacts/ include-hidden-files: true if-no-files-found: error release-draft: diff --git a/.github/workflows/workbuddy-plugin-ci.yml b/.github/workflows/workbuddy-plugin-ci.yml index c740dfa..ccf595a 100644 --- a/.github/workflows/workbuddy-plugin-ci.yml +++ b/.github/workflows/workbuddy-plugin-ci.yml @@ -3,10 +3,12 @@ name: WorkBuddy Plugin CI on: push: paths: + - 'packages/device-runtime/**' - 'workbuddy-plugin/**' - '.github/workflows/workbuddy-plugin-*.yml' pull_request: paths: + - 'packages/device-runtime/**' - 'workbuddy-plugin/**' - '.github/workflows/workbuddy-plugin-*.yml' diff --git a/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md b/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md new file mode 100644 index 0000000..b6c127c --- /dev/null +++ b/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md @@ -0,0 +1,301 @@ +# Codex / WorkBuddy 执行内核收敛方案 + +日期:2026-09-17。状态:供实施的设计方案,尚未实施,也未通过新版本验收。 + +## 1. 决策与交付目标 + +采用 **一份设备执行内核源码、两个宿主适配层、两个独立安装包和运行实例**。 + +Codex、WorkBuddy 都保留“聊天提出任务 → 展示手机 → 宿主模型看图决策 → OpenGUI 执行一个动作 → 返回新截图 → 验证结果”的使用方式。共享设备发现、动作约束、执行队列、会话资源管理、实时画面和错误语义。安装、权限、模型调用、任务生命周期、图片呈现由各宿主负责。 + +用户只安装对应宿主的插件;不增加公共服务安装步骤、模型 API Key、账号或配置项。两端可各自升级和回滚。本轮不把两个后台进程合成一个全局后台服务。 + +完成标准: + +1. 两端的共用设备执行代码只有一个人工维护来源;修复公共规则时,同一套契约测试验证两个消费者。 +2. 两端现有入口和安装方式继续可用;安装包脱离源码仓库仍能独立运行。 +3. 实际宿主中完成设备展示、看图执行、停止、清理及重新开始;分别保留可核验的验收证据。 +4. WorkBuddy 新版安装无需默认退出宿主;能区分配置写入、MCP 工具可见、Hook 生效、设备可用四个状态。 +5. 共用代码有可追溯的来源版本;不会因升级某一端而改写另一端或 DSH 的配置、进程、安装目录。 + +### 范围 + +纳入:Codex 独立插件、WorkBuddy MCP + Skill + Hook、本地 Android ADB/scrcpy 执行与实时查看器、构建发布和验收。 + +本轮不纳入:DSH 迁移,豆包工作/千问工作的接入实现,新的 Web 工作台,独立模型规划服务,Artemis 集成,UIAutomator2/A11y 树推理,云设备调度,全量录屏回放和跨宿主任务接力。它们不能成为本轮完成的前置条件。 + +**一个明确的支持边界:本轮不支持两个宿主同时控制同一台手机。** 当前设备锁在各自进程内;共享源码不会自动提供跨进程互斥。两端同时使用的验收场景是各控不同设备;切换同一设备时先结束原宿主任务。DSH 或其他 ADB 客户端同样不受该进程内锁约束。若必须支持同机并发争用,应另立跨宿主设备仲裁方案,不能给当前方案补一句“共享锁”就宣称解决。 + +## 2. 代码依据与现状 + +本方案按已刷新远端的 `origin/main` 编写,审阅基线为 `737c6255c893a2c6a5779866f60f2bfee6efca3c`。当前本地工作分支不是该基线;实施应从最新 main 建立 `codex/` 工作分支,保留现有未提交研究文件。 + +| 项目 | Codex | WorkBuddy | 收敛处理 | +|---|---|---|---| +| 包版本 / IPC 协议 | 0.2.0 / 3 | 0.3.1 / 8 | 继续分别版本化,禁止强行统一数字 | +| 宿主入口 | Skill → CLI → Unix socket daemon | Skill/MCP stdio → 本地 TCP broker,Hook 提供任务上下文 | 保留 | +| 模型与任务推进 | Codex 宿主 | WorkBuddy 宿主,自动续跑由 Hook 协作 | 不新增模型客户端或自己的第二套 Agent 循环 | +| 图片交付 | 本地受限权限 JPEG 文件 | MCP image 内容 + 结构化元数据 | 适配器转换,同一内部观察结构 | +| 生命周期 | thread owner,短命 CLI 请求,daemon 常驻 | MCP 连接归属 + root task / agent 上下文 | 共用资源机制,保留宿主事件解释 | +| 观察/会话默认时限 | `SESSION_IDLE_MS` 为 30 分钟 | 控制租约默认 10 分钟 | 先保留实际语义,用明确策略参数注入 | +| 图片实现 | macOS `sips` | `sharp`,含像素采样 | 留在适配器;公共模块不依赖 sharp | +| 构建 | tsc 检查 + tsdown 0.22.2 打包 | tsc 从 src 发射 lib | WorkBuddy 改为检查 + 打包,保持现有 lib 入口 | + +依据:[Codex 源码目录](https://github.com/Core-Mate/OpenGUI/tree/737c6255c893a2c6a5779866f60f2bfee6efca3c/plugins/opengui/src)、[WorkBuddy 源码目录](https://github.com/Core-Mate/OpenGUI/tree/737c6255c893a2c6a5779866f60f2bfee6efca3c/workbuddy-plugin/src)、两端的 `package.json`、`scripts/validate.mjs`、CI 和发布工作流。 + +两端有 12 个同名基础模块,其中 `viewer.ts`、`viewer-page.ts`、`websocket.ts` 三个文件在该基线逐字节相同,适合首先提取。`phone-controller.ts`、`forward-registry.ts` 和会话服务已有行为差异,不能选择一端直接覆盖另一端。 + +需要保留的现有差异: + +- WorkBuddy 已有执行前画面重采样、目标区域变化检查、执行后稳定帧等待、断连 epoch 和完成证据检查。Codex 不具备完全相同的像素保护,不能宣称已等价。 +- Codex 对部分外部副作用有既有显式确认流程;WorkBuddy 遵循宿主授权,并且 Hook 上下文只证明任务归属,不代表用户授权。 +- WorkBuddy 没收到 Hook 上下文时会报告 `automation.available=false`;这不等于所有基础 MCP 工具都不可用。已经绑定 Hook 的任务调用缺少上下文则必须拒绝。 +- Codex 当前部分显式 status 调用会更新活动时间;WorkBuddy 的状态轮询不续租。第一轮提取必须通过策略保留,不能在重构中悄悄改变。 + +### 与旧规则的关系 + +`docs/plans/simple-plugin-install.md` 的“不共享运行时、配置、版本或设备服务”和 `plugins/opengui/SOURCE.md` 的独立维护边界需同步澄清:本方案引入**构建时共享源码**,因此调整原先“源码只在宿主目录内”的约束;继续保留运行时、配置、版本、设备服务的实例隔离及独立安装要求。 + +现有验证器禁止越出宿主源码树的 import,CI 也只复制一个宿主目录;这两处需要改为允许且仅允许 `packages/device-runtime` 构建输入,同时加强成品脱仓运行验证。不得直接删除隔离检查。 + +根 `CLAUDE.md` 的公共发布边界、GUI/vision-first 和禁止恢复旧 A11y 推理路径继续有效;`server/`、`client/`、`deepseek-harness-plugin/` 不在修改范围。 + +## 3. 目标架构与职责 + +```text + 同一份 packages/device-runtime 源码 + / 构建打入 \ 构建打入 + v v +Codex 宿主模型/任务 WorkBuddy 宿主模型/任务 + | | +Skill + CLI + 图片文件适配 Skill + MCP + Hook 适配 + | | +Codex daemon WorkBuddy broker + 内核实例 A 内核实例 B + 独立状态/租约/资源 独立状态/租约/资源 + | | +ADB / scrcpy + 只读 Viewer ADB / scrcpy + 只读 Viewer + | | + 设备 A 设备 B +``` + +模型调用发生在宿主里。公共内核不读取模型配置、不保存 Key、不自行向远程模型发送截图。截图如何进入模型上下文仍由宿主决定;“本地执行器”不代表宿主模型离线运行。 + +### 公共内核 + +目录:`packages/device-runtime/`,作为仓库内部源码组件,不发布 npm 包,不给用户增加第三个安装件。保留提取文件的版权和已有 MIT/VIDEO-NOTICE 来源说明,在公共目录记录来源;两端成品继续携带原有声明。 + +| 模块 | 内容 | 不接管的内容 | +|---|---|---| +| `src/viewer.ts`、`viewer-page.ts`、`websocket.ts` | 只读页面、解码首帧凭证、token、流量边界 | 宿主打开面板的方法 | +| `src/actions.ts`、`device-fleet.ts` | 动作校验、坐标映射、发现结果解析、固定目标 | 宿主安装目录和 ADB 二进制定位 | +| `src/concurrency.ts`、`phone-execution.ts`、`phone-controller.ts` | 单设备动作队列、观察凭证、预算、防止重复执行 | 宿主模型判断和权限弹窗 | +| `src/errors.ts`、`frame-comparison.ts` | 有类型的执行结果、像素样本比较规则 | 图片解码库、MCP/CLI 序列化 | +| `src/scrcpy-stream.ts`、`forward-registry.ts` | 视频协议、订阅生命周期、精确资源清理 | 下载器、安装路径、宿主私有缓存 | +| `src/session-runtime.ts`、`contracts.ts` | owner、session、actor、任务资源归属与释放 | WorkBuddy Hook token 与续跑决策、Codex thread 识别 | + +不为每个类再包装一个接口。只有实际存在宿主差异的边界才注入:ADB 执行与二进制定位、截图编码/可选像素采样、Unicode 通道、scrcpy 资源准备、宿主会话策略。目录中不得 import 任一宿主目录、MCP SDK、sharp 或 DSH。 + +设备端 scrcpy 文件名继续区分 `opengui-codex-*` 与 `opengui-workbuddy-*`;通过资源配置传入公共流实现。forward 注册和删除必须精确到本实例拥有的设备与端口,禁止 `adb kill-server` 或全局移除转发。 + +### 宿主适配层 + +Codex 保留 `cli.ts`、`daemon.ts`、`state.ts`、`confirmation.ts`、`codex/screenshot.ts`、安装器和 Skill。`codex/service.ts` 逐步缩为参数映射、策略注入、图片交付及旧接口兼容。 + +WorkBuddy 保留 `mcp.ts`、`mcp-server.ts`、`broker*.ts`、`wire.ts`、`host-hook.ts`、`automation.ts`、`installation.ts`、`state.ts`、图片编码、原生窗口与旧 mirror 入口。`service.ts` 逐步缩为策略和兼容层。 + +旧的同名共用文件迁移后可短期保留纯 re-export,以兼容脚本和测试;不能继续保留第二份业务实现。适配器不能复制预算、观察有效性或资源所有权算法。 + +## 4. 契约与兼容决策 + +### 4.1 内部数据 + +所有公共类型只在 `contracts.ts` 定义;这里描述字段约束,不改变宿主已有工具名。 + +| 对象 | 必需内容和约束 | +|---|---| +| 执行归属 | 宿主类型、宿主 owner、任务归属、session、actor、固定 device;由适配器从可信通道构造,不能相信模型自由填写的 owner | +| 观察 | `observationId`、设备/actor 归属、尺寸、时间、foreground、JPEG 字节与元数据;可携带像素样本与 settled 状态 | +| 动作 | 沿用现有 tap/swipe/text/key/launch/wait 的校验规则;修改动作必须引用当前观察,禁止裸 shell 或任意 ADB 命令 | +| 执行结果 | `not_executed`、`executed`、`outcome_unknown`;一次动作成功不等于任务完成 | +| 错误 | `code`、`message`、`executionState`、`recovery`;恢复建议限 observe/reconnect/wait/replan/stop | +| 会话关闭 | 资源状态与任务结果分离;兼容 Codex 普通 close 和 WorkBuddy 带 outcome/evidence 的 close | + +保持 WorkBuddy 的现有 `screen_changed`、`completion_unverified` 等错误。Codex 可以获得公共内核的类型化错误;其原有外层返回格式继续存在,不要求旧消费者改名。适配器外的逻辑不得依赖解析错误文案。 + +MCP 继续返回真正的 image 内容、结构化元数据,并通过 `isError` 表示工具执行失败;不要把图片降为模型不可见的路径字符串。Codex 继续生成本地图片文件,并使用其现有呈现方式。这样符合 [MCP 内容与错误结构](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-06-18/schema.mdx);本项目不因此升级已锁定的 SDK 1.29.0。 + +### 4.2 不可退化的规则 + +1. **归属**:owner/session/device 精确匹配;不同 thread/task 的调用、晚到取消和清理不得影响新的持有者。 +2. **顺序**:同 actor 的动作串行;不同设备允许并行;停止后不再下发排队动作,已发出的动作不能伪装成未执行。 +3. **观察**:旧观察、跨设备观察、断连后的旧观察都不能驱动动作。发生不确定结果后先观察,不自动重放点击、文本等修改动作。 +4. **预算**:保留单设备操作预算和无进展限制;WorkBuddy 重开同一任务的 session 不重置任务预算,Hook 续跑最多 10 次。 +5. **观看与控制**:观看不给控制权限;首次控制前必须收到用户可见的真实解码首帧。准备视频依赖完成后至多等一次 30 秒;`display_timeout` 对当前任务为终止失败,不能重建会话绕过。 +6. **首帧之后**:用户最小化/关闭观看页面不自动停止已建立的任务;任务结束不替用户关闭查看器;新任务不能继承旧任务的首帧授权。 +7. **释放**:先标记终止、阻止新动作并 abort,再等待本任务在途操作结束,最后释放精确匹配的锁和资源。状态查询不应意外执行动作。 +8. **未知状态**:断连、超时发生在 dispatch 后时返回 `outcome_unknown`;没有后续观察证据不能宣称执行失败或完成。 + +### 4.3 有意保留的策略差异 + +| 策略 | Codex | WorkBuddy | +|---|---|---| +| owner 来源 | 既有 `CODEX_THREAD_ID` 与 daemon 请求校验 | 连接 owner;有 Hook 时绑定 root task / agent | +| 短连接结束 | 正常 CLI 结束不取消已建立任务;请求中断按原行为取消 | MCP 长连接退出清理它拥有的任务资源 | +| 租约与续期 | 保留 30 分钟与既有显式 status 活动语义 | 保留 10 分钟,状态/Viewer 轮询不续租 | +| 外部副作用 | 保留现有确认路径 | 保留宿主授权边界;Hook 不能代替授权 | +| 图像重采样 | 本轮保留现有能力;不新增 sharp/原生 helper | 保留 sharp 像素检查、目标区域复核、稳定帧等待 | +| 任务完成证据 | close 不自动提升为“完成”;最终图由宿主核验 | completed 必须引用各设备最新有效观察 | +| 旧只读入口 | `mode=observe` | `purpose=mirror` 与镜像恢复能力 | + +公共控制器提供像素样本/执行前检查的策略注入点,WorkBuddy 现有保护搬入共用算法并保持启用;Codex 不支持的像素采样明确标记为无此能力,不能返回伪造的“画面未变化”。基础契约两端通测,增强视觉契约对 WorkBuddy 单独验收。本轮的“收敛”是消除重复实现和统一基础语义,不是抹掉这些行为差异。 + +## 5. 构建、隔离与来源追溯 + +**选用构建时打包共享 TS 源码。** Codex 沿用 tsdown;WorkBuddy 增加与 Codex 相同且锁定版本的 tsdown 0.22.2 开发依赖。运行时依赖集合不增加。保留各自的 pnpm/npm,不做根目录 monorepo 包管理迁移。 + +WorkBuddy 的 `rootDir: src` 与直接导入共享源码冲突,不能只增加一个 TS alias。[TypeScript rootDir 文档](https://www.typescriptlang.org/tsconfig/rootDir.html)解释了发射路径约束;[project references](https://www.typescriptlang.org/docs/handbook/project-references)解决项目构建关系,也不自动让发布包包含公共源码。具体调整如下: + +1. 两端源码通过明确相对路径导入 `packages/device-runtime/src`。公共目录只依赖 Node 内置模块及内部文件。 +2. WorkBuddy tsc 改为 `--noEmit` 检查,移除发射用 rootDir/outDir/declaration 设置,允许 TypeScript 扩展导入;由 tsdown 产出 lib,再执行已有 finalize。 +3. WorkBuddy 现有顶层 src 模块全部作为具名构建入口,维持 `lib/mcp.js`、`broker-main.js`、`host-hook.js`、`automation.js`、`installation.js`、`tools.js`、`state.js` 等现有路径和导出;公共部分由 ESM shared chunks 复用,避免多个 OpenGuiError 类副本影响 instanceof。 +4. MCP SDK、sharp、ajv、tar、yauzl 继续作为外部 npm 依赖,保留 sharp 原生资源的安装方式。shebang、可执行位、Skill 复制、macOS helper 输出和相对 URL 资源定位分别验证。内核不使用 `import.meta.url` 推断宿主资源位置。 +5. Codex 保持单入口、无运行时 npm 安装依赖的现有成品形态。共同代码打入 `lib/cli.js`,不把 WorkBuddy 依赖带进去。 +6. WorkBuddy 本轮不再把 tsc 附带的 `.d.ts` 当作发布契约;该包是 MCP 可执行分发,并非公开 TS SDK。所有现有 JS 入口及脚本导入路径必须保持,发布检查不得依赖丢失的声明文件。 +7. CI 隔离构建目录只复制该宿主目录和 `packages/device-runtime`,保持仓库相对布局;不能靠整仓依赖目录使构建“偶然成功”。产物再移入另一处空目录、移除源码访问后做 smoke。 +8. 调整 import 校验为精确解析目标白名单,拒绝任何其他越界、符号链接逃逸、DSH 路径和私人路径;同时扫描生成 JS 的未解析相对 import。 + +Viewer 提取时先将 `VideoDevice`、`ScrcpyStreamSink` 等纯类型放入公共 `contracts.ts`,让公共 Viewer 不反向 import 任一宿主的流实现。Codex 浏览器测试当前也有 `rootDir` 限制:将其测试构建改为单独的 `tsdown.viewer.config.ts`,输出 `.artifacts/browser/viewer.js` 并更新现有 `test:viewer` 参数;保持浏览器测试命令不变,避免为了测试重新复制一份 Viewer。 + +在各包 `lib/runtime-manifest.json` 写入构建来源:宿主名、包版本、来源 commit、公共契约版本、公共源码 digest。digest 按公共目录相对路径排序,对路径与文件字节计算 SHA-256;不含时间戳,不记录机器绝对路径。来源 commit 在复制到隔离构建目录前从原 checkout 生成,随构建输入传递;发布构建缺少来源时直接失败,不在脱仓目录猜测 Git 信息。Codex stage allowlist 明确加入此文件,WorkBuddy lib 随包带入。 + +共同内核不单独滚动更新。每个宿主版本锁定构建时内核;回滚该宿主包即回滚它的内核。IPC 协议号仅在该宿主线协议实际发生不兼容时增加。 + +## 6. Skill 与热安装体验 + +两端保留各自 Skill 文件,因为工具名、图片呈现和生命周期确有差异。共用契约测试检查它们都表达:先可见首帧、一次一个动作、结果不明不重放、观察后结束任务、控制结束不关闭用户画面。不要把一份 WorkBuddy 操作指南原样安装到 Codex。 + +GitHub 安装 Skill 继续是给用户粘贴的入口,自动选择宿主安装器及可验证版本。版本号、包链接、checksum 和适配器元数据由同一次发布更新,不能再把“安装器运行成功”写成“手机已可控制”。 + +WorkBuddy 安装流程固定为: + +1. 预检宿主版本、选中的产品配置路径与写入条件。 +2. 5.5.6+ 走已有 live install 路径,先 `LIVE_PREFLIGHT_OK`,成功写入后 `LIVE_CONFIG_WRITTEN`;备份和原子更新沿用现有安装器。 +3. 在宿主内通过 `/hooks` 审阅并应用变化、通过 `/skills` 确认可见;验证 MCP 工具发现和 Hook 任务上下文。只有当前任务没有刷新时再新建任务。 +4. 旧兼容版本按 `HOST_RESTART_REQUIRED` 给出重启步骤;不能一概要求退出,也不能一概承诺热加载。 +5. 插件二进制升级与正在执行的任务分开处理:活动任务期间拒绝强行切换该实例;提示结束任务再重试。没有活动任务时只更新/重连本宿主拥有的后台进程。首版不做在途任务跨版本迁移。 +6. 最后做设备只读发现与首帧检查;手机 USB 授权仍由用户在设备端完成。 + +Codex 不套用 WorkBuddy 的 Hook/热发现机制。沿用自己的安装入口与宿主刷新方式;源码热更新、宿主配置刷新、后台进程重启是三件事。验收报告逐项写明实测宿主版本与加载方式。 + +## 7. 分阶段实施与交付 + +预计 **4 个独立可合并 PR、约 10–15 个工程工作日**,是单人实施与复核的估算,不包含等待真机、宿主版本或 CI 下载恢复的时间。整体会触及超过 8 个文件,预计 35–50 个源码、测试、构建和文档文件;不是一次机械搬目录的小改动。 + +| PR | 具体范围 | 本 PR 独立交付与门禁 | 估算 | +|---|---|---|---| +| 1:共享 Viewer 与构建 | 提取 3 个相同模块;引入公共目录;WorkBuddy 打包改造;manifest;CI 路径和隔离检查;更新 SOURCE/旧安装边界说明 | 两端功能保持原状,共用 Viewer 已生效;双方构建、打包、脱仓启动、浏览器首帧测试通过 | 2–3 天 | +| 2:共用动作执行 | 提取动作/设备/并发/执行状态/错误;控制器注入编码及可选视觉策略;公共采样比较逻辑;保持宿主输入输出 | 两端完成 observe→act→observe,WB 增强视觉保护不退化;停止、不确定结果、旧观察、预算测试通过 | 3–4 天 | +| 3:共用资源生命周期 | 提取 session-runtime、视频流与 owned forward 清理;两端 service 改为适配;保留 Hook/daemon 生命周期策略 | 两端不同设备同时运行;一端退出/超时/回滚不清理另一端;新任务与旧回调隔离 | 3–4 天 | +| 4:安装、Skill 与候选验收 | 更新两端 Skill 和安装说明;完成活动任务升级检查;为两端同一公共 commit 产出候选包;运行真实宿主矩阵并记录证据 | 从 GitHub 成品安装、热配置/重启路径、可见首帧、控制、停止、卸载/回滚证据齐全后才准入稳定发布 | 2–4 天 | + +每个 PR 合并后两端都必须可用;不能靠下一个 PR 修复构建或入口。每个 PR 都运行双方核心检查,产出可安装候选,不在 PR 4 才首次打包。PR 4 即使受真机门禁阻塞,前面代码仍可保持预发布状态。 + +### 文件落点 + +- 公共源码及测试:`packages/device-runtime/src/`、`packages/device-runtime/tests/`、`packages/device-runtime/README.md`。 +- Codex:`plugins/opengui/src/` 中上述共用模块及 `codex/service.ts`;`tsdown.config.ts`、新增 `tsdown.viewer.config.ts` 并替代 `tsconfig.browser.json` 的测试发射用途;`scripts/validate.mjs`、`scripts/stage.mjs`、`scripts/package.mjs`;`SOURCE.md`、Skill、README。 +- WorkBuddy:`workbuddy-plugin/src/` 中上述共用模块及 `service.ts`;新增 `tsdown.config.ts`;调整 `package.json`/lock/tsconfig;保留并检查 `scripts/finalize.mjs`、`validate.mjs`、`package.mjs`、`smoke-packed.mjs` 和安装器;调整 connector Skill。 +- CI:`.github/workflows/opengui-codex-{ci,release}.yml`、`.github/workflows/workbuddy-plugin-{ci,release}.yml`;公共目录变化必须触发双方 CI。 +- 发布证据:沿用各宿主现有 readiness 文件及 `docs/plans/2026-09-13-viewer-candidate-acceptance.md` 的门禁;新增本次验收记录,不能把历史 verified 直接搬到新版本。 + +公共测试用例通过两个宿主的 Vitest 配置各运行一次,不新增第三套测试依赖树。测试夹具使用可注入时间、虚拟 ADB、合成图片和流;核心夹具不得访问用户真实设备。 + +## 8. 验收矩阵 + +| 层级 | 必测路径 | 通过条件 | +|---|---|---| +| 契约 | 正常 observe/act;多设备;跨 owner;旧 observation;旋转/尺寸变化;重复动作/预算;取消排队动作 | 期望动作精确执行一次,非法请求零 dispatch,错误分类正确 | +| 执行失败 | 发送前失败;发送后超时/断连;截图失败;Unicode 部分失败;重连 | 不确定结果不重放;旧观察失效;重新观察后才可继续 | +| WB 增强视觉 | 点击目标改变、前台应用改变、画面未稳定、像素采样失败 | 保留 `screen_changed` 与 settled 语义,不能静默关闭检查 | +| 会话/资源 | 旧任务晚到清理、重复 close、lease 到期、进程退出、socket 中断、两端不同设备并行 | 精确释放本 owner;无孤儿子进程/forward;另一端继续工作 | +| Viewer | 可见真首帧、只有 WS 没画面、解码失败、30 秒超时、关闭/隐藏、换任务 | firstDisplay 由真实显示确认;终止失败不可绕过;生命周期符合第 4 节 | +| 成品 | 空目录安装、只读发现、无源码/父目录依赖、缓存后离线启动、manifest/checksum | 成品完整;离线只指依赖已缓存,不宣称首次离线安装 | +| WorkBuddy 宿主 | 新安装、0.3.1 升级、5.5.6+ live、旧版 restart、Hook 缺失/恢复、停止/续跑 | Skill/MCP/Hook 各自可见且语义正确;基础工具可用不冒充自动续跑可用 | +| Codex 宿主 | 已安装 Skill、CLI 进程退出、请求中断、跨 thread、新任务、图片文件呈现 | 真图进入当前模型上下文;任务隔离;正常短请求不误取消任务 | +| 用户任务 | 测试设备打开设置只读查看、主页无害滑动并恢复、测试输入框中英文输入后清空 | 用户看到过程;最终图验证目标;无发送/购买/发布等额外副作用 | +| 发布 | 安装下载、包哈希、真实运行、回滚、另一端配置未变 | commit、包、宿主版本、设备和证据一一对应 | + +设备测试先使用专用模拟器;真机阶段使用明确授权的测试设备。**至少两台物理设备的冲突/并行、30 分钟视频稳定性和实际宿主中的动态画面**仍按已有 Viewer 门禁验收;历史合成流结果不充当本版本真机证据。既有动态画面目标为至少 24fps、端到端 P95 延迟不高于 500ms,测量必须包含源事件到实际显示,不拿 WS 到达间隔替代。 + +macOS 是本轮用户控制体验的交付平台。WorkBuddy 的 Linux/Windows 打包启动 smoke 继续保留;其通过不等于完整桌面控制体验已获支持。国内/海外产品配置路径和版本分别记录;未实测的组合明确标为未验收。 + +### 实施后的验证命令 + +以下从仓库根目录执行;本方案没有实际运行这些产品测试。 + +```bash +rtk proxy pnpm --dir plugins/opengui check +rtk proxy pnpm --dir plugins/opengui package +rtk proxy pnpm --dir plugins/opengui test:viewer +rtk proxy npm --prefix workbuddy-plugin run check +rtk proxy npm --prefix workbuddy-plugin run pack:release +rtk proxy npm --prefix workbuddy-plugin run smoke:packed +rtk proxy npm --prefix workbuddy-plugin run test:viewer +rtk proxy npm --prefix workbuddy-plugin run test:browser +rtk proxy npm --prefix workbuddy-plugin run test:native +rtk proxy git diff --check +``` + +`test:native` 在 macOS 执行。依赖安装使用各自 lockfile 和禁用 lifecycle 的既有 CI 流程;不要为了跑打包 smoke 接管用户默认 ADB server。WorkBuddy 已有测试专属 ADB 端口/空发现适配器,必须继续使用。 + +真实宿主结果另外记入验收记录:来源 commit、宿主版本、插件版本、公共 digest、用例、开始/结束时间、结果、证据路径。截图/录屏仅用于已授权测试,公开记录不得包含私人屏幕、设备序列号、token URL 或任务文本。 + +## 9. 发布、升级与回滚 + +1. 各 PR 经双方检查后合并;公共变化的候选成品必须来自同一提交,并验证两份 manifest 的公共 digest 相同。 +2. 版本分别递增、沿用 `opengui-codex-v*` / `opengui-workbuddy-v*` tag 体系。正式候选发包时以 main 的最新版本决定下一个 minor,不沿用本文基线版本硬编码发布,禁止覆盖旧 tag/附件。 +3. 先预发布。GitHub 发布后重新下载资产并核对 checksum,再从下载包安装验收;本地 dist 成功不能代替 GitHub 资产成功。 +4. 稳定发布须两端各自门禁齐全;任一宿主缺真机/首帧/停止/回滚证据时,该宿主保持 prerelease。某一端已经通过不要求另一端跟着升级。 +5. 安装器保留原配置备份和独立版本目录。升级前检查该实例的活动任务;有活动任务则停止切换,保留运行版本。依赖下载/校验失败也保留原配置,不自动放宽校验或无上限重试。 +6. 回滚先结束目标宿主活动任务,再恢复它自己的上一版本与配置备份,重连该宿主运行实例;不复用新版本在途 session/observation,不操作另一端配置或 DSH。 +7. 核心状态继续为进程内态,不引入数据库迁移。回滚无需数据转换;正在执行的手机动作不可事务回滚,因此不能把“回滚插件”写成“撤销手机操作”。 + +## 10. 成本、风险与取舍 + +**最小方案**是只同步修订两端 Skill、保留两份代码,再加一组行为对照测试,约 1–2 天。它能修正文案和暴露差异,但以后动作、视频和清理问题仍需修两遍。本方案先从三个完全一致文件开始,逐步减少这个维护风险。 + +更重的方案是一个跨宿主 daemon + 一个任务 API,能进一步统一设备仲裁和任务状态,但会新增共享安装、全局升级、授权隔离、单点故障和旧版本协议协商。本轮不选择它;用户不必承担这些部署成本。 + +**最脆弱的假设**:两个宿主的差异主要能通过现有生命周期与图片交付边界隔离。如果必须在公共内核到处判断宿主名字,这个假设就不成立。应保留该部分宿主实现,仅提取已有共同机制;不要为了提高共用比例牺牲已验证行为。第 1 个 PR 的独立 Viewer 提取即使后续停止,仍有维护收益。 + +| 风险 | 处理与停止条件 | +|---|---| +| WB 打包改变动态文件定位或 native 依赖 | 保持全部原 JS 入口,外置 native 依赖,成品 smoke 必须包含 Hook/broker/installer,不只 `--help` | +| 公共 Bug 同时影响两端 | 同一契约双方运行;分别固定成品版本;先候选再发布,允许独立回滚 | +| 为了代码统一削弱 WB 保护 | 增强视觉与完成证据专项测试是阻断门禁;不通过则不迁移该模块 | +| Codex 新增截图/比较延迟 | 本轮不添加其缺少的像素采样;记录 observe/act P50/P95,相同夹具下 P95 超过基线 20% 时阻止发布并定位 | +| 依赖网络不可达 | 下载失败终止升级、保留旧版;已缓存启动单独验证,禁止把 offline-cache 说成完全离线 | +| 多设备负载扩大 | 保留当前最多 4 设备、并发媒体限制与背压;不因抽公共内核宣称支持更多设备 | +| 跨宿主同机争用 | 明确不支持,不声称进程内锁是全局保护;各控不同设备作为交付边界 | + +### 新增维护面清单 + +| 类别 | 增量 | 维护责任与回滚成本 | +|---|---|---| +| 用户命令、env、账号、API Key、配置页面 | +0 | 用户沿用原入口;没有新增凭据要求 | +| 常驻服务、端口、数据库 | +0 | 保留现有两端 daemon/broker,不引入第三个服务 | +| 内部源码组件 | +1:device-runtime | 插件维护者负责;随消费者 PR 审查,回滚对应包即可 | +| 构建开发依赖 | WB +1:tsdown,版本与 Codex 对齐 | 构建维护者负责 native/入口检查;不进入用户运行依赖 | +| 成品元数据 | 每包 +1:runtime-manifest.json | 构建生成、安装诊断读取;无用户配置和运行状态迁移 | +| 公共内部契约 | +1:contracts.ts | 配对适配器测试约束;不作为第三方可独立升级的公共 SDK | + +除现有 Node、ADB、scrcpy、GitHub 分发与宿主工具链外不依赖新服务。本轮已核对现有源码与 GitHub main,可用性验证不涵盖未来发布时的下载端点、两端实际宿主和 USB 设备;这些列为实施/发布的明确门禁,不能表述为已通过。 + +## 11. 给后续宿主留下的边界 + +DSH、豆包工作、千问工作将来应复用公共执行契约,分别实现工具发现、真实图片交付、可信任务身份、取消/结束信号、用户授权和安装更新适配。当前没有证据证明所有宿主都具备这些能力,因此不宣传“装同一个 Skill 就全部支持”。 + +本轮只需保证内核不引用 Codex/WorkBuddy 私有配置或模型 API;不提前创建空适配器、不增加宿主注册中心。未来接入以能力验收为依据,避免把今天两端的特殊 Hook 机制变成所有产品的强制要求。 + +实施入口:按 PR 1 → PR 2 → PR 3 → PR 4 顺序推进。每一步的评审重点分别是成品独立性、执行语义、生命周期隔离、实际安装与使用证据;以完成上述交付目标为终点,不以“代码已移动”作为收敛完成。 diff --git a/docs/plans/simple-plugin-install.md b/docs/plans/simple-plugin-install.md index 72034f7..687939e 100644 --- a/docs/plans/simple-plugin-install.md +++ b/docs/plans/simple-plugin-install.md @@ -23,3 +23,8 @@ - `workbuddy-plugin`: `npm run pack:release`、`npm run smoke:packed`、使用官方 Node 发行包执行 `node scripts/test-release-installer.mjs`(该测试需要发行包内的 npm)。 - 安装 Skill:Skill Creator 的 `quick_validate.py skills/opengui-plugin-install`。 - macOS 人工安装验收:使用临时 HOME / CODEX_HOME 和真实 Codex CLI 验证首装、重复安装;WorkBuddy 使用空 HOME 和不含 Node 的 PATH 验证私有运行时下载及三个配置入口。宿主桌面和真实手机验收仍沿用原发布清单。 + + +2026-09-17 implementation update: Codex and WorkBuddy share build-time source in +`packages/device-runtime`; runtime instances, configuration, versions, device +services and rollback remain independent. diff --git a/packages/device-runtime/LICENSE b/packages/device-runtime/LICENSE new file mode 100644 index 0000000..c1f7a78 --- /dev/null +++ b/packages/device-runtime/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/device-runtime/README.md b/packages/device-runtime/README.md new file mode 100644 index 0000000..4931315 --- /dev/null +++ b/packages/device-runtime/README.md @@ -0,0 +1,12 @@ +# Device runtime + +Shared build-time source for the standalone Codex and WorkBuddy packages. This is +not a separately installed service or npm package. Host runtimes retain their own +state, processes, configuration, versions and rollback boundaries. + +Source: extracted from the two adapters at 737c6255c893a2c6a5779866f60f2bfee6efca3c. +Preserve LICENSE and VIDEO-NOTICE.md in consuming distributions. + +The core may import Node built-ins and its own source only. Host resources, +codecs, model decisions and lifecycle events belong to the adapters. Device locks +are per instance, not a machine-wide guarantee across hosts. diff --git a/packages/device-runtime/VIDEO-NOTICE.md b/packages/device-runtime/VIDEO-NOTICE.md new file mode 100644 index 0000000..68b6ae8 --- /dev/null +++ b/packages/device-runtime/VIDEO-NOTICE.md @@ -0,0 +1,34 @@ +# Video implementation provenance + +The scrcpy stream parser/transport and Annex-B decoding were adapted from +`deepseek-harness-plugin/src/scrcpy-stream.ts`, its WebSocket transport and its +browser decoder in this repository. That subtree supplies the MIT license below. +Each host builds and runs its own copy; there is no runtime dependency on DSH. + +scrcpy 4.1 is by Genymobile and contributors under Apache-2.0. The installer +retrieves the pinned official distribution and verifies its checksum. Its license +is retained in that distribution: https://github.com/Genymobile/scrcpy/blob/v4.1/LICENSE. + +## Upstream subtree license + +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/device-runtime/build.mjs b/packages/device-runtime/build.mjs new file mode 100644 index 0000000..b6fe63c --- /dev/null +++ b/packages/device-runtime/build.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cp, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const core = dirname(fileURLToPath(import.meta.url)) +const repository = resolve(core, '../..') +const inside = (root, path) => path === root || path.startsWith(root + sep) + +async function files(root) { + const result = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = resolve(root, entry.name) + assert(!entry.isSymbolicLink(), `Symlink in build input: ${entry.name}`) + if (entry.isDirectory()) result.push(...await files(path)) + else result.push(path) + } + return result.sort() +} + +async function digest() { + const hash = createHash('sha256') + for (const path of await files(resolve(core, 'src'))) { + const data = await readFile(path) + hash.update(relative(core, path).split(sep).join('/')).update('\0') + hash.update(String(data.length)).update('\0').update(data) + } + return hash.digest('hex') +} + +async function sourceCommit() { + try { + const metadata = JSON.parse(await readFile(resolve(core, 'build-source.json'), 'utf8')) + assert.match(metadata.sourceCommit, /^[a-f0-9]{40}$/) + return metadata.sourceCommit + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }).trim() +} + +export async function validateSourceBoundary(hostRoot) { + const host = await realpath(resolve(hostRoot, 'src')) + const shared = await realpath(resolve(core, 'src')) + for (const root of [host, shared]) for (const path of await files(root)) { + if (!path.endsWith('.ts')) continue + const source = await readFile(path, 'utf8') + // Validate static imports, re-exports and literal dynamic imports/requires. + const imports = source.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"]([^'"]+)['"]/g) + for (const [, specifier] of imports) { + assert(!isAbsolute(specifier), `Absolute source import: ${path}`) + if (!specifier.startsWith('.')) { + if (root === shared) assert(specifier.startsWith('node:'), `Host dependency in core: ${specifier}`) + continue + } + const target = await realpath(resolve(dirname(path), specifier)) + assert(inside(shared, target) || (root === host && inside(host, target)), `Import escapes allowed source: ${path}`) + } + } +} + +export async function validateManifest(hostRoot) { + const manifest = JSON.parse(await readFile(resolve(hostRoot, 'lib/runtime-manifest.json'), 'utf8')) + const pkg = JSON.parse(await readFile(resolve(hostRoot, 'package.json'), 'utf8')) + assert.equal(manifest.packageVersion, pkg.version) + assert.equal(manifest.coreDigest, await digest()) + assert.equal(manifest.sourceCommit, await sourceCommit()) + assert.equal(manifest.contractVersion, 1) + assert.equal(manifest.host, pkg.name === 'opengui-codex' ? 'codex' : 'workbuddy') + // Bundles must not retain imports of source files or paths outside lib. + const lib = await realpath(resolve(hostRoot, 'lib')) + for (const path of await files(lib)) if (path.endsWith('.js') || path.endsWith('.mjs')) { + const text = await readFile(path, 'utf8') + for (const [, specifier] of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g)) { + assert(!isAbsolute(specifier), `Absolute bundle import: ${path}`) + if (specifier.startsWith('.')) { + assert(!specifier.endsWith('.ts'), `Uncompiled source import: ${path}`) + assert(inside(lib, await realpath(resolve(dirname(path), specifier))), `Bundle import escapes lib: ${path}`) + } + } + } +} + +async function manifest(host, root) { + assert(['codex', 'workbuddy'].includes(host)) + const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) + await writeFile(resolve(root, 'lib/runtime-manifest.json'), JSON.stringify({ + host, packageVersion: pkg.version, sourceCommit: await sourceCommit(), + contractVersion: 1, coreDigest: await digest(), + }, null, 2) + '\n') +} + +/** Export only one adapter and the core, preserving their relative layout. */ +async function stage(host, destination) { + const hostPath = host === 'codex' ? 'plugins/opengui' : host === 'workbuddy' ? 'workbuddy-plugin' : undefined + assert(hostPath, 'Expected codex or workbuddy') + const target = resolve(destination) + assert(!inside(repository, target) && !inside(target, repository), 'Use an external isolated build directory') + await mkdir(target) // Refuse to merge into an existing tree. + const filter = source => !/(^|[/\\])(node_modules|lib|dist|\.artifacts|coverage|artifacts)([/\\]|$)/.test(source) + await cp(resolve(repository, hostPath), resolve(target, hostPath), { recursive: true, filter }) + await cp(core, resolve(target, 'packages/device-runtime'), { recursive: true, filter }) + await writeFile(resolve(target, 'packages/device-runtime/build-source.json'), JSON.stringify({ sourceCommit: await sourceCommit() }) + '\n') +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [command, host, root] = process.argv.slice(2) + assert(root, 'Usage: build.mjs manifest|stage codex|workbuddy directory') + if (command === 'manifest') await manifest(host, resolve(root)) + else if (command === 'stage') await stage(host, root) + else throw new Error('Unknown build command') +} diff --git a/packages/device-runtime/src/contracts.ts b/packages/device-runtime/src/contracts.ts new file mode 100644 index 0000000..e866e8e --- /dev/null +++ b/packages/device-runtime/src/contracts.ts @@ -0,0 +1,9 @@ +/** Transport contracts shared by independently installed host runtimes. */ +export interface VideoDevice { readonly id: string; readonly serial: string } +export interface ScrcpyStreamSink { + sendText(text: string): void + sendBinary(data: Buffer): void + bufferedBytes(): number + close(code?: number, reason?: string): void + onClose(listener: () => void): void +} diff --git a/packages/device-runtime/src/viewer-page.ts b/packages/device-runtime/src/viewer-page.ts new file mode 100644 index 0000000..7867472 --- /dev/null +++ b/packages/device-runtime/src/viewer-page.ts @@ -0,0 +1,41 @@ +/** Read-only H.264 canvas. No model image capture or phone input route exists here. */ +export function viewerPage(): string { + return String.raw`OpenGUI · 实时设备墙 + +

OpenGUI 实时设备墙

准备中

画面仅供观看。停止 AI 任务请使用聊天中的停止入口。

+` +} diff --git a/packages/device-runtime/src/viewer.ts b/packages/device-runtime/src/viewer.ts new file mode 100644 index 0000000..2971f29 --- /dev/null +++ b/packages/device-runtime/src/viewer.ts @@ -0,0 +1,230 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import type { ScrcpyStreamSink, VideoDevice } from './contracts.ts' +import { acceptStreamWebSocket } from './websocket.ts' +import { viewerPage } from './viewer-page.ts' + +export interface ViewerDevice extends VideoDevice { readonly name: string } +export interface ViewerStreams { + prepare(signal: AbortSignal): Promise + subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void> + dispose(): Promise +} +type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed' +interface Connection { + id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string + issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void +} +interface Viewer { + id: string; token: string; owner: string; devices: readonly ViewerDevice[] + phase: Phase; deadline: number; established: boolean; ended: boolean + firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number + preparation?: Promise; readyDevices: Set +} + +/** Watching grants never contain a control credential or renew a control lease. */ +export class ViewerServer { + private server: Server | undefined + private starting: Promise | undefined + private origin = '' + private readonly viewers = new Map() + private readonly sweep: ReturnType + constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) { + this.sweep = setInterval(() => { + for (const viewer of this.viewers.values()) { + this.update(viewer) + for (const connection of viewer.connections.values()) { + if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive') + } + if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id) + } + }, 1000) + this.sweep.unref() + } + + get active(): boolean { + return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000)) + } + + async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) { + if (!owner) throw new Error('host_task_required') + if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices') + let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error))) + if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen') + if (!viewer) { + if (this.viewers.size >= 100) throw new Error('viewer_capacity') + viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() } + this.viewers.set(viewer.id, viewer) + const current = viewer + viewer.preparation = (async () => { try { + await this.streams.prepare(signal) + await this.start() + current.deadline = this.now() + 30_000 + current.phase = 'waiting_for_frame' + } catch (error) { + current.phase = 'error' + current.error = `dependency_prepare_failed: ${String(error)}` + } })() + } + await viewer.preparation + if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected' + // A repeated tool call cannot reset a failed first-display deadline. + return this.snapshot(viewer) + } + + find(owner: string, devices: readonly ViewerDevice[], id?: string): string { + const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices)) + if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first') + this.update(viewer) + if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required') + return viewer.id + } + + assertReady(id: string): void { + const viewer = this.require(id) + this.update(viewer) + if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action') + } + + async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) { + const viewer = this.require(id, owner) + const end = this.now() + Math.min(30_000, Math.max(0, waitMs)) + while (true) { + signal?.throwIfAborted() + this.update(viewer) + if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break + await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now()))) + } + return this.snapshot(viewer) + } + + closeViewer(id: string, owner: string) { + const viewer = this.require(id, owner) + viewer.phase = 'closed' + for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed') + for (const page of viewer.pages) page.close(1000, 'viewer_closed') + return this.snapshot(viewer) + } + endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true } + endTask(id: string): void { this.require(id).ended = true } + url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` } + async dispose(): Promise { + clearInterval(this.sweep) + for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner) + await this.streams.dispose() + this.server?.closeAllConnections() + await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve()) + } + + private same(v: Viewer, devices: readonly ViewerDevice[]): boolean { + return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial)) + } + private require(id: string, owner?: string): Viewer { + const v = this.viewers.get(id) + if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer') + return v + } + private update(v: Viewer): void { + if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') { + v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task' + } + } + private snapshot(v: Viewer) { + this.update(v) + return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established, + ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }), + taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing', + ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}), + nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait', + devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id), + state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) } + } + private local(req: IncomingMessage, websocket = false): boolean { + return req.headers.host === new URL(this.origin).host + && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin) + && !['cross-site'].includes(String(req.headers['sec-fetch-site'])) + } + private start(): Promise { + this.starting ??= new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const handle = async (): Promise => { + if (!this.local(req)) { res.writeHead(403).end(); return } + const url = new URL(req.url ?? '/', this.origin) + const [token, route = ''] = url.pathname.slice(1).split('/') + const v = [...this.viewers.values()].find(v => v.token === token) + if (!v) { res.writeHead(404).end(); return } + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'") + if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return } + if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return } + if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) { + let body = '' + for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } } + const input = JSON.parse(body) as Record + const c = v.connections.get(String(input.connectionId)) + this.update(v) + if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return } + c.painted = this.now() + if (!v.error) { + v.readyDevices.add(c.deviceId) + if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) { + v.firstFrameMs ??= this.now() - (v.deadline - 30_000) + v.established = true; v.phase = 'ready' + } + } + c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return + } + res.writeHead(404).end() + } + void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() }) + }) + this.server = server + server.on('upgrade', (req, socket, head) => { + if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } + const url = new URL(req.url ?? '/', this.origin) + const [token, route] = url.pathname.slice(1).split('/') + const v = [...this.viewers.values()].find(v => v.token === token) + if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) { + const page = acceptStreamWebSocket(req, socket, head) + v.pages.add(page) + page.onClose(() => v.pages.delete(page)) + return + } + const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId')) + if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } + const sink = acceptStreamWebSocket(req, socket, head) + const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false } + // The grace timestamp is not a rendered-frame receipt. + v.connections.set(c.id, c) + sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) + let closed = false + sink.onClose(() => { + closed = true; c.release?.(); v.connections.delete(c.id) + if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected' + }) + const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => { + const event = JSON.parse(text) as { type: string; message?: string } + if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return } + if (event.type === 'session' || event.type === 'reset') { + c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() + sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) + } + sink.sendText(text) + } } + void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => { + sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed') + }) + }) + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return } + this.origin = `http://127.0.0.1:${address.port}`; resolve() + }) + }) + return this.starting + } +} diff --git a/packages/device-runtime/src/websocket.ts b/packages/device-runtime/src/websocket.ts new file mode 100644 index 0000000..4ad8cf9 --- /dev/null +++ b/packages/device-runtime/src/websocket.ts @@ -0,0 +1,105 @@ +// Ported from the repository video transport; see VIDEO-NOTICE.md. +import { createHash } from 'node:crypto' +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import type { ScrcpyStreamSink } from './contracts.ts' + +const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +function frame(opcode: number, payload: Buffer): Buffer { + const size = payload.length + const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10) + header[0] = 0x80 | opcode + if (size < 126) header[1] = size + else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) } + else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) } + return Buffer.concat([header, payload]) +} + +/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */ +export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink { + const key = request.headers['sec-websocket-key'] + if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') { + socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') + throw new Error('invalid_websocket_upgrade') + } + const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64') + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '\r\n', + ].join('\r\n')) + let closed = false + let closeNotified = false + const closeListeners = new Set<() => void>() + let input = Buffer.alloc(0) + const send = (opcode: number, payload: Buffer): void => { + if (!closed && !socket.destroyed) socket.write(frame(opcode, payload)) + } + const consume = (chunk: Buffer): void => { + if (input.length + chunk.length > 8192) { socket.destroy(); return } + input = Buffer.concat([input, chunk]) + while (input.length >= 2) { + const masked = (input[1]! & 0x80) !== 0 + if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return } + let length = input[1]! & 0x7f + let offset = 2 + if (length === 126) { + if (input.length < 4) return + length = input.readUInt16BE(2); offset = 4 + } else if (length === 127) { + if (input.length < 10) return + const large = input.readBigUInt64BE(2) + if (large > 125n) { socket.destroy(); return } + length = Number(large); offset = 10 + } + if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return } + const maskBytes = masked ? 4 : 0 + if (input.length < offset + maskBytes + length) return + const opcode = input[0]! & 0x0f + let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length)) + if (masked) { + const mask = input.subarray(offset, offset + 4) + payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!)) + } + input = input.subarray(offset + maskBytes + length) + if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return } + if (opcode === 0x9) send(0xA, payload) + } + } + socket.on('data', chunk => consume(Buffer.from(chunk))) + const notifyClosed = (): void => { + if (closeNotified) return + closeNotified = true + closed = true + for (const listener of closeListeners) listener() + closeListeners.clear() + } + socket.once('end', () => { notifyClosed(); socket.destroy() }) + socket.once('close', notifyClosed) + socket.once('error', notifyClosed) + if (head.length > 0) consume(head) + return { + sendText: text => send(0x1, Buffer.from(text, 'utf8')), + sendBinary: data => send(0x2, data), + bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0), + close(code = 1000, reason = '') { + if (closed) return + closed = true + const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123) + const payload = Buffer.allocUnsafe(2 + reasonBuffer.length) + payload.writeUInt16BE(code, 0) + reasonBuffer.copy(payload, 2) + socket.end(frame(0x8, payload)) + notifyClosed() + const timer = setTimeout(() => socket.destroy(), 250) + timer.unref() + }, + onClose(listener) { + if (closed) listener() + else closeListeners.add(listener) + }, + } +} diff --git a/plugins/opengui/SOURCE.md b/plugins/opengui/SOURCE.md index 5aeb0a1..454b44a 100644 --- a/plugins/opengui/SOURCE.md +++ b/plugins/opengui/SOURCE.md @@ -1,6 +1,7 @@ # Source and production boundary -This standalone Codex package is maintained only in `Core-Mate/OpenGUI/plugins/opengui`. +This standalone Codex package is maintained in `Core-Mate/OpenGUI/plugins/opengui`, with shared build-time +source in `packages/device-runtime`. Its initial phone-control implementation was copied once from this repository's `deepseek-harness-plugin` at commit `674e35893219f47b03508ba58b84a13e57f31c57`. The original MIT license is retained in `LICENSE`. @@ -13,7 +14,8 @@ The source files were not moved or edited. There is no ongoing synchronization. The maintainer explicitly required production DSH isolation. This standalone package is an exception to the older dual-host source-location note. Do not edit that note, any DSH source/configuration/package/workflow, or the root marketplace -as part of Codex work. Never import a parent checkout or install DSH dependencies. +as part of Codex work. Only `packages/device-runtime/src` may be imported at build time outside the +adapter. The released bundle must never import a parent checkout. Never install DSH dependencies. Do not run DSH package scripts or replace/reload the production runtime. The Codex package owns its own version, lockfile, artifacts, state and release diff --git a/plugins/opengui/package.json b/plugins/opengui/package.json index 4814162..dfcebe5 100644 --- a/plugins/opengui/package.json +++ b/plugins/opengui/package.json @@ -5,18 +5,22 @@ "description": "Standalone local Android control for Codex on macOS", "type": "module", "license": "MIT", - "engines": { "node": ">=22.19.0" }, + "engines": { + "node": ">=22.19.0" + }, "packageManager": "pnpm@11.19.0", "scripts": { - "test:viewer": "tsc -p tsconfig.browser.json && node scripts/test-viewer-browser.mjs .artifacts/browser/src/viewer.js", - "build": "tsc --noEmit && tsdown", + "test:viewer": "tsdown --config tsdown.viewer.config.ts && node scripts/test-viewer-browser.mjs .artifacts/browser/viewer.js", + "build": "tsc --noEmit && tsdown && node ../../packages/device-runtime/build.mjs manifest codex .", "test": "vitest run", "validate": "node scripts/validate.mjs", "check": "pnpm test && pnpm build && pnpm validate", "stage": "node scripts/stage.mjs", "package": "node scripts/package.mjs" }, - "dependencies": { "tar": "7.5.22" }, + "dependencies": { + "tar": "7.5.22" + }, "devDependencies": { "@types/node": "24.10.1", "tsdown": "0.22.2", diff --git a/plugins/opengui/scripts/stage.mjs b/plugins/opengui/scripts/stage.mjs index f4fd0e5..9a4a440 100644 --- a/plugins/opengui/scripts/stage.mjs +++ b/plugins/opengui/scripts/stage.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') export const STAGED_PATHS = [ - '.codex-plugin', 'skills', 'scripts/opengui', 'assets', 'lib/cli.js', + '.codex-plugin', 'skills', 'scripts/opengui', 'assets', 'lib/cli.js', 'lib/runtime-manifest.json', 'LICENSE', 'VIDEO-NOTICE.md', 'SOURCE.md', 'README.md', 'README.zh-CN.md', 'docs', ] diff --git a/plugins/opengui/scripts/validate.mjs b/plugins/opengui/scripts/validate.mjs index 2703aed..c33c5bb 100644 --- a/plugins/opengui/scripts/validate.mjs +++ b/plugins/opengui/scripts/validate.mjs @@ -1,3 +1,4 @@ +import { validateSourceBoundary, validateManifest } from '../../../packages/device-runtime/build.mjs' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import { lstat, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' @@ -42,7 +43,7 @@ async function walk(directory) { } for (const path of await walk(join(root, 'src'))) { const text = await readFile(path, 'utf8') - assert.ok(!/from ['"][^'"]*(deepseek-harness|@deepseek|\.\.\/\.\.\/\.\.)/.test(text), 'Cross-package runtime import: ' + path) + assert.ok(!/from ['"][^'"]*(deepseek-harness|@deepseek)/.test(text), 'Cross-package runtime import: ' + path) assert.ok(!/DSH_HOME|coremate-mobile-scrcpy|dev-auto-reload/.test(text), 'Production coupling: ' + path) // state.ts may name .dsh only to reject it as an unsafe override. if (path !== join(root, 'src/state.ts')) assert.ok(!/\.dsh\b/.test(text), 'Legacy runtime path: ' + path) @@ -58,3 +59,6 @@ try { execFileSync('/bin/sh', ['-n', join(destination, 'scripts/opengui')]) console.log('Standalone manifest, dependencies, runtime, launcher, ADB checksum and staged upload verified.') } finally { await rm(temp, { recursive: true, force: true }) } + +await validateSourceBoundary(root) +await validateManifest(root) diff --git a/plugins/opengui/src/viewer-page.ts b/plugins/opengui/src/viewer-page.ts index 7867472..e581080 100644 --- a/plugins/opengui/src/viewer-page.ts +++ b/plugins/opengui/src/viewer-page.ts @@ -1,41 +1 @@ -/** Read-only H.264 canvas. No model image capture or phone input route exists here. */ -export function viewerPage(): string { - return String.raw`OpenGUI · 实时设备墙 - -

OpenGUI 实时设备墙

准备中

画面仅供观看。停止 AI 任务请使用聊天中的停止入口。

-` -} +export * from '../../../packages/device-runtime/src/viewer-page.ts' diff --git a/plugins/opengui/src/viewer.ts b/plugins/opengui/src/viewer.ts index 5e764db..304007f 100644 --- a/plugins/opengui/src/viewer.ts +++ b/plugins/opengui/src/viewer.ts @@ -1,230 +1 @@ -import { randomBytes, randomUUID } from 'node:crypto' -import { createServer, type IncomingMessage, type Server } from 'node:http' -import type { ScrcpyStreamSink, VideoDevice } from './scrcpy-stream.ts' -import { acceptStreamWebSocket } from './websocket.ts' -import { viewerPage } from './viewer-page.ts' - -export interface ViewerDevice extends VideoDevice { readonly name: string } -export interface ViewerStreams { - prepare(signal: AbortSignal): Promise - subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void> - dispose(): Promise -} -type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed' -interface Connection { - id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string - issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void -} -interface Viewer { - id: string; token: string; owner: string; devices: readonly ViewerDevice[] - phase: Phase; deadline: number; established: boolean; ended: boolean - firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number - preparation?: Promise; readyDevices: Set -} - -/** Watching grants never contain a control credential or renew a control lease. */ -export class ViewerServer { - private server: Server | undefined - private starting: Promise | undefined - private origin = '' - private readonly viewers = new Map() - private readonly sweep: ReturnType - constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) { - this.sweep = setInterval(() => { - for (const viewer of this.viewers.values()) { - this.update(viewer) - for (const connection of viewer.connections.values()) { - if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive') - } - if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id) - } - }, 1000) - this.sweep.unref() - } - - get active(): boolean { - return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000)) - } - - async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) { - if (!owner) throw new Error('host_task_required') - if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices') - let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error))) - if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen') - if (!viewer) { - if (this.viewers.size >= 100) throw new Error('viewer_capacity') - viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() } - this.viewers.set(viewer.id, viewer) - const current = viewer - viewer.preparation = (async () => { try { - await this.streams.prepare(signal) - await this.start() - current.deadline = this.now() + 30_000 - current.phase = 'waiting_for_frame' - } catch (error) { - current.phase = 'error' - current.error = `dependency_prepare_failed: ${String(error)}` - } })() - } - await viewer.preparation - if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected' - // A repeated tool call cannot reset a failed first-display deadline. - return this.snapshot(viewer) - } - - find(owner: string, devices: readonly ViewerDevice[], id?: string): string { - const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices)) - if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first') - this.update(viewer) - if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required') - return viewer.id - } - - assertReady(id: string): void { - const viewer = this.require(id) - this.update(viewer) - if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action') - } - - async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) { - const viewer = this.require(id, owner) - const end = this.now() + Math.min(30_000, Math.max(0, waitMs)) - while (true) { - signal?.throwIfAborted() - this.update(viewer) - if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break - await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now()))) - } - return this.snapshot(viewer) - } - - closeViewer(id: string, owner: string) { - const viewer = this.require(id, owner) - viewer.phase = 'closed' - for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed') - for (const page of viewer.pages) page.close(1000, 'viewer_closed') - return this.snapshot(viewer) - } - endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true } - endTask(id: string): void { this.require(id).ended = true } - url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` } - async dispose(): Promise { - clearInterval(this.sweep) - for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner) - await this.streams.dispose() - this.server?.closeAllConnections() - await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve()) - } - - private same(v: Viewer, devices: readonly ViewerDevice[]): boolean { - return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial)) - } - private require(id: string, owner?: string): Viewer { - const v = this.viewers.get(id) - if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer') - return v - } - private update(v: Viewer): void { - if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') { - v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task' - } - } - private snapshot(v: Viewer) { - this.update(v) - return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established, - ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }), - taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing', - ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}), - nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait', - devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id), - state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) } - } - private local(req: IncomingMessage, websocket = false): boolean { - return req.headers.host === new URL(this.origin).host - && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin) - && !['cross-site'].includes(String(req.headers['sec-fetch-site'])) - } - private start(): Promise { - this.starting ??= new Promise((resolve, reject) => { - const server = createServer((req, res) => { - const handle = async (): Promise => { - if (!this.local(req)) { res.writeHead(403).end(); return } - const url = new URL(req.url ?? '/', this.origin) - const [token, route = ''] = url.pathname.slice(1).split('/') - const v = [...this.viewers.values()].find(v => v.token === token) - if (!v) { res.writeHead(404).end(); return } - res.setHeader('Cache-Control', 'no-store') - res.setHeader('Referrer-Policy', 'no-referrer') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'") - if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return } - if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return } - if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) { - let body = '' - for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } } - const input = JSON.parse(body) as Record - const c = v.connections.get(String(input.connectionId)) - this.update(v) - if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return } - c.painted = this.now() - if (!v.error) { - v.readyDevices.add(c.deviceId) - if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) { - v.firstFrameMs ??= this.now() - (v.deadline - 30_000) - v.established = true; v.phase = 'ready' - } - } - c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() - res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return - } - res.writeHead(404).end() - } - void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() }) - }) - this.server = server - server.on('upgrade', (req, socket, head) => { - if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } - const url = new URL(req.url ?? '/', this.origin) - const [token, route] = url.pathname.slice(1).split('/') - const v = [...this.viewers.values()].find(v => v.token === token) - if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) { - const page = acceptStreamWebSocket(req, socket, head) - v.pages.add(page) - page.onClose(() => v.pages.delete(page)) - return - } - const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId')) - if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } - const sink = acceptStreamWebSocket(req, socket, head) - const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false } - // The grace timestamp is not a rendered-frame receipt. - v.connections.set(c.id, c) - sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) - let closed = false - sink.onClose(() => { - closed = true; c.release?.(); v.connections.delete(c.id) - if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected' - }) - const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => { - const event = JSON.parse(text) as { type: string; message?: string } - if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return } - if (event.type === 'session' || event.type === 'reset') { - c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() - sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) - } - sink.sendText(text) - } } - void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => { - sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed') - }) - }) - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return } - this.origin = `http://127.0.0.1:${address.port}`; resolve() - }) - }) - return this.starting - } -} +export * from '../../../packages/device-runtime/src/viewer.ts' diff --git a/plugins/opengui/src/websocket.ts b/plugins/opengui/src/websocket.ts index 1e0afd6..a7dfbe1 100644 --- a/plugins/opengui/src/websocket.ts +++ b/plugins/opengui/src/websocket.ts @@ -1,105 +1 @@ -// Ported from the repository video transport; see VIDEO-NOTICE.md. -import { createHash } from 'node:crypto' -import type { IncomingMessage } from 'node:http' -import type { Duplex } from 'node:stream' -import type { ScrcpyStreamSink } from './scrcpy-stream.ts' - -const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' - -function frame(opcode: number, payload: Buffer): Buffer { - const size = payload.length - const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10) - header[0] = 0x80 | opcode - if (size < 126) header[1] = size - else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) } - else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) } - return Buffer.concat([header, payload]) -} - -/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */ -export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink { - const key = request.headers['sec-websocket-key'] - if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') { - socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') - throw new Error('invalid_websocket_upgrade') - } - const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64') - socket.write([ - 'HTTP/1.1 101 Switching Protocols', - 'Upgrade: websocket', - 'Connection: Upgrade', - `Sec-WebSocket-Accept: ${accept}`, - '\r\n', - ].join('\r\n')) - let closed = false - let closeNotified = false - const closeListeners = new Set<() => void>() - let input = Buffer.alloc(0) - const send = (opcode: number, payload: Buffer): void => { - if (!closed && !socket.destroyed) socket.write(frame(opcode, payload)) - } - const consume = (chunk: Buffer): void => { - if (input.length + chunk.length > 8192) { socket.destroy(); return } - input = Buffer.concat([input, chunk]) - while (input.length >= 2) { - const masked = (input[1]! & 0x80) !== 0 - if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return } - let length = input[1]! & 0x7f - let offset = 2 - if (length === 126) { - if (input.length < 4) return - length = input.readUInt16BE(2); offset = 4 - } else if (length === 127) { - if (input.length < 10) return - const large = input.readBigUInt64BE(2) - if (large > 125n) { socket.destroy(); return } - length = Number(large); offset = 10 - } - if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return } - const maskBytes = masked ? 4 : 0 - if (input.length < offset + maskBytes + length) return - const opcode = input[0]! & 0x0f - let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length)) - if (masked) { - const mask = input.subarray(offset, offset + 4) - payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!)) - } - input = input.subarray(offset + maskBytes + length) - if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return } - if (opcode === 0x9) send(0xA, payload) - } - } - socket.on('data', chunk => consume(Buffer.from(chunk))) - const notifyClosed = (): void => { - if (closeNotified) return - closeNotified = true - closed = true - for (const listener of closeListeners) listener() - closeListeners.clear() - } - socket.once('end', () => { notifyClosed(); socket.destroy() }) - socket.once('close', notifyClosed) - socket.once('error', notifyClosed) - if (head.length > 0) consume(head) - return { - sendText: text => send(0x1, Buffer.from(text, 'utf8')), - sendBinary: data => send(0x2, data), - bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0), - close(code = 1000, reason = '') { - if (closed) return - closed = true - const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123) - const payload = Buffer.allocUnsafe(2 + reasonBuffer.length) - payload.writeUInt16BE(code, 0) - reasonBuffer.copy(payload, 2) - socket.end(frame(0x8, payload)) - notifyClosed() - const timer = setTimeout(() => socket.destroy(), 250) - timer.unref() - }, - onClose(listener) { - if (closed) listener() - else closeListeners.add(listener) - }, - } -} +export * from '../../../packages/device-runtime/src/websocket.ts' diff --git a/plugins/opengui/tsdown.viewer.config.ts b/plugins/opengui/tsdown.viewer.config.ts new file mode 100644 index 0000000..f001cc7 --- /dev/null +++ b/plugins/opengui/tsdown.viewer.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' +export default defineConfig({ + entry: { viewer: 'src/viewer.ts' }, outDir: '.artifacts/browser', + format: ['esm'], platform: 'node', target: 'node22', fixedExtension: false, + deps: { alwaysBundle: id => !id.startsWith('node:'), onlyBundle: false }, + outputOptions: { codeSplitting: false }, dts: false, clean: true, +}) diff --git a/plugins/opengui/vitest.config.ts b/plugins/opengui/vitest.config.ts index 1a11c5a..e22a06b 100644 --- a/plugins/opengui/vitest.config.ts +++ b/plugins/opengui/vitest.config.ts @@ -1,4 +1,2 @@ import { defineConfig } from 'vitest/config' - -// Browser QA compilation may leave generated tests below .artifacts. -export default defineConfig({ test: { include: ['tests/**/*.spec.ts'] } }) +export default defineConfig({ test: { include: ['tests/**/*.spec.ts', '../../packages/device-runtime/tests/**/*.spec.ts'] } }) diff --git a/workbuddy-plugin/package-lock.json b/workbuddy-plugin/package-lock.json index cea5519..013a892 100644 --- a/workbuddy-plugin/package-lock.json +++ b/workbuddy-plugin/package-lock.json @@ -22,6 +22,7 @@ "@types/node": "22.19.0", "@types/yauzl": "2.10.3", "fflate": "0.8.3", + "tsdown": "0.22.2", "typescript": "5.9.3", "vitest": "3.2.7", "yazl": "3.3.1" @@ -30,6 +31,96 @@ "node": "^22.19.0 || >=24" } }, + "node_modules/@babel/generator": { + "version": "8.0.0-rc.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.6.tgz", + "integrity": "sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.6", + "@babel/types": "^8.0.0-rc.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.6.tgz", + "integrity": "sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.0-rc.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.6.tgz", + "integrity": "sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -40,6 +131,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1054,6 +1156,27 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -1061,6 +1184,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1086,41 +1220,379 @@ "zod-to-json-schema": "^3.25.1" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@quansync/fs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.1.0.tgz", + "integrity": "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "glibc" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -1510,6 +1982,17 @@ "win32" ] }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1535,6 +2018,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", @@ -1716,6 +2206,16 @@ } } }, + "node_modules/ansis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1726,6 +2226,50 @@ "node": ">=12" } }, + "node_modules/ast-kit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz", + "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz", + "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/birpc": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.2.0.tgz", + "integrity": "sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1955,6 +2499,13 @@ "node": ">=6" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1973,6 +2524,27 @@ "node": ">=8" } }, + "node_modules/dts-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz", + "integrity": "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "oxc-resolver": ">=11.0.0" + }, + "peerDependenciesMeta": { + "oxc-resolver": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1993,6 +2565,16 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2346,6 +2928,22 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", + "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2391,6 +2989,13 @@ "node": ">=16.9.0" } }, + "node_modules/hookable": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.2.tgz", + "integrity": "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2427,6 +3032,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/import-without-cache": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.1.tgz", + "integrity": "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2479,6 +3097,19 @@ "dev": true, "license": "MIT" }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2663,6 +3294,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2822,6 +3467,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -2859,6 +3521,94 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rolldown-plugin-dts": { + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.25.2.tgz", + "integrity": "sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "8.0.0-rc.6", + "@babel/helper-validator-identifier": "8.0.0-rc.6", + "@babel/parser": "8.0.0-rc.6", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.5", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@ts-macro/tsc": "^0.3.6", + "@typescript/native-preview": ">=7.0.0-dev.20260325.1", + "rolldown": "^1.0.0", + "typescript": "^5.0.0 || ^6.0.0", + "vue-tsc": "~3.2.0" + }, + "peerDependenciesMeta": { + "@ts-macro/tsc": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -3271,6 +4021,109 @@ "node": ">=0.6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tsdown": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.2.tgz", + "integrity": "sha512-VX9gsyKXsTnBZjnIM4jsHl9aRv+GfgkE/k1hQslilaBfZMlaw3JuGR+6yhiU0QxWBtOCDnTjwOSoXzgB7Rr50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.3.1", + "cac": "^7.0.0", + "defu": "^6.1.7", + "empathic": "^2.0.1", + "hookable": "^6.1.1", + "import-without-cache": "^0.4.0", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "rolldown": "~1.1.0", + "rolldown-plugin-dts": "^0.25.2", + "semver": "^7.8.1", + "tinyexec": "^1.2.4", + "tinyglobby": "^0.2.17", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": "^22.18.0 || >=24.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.22.2", + "@tsdown/exe": "0.22.2", + "@vitejs/devtools": "*", + "publint": "^0.3.8", + "tsx": "*", + "typescript": "^5.0.0 || ^6.0.0", + "unplugin-unused": "^0.5.0", + "unrun": "*" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + } + } + }, + "node_modules/tsdown/node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/tsdown/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3323,6 +4176,20 @@ "node": ">=14.17" } }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/workbuddy-plugin/package.json b/workbuddy-plugin/package.json index c7ce222..54c1266 100644 --- a/workbuddy-plugin/package.json +++ b/workbuddy-plugin/package.json @@ -4,13 +4,29 @@ "description": "Independent OpenGUI Android MCP runtime for WorkBuddy", "type": "module", "license": "SEE LICENSE IN LICENSE", - "repository": { "type": "git", "url": "https://github.com/Core-Mate/OpenGUI.git", "directory": "workbuddy-plugin" }, - "bin": { "opengui-mcp": "lib/mcp.js" }, - "files": ["scripts/install-local.mjs", "lib", "assets", "LICENSE", "NOTICE.md", "VIDEO-NOTICE.md", "README.md"], - "engines": { "node": "^22.19.0 || >=24" }, + "repository": { + "type": "git", + "url": "https://github.com/Core-Mate/OpenGUI.git", + "directory": "workbuddy-plugin" + }, + "bin": { + "opengui-mcp": "lib/mcp.js" + }, + "files": [ + "scripts/install-local.mjs", + "lib", + "assets", + "LICENSE", + "NOTICE.md", + "VIDEO-NOTICE.md", + "README.md" + ], + "engines": { + "node": "^22.19.0 || >=24" + }, "scripts": { "test:viewer": "npm run build && node scripts/test-viewer-browser.mjs", - "build": "tsc -p tsconfig.json && node scripts/finalize.mjs", + "build": "tsc -p tsconfig.json && tsdown && node scripts/finalize.mjs && node ../packages/device-runtime/build.mjs manifest workbuddy .", "test": "vitest run", "check": "npm test && npm run build && node scripts/validate.mjs", "pack:release": "npm run check && node scripts/package.mjs", @@ -29,6 +45,7 @@ "@types/node": "22.19.0", "@types/yauzl": "2.10.3", "fflate": "0.8.3", + "tsdown": "0.22.2", "typescript": "5.9.3", "vitest": "3.2.7", "yazl": "3.3.1" diff --git a/workbuddy-plugin/scripts/validate.mjs b/workbuddy-plugin/scripts/validate.mjs index cccf8e9..f266e78 100644 --- a/workbuddy-plugin/scripts/validate.mjs +++ b/workbuddy-plugin/scripts/validate.mjs @@ -1,3 +1,4 @@ +import { validateSourceBoundary, validateManifest } from '../../packages/device-runtime/build.mjs' import { execFileSync } from 'node:child_process' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' @@ -61,7 +62,6 @@ async function sources(path) { else if (entry.name.endsWith('.ts')) { const text = await readFile(absolute, 'utf8') assert(!/@deepseek|deepseek-harness-plugin|\.codex|DSH_HOME|OPENGUI_CODEX_HOME/.test(text), `Production dependency in ${entry.name}`) - assert(!/from ['"]\.\.\//.test(text), `Import escapes independent source tree: ${entry.name}`) } } } @@ -75,3 +75,6 @@ if (process.argv.includes('--release')) { } } console.log('WorkBuddy manifest, fourteen-tool contract, production isolation, native helpers, and bundled ADB hashes verified.') + +await validateSourceBoundary(root) +await validateManifest(root) diff --git a/workbuddy-plugin/src/viewer-page.ts b/workbuddy-plugin/src/viewer-page.ts index 7867472..33f66a4 100644 --- a/workbuddy-plugin/src/viewer-page.ts +++ b/workbuddy-plugin/src/viewer-page.ts @@ -1,41 +1 @@ -/** Read-only H.264 canvas. No model image capture or phone input route exists here. */ -export function viewerPage(): string { - return String.raw`OpenGUI · 实时设备墙 - -

OpenGUI 实时设备墙

准备中

画面仅供观看。停止 AI 任务请使用聊天中的停止入口。

-` -} +export * from '../../packages/device-runtime/src/viewer-page.ts' diff --git a/workbuddy-plugin/src/viewer.ts b/workbuddy-plugin/src/viewer.ts index 5e764db..25e0023 100644 --- a/workbuddy-plugin/src/viewer.ts +++ b/workbuddy-plugin/src/viewer.ts @@ -1,230 +1 @@ -import { randomBytes, randomUUID } from 'node:crypto' -import { createServer, type IncomingMessage, type Server } from 'node:http' -import type { ScrcpyStreamSink, VideoDevice } from './scrcpy-stream.ts' -import { acceptStreamWebSocket } from './websocket.ts' -import { viewerPage } from './viewer-page.ts' - -export interface ViewerDevice extends VideoDevice { readonly name: string } -export interface ViewerStreams { - prepare(signal: AbortSignal): Promise - subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void> - dispose(): Promise -} -type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed' -interface Connection { - id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string - issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void -} -interface Viewer { - id: string; token: string; owner: string; devices: readonly ViewerDevice[] - phase: Phase; deadline: number; established: boolean; ended: boolean - firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number - preparation?: Promise; readyDevices: Set -} - -/** Watching grants never contain a control credential or renew a control lease. */ -export class ViewerServer { - private server: Server | undefined - private starting: Promise | undefined - private origin = '' - private readonly viewers = new Map() - private readonly sweep: ReturnType - constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) { - this.sweep = setInterval(() => { - for (const viewer of this.viewers.values()) { - this.update(viewer) - for (const connection of viewer.connections.values()) { - if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive') - } - if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id) - } - }, 1000) - this.sweep.unref() - } - - get active(): boolean { - return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000)) - } - - async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) { - if (!owner) throw new Error('host_task_required') - if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices') - let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error))) - if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen') - if (!viewer) { - if (this.viewers.size >= 100) throw new Error('viewer_capacity') - viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() } - this.viewers.set(viewer.id, viewer) - const current = viewer - viewer.preparation = (async () => { try { - await this.streams.prepare(signal) - await this.start() - current.deadline = this.now() + 30_000 - current.phase = 'waiting_for_frame' - } catch (error) { - current.phase = 'error' - current.error = `dependency_prepare_failed: ${String(error)}` - } })() - } - await viewer.preparation - if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected' - // A repeated tool call cannot reset a failed first-display deadline. - return this.snapshot(viewer) - } - - find(owner: string, devices: readonly ViewerDevice[], id?: string): string { - const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices)) - if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first') - this.update(viewer) - if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required') - return viewer.id - } - - assertReady(id: string): void { - const viewer = this.require(id) - this.update(viewer) - if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action') - } - - async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) { - const viewer = this.require(id, owner) - const end = this.now() + Math.min(30_000, Math.max(0, waitMs)) - while (true) { - signal?.throwIfAborted() - this.update(viewer) - if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break - await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now()))) - } - return this.snapshot(viewer) - } - - closeViewer(id: string, owner: string) { - const viewer = this.require(id, owner) - viewer.phase = 'closed' - for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed') - for (const page of viewer.pages) page.close(1000, 'viewer_closed') - return this.snapshot(viewer) - } - endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true } - endTask(id: string): void { this.require(id).ended = true } - url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` } - async dispose(): Promise { - clearInterval(this.sweep) - for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner) - await this.streams.dispose() - this.server?.closeAllConnections() - await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve()) - } - - private same(v: Viewer, devices: readonly ViewerDevice[]): boolean { - return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial)) - } - private require(id: string, owner?: string): Viewer { - const v = this.viewers.get(id) - if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer') - return v - } - private update(v: Viewer): void { - if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') { - v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task' - } - } - private snapshot(v: Viewer) { - this.update(v) - return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established, - ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }), - taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing', - ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}), - nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait', - devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id), - state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) } - } - private local(req: IncomingMessage, websocket = false): boolean { - return req.headers.host === new URL(this.origin).host - && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin) - && !['cross-site'].includes(String(req.headers['sec-fetch-site'])) - } - private start(): Promise { - this.starting ??= new Promise((resolve, reject) => { - const server = createServer((req, res) => { - const handle = async (): Promise => { - if (!this.local(req)) { res.writeHead(403).end(); return } - const url = new URL(req.url ?? '/', this.origin) - const [token, route = ''] = url.pathname.slice(1).split('/') - const v = [...this.viewers.values()].find(v => v.token === token) - if (!v) { res.writeHead(404).end(); return } - res.setHeader('Cache-Control', 'no-store') - res.setHeader('Referrer-Policy', 'no-referrer') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'") - if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return } - if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return } - if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) { - let body = '' - for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } } - const input = JSON.parse(body) as Record - const c = v.connections.get(String(input.connectionId)) - this.update(v) - if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return } - c.painted = this.now() - if (!v.error) { - v.readyDevices.add(c.deviceId) - if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) { - v.firstFrameMs ??= this.now() - (v.deadline - 30_000) - v.established = true; v.phase = 'ready' - } - } - c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() - res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return - } - res.writeHead(404).end() - } - void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() }) - }) - this.server = server - server.on('upgrade', (req, socket, head) => { - if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } - const url = new URL(req.url ?? '/', this.origin) - const [token, route] = url.pathname.slice(1).split('/') - const v = [...this.viewers.values()].find(v => v.token === token) - if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) { - const page = acceptStreamWebSocket(req, socket, head) - v.pages.add(page) - page.onClose(() => v.pages.delete(page)) - return - } - const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId')) - if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return } - const sink = acceptStreamWebSocket(req, socket, head) - const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false } - // The grace timestamp is not a rendered-frame receipt. - v.connections.set(c.id, c) - sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) - let closed = false - sink.onClose(() => { - closed = true; c.release?.(); v.connections.delete(c.id) - if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected' - }) - const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => { - const event = JSON.parse(text) as { type: string; message?: string } - if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return } - if (event.type === 'session' || event.type === 'reset') { - c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now() - sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge })) - } - sink.sendText(text) - } } - void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => { - sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed') - }) - }) - server.once('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return } - this.origin = `http://127.0.0.1:${address.port}`; resolve() - }) - }) - return this.starting - } -} +export * from '../../packages/device-runtime/src/viewer.ts' diff --git a/workbuddy-plugin/src/websocket.ts b/workbuddy-plugin/src/websocket.ts index 1e0afd6..1152f84 100644 --- a/workbuddy-plugin/src/websocket.ts +++ b/workbuddy-plugin/src/websocket.ts @@ -1,105 +1 @@ -// Ported from the repository video transport; see VIDEO-NOTICE.md. -import { createHash } from 'node:crypto' -import type { IncomingMessage } from 'node:http' -import type { Duplex } from 'node:stream' -import type { ScrcpyStreamSink } from './scrcpy-stream.ts' - -const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' - -function frame(opcode: number, payload: Buffer): Buffer { - const size = payload.length - const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10) - header[0] = 0x80 | opcode - if (size < 126) header[1] = size - else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) } - else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) } - return Buffer.concat([header, payload]) -} - -/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */ -export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink { - const key = request.headers['sec-websocket-key'] - if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') { - socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') - throw new Error('invalid_websocket_upgrade') - } - const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64') - socket.write([ - 'HTTP/1.1 101 Switching Protocols', - 'Upgrade: websocket', - 'Connection: Upgrade', - `Sec-WebSocket-Accept: ${accept}`, - '\r\n', - ].join('\r\n')) - let closed = false - let closeNotified = false - const closeListeners = new Set<() => void>() - let input = Buffer.alloc(0) - const send = (opcode: number, payload: Buffer): void => { - if (!closed && !socket.destroyed) socket.write(frame(opcode, payload)) - } - const consume = (chunk: Buffer): void => { - if (input.length + chunk.length > 8192) { socket.destroy(); return } - input = Buffer.concat([input, chunk]) - while (input.length >= 2) { - const masked = (input[1]! & 0x80) !== 0 - if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return } - let length = input[1]! & 0x7f - let offset = 2 - if (length === 126) { - if (input.length < 4) return - length = input.readUInt16BE(2); offset = 4 - } else if (length === 127) { - if (input.length < 10) return - const large = input.readBigUInt64BE(2) - if (large > 125n) { socket.destroy(); return } - length = Number(large); offset = 10 - } - if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return } - const maskBytes = masked ? 4 : 0 - if (input.length < offset + maskBytes + length) return - const opcode = input[0]! & 0x0f - let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length)) - if (masked) { - const mask = input.subarray(offset, offset + 4) - payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!)) - } - input = input.subarray(offset + maskBytes + length) - if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return } - if (opcode === 0x9) send(0xA, payload) - } - } - socket.on('data', chunk => consume(Buffer.from(chunk))) - const notifyClosed = (): void => { - if (closeNotified) return - closeNotified = true - closed = true - for (const listener of closeListeners) listener() - closeListeners.clear() - } - socket.once('end', () => { notifyClosed(); socket.destroy() }) - socket.once('close', notifyClosed) - socket.once('error', notifyClosed) - if (head.length > 0) consume(head) - return { - sendText: text => send(0x1, Buffer.from(text, 'utf8')), - sendBinary: data => send(0x2, data), - bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0), - close(code = 1000, reason = '') { - if (closed) return - closed = true - const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123) - const payload = Buffer.allocUnsafe(2 + reasonBuffer.length) - payload.writeUInt16BE(code, 0) - reasonBuffer.copy(payload, 2) - socket.end(frame(0x8, payload)) - notifyClosed() - const timer = setTimeout(() => socket.destroy(), 250) - timer.unref() - }, - onClose(listener) { - if (closed) listener() - else closeListeners.add(listener) - }, - } -} +export * from '../../packages/device-runtime/src/websocket.ts' diff --git a/workbuddy-plugin/tsconfig.json b/workbuddy-plugin/tsconfig.json index cb700cf..f46e707 100644 --- a/workbuddy-plugin/tsconfig.json +++ b/workbuddy-plugin/tsconfig.json @@ -3,19 +3,25 @@ "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "types": ["node"], - "rootDir": "src", - "outDir": "lib", - "declaration": true, - "rewriteRelativeImportExtensions": true, + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "types": [ + "node" + ], "esModuleInterop": true, "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noUnusedLocals": true, "noUnusedParameters": true, - "skipLibCheck": true + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts" + ] } diff --git a/workbuddy-plugin/tsdown.config.ts b/workbuddy-plugin/tsdown.config.ts new file mode 100644 index 0000000..d909c32 --- /dev/null +++ b/workbuddy-plugin/tsdown.config.ts @@ -0,0 +1,11 @@ +import { readdirSync } from 'node:fs' +import { defineConfig } from 'tsdown' + +// Retain the existing JS entry points consumed by the installer and package QA. +export default defineConfig({ + entry: Object.fromEntries(readdirSync('src').filter(name => name.endsWith('.ts')) + .map(name => [name.slice(0, -3), `src/${name}`])), + outDir: 'lib', format: ['esm'], platform: 'node', target: 'node22', + fixedExtension: false, dts: false, clean: true, + deps: { neverBundle: [/^@modelcontextprotocol\//, /^sharp(?:\/|$)/, /^ajv(?:\/|$)/, /^tar(?:\/|$)/, /^yauzl(?:\/|$)/] }, +}) diff --git a/workbuddy-plugin/vitest.config.ts b/workbuddy-plugin/vitest.config.ts new file mode 100644 index 0000000..ebd6658 --- /dev/null +++ b/workbuddy-plugin/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { include: ['tests/**/*.spec.ts', '../packages/device-runtime/tests/**/*.spec.ts'] } })