From d87f25478bedaaf7cf154f70bfe6082f44e770a9 Mon Sep 17 00:00:00 2001 From: harveysang Date: Tue, 8 Sep 2026 20:53:17 +0800 Subject: [PATCH] fix(workbuddy): detect host configuration and bound repeat installation The previous installer assumed one configuration root and the WorkBuddy process name; its tests mocked pgrep and could not catch Electron or overseas builds. Add host preflight and real installer regression fixtures, isolate configuration receipts, and reuse verified packages. Prepare 0.2.1 as a repair candidate without asserting pending desktop or device acceptance. --- skills/opengui-plugin-install/SKILL.md | 4 +- workbuddy-plugin/CHANGELOG.md | 8 ++ workbuddy-plugin/README.md | 22 ++- workbuddy-plugin/README.zh-CN.md | 37 +++-- .../connector/connector-meta.json | 2 +- workbuddy-plugin/connector/mcp.json | 2 +- .../connector/skills/control/SKILL.md | 2 +- workbuddy-plugin/docs/release-notes.md | 6 +- workbuddy-plugin/package-lock.json | 4 +- workbuddy-plugin/package.json | 4 +- workbuddy-plugin/release-readiness.json | 2 +- workbuddy-plugin/scripts/install-local.mjs | 67 +++++++-- .../scripts/install-macos.command | 132 +++++++++++++++--- workbuddy-plugin/scripts/test-install.mjs | 34 ++++- workbuddy-plugin/scripts/test-preflight.mjs | 37 +++++ workbuddy-plugin/scripts/test-publish.mjs | 4 +- .../scripts/test-release-installer.mjs | 39 ++++-- workbuddy-plugin/src/state.ts | 2 +- workbuddy-plugin/tests/mcp.spec.ts | 2 +- 19 files changed, 333 insertions(+), 77 deletions(-) create mode 100644 workbuddy-plugin/scripts/test-preflight.mjs diff --git a/skills/opengui-plugin-install/SKILL.md b/skills/opengui-plugin-install/SKILL.md index d73fdbc..a887d75 100644 --- a/skills/opengui-plugin-install/SKILL.md +++ b/skills/opengui-plugin-install/SKILL.md @@ -10,7 +10,7 @@ Infer Codex or WorkBuddy from the request; ask only if the target is missing. Th 1. Read the public GitHub releases API for `Core-Mate/OpenGUI`, following pagination. Select the newest non-draft, non-prerelease semantic version with tag `opengui-codex-vX.Y.Z` or `opengui-workbuddy-vX.Y.Z` and all matching assets below. If the user explicitly asks for the public testing/prerelease version, include non-draft prereleases for that host and explain their uncompleted device acceptance. An explicit requested version takes precedence; never silently substitute a different version. Do not use the repository-wide latest release, which may be DSH or an APK. 2. Download `opengui-HOST-X.Y.Z-install.command` and its `.sha256` asset from that release into a fresh temporary directory. Use HTTPS with redirect-to-HTTPS only. Require exact filenames and verify SHA-256 before executing the installer. The checksum establishes integrity relative to the selected public release, not an independent publisher signature. 3. Finish existing OpenGUI tasks before an upgrade. WorkBuddy must be closed before its configuration can be changed; do not kill it or its phone/mirror processes. Codex requires the native CLI with `codex plugin` support. Never remove a conflicting plugin source without the user's authorization. -4. Run `bash /absolute/path/opengui-HOST-X.Y.Z-install.command`. It downloads and verifies the matching package, prepares private Node 22.23.2, installs into a fresh version directory, and configures only the selected host. No Git clone, pnpm, source compilation, or user-run test suite is required. +4. For WorkBuddy, run the selected installer with `--check` first. Read its exact error code; do not inspect the entire application or rewrite configuration manually. Multiple applications require an explicit `--app` selection. If installing from within WorkBuddy itself, prepare the verified installer and give the user the single terminal command to run after Command-Q; do not repeatedly retry while the host is running. Normal installation uses the installer, not source checkout or agent-written JSON. Run `bash /absolute/path/opengui-HOST-X.Y.Z-install.command`. It downloads and verifies the matching package, prepares private Node 22.23.2, installs into a fresh version directory, and configures only the selected host. No Git clone, pnpm, source compilation, or user-run test suite is required. 5. Read the result. On success, ask for a new Codex chat or a WorkBuddy restart, then verify read-only device discovery. USB and system permissions remain user actions. Installation success does not prove phone control, desktop visibility, or two-device acceptance. Required release assets: @@ -19,4 +19,4 @@ Required release assets: If no complete release exists or downloads fail, report that precise state. Do not replace the installer with source builds or invent a working download link. For explicitly requested candidate testing, use a maintainer-provided archive with its adjacent checksum and the matching source installer: `bash install-macos.command --archive /absolute/package.tar.gz` (WorkBuddy uses `.tgz`). Keep candidate and published status separate. -Rollback uses the previous version's verified installer after tasks end. WorkBuddy also records scoped configuration backups in `~/.workbuddy/opengui/local-install.json`; Codex retains previous inventories and a configuration backup beside each immutable package. Preserve subsequent unrelated edits when recovering; do not reset an entire host or touch DSH. +Rollback uses the previous version's verified installer after tasks end. WorkBuddy also records scoped configuration backups in `~/.workbuddy/opengui/local-install-.json`; Codex retains previous inventories and a configuration backup beside each immutable package. Preserve subsequent unrelated edits when recovering; do not reset an entire host or touch DSH. diff --git a/workbuddy-plugin/CHANGELOG.md b/workbuddy-plugin/CHANGELOG.md index a5ea60d..03cc01f 100644 --- a/workbuddy-plugin/CHANGELOG.md +++ b/workbuddy-plugin/CHANGELOG.md @@ -1,3 +1,11 @@ +# OpenGUI for WorkBuddy 0.2.1 candidate + +- Discover the selected WorkBuddy bundle and its product-specific configuration root before downloading packages. +- Reject hosts below 5.5.3, missing lifecycle Hook declarations, and running Electron or helper processes. +- Keep runtime state stable while repairing host configuration; offer explicit repair of unchanged installer-owned legacy files and keep per-configuration receipts. +- Reuse verified package downloads and installed dependencies; make same-version configuration updates idempotent. +- Report preflight and configuration status separately from real host loading and device acceptance. + # OpenGUI for WorkBuddy 0.2.0 candidate Not released. Broker protocol 7 requires an explicit local runtime switch. diff --git a/workbuddy-plugin/README.md b/workbuddy-plugin/README.md index 9be7a7a..f0d2cae 100644 --- a/workbuddy-plugin/README.md +++ b/workbuddy-plugin/README.md @@ -2,10 +2,18 @@ [中文说明](README.zh-CN.md) -Independent local **MCP + Skill + lifecycle Hooks** connector for autonomous Android control, native read-only mirroring, and a read-only device wall. Version `0.2.0` (broker protocol `7`) is a testing candidate, not a stable release or a marketplace-approved connector. +Independent local **MCP + Skill + lifecycle Hooks** connector for autonomous Android control, native read-only mirroring, and a read-only device wall. Version `0.2.1` (broker protocol `7`) is a testing candidate, not a stable release or a marketplace-approved connector. Every OpenGUI request begins with `opengui_start`, displaying all connected authorized phones without taking control locks. Windows are read-only and silent, and persist across task completion, cancellation and MCP recycling. Only user-requested closure or device/runtime failure ends them. Phone tasks use the current WorkBuddy VLM in a screenshot–action–screenshot loop; standalone viewing sends no images to the model. On macOS the bundled helper verifies initial window visibility and renderer readiness once per control task. Subsequent minimization, occlusion, desktop switching, closure or renderer exit does not revoke control: the model receives independent phone screenshots. Initial display failure is reported and blocks operation until startup succeeds; it is never silently bypassed. First use downloads verified scrcpy into the independent WorkBuddy cache. +## Installer compatibility and repair + +Version 0.2.1 is an unpublished repair candidate; 0.2.0 is already a public prerelease. Use assets from the same published tag, or a maintainer-provided matching candidate archive. + +The installer checks the selected application before downloading: WorkBuddy 5.5.3 minimum, product-specific configuration directory, Hook declarations, and running Electron/helper processes. Use `--check` for a read-only preflight and `--app /absolute/WorkBuddy.app` when multiple bundles exist. The installer reads the application's `cli/product.json`, including the overseas `.workbuddy-ai` directory. A verified custom directory can be supplied with `--config-root`; `WORKBUDDY_CONFIG_DIR` and numbered instances are also supported. + +Host configuration is separate from the stable runtime directory `~/.workbuddy/opengui`. Per-configuration receipts preserve independent instances. Explicit `--repair-legacy` restores a confirmed mistaken legacy installation only when an installation receipt proves ownership and the entire file still matches the installed digest. Subsequent edits are retained and reported. Same-version installation reuses verified downloads and dependencies, returning `ALREADY_CONFIGURED` when configuration is unchanged. `CONFIG_WRITTEN` does not prove host loading, Hook delivery or phone acceptance; restart and verify read-only discovery first. + ## What it does - Discover USB/ADB-authorized Android phones; freeze one to four per session. @@ -42,7 +50,7 @@ system Node, Xcode, or user-run tests are required. Existing MCP servers, Hooks, configuration backups and old version directories are preserved. Reopen WorkBuddy and trust the MCP before read-only device discovery. -For unpublished candidates use `bash scripts/install-macos.command --archive /absolute/opengui-mcp-0.2.0.tgz` +For unpublished candidates use `bash scripts/install-macos.command --archive /absolute/opengui-mcp-0.2.1.tgz` with the adjacent `.sha256` file. This does not bypass public release acceptance. See the [Chinese installation guide](README.zh-CN.md#macos-安装) and the [agent installation Skill](../skills/opengui-plugin-install/SKILL.md). @@ -65,7 +73,7 @@ If the Skill is missing, check `~/.workbuddy/skills/opengui/SKILL.md` and reopen ### Roll back -Finish tasks, close WorkBuddy's OpenGUI mirrors and quit WorkBuddy. `~/.workbuddy/opengui/local-install.json` records each affected file and its backup. Restore the previous MCP and Hook configuration, Skill, and previous installation metadata if present, then reopen WorkBuddy. A `null` backup means that file did not exist before installation; remove only this installation's entries if other settings have since been added. Preserve subsequent unrelated edits, old packages and caches. Never reset the entire WorkBuddy configuration or touch DSH/Codex state. +Finish tasks, close WorkBuddy's OpenGUI mirrors and quit WorkBuddy. `~/.workbuddy/opengui/local-install-.json` records each affected file and its backup. Restore the previous MCP and Hook configuration, Skill, and previous installation metadata if present, then reopen WorkBuddy. A `null` backup means that file did not exist before installation; remove only this installation's entries if other settings have since been added. Preserve subsequent unrelated edits, old packages and caches. Never reset the entire WorkBuddy configuration or touch DSH/Codex state. ## Build and local testing @@ -94,11 +102,11 @@ Builds on macOS require Xcode command-line tools and bundle arm64/x64 window hel ## Distribution -Candidate tag convention: `opengui-workbuddy-v0.2.0` (not created by local installation). `pack:release` creates: +Candidate tag convention: `opengui-workbuddy-v0.2.1` (not created by local installation). `pack:release` creates: -- `dist/opengui-mcp-0.2.0.tgz` and `.sha256` -- `dist/opengui-workbuddy-connector-0.2.0.zip` and `.sha256` -- `dist/opengui-workbuddy-0.2.0-install.command` and `.sha256` +- `dist/opengui-mcp-0.2.1.tgz` and `.sha256` +- `dist/opengui-workbuddy-connector-0.2.1.zip` and `.sha256` +- `dist/opengui-workbuddy-0.2.1-install.command` and `.sha256` The ZIP contains `opengui/connector-meta.json`, `mcp.json`, `icon.svg`, and `skills/control/SKILL.md`. Its npx command pins the matching GitHub Release tarball. Do not distribute this candidate manifest as installable until that asset exists. The tarball includes code, ADB, notices, and package metadata; npm resolves its pinned runtime dependencies. No npm publish step is required. diff --git a/workbuddy-plugin/README.zh-CN.md b/workbuddy-plugin/README.zh-CN.md index ca8c6c2..0fddf8f 100644 --- a/workbuddy-plugin/README.zh-CN.md +++ b/workbuddy-plugin/README.zh-CN.md @@ -1,6 +1,6 @@ # OpenGUI WorkBuddy 连接器 -独立的本地 MCP + Skill + 生命周期 Hook 插件,提供 Android 手机自动操作、scrcpy 只读独立投屏窗口和只读设备墙。当前 `0.2.0`(broker 协议 `7`)是本地候选版本,尚未正式发布或通过 WorkBuddy 市场审核。 +独立的本地 MCP + Skill + 生命周期 Hook 插件,提供 Android 手机自动操作、scrcpy 只读独立投屏窗口和只读设备墙。当前 `0.2.1`(broker 协议 `7`)是本地修复候选版本;`0.2.0` 已公开预发布。稳定版和 WorkBuddy 市场审核另行验收。 每次调起 OpenGUI,Skill 首先调用 `opengui_start`,自动展示全部已连接且已授权的手机。投屏只读、静音,不占用控制锁;任务结束、取消、回复结束或 MCP 重连均不关闭窗口。首次展示验证通过后,最小化、遮挡、切换桌面、关窗或渲染进程退出只影响观看,不暂停手机任务、不抢焦点。取消任务应调用 `opengui_cancel`,不是关闭窗口;下次明确调起时恢复投屏。纯观看不发送模型截图;正常手机任务必须通过截图 → VLM 判断 → 单步操作 → 新截图完成闭环。真机断线会撤销观察凭据,重连或截图失败后必须重新观察,不自动重放操作。 @@ -16,12 +16,12 @@ DSH、Codex 的源码、依赖、安装配置、缓存和发布流程均不复 ## macOS 安装 -当前仍为未发布候选版。正式发布后,普通用户从对应 [WorkBuddy Release](https://github.com/Core-Mate/OpenGUI/releases) -下载 `opengui-workbuddy-版本-install.command` 和它的 `.sha256`。结束旧 OpenGUI 任务、关闭投屏并退出 WorkBuddy 后,在下载目录运行: +普通用户从对应 [WorkBuddy Release](https://github.com/Core-Mate/OpenGUI/releases) +选择已发布的版本,下载对应 `opengui-workbuddy-版本-install.command` 和它的 `.sha256`。以下 `0.2.1` 命令仅用于该版本发布后,未发布候选请使用维护者提供的匹配归档。结束旧 OpenGUI 任务、关闭投屏并退出 WorkBuddy 后,在下载目录运行: ```sh -shasum -a 256 -c opengui-workbuddy-0.2.0-install.command.sha256 -bash opengui-workbuddy-0.2.0-install.command +shasum -a 256 -c opengui-workbuddy-0.2.1-install.command.sha256 +bash opengui-workbuddy-0.2.1-install.command ``` 安装器自动下载并校验预构建包、准备私有 Node 22.23.2、安装依赖,并备份及增量配置 MCP、Skill 和生命周期 Hooks。 @@ -31,6 +31,23 @@ bash opengui-workbuddy-0.2.0-install.command 也可让 Agent 使用 [安装 Skill](../skills/opengui-plugin-install/SKILL.md),说“帮我安装 OpenGUI WorkBuddy 插件”。 没有完整发布资产时会停止并说明原因,不会改走源码构建。WorkBuddy 5.5.3、macOS 和支持图片与工具的模型仍是验收基线。 +### 安装前检查与旧安装修复 + +安装器在下载前识别 WorkBuddy 的应用身份、版本、产品目录及生命周期 Hook 声明。最低版本为 5.5.3;5.5.2 会提前停止并提示升级,不安装功能不完整的续跑配置。国内/海外版的目录来自应用自身 `cli/product.json`,不根据目录是否存在猜测。 + +```sh +bash opengui-workbuddy-0.2.1-install.command --check +# 多个版本并存或应用放在非标准目录时,指定要使用的应用: +bash opengui-workbuddy-0.2.1-install.command --app "/Applications/WorkBuddy.app" +``` + +`--check` 只读,不下载、不写配置。检测到主进程 Electron 或应用 Helper 时,需要结束任务后用 Command-Q 退出。DMG 上运行的应用同样会被检测。 +自定义实例可使用与宿主一致的 `WORKBUDDY_CONFIG_DIR`,或显式 `--config-root /已核实的目录`;该参数不能把不支持的宿主变成受支持版本。 + +MCP、Skill、Hooks 写入识别出的宿主目录;运行数据和旧包继续保存在 `~/.workbuddy/opengui`,安装记录按配置目录独立保存。已确认旧版误写目录时,可加 `--repair-legacy`:只有旧格式安装记录能证明归属且文件内容未被修改,才恢复原文件;普通安装保留其他目录。存在后续修改的文件保留,并在 `migration` 结果中标为 `retained`,不覆盖用户数据。没有安装记录的旧文件不会自动删除。 + +同版本重装复用校验过的归档和依赖;配置相同时返回 `ALREADY_CONFIGURED`。阶段输出包含耗时。`CONFIG_WRITTEN` 仅代表配置已写入,不代表宿主加载或 Hook 续跑已验证;仍须重启并完成下方检查。 + ### 开发者构建与候选测试 以下只在维护者构建机器执行,需要 Node.js 22.19+ 的 22.x 或 24+、npm 和 Xcode 命令行工具: @@ -42,11 +59,11 @@ npm run pack:release npm run smoke:packed ``` -把 `dist/opengui-mcp-0.2.0.tgz`、其 `.sha256` 和 `dist/opengui-workbuddy-0.2.0-install.command` +把 `dist/opengui-mcp-0.2.1.tgz`、其 `.sha256` 和 `dist/opengui-workbuddy-0.2.1-install.command` 送到测试 Mac,退出 WorkBuddy 后运行: ```sh -bash opengui-workbuddy-0.2.0-install.command --archive /绝对路径/opengui-mcp-0.2.0.tgz +bash opengui-workbuddy-0.2.1-install.command --archive /绝对路径/opengui-mcp-0.2.1.tgz ``` 无需把源码、编译器或测试工具带到测试机器。底层 `scripts/install-local.mjs` 已随包提供; @@ -59,11 +76,11 @@ bash opengui-workbuddy-0.2.0-install.command --archive /绝对路径/opengui-mcp 3. 输入 `/opengui` 并选中技能,发送“列出已连接手机,不操作手机”,确认工具可用且返回真实设备状态。 4. 在允许截图发送给当前模型的手机上,发送“打开手机设置,查看并告诉我 Android 版本”。核对实际投屏窗口、看图操作、结果和任务结束后的控制锁释放,投屏应继续保留。 -找不到技能时,检查 `~/.workbuddy/skills/opengui/SKILL.md` 并重开 WorkBuddy,只配置 MCP 不够。找不到工具时,检查宿主的 MCP 信任和连接状态,以及 Node、安装包路径。提示无法自动续跑时,检查 `settings.json` 中是否保留本插件的生命周期 Hooks,不要用反复输入“继续”代替修复。USB 授权和 macOS 权限弹窗需要用户在系统界面批准。构建和冒烟检查通过,不等于桌面和真机验收通过。 +找不到技能时,检查安装结果所示配置目录中的 `skills/opengui/SKILL.md` 并重开 WorkBuddy,只配置 MCP 不够。找不到工具时,检查宿主的 MCP 信任和连接状态,以及 Node、安装包路径。提示无法自动续跑时,检查 `settings.json` 中是否保留本插件的生命周期 Hooks,不要用反复输入“继续”代替修复。USB 授权和 macOS 权限弹窗需要用户在系统界面批准。构建和冒烟检查通过,不等于桌面和真机验收通过。 ### 回退 -结束任务,关闭 WorkBuddy OpenGUI 投屏并退出 WorkBuddy。`~/.workbuddy/opengui/local-install.json` 记录配置文件及对应备份,恢复上一版 MCP、Hook 配置、Skill,以及存在的上一版安装元数据,再重开 WorkBuddy。备份为 `null` 表示安装前没有该文件;如果此后加入其他配置,只移除本次安装的条目。保留后续无关修改、旧包和缓存,不重置整个 WorkBuddy 配置,不动 DSH/Codex 数据。 +结束任务,关闭 WorkBuddy OpenGUI 投屏并退出 WorkBuddy。`~/.workbuddy/opengui/local-install-<配置标识>.json` 记录配置文件及对应备份,恢复上一版 MCP、Hook 配置、Skill,以及存在的上一版安装元数据,再重开 WorkBuddy。备份为 `null` 表示安装前没有该文件;如果此后加入其他配置,只移除本次安装的条目。保留后续无关修改、旧包和缓存,不重置整个 WorkBuddy 配置,不动 DSH/Codex 数据。 ## 使用方式 @@ -82,7 +99,7 @@ bash opengui-workbuddy-0.2.0-install.command --archive /绝对路径/opengui-mcp ## 交付与发布 -候选版本标签约定:`opengui-workbuddy-v0.2.0`;本地安装不会创建标签。打包产物是 `dist/` 中的 MCP `.tgz`、连接器 `.zip` 及对应 SHA-256 文件,不需要发布到 npm。 +候选版本标签约定:`opengui-workbuddy-v0.2.1`;本地安装不会创建标签。打包产物是 `dist/` 中的 MCP `.tgz`、连接器 `.zip` 及对应 SHA-256 文件,不需要发布到 npm。 自动测试、归档包和标准 MCP 冒烟检查不等于真实 WorkBuddy 验收。`release-readiness.json` 中的宿主图片接入、真机动作、双机隔离、自动续跑和停止恢复等项目全部验收后,专属发布流程才允许创建 GitHub Release。WorkBuddy 市场提交与审核另行进行。 diff --git a/workbuddy-plugin/connector/connector-meta.json b/workbuddy-plugin/connector/connector-meta.json index 3dd1cfd..ede71e6 100644 --- a/workbuddy-plugin/connector/connector-meta.json +++ b/workbuddy-plugin/connector/connector-meta.json @@ -7,7 +7,7 @@ "description_en": "Control locally connected Android phones from WorkBuddy and monitor up to four phones in a read-only device wall.", "source": "opengui", "type": "mcp", - "version": "0.2.0", + "version": "0.2.1", "minWorkbuddyVersion": "5.5.3", "examples_zh": ["看看手机上的 Android 版本", "在设备墙里查看这两台手机的画面"], "examples_en": ["Check the Android version on my phone", "Show these two phones in the device wall"] diff --git a/workbuddy-plugin/connector/mcp.json b/workbuddy-plugin/connector/mcp.json index 0e7cd13..a1361d4 100644 --- a/workbuddy-plugin/connector/mcp.json +++ b/workbuddy-plugin/connector/mcp.json @@ -6,7 +6,7 @@ "args": [ "--yes", "--prefer-offline", - "--package=https://github.com/Core-Mate/OpenGUI/releases/download/opengui-workbuddy-v0.2.0/opengui-mcp-0.2.0.tgz", + "--package=https://github.com/Core-Mate/OpenGUI/releases/download/opengui-workbuddy-v0.2.1/opengui-mcp-0.2.1.tgz", "opengui-mcp" ], "runtime": { "type": "node", "version": "^22.19.0 || >=24" }, diff --git a/workbuddy-plugin/connector/skills/control/SKILL.md b/workbuddy-plugin/connector/skills/control/SKILL.md index bac59a5..88ae3b3 100644 --- a/workbuddy-plugin/connector/skills/control/SKILL.md +++ b/workbuddy-plugin/connector/skills/control/SKILL.md @@ -6,7 +6,7 @@ description: Autonomously complete user-authorized Android phone tasks using rea description_zh: 根据真实截图全自动完成用户指定的 Android 手机任务,默认持续投屏,自动恢复、核验结果并释放控制锁;不重复询问已授权步骤。 description_en: Complete authorized Android tasks through a real VLM screenshot-action loop, persistent local displays, bounded recovery and automatic task cleanup. category: productivity -version: 0.2.0 +version: 0.2.1 author: OpenGUI --- diff --git a/workbuddy-plugin/docs/release-notes.md b/workbuddy-plugin/docs/release-notes.md index 6902a5b..aad871f 100644 --- a/workbuddy-plugin/docs/release-notes.md +++ b/workbuddy-plugin/docs/release-notes.md @@ -1,4 +1,6 @@ -# OpenGUI for WorkBuddy 0.2.0 +# OpenGUI for WorkBuddy 0.2.1 + +Repair candidate: product-specific configuration discovery, WorkBuddy 5.5.3 preflight, Electron/helper detection, scoped legacy repair and cached repeat installation. Actual host loading and device acceptance remain separate from installation success. Public testing prerelease for WorkBuddy 5.5.3 or newer on macOS arm64/x64. @@ -6,6 +8,6 @@ Public testing prerelease for WorkBuddy 5.5.3 or newer on macOS arm64/x64. - Control authorized Android devices from screenshots while keeping a separate read-only phone mirror available. - Continue unfinished tasks through host lifecycle Hooks and preserve unrelated configuration and previous packages during upgrades. -Finish existing phone tasks, close their mirrors and quit WorkBuddy. Download `opengui-workbuddy-0.2.0-install.command` and its `.sha256`, verify the checksum, then run the installer with `bash`. Reopen WorkBuddy, trust the OpenGUI MCP and select `/opengui`; first request read-only device discovery. +Finish existing phone tasks, close their mirrors and quit WorkBuddy. Download `opengui-workbuddy-0.2.1-install.command` and its `.sha256`, verify the checksum, then run the installer with `bash`. Reopen WorkBuddy, trust the OpenGUI MCP and select `/opengui`; first request read-only device discovery. This prerelease is for testing. Automated tests, packaged startup and an isolated installation without system Node have passed locally. Real WorkBuddy desktop, phone actions, two-device conflicts and host stop/continuation acceptance remain incomplete. It is not a stable or marketplace-approved release. diff --git a/workbuddy-plugin/package-lock.json b/workbuddy-plugin/package-lock.json index d4b7010..a7b4edd 100644 --- a/workbuddy-plugin/package-lock.json +++ b/workbuddy-plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "opengui-mcp", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opengui-mcp", - "version": "0.2.0", + "version": "0.2.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/workbuddy-plugin/package.json b/workbuddy-plugin/package.json index 27ea016..8dd448a 100644 --- a/workbuddy-plugin/package.json +++ b/workbuddy-plugin/package.json @@ -1,6 +1,6 @@ { "name": "opengui-mcp", - "version": "0.2.0", + "version": "0.2.1", "description": "Independent OpenGUI Android MCP runtime for WorkBuddy", "type": "module", "license": "SEE LICENSE IN LICENSE", @@ -13,7 +13,7 @@ "test": "vitest run", "check": "npm test && npm run build && node scripts/validate.mjs", "pack:release": "npm run check && node scripts/package.mjs", - "smoke:packed": "node scripts/test-install.mjs && node scripts/smoke-packed.mjs", + "smoke:packed": "node scripts/test-preflight.mjs && node scripts/test-install.mjs && node scripts/smoke-packed.mjs && node scripts/test-release-installer.mjs", "test:browser": "npm run build && node scripts/test-wall-browser.mjs", "test:native": "npm run build && node scripts/test-native.mjs" }, diff --git a/workbuddy-plugin/release-readiness.json b/workbuddy-plugin/release-readiness.json index 78b439e..da50a27 100644 --- a/workbuddy-plugin/release-readiness.json +++ b/workbuddy-plugin/release-readiness.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "version": "0.2.1", "checks": { "workbuddyImageToolFlow": { "verified": false, "evidence": "" }, "realDeviceActionsIncludingUnicode": { "verified": false, "evidence": "" }, diff --git a/workbuddy-plugin/scripts/install-local.mjs b/workbuddy-plugin/scripts/install-local.mjs index 0a04d86..3306818 100644 --- a/workbuddy-plugin/scripts/install-local.mjs +++ b/workbuddy-plugin/scripts/install-local.mjs @@ -20,27 +20,48 @@ async function assertOwnedPath(path) { await assertOwnedPath(join(root, 'opengui', 'packages')) const packageDir = await realpath(resolve(option('--package-dir') ?? '')) const node = await realpath(resolve(option('--node') ?? process.execPath)) -const packagesRoot = await realpath(join(root, 'opengui', 'packages')) +const stateRoot = resolve(option('--state-root') ?? join(root, 'opengui')) +assert.equal(await realpath(stateRoot), stateRoot, 'Refuse redirected state root') +const packagesRoot = await realpath(join(stateRoot, 'packages')) assert(!relative(packagesRoot, packageDir).startsWith('..') && relative(packagesRoot, packageDir), 'Install from an immutable WorkBuddy version directory') const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) assert.equal(pkg.name, 'opengui-mcp') -assert.equal(pkg.version, '0.2.0') +assert.equal(pkg.version, '0.2.1') assert.match(execFileSync(node, ['--version'], { encoding: 'utf8' }).trim(), /^v(?:22\.(?:19|2\d|[3-9]\d)|2[4-9]\.|[3-9]\d\.)/) const quote = value => process.platform === 'win32' ? `'${value.replaceAll("'", "''")}'` : `'${value.replaceAll("'", `'"'"'`)}'` const command = `${quote(node)} ${quote(join(packageDir, 'lib', 'host-hook.js'))}` assert((await lstat(join(packageDir, 'lib', 'host-hook.js'))).isFile()) -const installState = join(root, 'opengui', 'local-install.json') -await assertOwnedPath(installState) +async function assertStatePath() { + assert.equal(await realpath(stateRoot), stateRoot, 'Refuse redirected state root') + try { assert(!(await lstat(installState)).isSymbolicLink(), 'Refuse redirected installation journal') } + catch (error) { if (error.code !== 'ENOENT') throw error } +} +const receiptKey = createHash('sha256').update(root).digest('hex').slice(0, 16) +const installState = join(stateRoot, `local-install-${receiptKey}.json`) +await assertStatePath() const optional = async path => { try { return await readFile(path, 'utf8') } catch (error) { if (error.code === 'ENOENT') return undefined; throw error } } -const previous = JSON.parse(await optional(installState) ?? '{}') +const ownReceipt = await optional(installState) +const legacy = ownReceipt ? {} : JSON.parse(await optional(join(stateRoot, 'local-install.json')) ?? '{}') +const previous = ownReceipt ? JSON.parse(ownReceipt) : (!legacy.configRoot || legacy.configRoot === root ? legacy : {}) +const legacyMigration = !ownReceipt && !legacy.configRoot && Boolean(legacy.packageDir) const targets = [join(root, 'mcp.json'), join(root, 'settings.json'), join(root, 'skills', 'opengui', 'SKILL.md')] const original = await Promise.all(targets.map(optional)) for (const path of targets) await assertOwnedPath(path) +const existingMcp = JSON.parse(original[0] ?? '{}').mcpServers?.opengui +if (existingMcp) { + const knownEntrypoints = [packageDir, previous.packageDir].filter(Boolean).map(path => join(path, 'lib', 'mcp.js')) + assert(Array.isArray(existingMcp.args) && existingMcp.args.length === 1 && knownEntrypoints.includes(existingMcp.args[0]), + 'MCP_CONFLICT: an unrecognized opengui server already exists; preserve it and resolve the conflict explicitly') +} const values = [ JSON.stringify(mergeMcpConfig(JSON.parse(original[0] ?? '{}'), node, join(packageDir, 'lib', 'mcp.js')), null, 2) + '\n', JSON.stringify(mergeHostHooks(JSON.parse(original[1] ?? '{}'), command, previous.hookCommands ?? []), null, 2) + '\n', await readFile(join(packageDir, 'lib', 'opengui-SKILL.md'), 'utf8'), ] +if (original.every((value, i) => value === values[i]) && previous.packageDir === packageDir && previous.configRoot === root) { + console.log(JSON.stringify({ status: 'ALREADY_CONFIGURED', version: pkg.version, installState, configRoot: root, hostLoaded: 'unverified' })) + process.exit(0) +} const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-') const backups = [] for (let i = 0; i < targets.length; i++) { @@ -56,9 +77,9 @@ for (let i = 0; i < targets.length; i++) { // Prepare all bytes and the recovery journal before switching any entry. // A live client must be stopped by the caller; never overwrite concurrent edits. if (await optional(installState)) await copyFile(installState, `${installState}.before-${stamp}`) -await assertOwnedPath(installState) +await assertStatePath() const journal = `${installState}.pending-${stamp}` -const state = { version: pkg.version, packageDir, hookCommands: [command], backups, installedAt: new Date().toISOString() } +const state = { configRoot: root, version: pkg.version, packageDir, hookCommands: [command], backups, installedAt: new Date().toISOString() } await writeFile(journal, JSON.stringify(state, null, 2) + '\n', { mode: 0o600, flag: 'wx' }) const temporary = targets.map(path => `${path}.opengui-${stamp}.tmp`) for (let i = 0; i < targets.length; i++) await writeFile(temporary[i], values[i], { mode: 0o600, flag: 'wx' }) @@ -72,7 +93,7 @@ try { await rename(temporary[i], targets[i]) switched.push(i) } - await assertOwnedPath(installState) + await assertStatePath() await rename(stateTemporary, installState) } catch (error) { const rollback = await Promise.allSettled(switched.map(async i => { @@ -88,4 +109,32 @@ try { console.error(JSON.stringify({ installationFailed: true, recoveryJournal: journal, rollbackComplete: rollback.every(result => result.status === 'fulfilled') })) throw error } -console.log(JSON.stringify({ version: pkg.version, packageDir, backups, recoveryJournal: journal, hookEvents: HOST_HOOK_EVENTS }, null, 2)) +// Restore only byte-identical files recorded by a previous installation in another +// host configuration root. Preserve any subsequent user edit for manual review. +const migration = [] +const oldRoot = previous.configRoot ?? dirname(stateRoot) +if (args.includes('--repair-legacy') && legacyMigration && oldRoot !== root) { + const allowed = ['mcp.json', 'settings.json', 'skills/opengui/SKILL.md'].map(path => join(oldRoot, path)) + for (const entry of previous.backups ?? []) { + if (!allowed.includes(entry.path)) continue + try { + assert.equal(await realpath(dirname(entry.path)), dirname(entry.path), 'Redirected previous configuration directory') + assert(!(await lstat(entry.path)).isSymbolicLink(), 'Redirected previous configuration') + const current = await readFile(entry.path, 'utf8') + assert.equal(createHash('sha256').update(current).digest('hex'), entry.installedSha256, 'Previous configuration has subsequent edits') + if (entry.backup === null) await unlink(entry.path) + else { + assert(entry.backup.startsWith(`${entry.path}.before-opengui-`), 'Unexpected backup path') + assert(!(await lstat(entry.backup)).isSymbolicLink(), 'Redirected backup') + const restored = `${entry.path}.restore-${stamp}` + await writeFile(restored, await readFile(entry.backup), {mode: 0o600, flag: 'wx'}) + assert.equal(await readFile(entry.path, 'utf8'), current, 'Concurrent edit retained') + await rename(restored, entry.path) + } + migration.push({path: entry.path, status: 'restored'}) + } catch (error) { + migration.push({path: entry.path, status: error.code === 'ENOENT' ? 'absent' : 'retained', reason: error.message}) + } + } +} +console.log(JSON.stringify({ status: 'CONFIG_WRITTEN', installState, configRoot: root, hostLoaded: 'unverified', migration, previousConfigRoot: previous.configRoot ?? null, version: pkg.version, packageDir, backups, recoveryJournal: journal, hookEvents: HOST_HOOK_EVENTS }, null, 2)) diff --git a/workbuddy-plugin/scripts/install-macos.command b/workbuddy-plugin/scripts/install-macos.command index a2345f7..b66d1a2 100755 --- a/workbuddy-plugin/scripts/install-macos.command +++ b/workbuddy-plugin/scripts/install-macos.command @@ -3,28 +3,94 @@ set -euo pipefail umask 077 HOST=workbuddy -VERSION=0.2.0 +VERSION=0.2.1 ARCHIVE_NAME=opengui-mcp-$VERSION.tgz usage() { echo "OpenGUI for $HOST $VERSION (macOS arm64/x64)" - echo "Usage: bash $0 [--archive /absolute/path/$ARCHIVE_NAME]" + echo "Usage: bash $0 [--check] [--repair-legacy] [--app /path/WorkBuddy.app] [--config-root /verified/path] [--archive /absolute/path/$ARCHIVE_NAME]" echo 'Downloads a verified prebuilt package and private Node. No sudo or source build.' echo 'Finish existing OpenGUI tasks before upgrading. Keep old packages for rollback.' } archive= -case "${1:-}" in - --help|-h) usage; exit 0 ;; - --archive) [ "$#" = 2 ] || { usage; exit 1; }; archive=$2 ;; - '') [ "$#" = 0 ] || { usage; exit 1; } ;; - *) usage; exit 1 ;; -esac +app= +config_root=${WORKBUDDY_CONFIG_DIR:-${CODEBUDDY_CONFIG_DIR:-}} +check_only=false +repair_legacy=false +while [ "$#" -gt 0 ]; do + case "$1" in + --help|-h) usage; exit 0 ;; + --archive|--app|--config-root) + [ "$#" -ge 2 ] || { usage; exit 1; } + case "$1" in --archive) archive=$2 ;; --app) app=$2 ;; --config-root) config_root=$2 ;; esac + shift 2 ;; + --check) check_only=true; shift ;; + --repair-legacy) repair_legacy=true; shift ;; + *) usage; exit 1 ;; + esac +done +fail() { echo "[$1] $2" >&2; exit 1; } +started=$SECONDS +stage() { echo "[$((SECONDS-started))s] $*"; } [ "$(uname -s)" = Darwin ] || { echo 'Only macOS is supported.' >&2; exit 1; } case "$(uname -m)" in arm64) arch=arm64; node_sha=61130f394c1630d211dd50aecc4353d379480f36d3ac913cd85dbba1aed585c6 ;; x86_64) arch=x64; node_sha=58e99022c2ff89395576cc7fd4d98cea24bb68081475d5f88b801ee8729fb026 ;; *) echo 'Unsupported architecture.' >&2; exit 1 ;; esac -if pgrep -x WorkBuddy >/dev/null; then echo 'Quit WorkBuddy after finishing OpenGUI tasks and closing its mirrors, then rerun this installer.' >&2; exit 1; fi +# Discover the actual bundle identity, including renamed and mounted applications. +candidates=() +for candidate in /Applications/*.app "$HOME"/Applications/*.app /Volumes/*/*.app; do + [ -f "$candidate/Contents/Info.plist" ] || continue + bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$candidate/Contents/Info.plist" 2>/dev/null || true) + case "$bundle_id" in com.tencent.workbuddy.*) candidates+=("$candidate") ;; esac +done +if [ -z "$app" ]; then + [ "${#candidates[@]}" -gt 0 ] || fail HOST_NOT_FOUND 'Install WorkBuddy first, or select its bundle with --app /path/WorkBuddy.app.' + [ "${#candidates[@]}" = 1 ] || fail HOST_AMBIGUOUS 'Multiple WorkBuddy bundles found. Select the intended one with --app /path/WorkBuddy.app.' + app=${candidates[0]} +fi +case "$app" in /*.app) ;; *) fail HOST_PATH 'The --app path must be an absolute .app bundle path.' ;; esac +[ -d "$app" ] || fail HOST_NOT_FOUND 'Selected application does not exist.' +app=$(cd "$app" && pwd -P) +bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist" 2>/dev/null || true) +case "$bundle_id" in com.tencent.workbuddy.*) ;; *) fail HOST_IDENTITY 'Selected bundle is not a recognized WorkBuddy application.' ;; esac +host_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist") +[[ "$host_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail HOST_VERSION 'Cannot determine a supported WorkBuddy version.' +IFS=. read -r major minor patch <<< "$host_version" +if (( 10#$major < 5 || (10#$major == 5 && 10#$minor < 5) || (10#$major == 5 && 10#$minor == 5 && 10#$patch < 3) )); then + fail HOST_TOO_OLD "WorkBuddy $host_version is below the 5.5.3 minimum. Upgrade WorkBuddy, then rerun this installer." +fi +product="$app/Contents/Resources/app.asar.unpacked/cli/product.json" +if [ -z "$config_root" ]; then + if ! folder=$(plutil -extract config.customUserDataDir raw -o - "$product" 2>/dev/null) || [ -z "$folder" ]; then + folder=$(plutil -extract dataFolderName raw -o - "$product" 2>/dev/null || true) + fi + case "$folder" in .workbuddy|.workbuddy-ai) ;; *) fail HOST_CONFIG_UNKNOWN 'Cannot resolve the product configuration directory. Use --config-root only with the verified host configuration path.' ;; esac + suffix=${WORKBUDDY_INSTANCE_NUMBER:-} + if [ -n "$suffix" ]; then [[ "$suffix" =~ ^[0-9]+$ ]] || fail HOST_INSTANCE 'Invalid WorkBuddy instance number.'; folder="$folder-$suffix"; fi + config_root="$HOME/$folder" +fi +case "$config_root" in /*) ;; *) fail HOST_CONFIG_PATH 'Configuration root must be absolute.' ;; esac +cli="$app/Contents/Resources/app.asar.unpacked/cli/dist/codebuddy.js" +for event in UserPromptSubmit PreToolUse Stop SubagentStop FinalStop SessionEnd StopFailure; do + grep -Fq "$event" "$cli" 2>/dev/null || fail HOST_HOOKS "The bundled CLI does not expose $event. Upgrade to a compatible WorkBuddy build." +done +ensure_stopped() { + local processes executable candidate + processes=$(ps -axo comm=) || fail HOST_PROCESS_CHECK 'Cannot inspect running applications.' + while IFS= read -r executable; do + for candidate in "$app" "${candidates[@]:-}"; do + [ -n "$candidate" ] || continue + case "$executable" in "$candidate"/Contents/*) fail HOST_RUNNING 'Quit WorkBuddy with Command-Q after finishing phone tasks, then rerun this installer. No configuration was changed.' ;; esac + done + done <<< "$processes" +} +stage "Preflight: WorkBuddy $host_version; configuration: $config_root" +ensure_stopped +if [ "$check_only" = true ]; then + stage 'PREFLIGHT_OK: no files changed. Hook declarations found; runtime delivery still requires host verification.' + exit 0 +fi # Refuse redirected parent directories before creating installation state. private_dir() { local path=$1 cursor=$1 @@ -37,6 +103,7 @@ private_dir() { } root="$HOME/.workbuddy/opengui" case "$root" in /*) ;; *) echo 'Installation home must be absolute.' >&2; exit 1 ;; esac +private_dir "$config_root" private_dir "$root" lock="$root/installer.lock" mkdir "$lock" 2>/dev/null || { echo "Installation busy or interrupted: inspect $lock before retrying." >&2; exit 1; } @@ -57,11 +124,26 @@ fetch() { } if [ -z "$archive" ]; then base="https://github.com/Core-Mate/OpenGUI/releases/download/opengui-$HOST-v$VERSION" + stage "Downloading verified OpenGUI package" archive="$temporary/$ARCHIVE_NAME" - if ! fetch "$base/$ARCHIVE_NAME" "$archive" || ! fetch "$base/$ARCHIVE_NAME.sha256" "$archive.sha256"; then + if ! fetch "$base/$ARCHIVE_NAME.sha256" "$archive.sha256"; then echo "No downloadable $HOST $VERSION package, or network unavailable. Check the release page; installation stopped without changing host configuration." >&2 exit 1 fi + digest=$(awk 'NR == 1 { print $1 }' "$archive.sha256") + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || fail ARCHIVE_DIGEST 'Invalid release checksum.' + private_dir "$root/downloads" + cached="$root/downloads/$digest.tgz" + if [ -f "$cached" ] && [ ! -L "$cached" ] && [ "$(shasum -a 256 "$cached" | awk '{print $1}')" = "$digest" ]; then + stage 'Reusing verified package download' + cp "$cached" "$archive" + else + fetch "$base/$ARCHIVE_NAME" "$archive" + [ "$(shasum -a 256 "$archive" | awk '{print $1}')" = "$digest" ] || fail ARCHIVE_CHECKSUM 'Archive checksum mismatch.' + cached_new=$(mktemp "$root/downloads/.verified.XXXXXXXX") + cp "$archive" "$cached_new" + mv -f "$cached_new" "$cached" + fi fi [ -f "$archive" ] && [ -f "$archive.sha256" ] || { echo 'Archive and adjacent .sha256 file are required.' >&2; exit 1; } # Parse the digest only. Never trust a sidecar filename as a local path. @@ -91,24 +173,38 @@ if ! valid_node; then printf '%s\n%s\n' "$node_sha" "$(shasum -a 256 "$temporary/$node_name/bin/node" | awk '{print $1}')" > "$temporary/$node_name/.verified" mv "$temporary/$node_name" "$node_dir" fi -"$node" - "$root" "$temporary/verified.tar.gz" "$VERSION" <<'INSTALL_JS' +ensure_stopped +stage "Installing configuration and checking runtime dependencies" +"$node" - "$root" "$temporary/verified.tar.gz" "$VERSION" "$config_root" "$expected" "$0" "$app" "$repair_legacy" <<'INSTALL_JS' const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); -const [root, archive, version] = process.argv.slice(2); +const [root, archive, version, configRoot, archiveSha256, installer, app, repairLegacy] = process.argv.slice(2); const packages = path.join(root, 'packages'); fs.mkdirSync(packages, { recursive: true }); if (fs.lstatSync(packages).isSymbolicLink()) throw Error('Redirected packages directory'); -const install = fs.mkdtempSync(path.join(packages, version + '-')); +const cache = path.join(packages, `${version}-${archiveSha256}`); +let install = cache; +const reusable = fs.existsSync(path.join(cache, '.complete')) && fs.readFileSync(path.join(cache, '.complete'), 'utf8') === archiveSha256; +if (!reusable) install = fs.mkdtempSync(path.join(packages, version + '-')); +if (fs.existsSync(cache) && (!reusable || fs.lstatSync(cache).isSymbolicLink())) throw Error('CACHE_INVALID: retain existing files and inspect the package cache before retrying'); const npm = path.resolve(process.execPath, '../../lib/node_modules/npm/bin/npm-cli.js'); -execFileSync(process.execPath, [npm, 'install', '--prefix', install, '--ignore-scripts', '--no-audit', '--no-fund', archive], { stdio: 'inherit' }); -const pkg = path.join(install, 'node_modules/opengui-mcp'); +if (!reusable) execFileSync(process.execPath, [npm, 'install', '--prefix', install, '--ignore-scripts', '--no-audit', '--no-fund', archive], { stdio: 'inherit' }); +let pkg = path.join(install, 'node_modules/opengui-mcp'); const meta = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'))); if (meta.name !== 'opengui-mcp' || meta.version !== version) throw Error('Archive package/version mismatch'); // Verify native dependencies before switching the host configuration. execFileSync(process.execPath, ['--input-type=module', '-e', 'await import("sharp"); await import("@modelcontextprotocol/sdk/client/index.js")'], { cwd: pkg, stdio: 'inherit' }); -execFileSync(process.execPath, [path.join(pkg, 'scripts/install-local.mjs'), '--package-dir', pkg, '--node', process.execPath], { stdio: 'inherit' }); -console.log('Installed MCP, Skill and lifecycle Hooks. Reopen WorkBuddy, trust OpenGUI MCP, choose /opengui and ask to list phones without operating them.'); -console.log('Rollback backups: ' + path.join(root, 'local-install.json') + '. Old packages are retained.'); +if (!reusable) { + fs.writeFileSync(path.join(install, '.complete'), archiveSha256, {mode: 0o600}); + fs.renameSync(install, cache); + pkg = path.join(cache, 'node_modules/opengui-mcp'); +} +execFileSync('bash', [installer, '--check', '--app', app, '--config-root', configRoot], {stdio: 'inherit'}); +execFileSync(process.execPath, [path.join(pkg, 'scripts/install-local.mjs'), '--package-dir', pkg, '--node', process.execPath, '--config-root', configRoot, '--state-root', root, ...(repairLegacy === 'true' ? ['--repair-legacy'] : [])], { stdio: 'inherit' }); +console.log('CONFIG_WRITTEN: MCP, Skill and lifecycle Hooks configured. Host loading and Hook delivery are NOT yet verified. Reopen WorkBuddy, trust OpenGUI MCP, choose /opengui and ask to list phones without operating them.'); +console.log('Rollback receipt: see installState in the result above. Old packages and per-configuration receipts are retained.'); INSTALL_JS + +stage "Finished. Reopen WorkBuddy and verify read-only device discovery." diff --git a/workbuddy-plugin/scripts/test-install.mjs b/workbuddy-plugin/scripts/test-install.mjs index b1d1338..d4b3f1e 100644 --- a/workbuddy-plugin/scripts/test-install.mjs +++ b/workbuddy-plugin/scripts/test-install.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' -import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile, copyFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,7 +9,7 @@ try { const root = join(temporary, 'workbuddy') const pkg = join(root, 'opengui', 'packages', 'test', 'node_modules', 'opengui-mcp') await mkdir(join(pkg, 'lib'), { recursive: true }) - await writeFile(join(pkg, 'package.json'), JSON.stringify({ name: 'opengui-mcp', version: '0.2.0' })) + await writeFile(join(pkg, 'package.json'), JSON.stringify({ name: 'opengui-mcp', version: '0.2.1' })) await writeFile(join(pkg, 'lib', 'host-hook.js'), '// Synthetic installer target; never executed.\n') await writeFile(join(pkg, 'lib', 'opengui-SKILL.md'), 'name: opengui\n') await writeFile(join(root, 'mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'untouched' } } })) @@ -23,6 +23,36 @@ try { run() assert.equal(await readFile(join(root, 'settings.json'), 'utf8'), before, 'Hook merge must be idempotent') assert.equal(JSON.parse(await readFile(first.backups[0].backup)).mcpServers.other.command, 'untouched') + // Simulate the 0.2.0 receipt format at its legacy location. + const legacyReceipt = JSON.parse(await readFile(first.installState, 'utf8')) + delete legacyReceipt.configRoot + await writeFile(join(root, 'opengui/local-install.json'), JSON.stringify(legacyReceipt)) + const alternate = join(temporary, 'workbuddy-ai') + await mkdir(alternate) + const migrated = JSON.parse(execFileSync(process.execPath, [script, '--repair-legacy', '--config-root', alternate, '--state-root', join(root, 'opengui'), '--package-dir', pkg], {encoding: 'utf8'})) + assert.equal(migrated.configRoot, alternate) + assert.equal(JSON.parse(await readFile(join(alternate, 'mcp.json'))).mcpServers.opengui.command, process.execPath) + assert.equal(JSON.parse(await readFile(join(root, 'mcp.json'))).mcpServers.other.command, 'untouched') + assert.equal(JSON.parse(await readFile(join(root, 'mcp.json'))).mcpServers.opengui, undefined) + assert(migrated.migration.some(entry => entry.status === 'restored')) + const repeated = JSON.parse(execFileSync(process.execPath, [script, '--config-root', alternate, '--state-root', join(root, 'opengui'), '--package-dir', pkg], {encoding: 'utf8'})) + assert.equal(repeated.status, 'ALREADY_CONFIGURED') + const secondInstance = join(temporary, 'workbuddy-ai-2') + await mkdir(secondInstance) + const firstInstanceConfig = await readFile(join(alternate, 'mcp.json'), 'utf8') + // An explicit receipt is a legitimate separate instance, not a legacy mistake. + await writeFile(join(root, 'opengui/local-install.json'), JSON.stringify({...legacyReceipt, configRoot: alternate})) + execFileSync(process.execPath, [script, '--config-root', secondInstance, '--state-root', join(root, 'opengui'), '--package-dir', pkg]) + assert.equal(await readFile(join(alternate, 'mcp.json'), 'utf8'), firstInstanceConfig) + assert.notEqual(repeated.installState, first.installState) + // Restore the original root for the independent redirection regression below. + run() + const conflictRoot = join(temporary, 'conflicting-host') + await mkdir(conflictRoot) + const conflict = JSON.stringify({mcpServers:{opengui:{command:'another-provider',args:[]}}}) + await writeFile(join(conflictRoot, 'mcp.json'), conflict) + assert.throws(() => execFileSync(process.execPath, [script, '--config-root', conflictRoot, '--state-root', join(root, 'opengui'), '--package-dir', pkg], {stdio:'pipe'}), /MCP_CONFLICT/) + assert.equal(await readFile(join(conflictRoot, 'mcp.json'), 'utf8'), conflict) const foreign = join(temporary, 'foreign-host') await mkdir(foreign) await writeFile(join(foreign, 'SKILL.md'), 'DO NOT CHANGE') diff --git a/workbuddy-plugin/scripts/test-preflight.mjs b/workbuddy-plugin/scripts/test-preflight.mjs new file mode 100644 index 0000000..42c5875 --- /dev/null +++ b/workbuddy-plugin/scripts/test-preflight.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm, access, realpath } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +const script = resolve(process.argv[2] ?? new URL('./install-macos.command', import.meta.url).pathname) +if (process.platform !== 'darwin') process.exit(0) +const temporary = await realpath(await mkdtemp(join(tmpdir(), 'workbuddy-preflight-'))) +try { + const app = join(temporary, 'Renamed WorkBuddy AI.app'), home = join(temporary, 'home'), bin = join(temporary, 'bin') + const cli = join(app, 'Contents/Resources/app.asar.unpacked/cli') + await mkdir(join(cli, 'dist'), { recursive: true }); await mkdir(home); await mkdir(bin) + await writeFile(join(bin, 'ps'), '#!/bin/sh\nprintf "%s\\n" "$TEST_PROCESS"\n', {mode: 0o755}) + const plist = version => `CFBundleIdentifiercom.tencent.workbuddy.macCFBundleShortVersionString${version}CFBundleExecutableElectron` + const product = folder => writeFile(join(cli, 'product.json'), JSON.stringify({dataFolderName: folder})) + const version = v => writeFile(join(app, 'Contents/Info.plist'), plist(v)) + await version('5.5.3'); await product('.workbuddy-ai') + await writeFile(join(cli, 'dist/codebuddy.js'), 'UserPromptSubmit PreToolUse Stop SubagentStop FinalStop SessionEnd StopFailure') + const run = (extra = {}, args = []) => spawnSync('bash', [script, '--check', '--app', app, ...args], {encoding:'utf8',env:{...process.env, HOME:home, PATH:bin+':'+process.env.PATH, WORKBUDDY_CONFIG_DIR:'',CODEBUDDY_CONFIG_DIR:'',WORKBUDDY_INSTANCE_NUMBER:'',TEST_PROCESS:'',...extra}}) + let r = run(); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /PREFLIGHT_OK/); assert(r.stdout.includes(join(home,'.workbuddy-ai'))) + await assert.rejects(access(join(home,'.workbuddy-ai'))) + await product('.workbuddy'); r=run(); assert.equal(r.status,0,r.stderr); assert(r.stdout.includes(join(home,'.workbuddy'))) + await writeFile(join(cli,'product.json'), JSON.stringify({dataFolderName:'.workbuddy',config:{customUserDataDir:'.workbuddy-ai'}})) + r=run(); assert.equal(r.status,0,r.stderr); assert(r.stdout.includes(join(home,'.workbuddy-ai'))) + await writeFile(join(cli,'product.json'), JSON.stringify({dataFolderName:'.workbuddy',config:{customUserDataDir:'.unknown-brand'}})) + r=run(); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_CONFIG_UNKNOWN/) + await product('.workbuddy') + await version('5.5.2'); r=run(); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_TOO_OLD/) + await version('5.5.3'); r=run({TEST_PROCESS:join(app,'Contents/MacOS/Electron')}); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_RUNNING/) + r=run({TEST_PROCESS:join(app,'Contents/Frameworks/WorkBuddy Helper.app/Contents/MacOS/WorkBuddy Helper')}); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_RUNNING/) + r=run({TEST_PROCESS:'/Applications/Unrelated.app/Contents/MacOS/Electron'}); assert.equal(r.status,0,r.stderr) + r=run({WORKBUDDY_CONFIG_DIR:join(home,'custom')}); assert.equal(r.status,0,r.stderr); assert(r.stdout.includes(join(home,'custom'))) + r=run({WORKBUDDY_INSTANCE_NUMBER:'2'}); assert.equal(r.status,0,r.stderr); assert(r.stdout.includes(join(home,'.workbuddy-2'))) + await product('unknown'); r=run(); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_CONFIG_UNKNOWN/) + await product('.workbuddy'); await writeFile(join(cli,'dist/codebuddy.js'),'UserPromptSubmit'); r=run(); assert.notEqual(r.status,0); assert.match(r.stderr,/HOST_HOOKS/) + console.log('PASS: product-specific paths, old version refusal, Electron/helper detection, unrelated Electron, custom root, instance suffix, unknown product, missing Hooks and zero-write preflight.') +} finally { await rm(temporary,{recursive:true,force:true}) } diff --git a/workbuddy-plugin/scripts/test-publish.mjs b/workbuddy-plugin/scripts/test-publish.mjs index 0eb89c2..ae9be1b 100644 --- a/workbuddy-plugin/scripts/test-publish.mjs +++ b/workbuddy-plugin/scripts/test-publish.mjs @@ -10,7 +10,7 @@ try { await writeFile(join(temp, 'gh'), `#!${process.execPath} const fs=require('fs');const a=process.argv.slice(2);if(a[1]==='view'){console.error('release not found');process.exit(1)}fs.writeFileSync(process.env.PUBLISH_TEST_OUTPUT,JSON.stringify(a)); `, {mode:0o755}) - const env={...process.env,PATH:temp+':'+process.env.PATH,PUBLISH_TEST_OUTPUT:output,GITHUB_REF_NAME:'opengui-workbuddy-v0.2.0'} + const env={...process.env,PATH:temp+':'+process.env.PATH,PUBLISH_TEST_OUTPUT:output,GITHUB_REF_NAME:'opengui-workbuddy-v0.2.1'} const script=fileURLToPath(new URL('./publish.mjs', import.meta.url)) let result=spawnSync(process.execPath,[script],{env:{...env,OPENGUI_PRERELEASE:'false'},encoding:'utf8'}) assert.notEqual(result.status,0);assert.match(result.stderr,/Unverified release gate/) @@ -18,6 +18,6 @@ const fs=require('fs');const a=process.argv.slice(2);if(a[1]==='view'){console.e assert.equal(result.status,0,result.stderr) const args=JSON.parse(await readFile(output,'utf8')) assert(args.includes('--prerelease'));assert(args.includes('--latest=false')) - assert(args.some(a=>a.endsWith('opengui-workbuddy-0.2.0-install.command.sha256'))) + assert(args.some(a=>a.endsWith('opengui-workbuddy-0.2.1-install.command.sha256'))) console.log('PASS: stable publication remains blocked by missing acceptance; public testing uses prerelease and installer assets.') } finally { await rm(temp,{recursive:true,force:true}) } diff --git a/workbuddy-plugin/scripts/test-release-installer.mjs b/workbuddy-plugin/scripts/test-release-installer.mjs index 64595f4..66f8ec7 100644 --- a/workbuddy-plugin/scripts/test-release-installer.mjs +++ b/workbuddy-plugin/scripts/test-release-installer.mjs @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict' import { createHash } from 'node:crypto' +import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile, readdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,30 +9,39 @@ if (process.platform !== 'darwin') { console.log('Release installer execution re const root = fileURLToPath(new URL('..', import.meta.url)) const temporary = await realpath(await mkdtemp(join(tmpdir(), 'opengui-workbuddy-installer-'))) try { - const home = join(temporary, 'home with spaces'), config = join(home, '.workbuddy'), bin = join(home, 'bin') + const home = join(temporary, 'home with spaces'), config = join(home, '.workbuddy-ai'), stateRoot = join(home, '.workbuddy/opengui'), bin = join(home, 'bin') await mkdir(bin, { recursive: true }) // Only the isolated test host is considered stopped; never quit the real app. - await writeFile(join(bin, 'pgrep'), '#!/bin/sh\nexit "${TEST_HOST_RUNNING:-1}"\n', {mode:0o755}) - const runtime = join(config, 'opengui/runtime', `node-v22.23.2-darwin-${process.arch}`) - await mkdir(join(runtime, 'bin'), { recursive: true }) - await writeFile(join(runtime, 'bin/node'), '#!/bin/sh\nexec ' + JSON.stringify(process.execPath) + ' "$@"\n', { mode:0o755 }) - const digest = createHash('sha256').update(await readFile(join(runtime, 'bin/node'))).digest('hex') - const sha = process.arch === 'arm64' ? '61130f394c1630d211dd50aecc4353d379480f36d3ac913cd85dbba1aed585c6' : '58e99022c2ff89395576cc7fd4d98cea24bb68081475d5f88b801ee8729fb026' - await writeFile(join(runtime, '.verified'), sha + '\n' + digest + '\n') + const app = join(temporary, 'WorkBuddy AI.app') + const cli = join(app, 'Contents/Resources/app.asar.unpacked/cli') + await mkdir(join(cli,'dist'), {recursive:true}) + await writeFile(join(app,'Contents/Info.plist'), 'CFBundleIdentifiercom.tencent.workbuddy.macCFBundleShortVersionString5.5.3') + await writeFile(join(cli,'product.json'), JSON.stringify({dataFolderName:'.workbuddy-ai'})) + await writeFile(join(cli,'dist/codebuddy.js'), 'UserPromptSubmit PreToolUse Stop SubagentStop FinalStop SessionEnd StopFailure') + await writeFile(join(bin, 'ps'), '#!/bin/sh\nif [ "$TEST_HOST_RUNNING" = 0 ]; then printf "%s\\n" "$TEST_APP/Contents/MacOS/Electron"; fi\n', {mode:0o755}) + await writeFile(join(bin, 'pgrep'), '#!/bin/sh\nexit 1\n', {mode:0o755}) + await mkdir(config, {recursive:true}) await writeFile(join(config, 'mcp.json'), JSON.stringify({mcpServers:{other:{command:'keep-me'}}})) await writeFile(join(config, 'settings.json'), JSON.stringify({custom:true,hooks:{Stop:[{hooks:[{type:'command',command:'other-hook'}]}]}})) - const archive = join(root, 'dist/opengui-mcp-0.2.0.tgz') - const run = (extra={}) => spawnSync('bash', [join(root, 'scripts/install-macos.command'), '--archive', archive], {encoding:'utf8',env:{...process.env,HOME:home,PATH:bin+':'+process.env.PATH,...extra}}) + const archive = join(root, 'dist/opengui-mcp-0.2.1.tgz') + const installer = process.argv[2] ?? join(root, 'scripts/install-macos.command') + const run = (extra={}) => spawnSync('bash', [installer, '--archive', archive, ...(process.argv[2] ? [] : ['--app', app])], {encoding:'utf8',env:{...process.env,HOME:home,WORKBUDDY_CONFIG_DIR:'',CODEBUDDY_CONFIG_DIR:'',WORKBUDDY_INSTANCE_NUMBER:'',TEST_APP:app,PATH:bin+':'+process.env.PATH,...extra}}) let result=run({TEST_HOST_RUNNING:'0'}); assert.notEqual(result.status,0); assert.match(result.stderr,/Quit WorkBuddy/) + const timings = [] for (let i=0;i<2;i++) { + const started = Date.now() result=run(); assert.equal(result.status,0,result.stderr+'\n'+result.stdout) const mcp=JSON.parse(await readFile(join(config,'mcp.json'))), settings=JSON.parse(await readFile(join(config,'settings.json'))) assert.equal(mcp.mcpServers.other.command,'keep-me'); assert.equal(settings.custom,true) - assert.equal(settings.hooks.Stop.length,2) + assert.equal(settings.hooks.Stop.length,2, JSON.stringify({iteration:i, settings, stdout:result.stdout, stderr:result.stderr})) assert.match(await readFile(join(config,'skills/opengui/SKILL.md'),'utf8'),/opengui/) - const state=JSON.parse(await readFile(join(config,'opengui/local-install.json'))) - assert.equal(state.version,'0.2.0'); assert(state.backups.every(b=>b.backup===null || b.backup.includes('before-opengui'))) + timings.push(Date.now() - started) + if (i === 1) assert.match(result.stdout, /ALREADY_CONFIGURED/) + const state=JSON.parse(await readFile(join(stateRoot,`local-install-${createHash('sha256').update(config).digest('hex').slice(0,16)}.json`))) + assert.equal(state.version,'0.2.1'); assert.equal(state.configRoot, config); assert(state.backups.every(b=>b.backup===null || b.backup.includes('before-opengui'))) assert((await readFile(join(state.packageDir,'scripts/install-local.mjs'),'utf8')).includes('mergeHostHooks')) } + assert.equal((await readdir(join(stateRoot, 'packages'))).length, 1, 'Repeat installation must reuse the same package directory') + console.log(JSON.stringify({firstInstallMs:timings[0], repeatInstallMs:timings[1]})) console.log('PASS: real prebuilt npm install, native dependency import, packaged config installer, upgrade, retained foreign MCP/Hooks, paths with spaces and running-host rejection.') } finally { await rm(temporary,{recursive:true,force:true}) } diff --git a/workbuddy-plugin/src/state.ts b/workbuddy-plugin/src/state.ts index e9f14be..5ba0fd7 100644 --- a/workbuddy-plugin/src/state.ts +++ b/workbuddy-plugin/src/state.ts @@ -3,7 +3,7 @@ import { lstat, mkdir, open, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join, resolve } from 'node:path' -export const VERSION = '0.2.0' +export const VERSION = '0.2.1' export const BROKER_PROTOCOL = 7 export function workbuddyStateDir(override?: string): string { diff --git a/workbuddy-plugin/tests/mcp.spec.ts b/workbuddy-plugin/tests/mcp.spec.ts index 229e569..54b9410 100644 --- a/workbuddy-plugin/tests/mcp.spec.ts +++ b/workbuddy-plugin/tests/mcp.spec.ts @@ -87,7 +87,7 @@ describe('standard MCP transport', () => { const { client: c, connection } = await client() const listed = await c.listTools() expect(listed.tools.map(tool => tool.name)).toEqual(OPENGUI_WORKBUDDY_TOOLS.map(tool => tool.name)) - expect(c.getServerVersion()).toMatchObject({ name: 'opengui-workbuddy', version: '0.2.0' }) + expect(c.getServerVersion()).toMatchObject({ name: 'opengui-workbuddy', version: '0.2.1' }) expect(connection.call).not.toHaveBeenCalled() })