From 459cc21e3dd853ef9c3158c03134c202a4c6a07a Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 29 Aug 2026 10:54:40 +0800 Subject: [PATCH] fix: make managed SSH sessions fast and resilient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Managed SSH project selection repeatedly activated the remote Host and could leave idle ACP bindings rejected as stale-controller after a controller generation change. Host environment updates also coupled project state to component installation evidence and presented a crowded control surface. Project selection is now local-only, one Host connection serves compatible project and conversation callers, idle bindings are safely replaced only after proven controller takeover, and active prompts keep exact resume/replay. Remote environment setup now uses explicit signed compound package preparation with bounded transfer, rollback, recovery, and clearer settings. Managed Hosts must be updated to separately distributed Agent 0.11.6 before the daemon-side controller recovery fix takes effect. Desktop packages do not embed the Agent or remote Runtime, and existing project data is preserved. Release note: 托管 SSH 项目切换不再重复连接,同一 Host 可顺畅承载多个项目和对话;同时修复重连后的 stale-controller,并优化远程环境安装、更新与状态布局。 --- BUILD.md | 72 +- FEATURES.md | 7 +- README.md | 2 +- agent-runtime-lock.json | 2 +- .../check-control-plane-package-installer.cjs | 74 + docs/README.md | 1 + .../remote-host-and-goodbuddy-agent-design.md | 135 +- ...te-host-environment-provisioning-design.md | 320 +++ ...istant-workbar-and-execution-spaces-prd.md | 107 +- electron.vite.config.ts | 6 + package.json | 2 +- src/agent-daemon/cli.test.ts | 56 +- src/agent-daemon/cli.ts | 90 +- .../installation-registry.test.ts | 12 + src/agent-daemon/installation-registry.ts | 29 +- .../installed-bundle-verifier.test.ts | 55 + src/agent-daemon/installed-bundle-verifier.ts | 160 ++ src/agent-daemon/protocol-server.test.ts | 49 + src/agent-daemon/protocol-server.ts | 2 + src/agent-daemon/runtime-acp-backend.test.ts | 183 +- src/agent-daemon/runtime-acp-backend.ts | 26 +- src/main/assistant/assistant-database.test.ts | 729 ++--- src/main/assistant/assistant-database.ts | 1198 ++------ .../execution-space-resolver.test.ts | 333 +-- .../execution-space-resolver.ts | 77 +- .../remote-runtime-request-validation.test.ts | 77 - .../remote-runtime-request-validation.ts | 21 - src/main/index.ts | 47 +- src/main/ipc.test.ts | 156 +- src/main/ipc.ts | 72 +- src/main/model-download-transport.ts | 20 +- .../agent-attach-transport.test.ts | 29 +- .../remote-agent/agent-attach-transport.ts | 22 +- .../agent-installation-manager.test.ts | 1483 +++------- .../agent-installation-manager.ts | 1007 +++---- .../agent-package-manager.test.ts | 756 +++++ .../remote-agent/agent-package-manager.ts | 1125 ++++++-- .../remote-agent/agent-package-verifier.ts | 18 +- .../bundled-agent-resources.test.ts | 21 +- .../remote-agent/bundled-agent-resources.ts | 11 + .../control-plane-package-installer-cli.ts | 3 + .../control-plane-package-installer.ts | 2508 +++++++++++++++++ src/main/remote-agent/index.ts | 5 +- .../managed-remote-acp-runtime.test.ts | 198 +- .../managed-remote-acp-runtime.ts | 323 ++- .../managed-remote-execution-services.ts | 55 +- ...d-remote-project-runtime-validator.test.ts | 60 +- ...anaged-remote-project-runtime-validator.ts | 31 +- ...ed-remote-workspace-access-factory.test.ts | 145 +- ...managed-remote-workspace-access-factory.ts | 55 +- ...rotocol-remote-workspace-transport.test.ts | 151 +- .../protocol-remote-workspace-transport.ts | 157 +- .../remote-agent-connection-manager.test.ts | 186 +- .../remote-agent-connection-manager.ts | 169 +- .../remote-agent-services.test.ts | 28 +- .../remote-agent/remote-agent-services.ts | 31 +- .../remote-environment-metadata.test.ts | 207 ++ .../remote-environment-metadata.ts | 142 + ...remote-environment-operation-store.test.ts | 68 + .../remote-environment-operation-store.ts | 141 + .../remote-environment-preparer.test.ts | 861 ++++++ .../remote-environment-preparer.ts | 825 ++++++ .../remote-environment-progress.ts | 35 + .../remote-environment-update-service.test.ts | 400 +-- .../remote-environment-update-service.ts | 367 ++- .../remote-host-target-identity.ts | 54 + .../remote-project-save-service.test.ts | 486 ++-- .../remote-project-save-service.ts | 395 +-- ...emote-runtime-installation-manager.test.ts | 751 ++--- .../remote-runtime-installation-manager.ts | 1098 ++++---- .../remote-runtime-resource-loader.test.ts | 381 +-- .../remote-runtime-resource-loader.ts | 32 +- src/main/ssh/bounded-sftp.test.ts | 246 +- src/main/ssh/bounded-sftp.ts | 175 +- src/main/ssh/ssh-connection-pool.test.ts | 821 +++--- src/main/ssh/ssh-connection-pool.ts | 1069 ++++--- src/main/ssh/ssh-connection-target.ts | 51 + .../ssh/ssh-host-directory-browser.test.ts | 4 + src/main/ssh/ssh-host-directory-browser.ts | 64 +- .../ssh/ssh-host-remote-environment.test.ts | 295 +- src/main/ssh/ssh-host-remote-environment.ts | 258 +- src/main/ssh/ssh-host-service.test.ts | 10 + src/main/ssh/ssh-host-service.ts | 15 +- src/main/ssh/ssh-host-store.test.ts | 26 + src/main/ssh/ssh-host-store.ts | 38 +- .../ssh/ssh-remote-package-bootstrap.test.ts | 1194 ++++++++ src/main/ssh/ssh-remote-package-bootstrap.ts | 1614 +++++++++++ .../workspace/remote-workspace-access.test.ts | 113 +- src/main/workspace/remote-workspace-access.ts | 97 +- src/preload/index.ts | 20 +- src/preload/preload-sandbox.test.ts | 3 +- src/renderer/src/App.test.tsx | 632 +---- src/renderer/src/App.tsx | 484 +--- src/renderer/src/ProjectSwitcher.test.tsx | 254 +- src/renderer/src/ProjectSwitcher.tsx | 377 +-- src/renderer/src/SettingsPanel.test.tsx | 5 +- src/renderer/src/SettingsPanel.tsx | 6 +- .../src/SshHostsSettingsSection.test.tsx | 521 +++- src/renderer/src/SshHostsSettingsSection.tsx | 453 ++- src/renderer/src/WorkspacePrimitives.tsx | 2 +- src/renderer/src/i18n/locales/en-US/app.ts | 15 - .../i18n/locales/en-US/settingsSections.ts | 60 +- .../src/i18n/locales/en-US/workspace.ts | 30 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 12 - .../i18n/locales/zh-CN/settingsSections.ts | 59 +- .../src/i18n/locales/zh-CN/workspace.ts | 30 +- src/renderer/src/styles.css | 183 +- src/shared/agent-package-contracts.test.ts | 11 + src/shared/assistant-contracts.test.ts | 104 +- src/shared/assistant-contracts.ts | 54 +- src/shared/contracts.ts | 9 +- src/shared/ipc-channels.ts | 1 - src/shared/ssh-host-contracts.test.ts | 85 +- src/shared/ssh-host-contracts.ts | 81 +- tests/agent-package.test.ts | 946 ++++++- ...trol-plane-package-installer-build.test.ts | 68 + tests/electron-vite-config.test.ts | 5 + 117 files changed, 19876 insertions(+), 9664 deletions(-) create mode 100644 build/check-control-plane-package-installer.cjs create mode 100644 docs/architecture/remote-host-environment-provisioning-design.md delete mode 100644 src/main/execution-space/remote-runtime-request-validation.test.ts delete mode 100644 src/main/execution-space/remote-runtime-request-validation.ts create mode 100644 src/main/remote-agent/agent-package-manager.test.ts create mode 100644 src/main/remote-agent/control-plane-package-installer-cli.ts create mode 100644 src/main/remote-agent/control-plane-package-installer.ts create mode 100644 src/main/remote-agent/remote-environment-metadata.test.ts create mode 100644 src/main/remote-agent/remote-environment-metadata.ts create mode 100644 src/main/remote-agent/remote-environment-operation-store.test.ts create mode 100644 src/main/remote-agent/remote-environment-operation-store.ts create mode 100644 src/main/remote-agent/remote-environment-preparer.test.ts create mode 100644 src/main/remote-agent/remote-environment-preparer.ts create mode 100644 src/main/remote-agent/remote-environment-progress.ts create mode 100644 src/main/remote-agent/remote-host-target-identity.ts create mode 100644 src/main/ssh/ssh-connection-target.ts create mode 100644 src/main/ssh/ssh-remote-package-bootstrap.test.ts create mode 100644 src/main/ssh/ssh-remote-package-bootstrap.ts create mode 100644 tests/control-plane-package-installer-build.test.ts diff --git a/BUILD.md b/BUILD.md index e308183d..f88e9b31 100644 --- a/BUILD.md +++ b/BUILD.md @@ -151,11 +151,50 @@ Main 在写入缓存前校验签名目录、目录签名、大小、SHA-256、 production/test 环境和撤销列表,不来自 JSON 空白或换行。读取桌面资源、复合包和 Host 已有 registry 时接受等价 UTF-8 JSON 格式;向 Host 写入前统一重新序列化为确定性 LF JSON。签名目录、包描述符、manifest、签名和 payload 的原始字节校验保持不变,包下载 -继续流式计算 SHA-256,不使用 MD5。 +继续流式计算 SHA-256。 -“SSH 主机 > 远程运行环境”的“更新版本”只使用已验证的本地复合包。对应 Host 架构 -尚未下载时会提示先到设置下载或导入,不会联网,也不会影响本地项目、普通桌面功能或 -其他架构的 Agent 包。 +“SSH 主机 > 远程运行环境”只有一个按版本事实显示“安装远程环境”“更新远程环境”或 +“重新安装”的主按钮。次级 SegmentedControl 选择默认且不持久化的“自动”、“Host 下载” +或“GoodBuddy 传输”: + +- “Host 下载”使用签名目录中的固定 GitHub/北京镜像 URL、大小、SHA-256、 + 有界重定向链和固定 SSH prepare channel。GoodBuddy 桌面控制面通过该通道发送有界 + one-shot installer;Host 校验目录绑定的归档大小和 SHA-256 后,由包内固定 Node 解码 + 并运行 installer。 +- “GoodBuddy 传输”在本地缺少精确候选时,于同一次用户操作下载、校验 SHA-256 与签名、 + 缓存并取得 lease,再以有界流式 SFTP 上传一个 compound archive 和归档内已验证的 + bootstrap Node;约 294 MiB 的整包不会一次读入 Main `Buffer`,Host 仍再次校验。 +- “自动”只在 operation/prepare 前执行 Host capability probe;直连明确可用才选择 + Host 下载,否则选择 GoodBuddy 传输。显式选择保持有效,prepare、commit 或 adoption + 失败后不跨 acquisition 自动 fallback。 + +两种 acquisition 都把同一签名 compound `.gbagent` 交付到固定 operation staging,随后 +共用 control-plane `prepare → commit → Agent activate/health → Runtime activate → +finalize → cleanup`。commit 终态持久化,通道丢失时只用 `commit-status` 读取恢复而不 +重放 commit。prepare 完成后、commit 前快照 Agent/Runtime 五个 metadata 文件;任一 adoption 失败恢复 +原字节或原缺失状态,已发布 side-by-side payload 不成为 current,确认 adoption 后显式 +cleanup。 + +进入“主机与远程执行”设置页只读取本地 Host 列表,不逐台连接。用户点击某台 Host 的 +“刷新版本”后才执行远端版本和直连准备能力探测。项目切换只更新本地选择,不建立 SSH +连接;首次实际使用远程 Workspace 或 Runtime 时才建立连接。当前进程会复用该 Host 已确认 +的 Agent/Runtime identity 和 Agent 连接,Host 编辑或环境更新会定向清空它们。 +直连安装本身只依赖 SSH,不依赖已有 Agent。显式选择 Host 下载时由实际安装流程重新探测 +并报告工具、权限、空间或来源不可达等原因;本次操作不会自动改用 GoodBuddy 传输。 +新建远程项目弹窗也只读本地 Host 验证记录;目录浏览或保存才连接所选 Host。Host 卡片 +在版本相同时仍允许显式重装;损坏的 GoodBuddy-owned 同 digest 目录会先隔离、替换并在 +失败时恢复。直连 bootstrap 使用固定短命令 `exec sh -s`,脚本和有界输入经 stdin 发送, +避免 SSH command 长度限制。 + +新增 Host 只保存并探测,不提供自动安装开关;只有用户明确点击主按钮后才传输完整包。现有 +package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host 直连;归档无需携带 +`agent/lib/package-installer.cjs`,签名目录不提供也不检查额外的 bootstrap 能力元数据。 +首次 bootstrap 不依赖已安装 Agent daemon。目标契约见 +[SSH Host 远程环境准备与直连下载设计](docs/architecture/remote-host-environment-provisioning-design.md)。 + +删除 Host 或远程项目属于纯本地管理操作。Host 确认框列出全部关联项目记录并级联清理 +本地数据库,但不连接 Host、不检查远端目录,也不删除远端文件;Host 设置文件和项目事务 +的普通失败会回滚已写入的一侧。 ## Runtime 资源 @@ -173,7 +212,12 @@ JSON。签名目录、包描述符、manifest、签名和 payload 的原始字 面向用户发布的是按远端 Host 架构区分的复合 `.gbagent`,每个包同时包含签名 Agent、固定 Node 和当前桌面源码维护并适配的签名 OpenCode Runtime。Runtime 不建立 -独立的用户版本流。生产构建命令为: +独立的用户版本流。远程直连所需的有界 one-shot installer 由桌面控制面打包并通过固定 +SSH prepare channel 发送,不是 `.gbagent` 的必需条目。Host 按签名目录校验完整归档 +大小和 SHA-256 后,包内固定 Node 解码并运行 installer。两种 acquisition 都在固定 +operation staging 中执行同一 prepare/commit;commit 终态持久化并可通过只读 +`commit-status` 恢复。Agent/Runtime adoption 使用 commit 前五个 metadata 文件的原字节 +或原缺失状态回滚,已发布 side-by-side payload 不会因此成为 current。生产构建命令为: ```bash node build/agent-package.cjs build \ @@ -271,15 +315,17 @@ broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等 唯一标识对应版本的精确工件。变更后必须重新构建、签名并导入 Linux x64/arm64 两套工件; 旧版本或旧 digest 会按设计校验失败。不要手工把本机 `.runtime-resources` 当作远程 bundle。 -桌面应用更新后不会在项目激活时自动下载 Agent。用户先在设置中显式下载“最新兼容” -版本或导入离线包;随后打开托管 SSH 项目时,Main 才使用该 Host 架构的本地验证包执行 -完整激活。所需版本健康且项目事务提交成功后才刷新项目绑定;失败不会覆盖旧安装或把 -未提交的新 identity 暴露给 Workspace/Runtime。 +桌面应用更新后不会在项目切换时下载或安装 Agent。用户先在 SSH Host 的远程运行环境 +卡片显式选择直连下载或 GoodBuddy 传输。托管 SSH 项目只保存 Host ID、远端路径、 +Runtime 选择和默认模式;创建或保存时验证 Host 当前环境,普通项目切换只读取这些本地 +配置。首次实际使用 Workspace 或 Runtime 时解析 Host current identity;同一进程内后续 +项目复用已确认的 Agent/Runtime identity 和 Agent 连接。该流程不取得安装包、不重读完整 +payload,也不发布组件。 -用户也可在 SSH Host 的远程运行环境卡片显式选择“更新版本”。该路径按 Agent、 -OpenCode Runtime 顺序使用对应架构的本地复合包,支持阶段进度和取消;成功后使引用该 Host -的项目重新验证。验收应覆盖激活与此手动入口,并确认失败或取消不会删除 -Host 配置、凭据、项目设置、Workspace 文件或仍可用的旧安装。 +两条手动准备路径都支持阶段进度和取消;成功后定向失效该 Host 的连接与 Runtime 缓存, +引用项目下一次打开自然使用新的 current 环境。验收应覆盖两种准备方式与项目当前环境 +验证入口,并确认失败或取消不会删除 Host 配置、凭据、项目设置、Workspace 文件、全局 +可信元数据或仍可用的旧安装。 ## 跨平台 CI 与 GitHub Release diff --git a/FEATURES.md b/FEATURES.md index 2c582089..b1d92427 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -23,8 +23,9 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 -- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.5。 -- [x] **SSH Host 运行环境手动更新**:远程运行环境卡片只读取 Host 已安装 Agent/Runtime 与当前本地复合包所需版本,不会联网。用户显式触发后,Main 按 Agent、Runtime 顺序使用对应 Host 架构的已验证本地 `.gbagent` 强制重新校验并安装,显示阶段进度并允许在最终收尾前取消;尚未下载时提示先到平台功能设置下载或导入。更新期间保留旧版本卡片,失败或取消后刷新实际状态并允许重试;成功后定向清理该 Host 缓存并使引用项目重新验证,不删除 Host 配置、凭据、项目设置或 Workspace 文件。 +- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;活动请求沿精确 resume/replay 恢复,controller 换代遗留的空闲 binding 只在 takeover 已证明且没有活动请求时换新 channel epoch 并恢复原 ACP session。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已更新为待发布 Agent 0.11.6。 +- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、持久化 commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 再次校验。通道丢失通过只读 `commit-status` 恢复而不重放 commit;组件 adoption 失败恢复 Agent/Runtime 五个 metadata 文件的原字节或原缺失状态,已发布 side-by-side payload 不成为 current。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,不安装或扫描 payload。详见 [设计说明](./docs/architecture/remote-host-environment-provisioning-design.md)。 +- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。 @@ -95,7 +96,7 @@ - [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。 - [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。 -- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP、Main-only 模型桥、Ask 只读、Execute 完整账号权限、取消、detached Agent 重连和 release-only 双架构资源校验已经接线。Windows 到 Linux x64 的真实 Host provider-free 安装、metadata-only Portable 复用与项目保存、桥接、断线恢复、输出重放、同 Session 续接与清理已经通过;Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 的真实模型 Ask 验收也已通过,单次用户操作只执行一个原生 read,两个 build 模型轮次均完成交付,没有 title 轮次或请求重放。公开 signing key registry 已供应。剩余门槛是生成并导入 Linux x64/arm64 当前发布版本的正式签名工件完整矩阵。门槛未满足时不提供未验证 SSH fallback。 +- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP、Main-only 模型桥、Ask 只读、Execute 完整账号权限、取消、detached Agent 重连和 release-only 双架构资源校验已经接线。Windows 到 Linux x64 的既有真实 Host provider-free 安装、metadata-only Portable 复用与项目保存、桥接、断线恢复、输出重放、同 Session 续接与清理已经通过;Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 的真实模型 Ask 验收也已通过,单次用户操作只执行一个原生 read,两个 build 模型轮次均完成交付,没有 title 轮次或请求重放。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构、取消和离线 GoodBuddy 传输的真实 Host 验收;门槛未满足时不宣称该路径已发布或通过验收。 - [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。 ## 规划原则 diff --git a/README.md b/README.md index eb4690a4..78d1565d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地 - **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。 - **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。 - **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。 -- **托管 SSH 项目(技术预览)**:远程能力默认关闭且不影响普通桌面使用。启用后,在“设置 > 平台功能 > 远程项目”按 Linux Host 架构手动下载独立签名的 Agent 包,或导入/导出 `.gbagent` 离线包;每个包包含 Agent、固定 Node 和 GoodBuddy 适配的远端 OpenCode Runtime,在线来源跟随“关于与更新”的 GitHub/镜像选择。 +- **托管 SSH 项目(技术预览)**:远程能力默认关闭且不影响普通桌面使用。添加 Host 只保存并探测,不自动安装;Host 卡片以一个按版本事实命名的主按钮准备环境,次级控件可选默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输。自动模式只在操作开始时择一,失败不跨路径重试;两种方式交付同一签名 compound `.gbagent` 后共用 prepare、commit、Agent/Runtime adoption 与 cleanup。项目创建与切换只使用已经准备好的 Host。GitHub/北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收仍待完成,当前不表示该路径已发布。 - **桌面上下文**:可选择文件、截图、应用窗口、剪贴板和语音作为上下文。 - **离线语音**:支持 SenseVoice、Paraformer 和 Whisper 本地模型。 - **富文本回答**:支持 Markdown、LaTeX 公式和受控 Mermaid 图表。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index bdaca5a7..514200e6 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.5", + "agentVersion": "0.11.6", "protocol": { "major": 2, "minor": 0 diff --git a/build/check-control-plane-package-installer.cjs b/build/check-control-plane-package-installer.cjs new file mode 100644 index 00000000..d9ed49e5 --- /dev/null +++ b/build/check-control-plane-package-installer.cjs @@ -0,0 +1,74 @@ +const { createHash } = require('node:crypto') +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { spawnSync } = require('node:child_process') + +const maximumInstallerBytes = 256 * 1024 + +function verifyControlPlanePackageInstaller( + filePath = resolve( + 'out', + 'main', + 'remote-package-installer.mjs' + ) +) { + const bytes = readFileSync(filePath) + if ( + bytes.byteLength === 0 || + bytes.byteLength > maximumInstallerBytes + ) { + throw new Error( + 'Control-plane package installer exceeds its transfer limit' + ) + } + const source = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true + }).decode(bytes) + if ( + /(?:^|\n)\s*(?:import|export)\b[^\r\n]*\bfrom\s*['"]\.{1,2}\//u.test( + source + ) || + /(?:^|\n)\s*import\s*['"]\.{1,2}\//u.test(source) || + /\bimport\s*\(\s*['"]\.{1,2}\//u.test(source) + ) { + throw new Error( + 'Control-plane package installer is not a standalone bundle' + ) + } + const syntax = spawnSync( + process.execPath, + ['--check', filePath], + { + encoding: 'utf8', + windowsHide: true + } + ) + if (syntax.status !== 0) { + throw new Error( + `Control-plane package installer is not executable: ${ + syntax.stderr.trim() || syntax.stdout.trim() + }` + ) + } + return { + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex') + } +} + +if (require.main === module) { + const result = verifyControlPlanePackageInstaller( + process.argv[2] + ? resolve(process.argv[2]) + : undefined + ) + process.stdout.write( + `Control-plane package installer: ${result.size} bytes, sha256 ${result.sha256}\n` + ) +} + +module.exports = { + maximumInstallerBytes, + verifyControlPlanePackageInstaller +} diff --git a/docs/README.md b/docs/README.md index 8bb51b4e..88723bcb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先 - [DeepSeek Harness Runtime 设计](./architecture/deepseek-harness-runtime-design.md) - [应用内反馈系统设计与对接](./architecture/in-app-feedback-integration-design.md) - [SSH 远程主机与 GoodBuddy Agent 稳定终态设计](./architecture/remote-host-and-goodbuddy-agent-design.md) +- [SSH Host 远程环境准备与控制面直连设计](./architecture/remote-host-environment-provisioning-design.md) - [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md) - [长期助手路线图](./roadmap/long-term-assistant-roadmap.md) - [电脑控制实施状态](./status/computer-control-implementation-status.md) diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 3f333e34..73539008 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -2,14 +2,28 @@ ## 状态 -本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.5`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 +本文记录截至 2026-08-28 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备 +Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线, +详细事务与验收边界见 +[SSH Host 远程环境准备与直连下载设计](./remote-host-environment-provisioning-design.md); +控制面直连源码可直接使用既有 package format v1 包,不等待携带 installer 的新 Agent +包,也不通过额外目录元数据判断 bootstrap 能力;公开能力仍等待 GitHub/北京镜像、 +Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。 +Windows 到 Linux x64 的既有安装、 +Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 +Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 +OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback +路径 capability 后,当前源码 lock 已更新为待发布 Agent `0.11.6`。失败的 `agent-v0.11.3` +保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry +已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 +复合包和签名累计目录。 ## 产品语义 远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立 “远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存 远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、 -项目或凭据;进行中的激活允许取消,当前远程项目切回第一个普通本地项目。应用启动仍固定 +项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定 选择普通本地项目,不会因为保存了远程项目而自动连接 Host。 远程项目只有两种工作模式: @@ -28,7 +42,7 @@ Main -> SSH Host 与 Host Key 管理 -> SSH connection pool -> 签名 Agent / Runtime 安装 - -> 远程项目验证与 SQLite 事务 + -> 远程项目当前环境准备与稳定配置 SQLite 事务 -> Main-owned model bridge SSH attach -> 远端私有 Unix socket @@ -46,7 +60,7 @@ Detached GoodBuddy Agent - Main 在认证前取得 Host Key,展示算法与 SHA-256 指纹,并保存完整 key blob。 - 首次连接需要用户确认。Host Key 变化展示旧/新值并要求显式替换。 - 密码使用 Electron `safeStorage`;系统 SSH Agent 认证不启用 Agent forwarding。 -- Host 地址、用户或 Host Key generation 变化时关闭旧连接,并定向退役依赖旧 Host identity 的 Workspace 和 Runtime 会话。保存开始后,所有引用该 Host 的项目保持“需要重新激活”状态;当前项目立即执行完整激活并原子刷新 Host revision 等验证结果,其他项目在下次选择时刷新。激活失败或取消不会恢复发送能力,Composer 和来自 Main 持久队列的用户消息都要等待后续成功激活;排队消息会退回原队列,并在成功后通过既有 queue-ready 流程继续。用户重新选择当前 SSH 项目也会显式触发相同验证,而不是复用旧项目快照。 +- Host 地址、用户或 Host Key generation 变化时关闭旧连接,并定向退役依赖旧 Host identity 的 Workspace 和 Runtime 会话。下一次选择使用 Host 管理的当前连接记录解析 current Agent/Runtime,不读取项目中的旧 revision 或组件 identity;当前 registry、连接或 capability 无效时才要求显式修复。 ## Agent 安装与生命周期 @@ -56,23 +70,83 @@ Detached GoodBuddy Agent manifest、registry identity、owner/mode/size 和 Node digest 都可验证,且候选 manifest 声明完全相同的 Node,安装器直接在新的 side-by-side 目录中复用该 Node; 否则正常上传候选 Node。Agent 不匹配不会阻止升级,也不会覆盖旧安装。 -- 桌面包只携带版本 lock 与 production 公钥,不携带远端 payload。用户必须先在设置中显式下载最新兼容的目标架构复合包,或导入离线 `.gbagent`;项目激活不会联网下载。每个复合包绑定 Agent、固定 Node 与桌面维护的 OpenCode Runtime 精确工件。应用重启固定进入第一个普通本地项目,不自动连接上次远程项目;用户主动切换托管 SSH 项目时,Main 从本地验证缓存选择 Host 架构工件,Host 尚未安装该 identity 时上传并启动。相同 Renderer 对同一项目的并发激活共享一个 Main 操作。 -- Agent、Workspace 和 Runtime 全部验证成功后,Main 才在同一 SQLite 事务中刷新项目保存的 Agent/Runtime evidence,然后把项目设为当前项目。上传、启动、验证或事务失败时,项目继续绑定旧 identity,不能用未提交的新 identity 绕过持久化绑定校验。 +- 桌面包只携带版本 lock 与 production 公钥,不携带远端 payload。Host 卡片只有一个按 + 版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮;次级 + SegmentedControl 选择“自动”“Host 下载”或“GoodBuddy 传输”,默认“自动”且不持久化。 + 每个复合包绑定 Agent、固定 Node 与桌面维护的 OpenCode Runtime 精确工件。添加或重新 + 验证 Host 只保存并探测,不自动传输完整包。 +- Host 管理独占 Agent/Runtime 的包准备、更新和完整 payload 验证。已有项目的打开和切换 + 只更新本地项目选择。Workspace 和执行路径按需通过安装管理器解析 Host current identity, + 执行固定 `attach-or-bootstrap` 并复用当前 Agent 连接;同一进程内复用已确认 identity。 + 它们不取得安装包、不发布组件,也不通过 SFTP 重读或哈希 payload。 +- Agent 的固定 attach/按需启动命令只读取 Host 管理已提交的 installation registry 和 + 与其 digest 匹配的有界 manifest 元数据,不在每次项目切换时重新验签或哈希完整 payload。 + 完整验签、payload 扫描和 registry 写入仍只发生在显式 Host 准备/更新流程。 +- 项目切换不执行远程核对,立即选择本地项目配置。Workspace 或 Runtime 首次实际使用时 + 才通过当前 Agent 连接核对所需能力;失败只影响该操作。新建或显式保存项目仍执行完整 + 准备,并只事务写入 Host、路径、Runtime 选择和工作模式。 - SSH 连接先尝试 attach;Agent 不存在或未运行时执行幂等 bootstrap,然后重新 attach。 - Agent 是按需启动的 detached process,不注册开机服务,不依赖 systemd、D-Bus 或 Linger。 - 每个模型桥 helper 都为 loopback HTTP 入口生成一次性随机路径 capability;只有写入当前 OpenCode 子进程配置的 URL 可以访问该入口,其他本机用户即使发现临时端口也不能提交模型请求。 - Agent 监听当前用户拥有的私有 Unix socket。SSH 中断只关闭 relay,不拥有 Agent 和活动 Runtime 的生命周期。 -- SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前 GoodBuddy 所需版本比较;打开设置或“刷新版本”只读取状态,不安装或切换远端组件。 +- SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前 + GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host; + 用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮 + 解耦且不建立连接;实际远程操作才建立或复用 SSH/Agent 连接。 - “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中 Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime 签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的 小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、 协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线 包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。 -- 当任一组件缺失或不是当前版本时,用户可显式选择“更新版本”。Main 使用同一个取消信号依次强制执行 Agent、Runtime 安装器,并把 Agent、Runtime、Finalizing 阶段只发送给发起更新的 Renderer。更新启动仍受技术预览开关和可信 Sender 校验约束;取消不受开关约束,确保操作开始后即使关闭功能也能停止。 -- 更新成功后才定向清理该 Host 的 Agent 连接和 Runtime 选择缓存,并让当前引用项目重新激活;失败或取消保留 Host 配置、凭据、项目、Workspace 和可继续验证的旧组件,Renderer 刷新实际版本并允许重试。同一 Renderer 同时只更新一个 Host,窗口销毁或 Main 退出会取消活动更新并执行有界收尾。 +- “自动”只在 operation/prepare 前用 Host capability probe 选择 acquisition:直连明确 + 可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或 + adoption 失败后都不跨 acquisition 自动 fallback。 +- Host 下载由桌面控制面通过固定 SSH prepare channel 和结构化 stdin 发送候选信息, + Host 取得 compound `.gbagent` 并核对大小与 SHA-256。GoodBuddy 传输在本地缺少精确 + 候选时,于同一次操作下载、验 SHA-256 与签名、缓存并取得 lease,再以有界流式 SFTP + 上传一个 compound archive 和归档中已验证的 bootstrap Node;约 294 MiB 完整包不会 + 一次读入 Main `Buffer`,Host 仍再次校验完整包。 +- 两种 acquisition 交付到固定 operation staging 后,完全共用 control-plane + `prepare → commit → Agent activate/health → Runtime activate → finalize → cleanup`。 + commit 终态持久化;通道丢失只用 `commit-status` 只读恢复,不重放 commit。prepare + 完成后、commit 前快照 Agent/Runtime 五个 metadata 文件,任一组件 adoption 失败都恢复其原字节或原缺失 + 状态;已经发布的 side-by-side payload 可以保留但不能成为 current。确认 adoption 后 + 才显式 cleanup。首次 bootstrap 不依赖既有 Agent daemon,也不要求 format v1 归档携带 + `agent/lib/package-installer.cjs`。 +- 即使版本号均为当前版本,Host 卡片仍提供同版本“重新安装”,用于修复 registry、签名 + 或安装 identity 异常。GoodBuddy-owned 同 digest 目录损坏时先隔离后替换,并在发布或 + 激活失败时恢复;不要求删除 Host、凭据或引用项目。 +- Host 卡片的“版本匹配”badge 只表达版本事实,不代表 Agent 正在运行或环境健康。重新安装 + 失败时明确显示本次操作未完成,并以随后重新检查的版本卡片表达当前事实;提交结果 + 不确定时不声称旧版本未被替换。 +- 更新成功后才定向清理该 Host 的 Agent 连接和 Runtime 选择缓存。引用项目再次打开时 + 解析新的 current registry 并建立当前连接,无需刷新项目记录;当前环境无效时要求用户 + 显式修复,但不在项目切换中下载或安装。失败或取消保留 Host 配置、凭据、项目、 + Workspace 和旧组件。 - 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。 +### Host 级环境生命周期 + +- Agent、Node 和 Runtime 是 Host/SSH 账号级共享环境。新增或重新验证 Host 后先保存 Host + 并只读探测,不自动安装;用户随后在 Host 卡片明确选择安装或更新方式,失败保留 Host + 并允许重试。 +- Host 可以按签名目录固定的 URL、大小和 SHA-256 直接从当前 GitHub/北京镜像来源下载 + 完整 `.gbagent`,也可使用 GoodBuddy 本机下载、流式 SFTP 传输和离线导入。自动模式只在 + 操作开始时择一;执行失败不会在同一次操作中切换 acquisition。 +- 项目只保存 Host ID、远端路径、Runtime 选择和默认模式。创建或保存时验证 Host current + 环境;打开或切换只读取本地配置。Workspace/Runtime 实际使用时从 Host current registry + 和当前 Agent 连接取得 live identity,不再扫描、下载、上传或发布 Agent/Runtime。完整 + 事务、兼容边界和验收要求以 + [Host 环境准备设计](./remote-host-environment-provisioning-design.md) 为准。 +- 打开新建/项目设置弹窗只读取本地 Host 验证记录,不并发检查所有 Host;目录浏览或保存 + 才连接所选 Host。 +- 项目选择器中的管理操作默认浮动隐藏,在悬停、键盘焦点或触屏环境显示。管理任意已保存 + 项目不先激活项目,因此 Host 不可达或远端目录已不存在时仍可删除其本地记录。 +- 删除 Host 时确认框列出所有引用它的本地项目记录,包括归档项目。确认后只删除本机 + Host、凭据、项目及其关联记录,不连接 Host、不删除远端目录或内容;Host 设置写入或 + 项目事务失败时恢复另一侧,避免只删掉其中一类本地记录。 + ## Workspace Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、访问模式、Git 状态和有界能力。 @@ -132,7 +206,7 @@ Execute 不经过 Ask 的 bubblewrap profile: 调度、跨 channel 超车、批发送或第二套拥塞控制。 - Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。 - Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。 -- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。 +- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。 - Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。 - 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。 - 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。 @@ -170,7 +244,7 @@ Execute 不经过 Ask 的 bubblewrap profile: - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 - 的双架构验收。当前源码 lock 是正式版本 `0.11.5`,需要由新的复合包发布流程另行验证。 + 的双架构验收。当前源码 lock 是待发布版本 `0.11.6`,需要由新的复合包发布流程另行验证。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 @@ -182,31 +256,36 @@ Execute 不经过 Ask 的 bubblewrap profile: ## 项目保存 -远程项目持久化稳定配置和当前验证结果: +远程项目只持久化稳定配置: -- Host ID、Host revision 与 Host Key generation; -- 远端工作目录和 Workspace identity; -- Agent installation identity; -- Runtime selection、bundle digest、adapter digest; -- `ask | execute`。 +- Host ID; +- 规范远端工作目录; +- Runtime selection; +- `ask | execute` 默认模式。 -打开托管 SSH 项目会从这些已保存字段重建验证输入,不接受 Renderer 回传的旧项目草稿。桌面更新后,用户先显式准备兼容复合包,第一次打开才可安装对应 Agent/Runtime 并原子刷新以上字段,同时保留项目名称、说明、Host、远端工作目录、Runtime selection 和默认工作模式。 +Agent installation、Host revision/Host Key generation、Workspace identity、Runtime +bundle/adapter digest 和 capability generation 都属于 live connection/lease,不进入项目 +domain object 或 SQLite。打开托管 SSH 项目不接受 Renderer 回传的旧项目草稿,而是从 Host +store 解析当前连接目标,通过 Agent/Runtime 安装管理器的 current/activate 读取路径取得 +registry identity,再用同一个 Agent 连接验证 Workspace 路径和 Runtime capability。该流程 +不会扫描完整 payload、取得安装包或发布组件。 -请求前置校验只要求 Workspace 验证与 Runtime 验证来自同一个当前 Agent -installation。它不比较激活时保存的模型 profile 或默认工作模式与当前会话选择, -因为这些不是签名远端 Runtime 的身份;切换当前模型配置或 Ask/Execute 不需要重新激活 -项目。实时 Runtime 创建仍只接受 OpenCode,使用当前解析后的模型 profile 建立 Main-only -模型桥,并再次精确核对 Agent identity、Runtime bundle/adapter digest、架构、capability -和连接身份。Ask/Execute 权限继续由每次 Prompt 的 ACP Runtime 边界执行。 +实时 Runtime 创建只接受 OpenCode,使用当前解析后的模型 profile 建立 Main-only 模型桥, +从当前 Agent 连接和 Runtime registry 取得会话 identity,并在该连接上打开 Workspace 和 +ACP channel。Host 编辑或环境更新会定向失效 Agent 连接与 Runtime 缓存,下一次请求自然 +重新取得 current 环境;无需修改项目。Ask/Execute 权限继续由每次 Prompt 的 ACP Runtime +边界执行。 -Renderer 在用户主动打开托管 SSH 项目时订阅同一次 Main 保存操作的进度,依次显示 -Host、Agent、Workspace、Runtime 和 Saving。操作完成、失败或取消后会清除阻塞进度; -进行中可显式取消,并禁用会产生冲突的项目切换、创建和设置入口。远端 RPC 拒绝只向 +Renderer 在用户主动打开已有托管 SSH 项目时立即切换,不显示远程激活状态。Host、Agent、 +Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进行中可显式取消并禁用会 +产生冲突的项目创建和设置入口。远端 RPC 拒绝只向 Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。 `runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding, 不会把确定性失败错误保留为 `outcome-unknown`。 -不持久化 T2/T3、consent、approval bridge 或 confinement 证明。数据库迁移可以保留旧列以安全读取历史数据库,但新 domain object 不再暴露这些字段。 +不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema +v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id` +四列,并删除旧 Runtime 验证表。 ## 资源与发布 diff --git a/docs/architecture/remote-host-environment-provisioning-design.md b/docs/architecture/remote-host-environment-provisioning-design.md new file mode 100644 index 00000000..8bc30558 --- /dev/null +++ b/docs/architecture/remote-host-environment-provisioning-design.md @@ -0,0 +1,320 @@ +# SSH Host 远程环境准备与直连下载设计 + +## 状态 + +本文记录 2026-08-28 确认的目标设计及当前实施状态。源码已经实现 Host 手动准备、 +GoodBuddy 传输、Host 直连下载、control-plane one-shot installer、发布后健康 adoption, +以及项目路径只验证已安装环境。Host 负责 Agent/Runtime 的安装与更新生命周期,项目流程 +只消费已经准备好的 Host 环境。 + +当前源码可以直接使用既有 package format v1 `.gbagent`:归档已经包含固定 Agent 和 +Node,installer 由 GoodBuddy 桌面控制面打包并通过固定 SSH prepare channel 单次发送, +不要求归档包含 `agent/lib/package-installer.cjs`,签名目录也不提供或检查额外的 +bootstrap 能力元数据。公开直连能力仍须完成本文末尾 GitHub/北京镜像、Linux x64/arm64、 +取消和离线 GoodBuddy 传输的真实 Host 验收;本文 +不表示这些验收或新的 Agent 发布已经发生。当前实现细节同时以 +[SSH 远程主机与 GoodBuddy Agent 实现说明](./remote-host-and-goodbuddy-agent-design.md) +和源码为准。 + +## 设计结论 + +将安装和更新从项目切换移到 Host 管理是合理的,原因如下: + +- Agent、固定 Node 和 OpenCode Runtime 安装在 SSH 账号 Home 下,由同一 Host/账号上的 + 多个项目共享,不属于某一个 Workspace。 +- 大文件传输发生在项目切换时会阻塞导航,也把 Host 级故障误显示成项目故障。 +- Host Key、认证、平台和架构本来就在新增 Host 时验证,此时继续只读探测并在 Host 卡片 + 提供手动准备入口最容易理解。 +- 项目只保存 Host ID、远端路径、Runtime 选择和默认工作模式。Agent installation、 + Runtime digest、Workspace identity 和 Host revision 都从当次连接取得,不写入项目; + “移出安装”不等于“跳过当前环境检查”。 + +目标职责是: + +| 流程 | 职责 | +| --- | --- | +| 新增或重新验证 Host | 验证身份和认证,保存 Host 并探测环境,不传输安装包 | +| Host 管理 | 用户明确刷新目标 Host,并可选择安装、同版本重修、取消和重试 | +| 创建远程项目 | 只读本地 Host 验证记录,保存时连接所选 Host 并验证当前 Agent、Workspace、Runtime 和模型配置,只持久化稳定配置 | +| 切换远程项目 | 只切换本地项目配置,不连接 Host;实际使用 Workspace 或 Runtime 时按需复用 Host 当前环境 | + +## 不变量 + +- Host 配置与远程环境准备是两个事务。SSH 验证成功后先原子保存 Host、凭据和完整 Host + Key,再开始可能耗时的下载和安装。 +- 环境准备失败或取消时保留已保存 Host,显示“未就绪”并允许重试,不创建半完成项目。 +- 不把“环境健康”保存为长期可信布尔值。设置页从 Host 的签名 registry 和当前 Host + identity 派生版本状态;准备完成和实际使用时再执行 bootstrap/health。合法但暂未运行的 + detached Agent 仍可显示为已安装,打开设置不能为了刷新卡片隐式启动或更新它。 +- 所有安装继续使用 GoodBuddy-owned staging 和 side-by-side digest 目录。失败不得覆盖 + 可用旧安装,也不得删除 Host 上无关文件。 +- Renderer 不接收下载 URL、缓存路径、签名 key、digest、SSH 命令或凭据。Main 负责来源 + 选择、可信目录解析、操作编排和 IPC 输入校验。 +- 产品不提供“自动安装远程环境”开关。保存、重新验证或打开 Host 都不能自动传输 + `.gbagent`;只有用户点击 Host 卡片的主按钮后,Main 才开始准备。 +- Host 卡片只有一个主按钮,按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。 + 次级 SegmentedControl 选择“自动”“Host 下载”或“GoodBuddy 传输”;默认“自动”,只影响 + 下一次操作,不持久化为 Host 配置。 +- “自动”只在 operation/prepare 前执行一次 Host capability probe:直连明确可用时选择 + Host 下载,否则选择 GoodBuddy 传输。用户显式选择的方式保持有效;一旦 acquisition + 已选定,prepare、commit 或 adoption 的任何失败都停止本次操作,不跨 acquisition + 自动回退。 +- 直连安装只要求已验证 SSH 连接和远端系统工具,不要求现有 Agent daemon。轻量能力探测 + 返回确定性缺少工具、空间、权限或来源不可达时说明原因;探测本身异常时仍允许用户点击 + 直连安装,由实际安装流程重新解析签名候选并通过 SSH 重试。 +- 项目路径不得为了“方便”回退到隐式安装。Host 未就绪时应引导到 Host 设置,不在项目 + 切换器中启动大文件传输。 + +## 新增 Host 流程 + +新增向导保持现有 Host Key 和认证顺序,并在系统探针之后显示一次只读环境结果: + +1. 输入名称、地址、端口、用户名和认证方式。 +2. 在发送认证材料前读取并确认 Host Key。 +3. 完成 SSH 认证以及平台、架构、Shell、Home 有界探针。 +4. 原子保存 Host、加密凭据和完整 Host Key。 +5. 读取 Host 已安装的 Agent/Runtime registry,并执行一次轻量下载能力探测。 +6. 如果已安装 identity 满足当前 GoodBuddy 要求,显示“版本匹配”并结束;该 badge 不代表 + Agent 正在运行或环境健康。 +7. 如果组件缺失或需要更新,显示目标 Agent、Node、Runtime、架构、来源和完整包大小, + 告知用户保存后前往 Host 卡片安装,然后结束新增向导。 + +新增流程到此结束,不显示自动安装开关,也不自动开始环境安装。保存后的 Host 卡片提供 +一个手动准备主按钮,并以次级 SegmentedControl 选择“自动”“Host 下载”或 +“GoodBuddy 传输”。主按钮按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。 +本地尚无精确包不要求用户先离开当前页面;选择 GoodBuddy 传输时,同一次操作会取得并 +验证候选后继续上传。 + +探测不得自动下载完整 `.gbagent`。只有用户明确启动安装后才允许传输大包。 + +## Host 编辑 + +- 仅修改显示名称不要求重装远端环境。 +- 地址、端口、用户名或 Host Key 变化时,旧 Host identity 立即失效,关闭旧连接和对应 + Runtime 缓存。项目记录不含旧组件 identity;下一次打开直接解析新 Host 的当前环境。 + 保存新身份后只重新执行环境探测,所需安装由用户在 Host 卡片手动启动。 +- 只替换同一目标的认证材料时仍重新认证并读取实际环境;如果签名 identity 和健康状态 + 未变化,不重复安装。 +- 当前项目引用的 Host 被编辑时,Main 立即失效该 Host 的 identity 和连接缓存;下一次 + Workspace/Runtime 操作按需使用新目标。环境未准备好时该操作失败,不回退旧连接。 + +## 下载能力探测 + +探测在 Host Key 已固定且 SSH 认证成功后执行,只检查当前选择的“关于与更新”来源: + +- Host 是受支持的 Linux `x64` 或 `arm64`。 +- SSH 账号 Home 可写,GoodBuddy-owned staging 可创建。 +- 可用空间满足签名目录声明的包大小、解包上限和安全余量。 +- Host 可以通过 HTTPS 访问所选 GitHub 或北京镜像 URL。 +- 首次安装所需的下载、SHA-256 和运行归档内固定 Node 的能力可用。 +- 已安装 Agent 是否报告未来的直连更新能力。 + +探测使用小型签名目录或有界请求,不以一次成功 `HEAD` 代替真实安装验证。结果只用于当次 +展示,不持久化为永久能力;开始安装前重新检查,因为网络、代理和磁盘状态可能变化。 + +探测失败不能阻止保存 Host,也不能禁用显式 GoodBuddy 传输或离线导入路径。显式选择 +Host 下载时由实际操作报告不可用原因;“自动”只把“直连明确可用”作为选择 Host 下载的 +条件,其他结果选择 GoodBuddy 传输。 + +## 工件选择与来源 + +Main 继续读取并验证 `agent-catalog.json`、`agent-catalog.sig` 和 production 公钥 +registry,按以下条件选择唯一候选: + +- Host 平台和架构匹配; +- 最低桌面版本满足; +- Agent protocol major 相同,minor 不高于桌面支持值; +- 使用满足条件的最高 SemVer; +- URL、文件名、大小和 SHA-256 来自签名目录; +- 下载来源跟随“关于与更新”的 GitHub/镜像选择。 + +远程下载只优化大包的网络路径。Main 仍需先取得并验证小型目录,再通过经过 Host Key +验证的 SSH 控制面传递固定候选元数据。产品不接受 Renderer、项目、Host 文件或用户输入的 +任意下载 URL。 + +## 获取与统一安装事务 + +首次安装不能假设 Host 已有 GoodBuddy Agent、installer 或系统 Node,也不由既有 Agent +daemon 参与。两种 acquisition 的差别只在于如何把同一个签名 compound `.gbagent` 交付到 +固定 operation staging: + +- **Host 下载**:控制面通过固定 SSH prepare channel 发送签名目录绑定的 URL、大小和 + SHA-256;Host 有界下载并校验完整归档。 +- **GoodBuddy 传输**:若本地缺少精确候选,同一次用户操作先下载、验证 SHA-256 和签名、 + 写入缓存并取得 lease;随后以有界流式 SFTP 上传一个 compound archive,以及从该归档中 + 已验证的 bootstrap Node。约 294 MiB 的完整包不会一次读入 Main `Buffer`,Host 收到后 + 仍再次校验完整包。 + +交付后两种 acquisition 完全共用一个 control-plane 事务: + +1. Main 创建带 operation ID、总时限、输出上限和取消信号的准备操作,并通过固定 + `prepare` channel 发送有界 one-shot installer 与结构化输入。 +2. Host 校验归档大小、SHA-256、外层及内层签名、平台、架构、协议和 payload digest, + 只把候选准备到 side-by-side 目录,不改变 current。 +3. `prepare` 完成后、`commit` 前按原字节记录 Agent 与 Runtime 的五个 + current/registry metadata 文件, + 同时记录原本缺失的状态。 +4. `commit` 终态先持久化到 operation staging。控制通道丢失时,Main 只用同一 operation + 的 `commit-status` 恢复结果,绝不重放 `commit`。 +5. commit 后依次完成 Agent activate/health、Runtime activate 和 finalize。任一组件 + adoption 失败都停止候选,并把五个 metadata 文件恢复为原字节或原缺失状态;已经发布的 + side-by-side payload 可以保留,但不能成为 current。 +6. Main 确认 adoption 后才显式执行 cleanup,释放 staging、缓存 lease 和操作资源。 + +这不是两个并行安装管理器,也不存在把 Agent 与 Runtime payload 逐文件走 SFTP 安装的 +第二条路径。两种 acquisition 共享相同的 prepare、commit、adoption、finalize 与 cleanup。 + +Host 缺少可靠的下载、SHA-256 或 ZIP 能力时,不自动安装系统软件,也不要求 root。显式 +Host 下载返回明确失败;本次操作不自动切换 acquisition,用户可改选 GoodBuddy 传输后 +重新点击主按钮。后续更新和同版本重装复用同一事务,不要求用户先通过项目切换完成 +“过渡升级”。 + +## 本机传输与离线路径 + +现有 `.gbagent` 本地缓存、在线下载、离线导入和导出继续保留: + +- GoodBuddy 路径在本地缺少精确候选时,于同一次操作完成下载、整包大小与 SHA-256、 + 全部签名和 payload 验证、缓存发布及 lease 获取。 +- SFTP 有界流式上传单个 compound archive 与其中已验证的 bootstrap Node 到操作自己的 + staging;Main 不把完整归档读入内存,Host 再次校验后进入统一 control-plane 事务。 +- Host 无公网、代理/TLS 异常或缺少 bootstrap 工具时,用户可显式使用此路径;离线导入 + 后也走同一缓存 lease、上传和 Host 校验流程。 +- 本地项目和普通桌面打包不依赖任何远端 Agent 工件。 + +## 完整包与复用 + +第一阶段继续发布和下载完整复合 `.gbagent`,不同时引入组件化增量发布: + +- 远程直连和 GoodBuddy 本机下载都会传输完整包,其中包含 Agent、固定 Node 和 OpenCode + Runtime。 +- 新 Agent 安装时,如果旧 manifest、registry identity、owner/mode/size 和 Node digest + 全部可验证,并且候选声明同一 Node,则使用硬链接复用,不重新写入 Node 数据。 +- 相同 Runtime bundle digest 已存在且完整时只验证并激活,不重复上传或解包。 +- Agent 其他文件或变化后的 Runtime 仍按完整候选安装。 + +只有真实包体和发布频率证明网络成本仍不可接受时,才另行设计 Agent、Node 和 Runtime +独立签名组件目录;本功能不提前引入第二套发布协议。 + +## Host 准备状态 + +设置中的 Host 卡片分开表达版本事实和操作结果: + +- **未验证**:Host Key、认证或系统探针尚未完成。 +- **尚未安装**:Host 可连接,但 Agent 或 Runtime 缺失。 +- **需要更新**:已安装 identity 与当前兼容候选不一致。 +- **正在准备**:正在探测、下载、验签、安装或健康检查。 +- **版本匹配**:版本 badge 只表示 Agent/Runtime identity 与当前兼容候选匹配,不等同于 + 进程正在运行或环境健康;设置页普通刷新不会启动已停止的 Agent。 +- **准备失败**:保留 Host 和可用旧组件,展示有界错误与重试入口。 + +主按钮按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。重新安装失败后, +界面显示本次操作未完成,并以随后重新检查的版本卡片表达当前事实,不在提交结果不确定时 +断言旧版本未被替换,也不得把“版本匹配”改称“环境健康”。统一进度以 `探测`、 +`下载/传输`、`校验`、`应用环境`、`Agent 激活与健康检查`、`Runtime 激活` 和 +`完成设置` 表达用户可理解的阶段。 + +同一 Host identity 同时只允许一个由用户启动、Main-owned 的准备操作。重复观察者加入 +已有操作,不启动第二次发布;窗口关闭、显式取消、Host identity 改变或 Main 退出时执行 +有界取消。Main 在 commit 前为该 Host 持久化一个不含凭据的 pending operation 记录; +commit 指令开始后若 SSH 通道、输出或终态丢失,当前操作保留该记录和远端 staging。 +下一次重试或应用重启后的首次操作先对同一 Host identity 执行只读 `commit-status` 和 +cleanup,不重放未知 commit,也不开始第二条 acquisition;确认清理后删除 pending 记录。 +取消在最终原子切换完成后不再伪装成回滚成功。 + +## 项目创建与切换 + +- 新建托管 SSH 项目弹窗只读取本地 Host Key/连接验证记录,不逐台连接或刷新版本。只有 + 已完成本地验证的 Host 可选;浏览目录或保存时才连接所选 Host,并由保存流程完整检查 + Agent、Workspace 和 Runtime,但不隐式安装。 +- 项目保存验证 Host 当前 revision 与 Host Key generation、当前 Agent、Workspace、 + Runtime、模型 profile 和 Ask/Execute 配置;SQLite 事务只写入 Host ID、规范远端路径、 + Runtime 选择和默认工作模式。 +- 打开或切换已有项目只提交本地项目选择,不执行 SSH、registry、Workspace 或 Runtime + 检查。首次实际使用远程 Workspace 或 Runtime 时才解析 Host 管理的当前 Agent/Runtime, + 并建立或复用当前 Agent 连接。已安装 Agent 暂未运行时, + 固定 `attach-or-bootstrap` 命令幂等启动 registry 中的 current identity;这里的 + bootstrap 只启动进程,不安装组件,也不重新读取、哈希或验签完整 payload。 + Agent 进程读取 Host 管理已提交的 registry 和匹配 manifest 元数据,不重新验签或扫描 + 完整安装 payload。 +- Workspace 和 Runtime 使用当前 Agent 连接产生的 live handle/lease。项目不保存或核对 + 历史 Workspace/Runtime identity;当前 registry、连接状态或 capability 无效时停止并 + 引导到 Host 管理,不在项目流程中下载或安装。 +- 桌面更新导致 Host 组件过旧时,项目保持未切换并提示前往 Host 设置。应用启动仍进入 + 第一个普通本地项目,不自动连接或更新远端 Host。 +- Host 环境更新成功后,Main 定向失效该 Host 的 Agent/Runtime identity、连接和 Runtime + 会话缓存。所有项目在下一次实际操作时自然使用新的 current 环境;无需改写项目记录。 +- 环境准备失败时不改变已有项目绑定。Host identity 编辑失败时则继续保持 fail-closed, + 因为旧身份不能替代用户刚保存的新身份。 +- 项目管理入口与项目选择分离。项目选择器中的浮动管理按钮直接打开目标项目设置;删除 + 不访问 Workspace、Agent 或 Host,因此远端目录已删除或 Host 不可达都不能阻止本地 + 项目记录清理。删除当前项目或 Host 级联删除当前项目时只回退到普通本地项目。 +- Host 删除确认使用本地数据库列出全部引用项目,包括归档项目;确认后级联删除本地项目 + 及其会话、任务、成果、记忆、计划和心跳关联,再删除本地 Host 与凭据。整个流程不发起 + SSH,也不删除远端内容;跨 Host 设置文件与 SQLite 的普通失败会恢复已写入的一侧。 + +项目切换不展示远程激活进度,也不阻塞项目导航。`Host`、`Agent`、`Workspace`、`Runtime` +和 `Saving` 进度只属于新建或显式保存项目;实际 Workspace/Runtime 操作使用正常的局部 +加载和错误反馈。 + +## 实现结构 + +当前实现复用现有机制,没有新增平行的安装管理器: + +- 扩展当前 Host 系统探针和远程环境 inspector,返回瞬时下载准备能力。 +- 让当前 `RemoteEnvironmentUpdateService` 演进为 Host 环境准备编排器,统一用户手动 + 启动的首次安装和后续更新、自动 acquisition 选择、进度、取消和最终失效。 +- Host 管理使用安装管理器完成准备、更新和完整验证。项目切换不调用安装管理器; + Workspace 和执行路径按需解析 current identity,并在当前进程内复用已确认 identity 和 + Agent 连接;Host 编辑或环境更新定向清空这些缓存。这些路径不会取得安装包或发布组件。 +- Host 卡片即使显示相同版本也保留显式重装操作,用于修复 registry、签名或 payload + identity 异常;若同一 digest 的 GoodBuddy-owned 目录校验失败,重装会先隔离旧目录, + 完整发布并验证新目录,失败时恢复旧目录。重装不要求删除 Host、凭据或项目,也不会处理 + 所有者异常、符号链接或非 GoodBuddy 管理路径。 +- 由桌面控制面打包有界 one-shot installer,并通过固定短命令 `exec sh -s` 和 stdin + 发送脚本及结构化输入,避免 SSH command 长度限制;既有 package format v1 归档内固定 + Node 负责解码和运行它。 +- Preload 只暴露明确的 Host 探测、准备、取消和进度方法,不暴露 URL 或任意 SSH 命令。 +- 现有 Agent/Runtime side-by-side、Node 硬链接复用、Host identity 二次核对、Host 端 + 发布后复验、连接失效和项目稳定配置事务继续作为唯一实现。 + +## 迁移 + +- 已保存 Host 打开设置时只显示本地记录,不自动连接或检查版本;用户点击对应 Host 的 + “刷新版本”后才执行只读状态探测。项目切换不依赖该按钮,也不建立连接。 +- 数据库升级保留远程项目、Host ID、远端路径、Runtime 选择及关联会话/任务,并删除旧的 + Agent/Runtime/Workspace/Host 验证列和 Runtime 验证表。下一次实际远程操作从 Host + current 环境重新取得所有 live identity。 +- 如果 Host 未就绪或版本不兼容,项目仍可在本地选择,但实际远程操作失败;不再走旧的 + 隐式安装。 +- 已下载或导入的本地 `.gbagent` 保持可用,不能因加入远程直连而迁移或删除。 +- 远程直连继续使用现有 package format version 1,不要求归档携带 installer,也不要求 + 目录声明额外的 bootstrap 能力元数据。 + +## 验收标准 + +1. 新 Host 保存和探测完成后不会自动下载、上传或安装任何包,也不存在自动安装开关; + 再次进入 Host 设置不会逐台连接,只有显式“刷新版本”才探测目标 Host。 +2. Host 卡片只有一个按版本事实命名的主按钮;次级 SegmentedControl 提供默认且不持久化的 + “自动”及显式“Host 下载”“GoodBuddy 传输”。没有预装 Agent、Node 或 Runtime 的 Host + 可以完成准备;远程下载不可用时可手动改用 GoodBuddy 传输。 +3. 探测不下载完整包,失败不阻止保存 Host;非确定性探测失败不禁用直连重试,安装失败 + 或取消后 Host 仍可重试。 +4. 直接下载严格匹配签名目录中的 URL、大小和 SHA-256;GoodBuddy 传输可在同一次操作中 + 下载并验证缺失候选,以有界流式 SFTP 上传完整归档和已验证 bootstrap Node。Host 对 + 两者完成相同的外层与内层签名、路径、架构、协议和 payload 校验。 +5. 相同 Node 在可验证且支持硬链接时不重复传输或写入;相同 Runtime digest 不重复安装。 +6. 创建、打开和切换项目不会产生 `.gbagent` 下载、Agent payload SFTP 上传或安装目录发布。 +7. 未就绪 Host 不能创建远程项目;已有项目仍可本地选择,但远程操作失败并引导到 Host 设置。 +8. Host 编辑或更新后,引用项目使用 Host 管理的当前连接和 current registry,不读取项目 + 中的旧 Host/Agent/Runtime/Workspace identity;当前环境无效时才停止并要求在 Host 管理 + 中修复。 +9. Host 地址、用户或 Host Key 在准备期间变化时停止候选发布,旧身份不会被重新启用。 +10. GitHub、北京镜像、无公网 Host、取消、应用退出、磁盘不足、损坏下载和不支持架构均有 + 明确结果,且不触碰 GoodBuddy-owned 目录之外的文件。 +11. Linux x64 和 arm64 均通过 GitHub/北京镜像、取消、离线 GoodBuddy 传输,以及项目使用 + Host current 环境执行 Workspace/Runtime 操作的真实 Host 测试;这些真实 Host 验收当前 + 仍待完成。 +12. Host 不可达或远端目录不存在时,用户仍可从项目浮动管理入口删除本地项目;删除 Host + 会列出并本地清理全部关联项目,但不会连接 Host 或删除任何远端目录和内容。 +13. commit 终态可在通道丢失后用 `commit-status` 只读恢复且不重放;Agent 或 Runtime + adoption 失败恢复五个 metadata 文件的原字节/原缺失状态,确认 adoption 后显式 cleanup。 diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 9b6ff6c2..c69e536a 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -6,7 +6,7 @@ | --- | --- | | 状态 | 设计中 | | 版本 | 0.5 | -| 日期 | 2026-08-21 | +| 日期 | 2026-08-28 | | 适用产品 | GoodBuddy 桌面端 | | 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[SSH 远程主机与 GoodBuddy Agent 稳定终态设计](../../architecture/remote-host-and-goodbuddy-agent-design.md) | | 相关能力 | [会话监督](../supervision/conversation-supervision-prd.md)、[自动化平台](../../architecture/automation-platform-architecture.md)、[长期助手路线图](../../roadmap/long-term-assistant-roadmap.md) | @@ -477,8 +477,8 @@ type WorkbarPanelInstance = { 设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示 “主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的 -SSH/远程项目操作;已有 Host、项目和凭据保持不变。若关闭时正在激活远程项目,应用发出 -取消并保持或切回普通本地项目;若当前项目已经是远程项目,则切回项目列表中的第一个普通 +SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前项目已经是远程项目,则切回 +项目列表中的第一个普通 本地项目。重新启用后恢复完整入口和既有数据。 启用后,设置中心显示“主机与远程执行”分类,管理: @@ -491,6 +491,9 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若关闭 - 删除、重新验证或更新 Host Key。 主机配置是全局资源。项目或工作栏只引用主机 ID,不能复制凭据。 +删除 Host 前列出所有引用它的本地项目记录,包括归档项目;确认后仅删除本机 Host、凭据、 +项目和关联数据,不建立 SSH 连接,也不删除远端目录或内容。若当前项目被删除,界面切回 +普通本地项目。 当前实施状态:设置中心已经实现主机 CRUD 和模态验证向导。新增或目标/认证编辑依次完成 连接信息、认证前 Host Key 核对、密码/系统 SSH Agent 认证和平台/架构/Shell/Home 有界 @@ -501,22 +504,49 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若关闭 和文本模型 profile 验证后原子提交。私钥导入仍未开放。旧流程已保存但 没有成功探针记录的主机继续读取,但在完成新向导前显示为“需要重新验证”。 -应用重启后固定进入项目列表中的第一个普通本地项目,不自动激活上次远程项目。用户主动 -打开已有托管 SSH 项目时,Main 会用数据库中的项目字段重新执行同一套 -Host/Agent/Workspace/Runtime 验证。桌面版本携带的 Agent 或 Runtime identity 已变化时, -先 side-by-side 安装新候选,全部成功后再原子刷新项目 evidence 并切换当前项目;失败保留 -此前项目和安装绑定,不由 Renderer 用旧草稿重建更新请求。同一 Renderer 对同一项目的 -重复激活共享一个操作,不会并行启动第二次保存。 -主动打开期间,项目切换器在原位置展示 Host、Agent、Workspace、Runtime、Saving -五个阶段及原生 `progressbar` 语义;用户可以取消,项目切换、创建和设置入口在操作结束前 -保持禁用。成功后才提交项目选择,失败或取消继续保留此前项目。 +当前源码已把 Agent/Runtime 生命周期归入 Host 管理。新增或重新验证 Host +在身份、认证和系统探针成功后先保存 Host,再只读探测实际组件状态和瞬时下载能力,然后 +结束新增向导。产品不提供自动安装开关,也不在保存后传输包。Host 卡片只有一个按版本 +事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮;次级 SegmentedControl +选择“自动”“Host 下载”或“GoodBuddy 传输”,默认“自动”且不持久化。版本匹配 badge +不代表环境健康。环境准备失败或取消保留 Host 并显示可重试状态;重新安装失败时显示 +本次操作未完成,并以随后重新检查的版本卡片表达当前事实,不对不确定提交宣称旧版本 +未被替换。详细技术契约见 +[SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)。 + +进入或切回“主机与远程执行”页面只读取本地 Host 列表,不逐台建立 SSH 连接。版本和瞬时 +下载能力仅在用户点击某台 Host 的“刷新版本”时探测。该动作不是项目连接前置条件;用户 +切换已有远程项目只更新 Renderer 的本地项目选择,不建立 Host/Agent 连接。 +新建远程项目弹窗同样只读取本地 Host 验证记录,不扫描所有 Host;浏览目录或保存时才连接 +所选 Host。版本号相同时仍允许用户显式重新安装,以修复 registry、签名或 identity 异常, +且不删除 Host、凭据或项目。由远程主机安装只使用 SSH 和远端系统工具,不依赖已有 Agent; +“自动”仅在操作开始时探测:直连明确可用才选 Host 下载,否则选 GoodBuddy 传输;用户 +显式选择不被改写,prepare、commit 或 adoption 失败后不在同次操作中切换 acquisition。 + +项目选择器中每个项目的管理按钮默认隐藏,在悬停、键盘焦点或触屏环境显示。该入口直接 +打开目标项目设置而不先切换项目,因此 Host 不可达或远端目录已不存在时仍可删除本地项目 +记录;删除操作从不删除远端项目内容。 + +应用重启后固定进入项目列表中的第一个普通本地项目。用户主动打开已有托管 SSH 项目时只 +切换本地项目配置;首次实际使用 Workspace 或 Runtime 时,Main 才通过 Agent/Runtime +安装管理器解析 Host current identity,执行固定 `attach-or-bootstrap`,并使用同一 Agent +连接。 +项目数据库不保存 Agent installation、Host revision、Workspace identity 或 Runtime +digest。当前 registry、连接或 capability 无效时项目保持未切换,并提示到 Host 管理显式 +修复。当前进程缓存每个 Host 已确认的 Agent/Runtime identity,并复用 Agent 连接;Host +编辑或环境更新会定向清空。Agent 的 attach/按需启动只读取 Host 管理已提交的 registry 与 +匹配 manifest 元数据,不重新验签或哈希完整 Agent payload。项目切换器立即提交本地选择, +不展示远程激活进度;完整进度和取消只属于新建或显式保存项目。 + +项目只持久化 Host ID、规范远端路径、Runtime 选择和默认工作模式。新建和保存验证当前 +Workspace/Runtime,但不写入组件 identity;普通切换不做远程验证。未就绪或版本过旧时, +实际 Workspace/Runtime 操作显示错误。桌面更新造成组件版本变化时,用户先在 Host 管理中 +准备环境;项目切换本身不连接、不下载、不扫描、不安装,也不改写项目。 已保存 Host 的地址、用户或固定 Host Key 变化后,Main 关闭旧连接并定向退役该 Host 的 -Runtime 缓存。当前项目引用该 Host 时,设置保存成功会立即运行上述完整激活流程并刷新 -项目验证;其他项目在下次选择时刷新。用户再次选择当前 SSH 项目也会执行完整激活, -不能因为项目已经选中而跳过验证。从 Host 保存到项目成功激活之间,项目持续保持需要 -重新连接状态;失败或取消不会重新开放 Composer。已由 Main 派发的用户排队消息退回原 -持久队列,不生成失败消息、不丢失附件,并在成功激活后的 queue-ready 通知中继续派发。 +Runtime 缓存。引用项目下次选择时使用 Host 管理的当前 revision、Host Key 和用户执行固定 +attach 并读取 current registry,不存在项目中的旧 Host revision 或组件 identity; +当前环境无效时才要求显式修复。 新建托管 SSH 项目时,远端工作目录既可手工输入,也可通过文件夹按钮打开 Main 管理的 只读、有界 SFTP 目录选择器。选择器从 SSH 账号 Home 或当前有效绝对路径开始,只返回目录, @@ -545,11 +575,23 @@ Runtime 缓存。当前项目引用该 Host 时,设置保存成功会立即运 - 安装到远程当前用户的 GoodBuddy-owned 目录,不要求 root。首次连接先 attach,Agent 不存在或未运行时幂等 bootstrap 为 detached process,再重新 attach;不注册开机服务, 不依赖 systemd、D-Bus 或 `Linger=yes`。 -- SSH 负责 Host Key、认证、SFTP 安装和固定 `goodbuddy-agent attach` 中继;attach 只连接 - 用户私有 Unix socket。SSH relay 断开不终止 Agent 或活动 Runtime。 -- 上传内容使用应用内置公钥验证的签名 manifest,并校验组件版本、协议、平台、架构、大小 - 和 SHA-256。Agent 与 Runtime 各自维护版本,签名摘要标识精确工件;使用 side-by-side - 安装、自检和健康切换,失败时不改变当前项目绑定。 +- SSH 负责 Host Key、认证、固定 bootstrap、可选 SFTP 安装和 `goodbuddy-agent attach` + 中继;attach 只连接用户私有 Unix socket。SSH relay 断开不终止 Agent 或活动 Runtime。 +- Host 环境准备可让远端按签名目录固定的 GitHub/北京镜像 URL、大小和 SHA-256 下载完整 + `.gbagent`,也可由 GoodBuddy 下载、完整验证后通过有界流式 SFTP 传输一个 compound + archive 和归档中已验证的 bootstrap Node;完整包不一次读入 Main `Buffer`,Host 仍再次 + 校验。两种 acquisition 交付到固定 operation staging 后共用 + `prepare → commit → Agent activate/health → Runtime activate → finalize → cleanup`。 +- 直连首次 bootstrap 不依赖已安装 Agent daemon。GoodBuddy 桌面控制面通过固定 SSH + prepare channel 发送有界 one-shot installer;Host 先验证目录绑定的归档大小和 SHA-256, + 再由 format v1 归档内固定 Node 解码并运行 installer。归档无需包含 + `agent/lib/package-installer.cjs`,目录不提供或检查额外的 bootstrap 能力元数据。 +- Agent 与 Runtime 各自维护版本,签名摘要标识精确工件;使用 side-by-side 安装、自检和 + 健康切换,失败时不改变当前项目绑定。相同且可验证的 Node 使用硬链接复用,相同 Runtime + digest 不重复安装。 +- commit 终态持久化,控制通道丢失时只用 `commit-status` 恢复而不重放。prepare 完成后、commit 前快照 + Agent/Runtime 五个 metadata 文件;任一组件 adoption 失败恢复原字节或原缺失状态, + 已发布 payload 不得成为 current,确认 adoption 后显式 cleanup。 - 握手报告 installation、Daemon boot identity、协议、系统、架构、用户监督器和能力。 - 在远程执行路径规范化、Git、文件、PTY、进程组和 Runtime 管理。 - 持久化有界 Agent 到 Main 输出;SSH 断开后 Agent 继续运行,同一 Agent 重连时只恢复 @@ -791,8 +833,18 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - 先完成签名 OpenCode bundle、Ask bubblewrap、Execute 直接进程、官方 ACP channel、 Main 模型网关、取消、超时和活动记录。 - OpenCode 闭环稳定后,Continue 与 DeepSeek Harness 复用相同面板和通道契约逐个接入。 -- 一次远程项目保存请求完成 Host/Agent/Workspace/Runtime 全量验证和 SQLite 原子提交。 -- 桌面更新后,用户主动首次打开远程项目时自动安装当前签名 Agent/Runtime,并在成功后原子刷新项目绑定;应用启动本身不连接远程项目。 +- 新增或重新验证 Host 时只保存并探测 Agent/Runtime,不自动安装。用户在 Host 卡片用 + 单一主按钮启动,并用次级控件选择自动、Host 下载或 GoodBuddy 传输;Host 保存与环境 + 准备是两个可独立重试的事务。 +- 控制面直连源码直接使用既有 package format v1 包;不等待发布携带 installer 的新 + Agent 包,也不通过额外目录元数据判断 bootstrap 能力。GitHub/北京镜像、 + Linux x64/arm64、取消和离线 GoodBuddy 传输仍须真实 Host 验收,不能据此声称已发布或已完成 + 真实 Host 测试。 +- 一次远程项目保存请求完成 Host/Agent/Workspace/Runtime 当前环境验证和 SQLite 原子 + 提交,但只持久化 Host、路径、Runtime 选择和模式,也不获取或安装组件。 +- 桌面更新后,用户先在 Host 管理中更新当前签名 Agent/Runtime;远程项目下一次实际使用 + Workspace/Runtime 时直接使用新的 current 环境,无需刷新项目绑定。应用启动和项目切换 + 本身都不连接 Host,也不下载或安装组件。 - SSH、Main、Daemon、Runtime 和远端重启后的核对、恢复与 `outcome_unknown`。 - 支持平台的安装、Ask、Execute、取消和重连冒烟测试通过后开放对应 OpenCode 能力。 @@ -843,6 +895,11 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - [ ] 重连先恢复 event cursor 并查询 operation;有副作用操作不会自动重放,无法确认的 结果显示为 `outcome_unknown`。 - [ ] Agent side-by-side 更新支持所需签名版本的健康切换,失败时保留旧项目绑定。 +- [x] 新增 Host 只探测远端下载能力,不提供自动安装开关,也不传输完整包;Host 卡片用 + 一个按版本事实命名的主按钮与不持久化的 acquisition SegmentedControl 启动准备。 +- [x] 创建、保存、打开和切换托管 SSH 项目都从 Host current registry 与当前 Agent + 连接验证环境;项目只保存稳定配置,且这些流程不下载 `.gbagent`、上传 payload 或发布 + 安装。 - [ ] 切换跟随目标后不显示上一对象的过期状态。 - [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。 - [ ] 日志、终端、文件、成果和远程传输均有明确上限和背压。 @@ -867,6 +924,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。 - [长期助手路线图](../../roadmap/long-term-assistant-roadmap.md) 记录整体长期能力与实施背景。 - [DeepSeek Harness Runtime 设计](../../architecture/deepseek-harness-runtime-design.md) 定义该 Runtime 的具体适配边界。 +- [SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md) + 定义 Host 级组件获取、首次准备、更新、回退和项目只验证边界。 - [统一界面设计系统](../../../UI-DESIGN.md) 定义视觉、语义、响应式和无障碍规则。 若其他文档把工作栏描述为八个同时固定显示的栏目、根据项目或 Runtime 自动裁剪的动态入口, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7034f70a..92195991 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -136,6 +136,9 @@ function rendererBundleModuleManifestPlugin(): Plugin { export function stableMainEntryFileName(chunk: { readonly name: string }): string { + if (chunk.name === 'remote-package-installer') { + return 'remote-package-installer.mjs' + } return [ 'deepseek-harness-host-bootstrap', 'embedding-inference-bootstrap' @@ -191,6 +194,9 @@ export default defineConfig({ ), 'embedding-inference-bootstrap': resolve( 'src/main/embedding-inference-bootstrap.ts' + ), + 'remote-package-installer': resolve( + 'src/main/remote-agent/control-plane-package-installer-cli.ts' ) }, external: [ diff --git a/package.json b/package.json index 66444a8c..625df5ef 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:watch": "vitest", "eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts", "build": "npm run typecheck && npm run build:bundle", - "build:bundle": "electron-vite build && node build/check-renderer-bundle.cjs", + "build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs && node build/check-renderer-bundle.cjs", "smoke:deepseek-harness": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs", "smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs", "agent:build": "node build/agent-bundle.cjs build", diff --git a/src/agent-daemon/cli.test.ts b/src/agent-daemon/cli.test.ts index dbbacc14..94a91efc 100644 --- a/src/agent-daemon/cli.test.ts +++ b/src/agent-daemon/cli.test.ts @@ -26,7 +26,10 @@ import { type ManagedInstallationPaths } from './cli' import { InstallationRegistry } from './installation-registry' -import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' +import { + RegisteredAgentBundleError, + type VerifiedInstalledAgentBundle +} from './installed-bundle-verifier' import { RuntimeBundleRegistry } from './runtime-bundle-registry' import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier' import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts' @@ -90,7 +93,7 @@ describe('Agent CLI fixed command contract', () => { expect(runModelBridgeHelper).toHaveBeenCalledOnce() }) - it('passes verified manifest version and protocol into the daemon', async () => { + it('starts the daemon from Host-managed registry evidence without full verification', async () => { const root = privateTemporaryDirectory() const paths: ManagedInstallationPaths = { executablePath: resolve(root, 'install-1', 'goodbuddy-agent'), @@ -115,6 +118,8 @@ describe('Agent CLI fixed command contract', () => { status: () => ({ daemonBootId: 'daemon-boot-1' }) })) const recordCurrentDaemonReady = vi.fn(async () => undefined) + const verifyInstallation = vi.fn(async () => verified) + const loadRegisteredInstallation = vi.fn(async () => verified) const io = cliIo() const result = await runAgentCli( @@ -123,7 +128,8 @@ describe('Agent CLI fixed command contract', () => { installationPaths: () => paths, releaseKeyRegistry, installationRegistry: registry, - verifyInstallation: async () => verified, + verifyInstallation, + loadRegisteredInstallation, peerIdentityProvider: { async getPeerIdentity() { return { uid: 1 } @@ -156,6 +162,8 @@ describe('Agent CLI fixed command contract', () => { expect(recordCurrentDaemonReady).toHaveBeenCalledWith( 'daemon-boot-1' ) + expect(loadRegisteredInstallation).toHaveBeenCalledOnce() + expect(verifyInstallation).not.toHaveBeenCalled() }) it('dispatches generated doctor and lifecycle argv through the real parser', async () => { @@ -274,6 +282,8 @@ describe('Agent CLI fixed command contract', () => { storagePath: resolve(root, 'registry.json') }) installationRegistry.stageCandidate(verified) + const verifyInstallation = vi.fn(async () => verified) + const loadRegisteredInstallation = vi.fn(async () => verified) const io = cliIo() const attachResult = await runAgentCli( @@ -283,7 +293,8 @@ describe('Agent CLI fixed command contract', () => { installationPaths: () => paths, releaseKeyRegistry, installationRegistry, - verifyInstallation: async () => verified, + verifyInstallation, + loadRegisteredInstallation, io } ) @@ -297,6 +308,43 @@ describe('Agent CLI fixed command contract', () => { secret: Buffer.alloc(32, 7) }) ) + expect(loadRegisteredInstallation).toHaveBeenCalledOnce() + expect(verifyInstallation).not.toHaveBeenCalled() + }) + + it('emits a fixed repair diagnostic when registered attach metadata is invalid', async () => { + const installationId = verifyAgentInstallationId('install-1') + const root = privateTemporaryDirectory() + const paths = { + executablePath: resolve(root, 'agent', 'goodbuddy-agent'), + stateDirectory: resolve(root, 'state', 'install-1'), + socketPath: resolve(root, 'run', 'install-1.sock') + } + const verified = verifiedInstallation(paths, installationId) + const installationRegistry = new InstallationRegistry({ + storagePath: resolve(root, 'registry.json') + }) + installationRegistry.stageCandidate(verified) + const io = cliIo() + + const result = await runAgentCli( + buildFixedAgentCliArgv(installationId, { kind: 'attach' }), + { + installationPaths: () => paths, + installationRegistry, + loadRegisteredInstallation: vi.fn(async () => { + throw new RegisteredAgentBundleError( + 'private installation detail' + ) + }), + io + } + ) + + expect(result).toBe(2) + expect(io.error.read()?.toString()).toBe( + `${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}installation-repair-required\n` + ) }) it('derives fixed Linux paths and rejects untrusted installation IDs', () => { diff --git a/src/agent-daemon/cli.ts b/src/agent-daemon/cli.ts index 030bc777..1d115fea 100644 --- a/src/agent-daemon/cli.ts +++ b/src/agent-daemon/cli.ts @@ -28,7 +28,9 @@ import { } from './private-endpoint' import { createLinuxPeerIdentityProvider } from './peer-identity-provider' import { + loadRegisteredAgentBundle, readManagedAgentReleaseKeyRegistry, + RegisteredAgentBundleError, verifyInstalledAgentBundle, type VerifiedInstalledAgentBundle } from './installed-bundle-verifier' @@ -79,6 +81,7 @@ export type AgentCliDependencies = { releaseKeyRegistry: AgentReleaseKeyRegistry } ) => Promise + loadRegisteredInstallation?: typeof loadRegisteredAgentBundle runtimePaths?: ( installationId: string, runtimeId: 'opencode', @@ -124,8 +127,8 @@ export async function runAgentCli( output: process.stdout, error: process.stderr } + const [command, ...rest] = argv try { - const [command, ...rest] = argv switch (command) { case 'daemon': await runDaemon(rest, dependencies) @@ -151,9 +154,18 @@ export async function runAgentCli( ) } } catch (error) { - io.error.write( - `${error instanceof Error ? error.message : String(error)}\n` - ) + if ( + error instanceof RegisteredAgentBundleError && + (command === 'daemon' || command === 'attach-or-bootstrap') + ) { + io.error.write( + `${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}installation-repair-required\n` + ) + } else { + io.error.write( + `${error instanceof Error ? error.message : String(error)}\n` + ) + } return error instanceof AgentUnsupportedError ? 3 : 2 } } @@ -294,12 +306,11 @@ async function runDaemon( options['installation-id']! ) const paths = resolveInstallationPaths(installationId, dependencies) - const { verified, registry } = await verifyManagedInstallation( + const { verified } = await loadManagedInstallation( installationId, paths, dependencies ) - registry.assertVerifiedRole(verified, ['current', 'candidate']) prepareManagedSocketDirectory(paths) const daemonOptions: ConstructorParameters[0] = { installationId, @@ -340,7 +351,8 @@ async function runDaemon( await createDetachedLifecycle( installationId, paths, - dependencies + dependencies, + 'registered' ).recordCurrentDaemonReady(daemon.status().daemonBootId) await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal()) } finally { @@ -362,12 +374,11 @@ async function runAttach( installationId, dependencies ) - const { verified, registry } = await verifyManagedInstallation( + await loadManagedInstallation( installationId, paths, dependencies ) - registry.assertVerifiedRole(verified, ['current', 'candidate']) prepareManagedSocketDirectory(paths) const stateDirectory = paths.stateDirectory ensurePrivateDirectory(stateDirectory, { create: false }) @@ -387,7 +398,8 @@ async function runAttach( await createDetachedLifecycle( installationId, paths, - dependencies + dependencies, + 'registered' ).bootstrap() } }) @@ -518,7 +530,8 @@ async function runLifecycle( function createDetachedLifecycle( installationId: string, paths: ManagedInstallationPaths, - dependencies: AgentCliDependencies + dependencies: AgentCliDependencies, + identitySource: 'verified' | 'registered' = 'verified' ): DetachedAgentLifecycle { const options: DetachedAgentLifecycleOptions = { installationId, @@ -526,12 +539,25 @@ function createDetachedLifecycle( stateDirectory: paths.stateDirectory, socketPath: paths.socketPath, verifyInstallation: async () => { - const { verified, registry } = await verifyManagedInstallation( - installationId, - paths, - dependencies - ) - registry.assertVerifiedRole(verified, ['current', 'candidate']) + if (identitySource === 'registered') { + return ( + await loadManagedInstallation( + installationId, + paths, + dependencies + ) + ).verified + } + const { verified, registry } = + await verifyManagedInstallation( + installationId, + paths, + dependencies + ) + registry.assertVerifiedRole(verified, [ + 'current', + 'candidate' + ]) return verified }, probeEndpoint: async (verified) => @@ -597,6 +623,36 @@ async function verifyManagedInstallation( return { verified, registry, releaseKeyRegistry } } +async function loadManagedInstallation( + installationId: string, + paths: ManagedInstallationPaths, + dependencies: AgentCliDependencies +): Promise<{ + verified: VerifiedInstalledAgentBundle + registry: InstallationRegistry +}> { + const architecture = currentAgentArchitecture() + const agentRoot = dirname(dirname(dirname(paths.executablePath))) + const registry = + dependencies.installationRegistry ?? + new InstallationRegistry({ + storagePath: resolve(agentRoot, 'registry.json') + }) + const registered = registry.assertRegisteredRole( + installationId, + ['current', 'candidate'] + ) + const verified = await ( + dependencies.loadRegisteredInstallation ?? + loadRegisteredAgentBundle + )(dirname(paths.executablePath), { + installationId, + architecture, + registered + }) + return { verified, registry } +} + export function deriveManagedInstallationPaths( installationIdInput: string, options: { diff --git a/src/agent-daemon/installation-registry.test.ts b/src/agent-daemon/installation-registry.test.ts index 7485b34a..5e3105d4 100644 --- a/src/agent-daemon/installation-registry.test.ts +++ b/src/agent-daemon/installation-registry.test.ts @@ -62,6 +62,18 @@ describe('Agent installation registry', () => { expect(() => registry.assertVerifiedRole(current, ['current']) ).not.toThrow() + expect( + registry.assertRegisteredRole('install-a', ['current']) + ).toMatchObject({ + installationId: 'install-a', + manifestSha256: 'a'.repeat(64) + }) + expect(() => + registry.assertRegisteredRole('missing', [ + 'current', + 'candidate' + ]) + ).toThrow('authorized registry role') }) it('replaces a stale candidate with the requested signed identity', () => { diff --git a/src/agent-daemon/installation-registry.ts b/src/agent-daemon/installation-registry.ts index 32d42e8a..9d4288f2 100644 --- a/src/agent-daemon/installation-registry.ts +++ b/src/agent-daemon/installation-registry.ts @@ -105,19 +105,34 @@ export class InstallationRegistry { roles: readonly ('current' | 'candidate')[] ): InstallationRegistryEntry { const entry = entryFromVerified(verified) + const registered = this.assertRegisteredRole( + entry.installationId, + roles + ) + if (!entryEquals(registered, entry)) { + throw new Error( + 'Verified Agent installation does not match its authorized registry role' + ) + } + return registered + } + + assertRegisteredRole( + installationIdInput: string, + roles: readonly ('current' | 'candidate')[] + ): InstallationRegistryEntry { + const installationId = installationRegistryIdSchema.parse( + installationIdInput + ) const registered = roles .map((role) => this.#state[role]) .find( (candidate) => - candidate !== undefined && - candidate.installationId === entry.installationId + candidate?.installationId === installationId ) - if ( - registered === undefined || - !entryEquals(registered, entry) - ) { + if (registered === undefined) { throw new Error( - 'Verified Agent installation does not match its authorized registry role' + 'Agent installation does not have an authorized registry role' ) } return registered diff --git a/src/agent-daemon/installed-bundle-verifier.test.ts b/src/agent-daemon/installed-bundle-verifier.test.ts index cb7f2009..614070d1 100644 --- a/src/agent-daemon/installed-bundle-verifier.test.ts +++ b/src/agent-daemon/installed-bundle-verifier.test.ts @@ -23,6 +23,7 @@ import { import { canonicalInstalledAgentManifestBytes, installedAgentManifestSignaturePayload, + loadRegisteredAgentBundle, verifyInstalledAgentBundle } from './installed-bundle-verifier' @@ -132,6 +133,60 @@ describe('installed Agent bundle verifier', () => { ) }) + + it('loads Host-managed registry evidence without repeating signature or payload verification', async () => { + writeBundle() + const manifestBytes = readFileSync( + join(installationDirectory, 'manifest.json') + ) + const manifestSha256 = createHash('sha256') + .update(manifestBytes) + .digest('hex') + writeFileSync( + join(installationDirectory, 'manifest.sig'), + 'not-used-during-attach\n', + { mode: 0o644 } + ) + + await expect( + loadRegisteredAgentBundle(installationDirectory, { + installationId: 'install-10', + architecture: 'x64', + registered: { + installationId: 'install-10', + agentVersion: '0.11.0', + manifestSha256, + arch: 'x64' + }, + enforceFilesystemMode: process.platform !== 'win32' + }) + ).resolves.toMatchObject({ + installationId: 'install-10', + manifestSha256, + binaryDigest: `sha256:${manifestSha256}`, + manifest: { + agentVersion: '0.11.0', + protocol: { major: 1, minor: 0 } + } + }) + + writeFileSync( + join(installationDirectory, 'manifest.json'), + Buffer.concat([manifestBytes, Buffer.from(' ')]) + ) + await expect( + loadRegisteredAgentBundle(installationDirectory, { + installationId: 'install-10', + architecture: 'x64', + registered: { + installationId: 'install-10', + agentVersion: '0.11.0', + manifestSha256, + arch: 'x64' + } + }) + ).rejects.toThrow('Host-managed registry') + }) }) function resetInstallation(): void { diff --git a/src/agent-daemon/installed-bundle-verifier.ts b/src/agent-daemon/installed-bundle-verifier.ts index cd137dc3..6f6402a8 100644 --- a/src/agent-daemon/installed-bundle-verifier.ts +++ b/src/agent-daemon/installed-bundle-verifier.ts @@ -17,6 +17,10 @@ import { type AgentReleaseKey, type AgentReleaseKeyRegistry } from '../shared/agent-installation-contracts' +import { + installationRegistryEntrySchema, + type InstallationRegistryEntry +} from '../shared/remote-environment-registry-contracts' import { assertAbsoluteManagedPath } from './managed-paths' import { assertElfArchitecture, @@ -56,6 +60,22 @@ export type VerifyInstalledAgentBundleOptions = { uid?: number } +export type LoadRegisteredAgentBundleOptions = { + installationId: string + architecture: AgentArchitecture + registered: InstallationRegistryEntry + enforceFilesystemMode?: boolean + filesystemPlatform?: NodeJS.Platform + uid?: number +} + +export class RegisteredAgentBundleError extends Error { + constructor(message: string, cause?: unknown) { + super(message, { cause }) + this.name = 'RegisteredAgentBundleError' + } +} + export async function readManagedAgentReleaseKeyRegistry( filePath: string, options: { uid?: number; filesystemPlatform?: NodeJS.Platform } = {} @@ -202,6 +222,126 @@ export async function verifyInstalledAgentBundle( } } +/** + * Reconstructs the identity previously verified and committed by Host + * management. Normal attach/bootstrap uses this bounded metadata path instead + * of rehashing the complete installation on every project switch. + */ +export async function loadRegisteredAgentBundle( + installationDirectoryInput: string, + options: LoadRegisteredAgentBundleOptions +): Promise { + try { + return await loadRegisteredAgentBundleUnchecked( + installationDirectoryInput, + options + ) + } catch (error) { + if (error instanceof RegisteredAgentBundleError) { + throw error + } + throw new RegisteredAgentBundleError( + error instanceof Error + ? error.message + : 'Registered Agent installation is invalid', + error + ) + } +} + +async function loadRegisteredAgentBundleUnchecked( + installationDirectoryInput: string, + options: LoadRegisteredAgentBundleOptions +): Promise { + const installationId = validateInstallationId(options.installationId) + const installationDirectory = resolve(installationDirectoryInput) + assertAbsoluteManagedPath(installationDirectory) + if (basename(installationDirectory) !== installationId) { + throw new Error( + 'Agent installation ID does not match its managed directory' + ) + } + const registered = installationRegistryEntrySchema.parse( + options.registered + ) + if ( + registered.installationId !== installationId || + registered.arch !== options.architecture + ) { + throw new Error( + 'Registered Agent identity does not match the current installation' + ) + } + + const rootStat = await lstat(installationDirectory) + assertOwnedDirectory( + rootStat, + 'Agent installation directory', + options.uid + ) + if ( + shouldEnforceMode(options) && + modeString(rootStat.mode) !== '0700' + ) { + throw new Error( + 'Agent installation directory permissions must be 0700' + ) + } + + const manifestBytes = await readMetadataFile( + join(installationDirectory, MANIFEST_FILE_NAME), + 'Agent manifest', + options + ) + if (sha256Bytes(manifestBytes) !== registered.manifestSha256) { + throw new Error( + 'Agent manifest does not match the Host-managed registry' + ) + } + let untrusted: unknown + try { + untrusted = JSON.parse(manifestBytes.toString('utf8')) + } catch (error) { + throw new Error('Agent manifest JSON is invalid', { cause: error }) + } + const manifest = agentBundleManifestSchema.parse(untrusted) + if ( + !canonicalInstalledAgentManifestBytes(manifest).equals( + manifestBytes + ) || + manifest.agentVersion !== registered.agentVersion || + manifest.arch !== registered.arch + ) { + throw new Error( + 'Agent manifest does not match the Host-managed registry' + ) + } + + const executablePath = join( + installationDirectory, + manifest.entrypoint.path + ) + await assertRegisteredEntrypoint(executablePath, '0755', options) + await assertRegisteredEntrypoint( + join(installationDirectory, manifest.entrypoint.runtimePath), + '0755', + options + ) + await assertRegisteredEntrypoint( + join(installationDirectory, manifest.entrypoint.scriptPath), + '0644', + options + ) + return { + installationId, + installationDirectory, + executablePath, + manifest, + manifestSha256: registered.manifestSha256, + binaryDigest: `sha256:${registered.manifestSha256}` + } +} + export function canonicalInstalledAgentManifestBytes( manifest: AgentBundleManifest ): Buffer { @@ -351,6 +491,26 @@ function importPublicKey(key: AgentReleaseKey) { return publicKey } +async function assertRegisteredEntrypoint( + filePath: string, + expectedMode: string, + options: Pick< + LoadRegisteredAgentBundleOptions, + 'uid' | 'filesystemPlatform' | 'enforceFilesystemMode' + > +): Promise { + const stat = await lstat(filePath) + assertOwnedRegularFile(stat, filePath, options.uid) + if ( + shouldEnforceMode(options) && + modeString(stat.mode) !== expectedMode + ) { + throw new Error( + `Agent payload mode mismatch: ${basename(filePath)}` + ) + } +} + async function readMetadataFile( filePath: string, label: string, diff --git a/src/agent-daemon/protocol-server.test.ts b/src/agent-daemon/protocol-server.test.ts index 9d08a57e..922adc74 100644 --- a/src/agent-daemon/protocol-server.test.ts +++ b/src/agent-daemon/protocol-server.test.ts @@ -673,6 +673,55 @@ describe('AgentProtocolServer connection bounds', () => { harness.close() }) + it('marks method context only after exact controller takeover', async () => { + const takeoverStates: Array = [] + const harness = createHarness({ + reconnect: true, + methods: { + 'runtime/openAcpChannel': (_params, context) => { + takeoverStates.push(context.controllerTakeoverProven) + return null + } + } + }) + const previous = harness.previousController! + harness.socket.receive( + controlFrame(harness.controller, 'before-resume', '1', 1, { + jsonrpc: '2.0', + id: 1, + method: 'runtime/openAcpChannel', + params: {} + }) + ) + await waitFor(() => harness.socket.writes.length === 2) + harness.socket.receive( + controlFrame(harness.controller, 'resume', '1', 1, { + jsonrpc: '2.0', + id: 2, + method: 'controller/resume', + params: { + previousGeneration: previous.generation, + previousConnectionId: previous.connectionId, + daemonBootId: daemonStatus.daemonBootId, + capabilityGeneration: previous.capabilityGeneration + } + }) + ) + await waitFor(() => harness.socket.writes.length === 4) + harness.socket.receive( + controlFrame(harness.controller, 'after-resume', '1', 1, { + jsonrpc: '2.0', + id: 3, + method: 'runtime/openAcpChannel', + params: {} + }) + ) + await waitFor(() => harness.socket.writes.length === 6) + + expect(takeoverStates).toEqual([false, true]) + harness.close() + }) + it.each([ ['previous connection', { previousConnectionId: 'connection-wrong' }], ['capability generation', { capabilityGeneration: 2 }], diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts index 2d6e5e37..39b5481f 100644 --- a/src/agent-daemon/protocol-server.ts +++ b/src/agent-daemon/protocol-server.ts @@ -82,6 +82,7 @@ const RUNTIME_ACP_METHODS = [ export type ProtocolMethodContext = { controller: ControllerLease channelId: string + controllerTakeoverProven?: boolean signal?: AbortSignal } @@ -841,6 +842,7 @@ class ProtocolConnection { const result = await handler(request.params, { controller: this.#controller, channelId: channel.channelId, + controllerTakeoverProven: this.#takeoverProven, signal: this.#abortController.signal }) if (request.method === 'runtime/resumeAcpChannel') { diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index 86910a24..bcb20491 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -155,11 +155,7 @@ class MemoryAcpJournal implements RuntimeAcpJournal { } class FakeProcess implements RuntimeAcpProcessOwner { - readonly identity: RuntimeAcpProcessIdentity = { - launchId: 'launch-1', - processId: 'process-1', - supervisorIdentityDigest: digest('9') - } + readonly identity: RuntimeAcpProcessIdentity readonly writes: Uint8Array[] = [] readonly stops: string[] = [] readonly prompts: Array<{ @@ -167,15 +163,24 @@ class FakeProcess implements RuntimeAcpProcessOwner { maximumInputBytes: number }> = [] completions = 0 - reconciliation: RuntimeAcpProcessReconciliation = { - identity: this.identity, - state: 'running', - processTree: 'running' - } + reconciliation: RuntimeAcpProcessReconciliation #listener?: ( output: RuntimeAcpProcessOutput ) => void | Promise + constructor(identity = '1') { + this.identity = { + launchId: `launch-${identity}`, + processId: `process-${identity}`, + supervisorIdentityDigest: digest(identity) + } + this.reconciliation = { + identity: this.identity, + state: 'running', + processTree: 'running' + } + } + beginPrompt(input: { deadlineAt: string maximumInputBytes: number @@ -238,6 +243,8 @@ function harness(input: { now?: number maximumOutputBytes?: number bridgeCloseError?: boolean + uniqueProcesses?: boolean + outputGate?: Promise } = {}) { const workMode = input.workMode ?? 'ask' let now = input.now ?? 1_000 @@ -275,10 +282,13 @@ function harness(input: { workMode: launch.workMode, scratch: launch.scratch }) - return process + return input.uniqueProcesses && launches.length > 1 + ? new FakeProcess(String(launches.length)) + : process }), now: () => now, outputSink: vi.fn(async (frame) => { + await input.outputGate outputFrames.push(Buffer.from(frame.payload).toString()) }), blobSink, @@ -644,6 +654,113 @@ describe('RuntimeAcpBackend', () => { ).rejects.toMatchObject({ code: 'stale-controller' }) }) + it('reopens a detached idle binding after a proven same-controller takeover', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', + operationId: 'request-1', + requestId: 'request-1' + }) + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + + const replacement = protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + replacement.controllerTakeoverProven = true + await expect( + fixture.backend.methods['runtime/openAcpChannel']!( + fixture.openRequest, + replacement + ) + ).resolves.toMatchObject({ + bindingId: 'binding-1', + channelEpoch: '1001' + }) + expect(fixture.process.stops).toEqual(['binding-closed']) + expect(fixture.journal.retired).toContainEqual({ + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '1000' + }) + }) + + it('drains the detached old epoch before replacing an idle binding', async () => { + let releaseOutput!: () => void + const outputGate = new Promise((resolve) => { + releaseOutput = resolve + }) + const fixture = harness({ outputGate }) + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + const pendingOutput = fixture.process.emit('stdout', 'pending') + await new Promise((resolve) => setImmediate(resolve)) + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', + operationId: 'request-1', + requestId: 'request-1' + }) + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + + const replacement = protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + replacement.controllerTakeoverProven = true + let replaced = false + const reopen = fixture.backend.methods[ + 'runtime/openAcpChannel' + ]!(fixture.openRequest, replacement).then((result) => { + replaced = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(replaced).toBe(false) + + releaseOutput() + await pendingOutput + await expect(reopen).resolves.toMatchObject({ + bindingId: 'binding-1', + channelEpoch: '1001' + }) + expect(fixture.journal.frames).toContainEqual( + expect.objectContaining({ + bindingId: 'binding-1', + channelEpoch: '1000', + sequence: '1' + }) + ) + }) + + it('does not adopt an idle binding without exact takeover proof', async () => { + const fixture = harness() + await open(fixture) + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + + await expect( + fixture.backend.methods['runtime/openAcpChannel']!( + fixture.openRequest, + protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + ) + ).rejects.toMatchObject({ code: 'stale-controller' }) + }) + it('reopens a definitively closed stable binding with a fresh epoch and generation', async () => { const fixture = harness() await open(fixture) @@ -799,6 +916,13 @@ describe('RuntimeAcpBackend', () => { generation: 2, connectionId: 'connection-2' }) + resumedContext.controllerTakeoverProven = true + await expect( + fixture.backend.methods['runtime/openAcpChannel']!( + fixture.openRequest, + resumedContext + ) + ).rejects.toMatchObject({ code: 'stale-controller' }) await expect( fixture.backend.methods['runtime/resumeAcpChannel']!( { @@ -1019,6 +1143,43 @@ describe('RuntimeAcpBackend', () => { ).rejects.toMatchObject({ code: 'identity' }) expect(fixture.process.stops).toEqual(['identity-conflict']) }) + + it('runs independent conversations for two workspaces on one controller', async () => { + const fixture = harness({ uniqueProcesses: true }) + await open(fixture) + const secondOpen = { + ...fixture.openRequest, + bindingId: 'binding-2', + workspaceIdentity: 'workspace-identity-2' + } + await invoke( + fixture, + 'runtime/openAcpChannel', + secondOpen + ) + + await Promise.all([ + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ), + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + bindingId: 'binding-2', + operationId: 'request-2', + requestId: 'request-2', + channelEpoch: '1001', + workspaceIdentity: 'workspace-identity-2' + }) + ) + ]) + + expect(fixture.launches).toHaveLength(2) + expect(fixture.process.stops).toEqual([]) + }) }) async function open(fixture: Harness): Promise> { diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index fdd11ed3..1cdfe2f6 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -621,8 +621,30 @@ export class RuntimeAcpBackend { ) } if (existing.state !== 'closed' || existing.process !== undefined) { - this.#assertBindingOwner(existing, context) - return acpOpenChannelResultSchema.parse(existing.openResult) + const ownedByCurrentConnection = + existing.controllerGeneration === + context.controller.generation && + existing.connectionId === + context.controller.connectionId + if (ownedByCurrentConnection) { + return acpOpenChannelResultSchema.parse(existing.openResult) + } + const canReplaceDetachedIdleBinding = + context.controllerTakeoverProven === true && + context.controller.generation > + existing.controllerGeneration && + existing.transportState === 'detached' && + existing.activeOperationId === undefined && + (existing.state === 'open' || + existing.state === 'running') + if (!canReplaceDetachedIdleBinding) { + this.#assertBindingOwner(existing, context) + } + await Promise.all([ + existing.inputTail, + existing.outputTail + ]) + await this.#stopAndReconcile(existing, 'binding-closed') } this.#assertOpenCapacity(context.controller.controllerId) const channelEpoch = this.#allocateChannelEpoch() diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index c766fbf5..a1739c33 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -41,26 +41,20 @@ async function createDatabase( const validatedSshHostId = '00000000-0000-4000-8000-000000000321' -const validatedSshAgentId = 'agent-installation-1' -const validatedSshDigest = `sha256:${'b'.repeat(64)}` function validatedSshProjectWrite( overrides: { rootPath?: string hostId?: string - agentInstallationId?: string - runtimeSelectionKey?: string - assertSshHostCurrent?: Parameters< - AssistantDatabase['createValidatedSshProject'] - >[0]['assertSshHostCurrent'] + assertCurrent?: Parameters< + AssistantDatabase['createSshProject'] + >[0]['assertCurrent'] } = {} ): Parameters< - AssistantDatabase['createValidatedSshProject'] + AssistantDatabase['createSshProject'] >[0] { const rootPath = overrides.rootPath ?? '/srv/goodbuddy' const hostId = overrides.hostId ?? validatedSshHostId - const agentInstallationId = - overrides.agentInstallationId ?? validatedSshAgentId return { project: { name: '远程项目', @@ -72,31 +66,9 @@ function validatedSshProjectWrite( executionSpace: { kind: 'ssh', hostId, - remoteRootPath: rootPath, - validation: { - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-identity', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: agentInstallationId, - agentBinaryDigestAtValidation: validatedSshDigest, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64', - validatedAt: '2026-08-21T00:00:00.000Z' - } + remoteRootPath: rootPath }, - runtimeValidation: { - runtimeSelectionKey: - overrides.runtimeSelectionKey ?? 'opencode:default', - runtimeBundleDigest: `sha256:${'c'.repeat(64)}`, - runtimeAdapterDigest: `sha256:${'d'.repeat(64)}`, - agentInstallationIdAtValidation: agentInstallationId, - validatedAt: '2026-08-21T00:01:00.000Z', - workMode: 'execute' - }, - assertSshHostCurrent: - overrides.assertSshHostCurrent ?? (() => {}) + assertCurrent: overrides.assertCurrent ?? (() => {}) } } @@ -230,7 +202,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 30', async () => { + it('migrates existing databases to schema version 31', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -259,7 +231,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(30) + ).toBe(31) expect( current .prepare( @@ -391,7 +363,6 @@ describe('AssistantDatabase', () => { .prepare('SELECT * FROM projects ORDER BY rowid') .all() legacy.exec(` - DROP TABLE project_runtime_validations; DROP TABLE project_execution_spaces; PRAGMA user_version = 26; `) @@ -422,7 +393,7 @@ describe('AssistantDatabase', () => { enableForeignKeyConstraints: true }) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 30 + user_version: 31 }) expect( inspected.prepare('SELECT * FROM projects ORDER BY rowid').all() @@ -430,15 +401,7 @@ describe('AssistantDatabase', () => { expect( inspected .prepare( - `SELECT project_id, kind, root_path, ssh_host_id, - host_revision, host_key_generation, remote_username, - workspace_identity, - agent_installation_id_at_validation, - agent_binary_digest_at_validation, - agent_version_at_validation, - agent_architecture_at_validation, - agent_protocol_major, trust_attestation_revision, - validated_at + `SELECT project_id, kind, root_path, ssh_host_id FROM project_execution_spaces ORDER BY project_id` ) @@ -448,35 +411,13 @@ describe('AssistantDatabase', () => { project_id: blankRootProject.id, kind: 'local', root_path: '', - ssh_host_id: null, - host_revision: null, - host_key_generation: null, - remote_username: null, - workspace_identity: null, - agent_installation_id_at_validation: null, - agent_binary_digest_at_validation: null, - agent_version_at_validation: null, - agent_architecture_at_validation: null, - agent_protocol_major: null, - trust_attestation_revision: null, - validated_at: null + ssh_host_id: null }, { project_id: project.id, kind: 'local', root_path: 'D:\\Migration', - ssh_host_id: null, - host_revision: null, - host_key_generation: null, - remote_username: null, - workspace_identity: null, - agent_installation_id_at_validation: null, - agent_binary_digest_at_validation: null, - agent_version_at_validation: null, - agent_architecture_at_validation: null, - agent_protocol_major: null, - trust_attestation_revision: null, - validated_at: null + ssh_host_id: null } ].sort((left, right) => left.project_id.localeCompare(right.project_id) @@ -493,27 +434,6 @@ describe('AssistantDatabase', () => { on_delete: 'CASCADE' }) ]) - expect( - inspected - .prepare('PRAGMA foreign_key_list(project_runtime_validations)') - .all() - ).toEqual([ - expect.objectContaining({ - table: 'projects', - from: 'project_id', - to: 'id', - on_delete: 'CASCADE' - }) - ]) - expect( - inspected - .prepare('PRAGMA index_list(project_runtime_validations)') - .all() - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ origin: 'pk', unique: 1 }) - ]) - ) expect(() => inspected .prepare( @@ -526,47 +446,10 @@ describe('AssistantDatabase', () => { blankRootProject.id ) ).toThrow() - const insertRuntimeValidation = inspected.prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, runtime_bundle_digest, - runtime_adapter_digest, confinement_policy_digest, - approval_bridge_version, - agent_installation_id_at_validation, validated_at, work_mode, - trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - insertRuntimeValidation.run( - project.id, - 'continue', - `sha256:${'c'.repeat(64)}`, - `sha256:${'d'.repeat(64)}`, - `sha256:${'d'.repeat(64)}`, - 'unused', - 'agent-installation', - '2026-08-21T00:00:00.000Z', - 'execute', - null, - null - ) - expect(() => - insertRuntimeValidation.run( - project.id, - 'opencode', - `sha256:${'f'.repeat(64)}`, - `sha256:${'1'.repeat(64)}`, - `sha256:${'1'.repeat(64)}`, - 'unused', - 'other-agent-installation', - '2026-08-21T00:01:00.000Z', - 'execute', - null, - null - ) - ).toThrow() inspected.close() }) - it('preserves v27 SSH rows but fails closed until live Agent identity is revalidated', async () => { + it('preserves SSH project locations when removing legacy evidence', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-v27-ssh-migration-') ) @@ -574,93 +457,23 @@ describe('AssistantDatabase', () => { const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') - const project = initial.createProject({ - name: '旧远程项目', - description: '', - rootPath: '/srv/legacy', - defaultWorkMode: 'execute', - runtimeSelection: { provider: 'opencode' } - }) - initial.close() - - const legacy = new DatabaseSync(databasePath) - legacy.exec(` - DROP TRIGGER project_execution_spaces_validate_insert; - DROP TRIGGER project_execution_spaces_validate_update; - ALTER TABLE project_execution_spaces - DROP COLUMN agent_binary_digest_at_validation; - ALTER TABLE project_execution_spaces - DROP COLUMN agent_version_at_validation; - ALTER TABLE project_execution_spaces - DROP COLUMN agent_architecture_at_validation; - `) - legacy - .prepare( - `UPDATE project_execution_spaces - SET kind = 'ssh', ssh_host_id = ?, host_revision = 2, - host_key_generation = 3, remote_username = 'builder', - workspace_identity = 'workspace-legacy', - agent_installation_id_at_validation = 'agent-legacy', - agent_protocol_major = 1, - trust_attestation_revision = 4, - validated_at = '2026-08-20T00:00:00.000Z' - WHERE project_id = ?` - ) - .run(validatedSshHostId, project.id) - legacy.exec('PRAGMA user_version = 27;') - legacy.close() - - const migrated = new AssistantDatabase(databasePath) - migrated.initialize('C:\\Workspace') - expect(() => migrated.getProject(project.id)).toThrow( - '执行空间配置无效' - ) - migrated.close() - - const inspected = new DatabaseSync(databasePath) - expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 30 - }) - expect( - inspected - .prepare( - `SELECT agent_binary_digest_at_validation, - agent_version_at_validation, - agent_architecture_at_validation - FROM project_execution_spaces - WHERE project_id = ?` - ) - .get(project.id) - ).toEqual({ - agent_binary_digest_at_validation: null, - agent_version_at_validation: null, - agent_architecture_at_validation: null - }) - inspected.close() - }) - - it('migrates v28 Runtime rows without inventing trust evidence', async () => { - const directory = await mkdtemp( - join(tmpdir(), 'goodbuddy-v28-runtime-migration-') - ) - temporaryDirectories.push(directory) - const databasePath = join(directory, 'assistant.sqlite') - const initial = new AssistantDatabase(databasePath) - initial.initialize('C:\\Workspace') - const project = initial.createValidatedSshProject( - validatedSshProjectWrite() + const project = initial.createSshProject( + validatedSshProjectWrite({ rootPath: '/srv/legacy' }) ) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` - DROP TRIGGER project_runtime_validations_validate_insert; - DROP TRIGGER project_runtime_validations_validate_update; - ALTER TABLE project_runtime_validations DROP COLUMN work_mode; - ALTER TABLE project_runtime_validations DROP COLUMN trust_tier; - ALTER TABLE project_runtime_validations - DROP COLUMN trust_attestation_revision; - PRAGMA user_version = 28; + ALTER TABLE project_execution_spaces + ADD COLUMN workspace_identity TEXT; + UPDATE project_execution_spaces + SET workspace_identity = 'workspace-legacy' + WHERE project_id = '${project.id}'; + CREATE TABLE project_runtime_validations ( + project_id TEXT PRIMARY KEY, + runtime_bundle_digest TEXT NOT NULL + ); + PRAGMA user_version = 30; `) legacy.close() @@ -668,56 +481,116 @@ describe('AssistantDatabase', () => { migrated.initialize('C:\\Workspace') expect(migrated.getProject(project.id)).toMatchObject({ id: project.id, - defaultWorkMode: 'execute' + executionSpace: { + kind: 'ssh', + hostId: validatedSshHostId, + remoteRootPath: '/srv/legacy' + } }) - expect( - migrated.getProject(project.id).runtimeValidation - ).toBeUndefined() + migrated.close() const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 30 + user_version: 31 }) expect( inspected .prepare( - `SELECT work_mode, confinement_policy_digest, - approval_bridge_version, runtime_adapter_digest, - trust_tier, trust_attestation_revision - FROM project_runtime_validations - WHERE project_id = ?` + `SELECT project_id, kind, root_path, ssh_host_id + FROM project_execution_spaces WHERE project_id = ?` ) .get(project.id) ).toEqual({ - work_mode: null, - confinement_policy_digest: `sha256:${'d'.repeat(64)}`, - approval_bridge_version: 'unused', - runtime_adapter_digest: `sha256:${'d'.repeat(64)}`, - trust_tier: null, - trust_attestation_revision: null + project_id: project.id, + kind: 'ssh', + root_path: '/srv/legacy', + ssh_host_id: validatedSshHostId }) - inspected - .prepare( - `UPDATE project_runtime_validations - SET work_mode = 'execute' - WHERE project_id = ?` - ) - .run(project.id) - expect(() => - inspected - .prepare( - `UPDATE project_runtime_validations - SET work_mode = 'ask', trust_tier = 'T3', - trust_attestation_revision = 5 - WHERE project_id = ?` - ) - .run(project.id) - ).toThrow('invalid project runtime validation') inspected.close() + }) - expect(migrated.getProject(project.id).runtimeValidation).toMatchObject({ + it('migrates v30 projects while deleting installation evidence', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-v30-evidence-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const project = initial.createSshProject( + validatedSshProjectWrite() + ) + const taskId = '00000000-0000-4000-8000-000000000399' + initial.createTask({ + id: taskId, + projectId: project.id, + title: '保留任务', + instructions: '迁移后仍应存在', workMode: 'execute' }) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + ALTER TABLE project_execution_spaces + ADD COLUMN agent_installation_id_at_validation TEXT; + UPDATE project_execution_spaces + SET agent_installation_id_at_validation = 'old-agent' + WHERE project_id = '${project.id}'; + CREATE TABLE project_runtime_validations ( + project_id TEXT PRIMARY KEY, + runtime_bundle_digest TEXT NOT NULL + ); + INSERT INTO project_runtime_validations + (project_id, runtime_bundle_digest) + VALUES ('${project.id}', 'sha256:${'d'.repeat(64)}'); + PRAGMA user_version = 30; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect(migrated.getProject(project.id)).toMatchObject({ + id: project.id, + defaultWorkMode: 'execute', + executionSpace: { + kind: 'ssh', + hostId: validatedSshHostId, + remoteRootPath: '/srv/goodbuddy' + } + }) + expect( + migrated.listTasks().find((task) => task.id === taskId) + ).toMatchObject({ + id: taskId, + projectId: project.id + }) + + const inspected = new DatabaseSync(databasePath) + expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ + user_version: 31 + }) + expect( + inspected + .prepare('PRAGMA table_info(project_execution_spaces)') + .all() + .map((column) => column.name) + ).toEqual([ + 'project_id', + 'kind', + 'root_path', + 'ssh_host_id' + ]) + expect( + inspected + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' + AND name = 'project_runtime_validations'` + ) + .get() + ).toBeUndefined() + inspected.close() migrated.close() }) @@ -945,7 +818,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(30) + ).toBe(31) expect( current .prepare( @@ -1083,7 +956,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 30 }) + ).toEqual({ user_version: 31 }) expect( inspected .prepare( @@ -1373,63 +1246,22 @@ describe('AssistantDatabase', () => { name: '远程项目', description: '', rootPath: 'C:\\LegacyProjection', - defaultWorkMode: 'ask' + defaultWorkMode: 'ask', + runtimeSelection: { provider: 'opencode' } }) const hostId = '00000000-0000-4000-8000-000000000311' const raw = new DatabaseSync(databasePath) raw .prepare( `UPDATE project_execution_spaces - SET kind = 'ssh', root_path = ?, ssh_host_id = ?, - host_revision = ?, host_key_generation = ?, - remote_username = ?, workspace_identity = ?, - agent_installation_id_at_validation = ?, - agent_binary_digest_at_validation = ?, - agent_version_at_validation = ?, - agent_architecture_at_validation = ?, - agent_protocol_major = ?, - trust_attestation_revision = ?, validated_at = ? + SET kind = 'ssh', root_path = ?, ssh_host_id = ? WHERE project_id = ?` ) .run( '/srv/goodbuddy', hostId, - 2, - 3, - 'builder', - 'workspace-identity', - 'agent-installation', - `sha256:${'a'.repeat(64)}`, - '0.11.0', - 'x64', - 1, - 0, - '2026-08-21T00:00:00.000Z', project.id ) - raw - .prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, runtime_bundle_digest, - runtime_adapter_digest, confinement_policy_digest, - approval_bridge_version, - agent_installation_id_at_validation, validated_at, work_mode, - trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - project.id, - 'opencode:profile', - `sha256:${'b'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - 'unused', - 'agent-installation', - '2026-08-21T00:01:00.000Z', - 'ask', - null, - null - ) raw.close() expect(database.getProject(project.id)).toEqual({ @@ -1439,43 +1271,93 @@ describe('AssistantDatabase', () => { executionSpace: { kind: 'ssh', hostId, - remoteRootPath: '/srv/goodbuddy', - validation: { - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-identity', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: 'agent-installation', - agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64', - validatedAt: '2026-08-21T00:00:00.000Z' - } - }, - runtimeValidation: { - runtimeSelectionKey: 'opencode:profile', - runtimeBundleDigest: `sha256:${'b'.repeat(64)}`, - runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`, - agentInstallationIdAtValidation: 'agent-installation', - validatedAt: '2026-08-21T00:01:00.000Z', - workMode: 'ask' + remoteRootPath: '/srv/goodbuddy' } }) - expect(database.listProjectIdsReferencingSshHost(hostId)).toEqual([ - project.id + database.setProjectArchived(project.id, true) + const taskId = '00000000-0000-4000-8000-000000000313' + database.createTask({ + id: taskId, + projectId: project.id, + title: '无法连接的远程任务', + instructions: '本地清理不应等待远端任务停止', + workMode: 'execute' + }) + expect(database.listProjectsReferencingSshHost(hostId)).toEqual([ + { id: project.id, name: project.name } + ]) + expect(database.listSshHostProjectReferences()).toEqual([ + { hostId, id: project.id, name: project.name } ]) expect( - database.listProjectIdsReferencingSshHost( + database.listProjectsReferencingSshHost( '00000000-0000-4000-8000-000000000312' ) ).toEqual([]) + expect( + database.deleteProjectsReferencingSshHost(hostId) + ).toEqual([{ id: project.id, name: project.name }]) + expect( + database.listProjects(true).some( + (candidate) => candidate.id === project.id + ) + ).toBe(false) + expect( + database.listTasks().some((task) => task.id === taskId) + ).toBe(false) + expect(database.listProjects()).toHaveLength(1) database.close() }) - it('fails closed on malformed persisted validation timestamps', async () => { + it('keeps the final active project when deleting its Host', async () => { + const database = await createDatabase() + const localProject = database.listProjects()[0]! + const remoteProject = database.createSshProject( + validatedSshProjectWrite() + ) + database.setProjectArchived(localProject.id, true) + + expect(() => + database.deleteProjectsReferencingSshHost( + validatedSshHostId + ) + ).toThrow('至少需要保留一个可用项目') + expect(database.getProject(remoteProject.id)).toMatchObject({ + id: remoteProject.id, + status: 'active' + }) + database.close() + }) + + it('requires a local fallback before deleting an active remote project', async () => { + const database = await createDatabase() + const localProject = database.listProjects()[0]! + const remoteProject = database.createSshProject( + validatedSshProjectWrite() + ) + database.createSshProject( + validatedSshProjectWrite({ + rootPath: '/srv/other-project' + }) + ) + database.setProjectArchived(localProject.id, true) + + expect(() => + database.deleteProject( + remoteProject.id, + remoteProject.name, + { allowActiveTasks: true } + ) + ).toThrow('至少需要保留一个可用项目') + expect(database.getProject(remoteProject.id).id).toBe( + remoteProject.id + ) + database.close() + }) + + it('fails closed when an SSH project lacks a Runtime selection', async () => { const directory = await mkdtemp( - join(tmpdir(), 'goodbuddy-invalid-execution-trust-') + join(tmpdir(), 'goodbuddy-invalid-ssh-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') @@ -1486,71 +1368,18 @@ describe('AssistantDatabase', () => { raw .prepare( `UPDATE project_execution_spaces - SET kind = 'ssh', root_path = ?, ssh_host_id = ?, - host_revision = ?, host_key_generation = ?, - remote_username = ?, workspace_identity = ?, - agent_installation_id_at_validation = ?, - agent_binary_digest_at_validation = ?, - agent_version_at_validation = ?, - agent_architecture_at_validation = ?, - agent_protocol_major = ?, - trust_attestation_revision = ?, validated_at = ? + SET kind = 'ssh', root_path = ?, ssh_host_id = ? WHERE project_id = ?` ) .run( '/srv/goodbuddy', '00000000-0000-4000-8000-000000000313', - 1, - 1, - 'builder', - 'workspace-identity', - 'agent-installation', - `sha256:${'a'.repeat(64)}`, - '0.11.0', - 'arm64', - 1, - 0, - 'not-a-timestamp', project.id ) - - expect(() => database.getProject(project.id)).toThrow( - '执行空间配置无效' - ) - raw - .prepare( - `UPDATE project_execution_spaces - SET validated_at = ? - WHERE project_id = ?` - ) - .run('2026-08-21T00:00:00.000Z', project.id) - raw - .prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, runtime_bundle_digest, - runtime_adapter_digest, confinement_policy_digest, - approval_bridge_version, - agent_installation_id_at_validation, validated_at, work_mode, - trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - project.id, - 'opencode', - `sha256:${'b'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - 'unused', - 'agent-installation', - 'not-a-timestamp', - 'ask', - null, - null - ) raw.close() expect(() => database.getProject(project.id)).toThrow( - 'Runtime 验证配置无效' + '缺少远程 Runtime 选择' ) database.close() }) @@ -1564,25 +1393,13 @@ describe('AssistantDatabase', () => { const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const precondition = vi.fn() - const project = database.createValidatedSshProject( + const project = database.createSshProject( validatedSshProjectWrite({ - assertSshHostCurrent: precondition + assertCurrent: precondition }) ) - expect(precondition).toHaveBeenCalledWith({ - hostId: validatedSshHostId, - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - remoteRootPath: '/srv/goodbuddy', - workspaceIdentity: 'workspace-identity', - agentProtocolMajor: 1, - agentInstallationId: validatedSshAgentId, - agentBinaryDigest: validatedSshDigest, - agentVersion: '0.11.0', - agentArchitecture: 'x64' - }) + expect(precondition).toHaveBeenCalledWith() expect(project).toMatchObject({ name: '远程项目', rootPath: '/srv/goodbuddy', @@ -1590,17 +1407,7 @@ describe('AssistantDatabase', () => { executionSpace: { kind: 'ssh', hostId: validatedSshHostId, - remoteRootPath: '/srv/goodbuddy', - validation: { - agentInstallationIdAtValidation: validatedSshAgentId, - agentBinaryDigestAtValidation: validatedSshDigest, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64' - } - }, - runtimeValidation: { - runtimeSelectionKey: 'opencode:default', - agentInstallationIdAtValidation: validatedSshAgentId + remoteRootPath: '/srv/goodbuddy' } }) expect(project).toEqual(database.getProject(project.id)) @@ -1611,15 +1418,12 @@ describe('AssistantDatabase', () => { `SELECT (SELECT COUNT(*) FROM projects WHERE id = ?) AS projects, (SELECT COUNT(*) FROM project_execution_spaces - WHERE project_id = ?) AS execution_spaces, - (SELECT COUNT(*) FROM project_runtime_validations - WHERE project_id = ?) AS runtime_validations` + WHERE project_id = ?) AS execution_spaces` ) - .get(project.id, project.id, project.id) + .get(project.id, project.id) ).toEqual({ projects: 1, - execution_spaces: 1, - runtime_validations: 1 + execution_spaces: 1 }) raw.close() database.close() @@ -1638,34 +1442,19 @@ describe('AssistantDatabase', () => { const rootMismatch = validatedSshProjectWrite() rootMismatch.executionSpace.remoteRootPath = '/srv/other' expect(() => - database.createValidatedSshProject(rootMismatch) + database.createSshProject(rootMismatch) ).toThrow('目录不匹配') + const missingRuntime = validatedSshProjectWrite() + delete missingRuntime.project.runtimeSelection expect(() => - database.createValidatedSshProject( + database.createSshProject(missingRuntime) + ).toThrow('必须选择 Runtime') + + expect(() => + database.createSshProject( validatedSshProjectWrite({ - runtimeSelectionKey: 'continue:platform' - }) - ) - ).toThrow('Runtime 验证与项目 Runtime 选择不匹配') - - const modeMismatch = validatedSshProjectWrite() - modeMismatch.project.defaultWorkMode = 'ask' - expect(() => - database.createValidatedSshProject(modeMismatch) - ).toThrow('Runtime 验证与项目默认工作模式不匹配') - - const identityMismatch = validatedSshProjectWrite() - identityMismatch.runtimeValidation.agentInstallationIdAtValidation = - 'other-agent' - expect(() => - database.createValidatedSshProject(identityMismatch) - ).toThrow('Agent 身份不匹配') - - expect(() => - database.createValidatedSshProject( - validatedSshProjectWrite({ - assertSshHostCurrent: () => { + assertCurrent: () => { throw new Error('SSH Host 已变化') } }) @@ -1673,13 +1462,6 @@ describe('AssistantDatabase', () => { ).toThrow('SSH Host 已变化') expect(database.listProjects()).toHaveLength(baseline) - const raw = new DatabaseSync(databasePath) - expect( - raw - .prepare('SELECT COUNT(*) AS count FROM project_runtime_validations') - .get() - ).toEqual({ count: 0 }) - raw.close() database.close() }) @@ -1687,39 +1469,36 @@ describe('AssistantDatabase', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-21T01:00:00.000Z')) const database = await createDatabase() - const created = database.createValidatedSshProject( + const created = database.createSshProject( validatedSshProjectWrite() ) const write = validatedSshProjectWrite() write.project.name = '已重新验证' write.project.description = '更新后的配置' - write.executionSpace.validation.validatedAt = - '2026-08-21T01:01:00.000Z' - write.runtimeValidation.validatedAt = - '2026-08-21T01:01:01.000Z' - const updated = database.updateValidatedSshProject( + const updated = database.updateSshProject( created.id, created.updatedAt, write ) expect(updated.name).toBe('已重新验证') expect(updated.updatedAt).not.toBe(created.updatedAt) - expect( - updated.executionSpace.kind === 'ssh' && - updated.executionSpace.validation?.validatedAt - ).toBe('2026-08-21T01:01:00.000Z') + expect(updated.executionSpace).toEqual({ + kind: 'ssh', + hostId: validatedSshHostId, + remoteRootPath: '/srv/goodbuddy' + }) expect(updated).toEqual(database.getProject(created.id)) const beforeRejectedPrecondition = database.getProject(created.id) const rejectedWrite = validatedSshProjectWrite({ - assertSshHostCurrent: () => { + assertCurrent: () => { throw new Error('SSH Host 已变化') } }) rejectedWrite.project.name = '不应保存' expect(() => - database.updateValidatedSshProject( + database.updateSshProject( created.id, updated.updatedAt, rejectedWrite @@ -1730,7 +1509,7 @@ describe('AssistantDatabase', () => { ) expect(() => - database.updateValidatedSshProject( + database.updateSshProject( created.id, created.updatedAt, write @@ -1741,7 +1520,7 @@ describe('AssistantDatabase', () => { rootPath: '/srv/moved' }) expect(() => - database.updateValidatedSshProject( + database.updateSshProject( created.id, updated.updatedAt, moved @@ -1752,7 +1531,7 @@ describe('AssistantDatabase', () => { hostId: '00000000-0000-4000-8000-000000000322' }) expect(() => - database.updateValidatedSshProject( + database.updateSshProject( created.id, updated.updatedAt, otherHost @@ -1766,7 +1545,7 @@ describe('AssistantDatabase', () => { defaultWorkMode: 'ask' }) expect(() => - database.updateValidatedSshProject( + database.updateSshProject( localProject.id, localProject.updatedAt, validatedSshProjectWrite() @@ -1777,7 +1556,7 @@ describe('AssistantDatabase', () => { channelDefaultProfileId ) expect(() => - database.updateValidatedSshProject( + database.updateSshProject( channelProject!.id, channelProject!.updatedAt, validatedSshProjectWrite() @@ -1796,11 +1575,11 @@ describe('AssistantDatabase', () => { database.initialize('C:\\Workspace') const raw = new DatabaseSync(databasePath) raw.exec(` - CREATE TRIGGER corrupt_created_runtime_validation - AFTER INSERT ON project_runtime_validations + CREATE TRIGGER corrupt_created_execution_space + AFTER INSERT ON project_execution_spaces + WHEN NEW.kind = 'ssh' BEGIN - UPDATE project_runtime_validations - SET validated_at = 'not-a-timestamp' + DELETE FROM project_execution_spaces WHERE project_id = NEW.project_id; END; `) @@ -1808,24 +1587,24 @@ describe('AssistantDatabase', () => { const projectsBefore = database.listProjects().length expect(() => - database.createValidatedSshProject(validatedSshProjectWrite()) - ).toThrow('Runtime 验证配置无效') + database.createSshProject(validatedSshProjectWrite()) + ).toThrow('缺少执行空间配置') expect(database.listProjects()).toHaveLength(projectsBefore) const setup = new DatabaseSync(databasePath) - setup.exec('DROP TRIGGER corrupt_created_runtime_validation') + setup.exec('DROP TRIGGER corrupt_created_execution_space') setup.close() - const created = database.createValidatedSshProject( + const created = database.createSshProject( validatedSshProjectWrite() ) const persistedBeforeUpdate = database.getProject(created.id) const updateTrigger = new DatabaseSync(databasePath) updateTrigger.exec(` - CREATE TRIGGER corrupt_updated_runtime_validation - AFTER UPDATE ON project_runtime_validations + CREATE TRIGGER corrupt_updated_execution_space + AFTER UPDATE ON project_execution_spaces + WHEN NEW.kind = 'ssh' BEGIN - UPDATE project_runtime_validations - SET validated_at = 'not-a-timestamp' + DELETE FROM project_execution_spaces WHERE project_id = NEW.project_id; END; `) @@ -1834,12 +1613,12 @@ describe('AssistantDatabase', () => { write.project.name = '不应提交的项目名' expect(() => - database.updateValidatedSshProject( + database.updateSshProject( created.id, created.updatedAt, write ) - ).toThrow('Runtime 验证配置无效') + ).toThrow('缺少执行空间配置') expect(database.getProject(created.id)).toEqual( persistedBeforeUpdate ) @@ -1857,18 +1636,19 @@ describe('AssistantDatabase', () => { database.initialize('C:\\Workspace') const raw = new DatabaseSync(databasePath) raw.exec(` - CREATE TRIGGER reject_runtime_validation - BEFORE INSERT ON project_runtime_validations + CREATE TRIGGER reject_ssh_execution_space + BEFORE INSERT ON project_execution_spaces + WHEN NEW.kind = 'ssh' BEGIN - SELECT RAISE(ABORT, 'test runtime rejection'); + SELECT RAISE(ABORT, 'test SSH rejection'); END; `) raw.close() const projectsBefore = database.listProjects().length expect(() => - database.createValidatedSshProject(validatedSshProjectWrite()) - ).toThrow('test runtime rejection') + database.createSshProject(validatedSshProjectWrite()) + ).toThrow('test SSH rejection') const inspected = new DatabaseSync(databasePath) expect( @@ -1883,11 +1663,6 @@ describe('AssistantDatabase', () => { ) .get() ).toEqual({ count: 0 }) - expect( - inspected - .prepare('SELECT COUNT(*) AS count FROM project_runtime_validations') - .get() - ).toEqual({ count: 0 }) inspected.close() database.close() }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 9a8d489d..3943d67c 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -8,7 +8,6 @@ import { normalizeInteractiveWorkMode, persistedProjectExecutionSpaceSchema, projectCreateSchema, - projectRuntimeValidationSchema, scheduleCreateSchema } from '../../shared/assistant-contracts' import type { @@ -36,12 +35,13 @@ import type { ModelUsageCallInput, ProjectChannel, ProjectCreateInput, - ProjectRuntimeValidation, ScheduleCreateInput, - SshExecutionValidation, TokenUsageRecord, TokenUsageSummary } from '../../shared/assistant-contracts' +import type { + SshHostProjectReference +} from '../../shared/ssh-host-contracts' import { computerControlErrorCodeSchema, computerControlRiskSchema, @@ -82,9 +82,7 @@ import { } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 30 - -const LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION = 'unused' +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 31 type ProjectRow = { id: string @@ -103,23 +101,6 @@ type ProjectRow = { execution_space_kind: string | null execution_space_root_path: string | null execution_space_ssh_host_id: string | null - execution_space_host_revision: number | null - execution_space_host_key_generation: number | null - execution_space_remote_username: string | null - execution_space_workspace_identity: string | null - execution_space_agent_installation_id_at_validation: string | null - execution_space_agent_binary_digest_at_validation: string | null - execution_space_agent_version_at_validation: string | null - execution_space_agent_architecture_at_validation: string | null - execution_space_agent_protocol_major: number | null - execution_space_validated_at: string | null - runtime_validation_project_id: string | null - runtime_validation_runtime_selection_key: string | null - runtime_validation_runtime_bundle_digest: string | null - runtime_validation_runtime_adapter_digest: string | null - runtime_validation_agent_installation_id_at_validation: string | null - runtime_validation_validated_at: string | null - runtime_validation_work_mode: string | null } const projectSelect = ` @@ -130,46 +111,10 @@ const projectSelect = ` project_execution_spaces.root_path AS execution_space_root_path, project_execution_spaces.ssh_host_id - AS execution_space_ssh_host_id, - project_execution_spaces.host_revision - AS execution_space_host_revision, - project_execution_spaces.host_key_generation - AS execution_space_host_key_generation, - project_execution_spaces.remote_username - AS execution_space_remote_username, - project_execution_spaces.workspace_identity - AS execution_space_workspace_identity, - project_execution_spaces.agent_installation_id_at_validation - AS execution_space_agent_installation_id_at_validation, - project_execution_spaces.agent_binary_digest_at_validation - AS execution_space_agent_binary_digest_at_validation, - project_execution_spaces.agent_version_at_validation - AS execution_space_agent_version_at_validation, - project_execution_spaces.agent_architecture_at_validation - AS execution_space_agent_architecture_at_validation, - project_execution_spaces.agent_protocol_major - AS execution_space_agent_protocol_major, - project_execution_spaces.validated_at - AS execution_space_validated_at, - project_runtime_validations.project_id - AS runtime_validation_project_id, - project_runtime_validations.runtime_selection_key - AS runtime_validation_runtime_selection_key, - project_runtime_validations.runtime_bundle_digest - AS runtime_validation_runtime_bundle_digest, - project_runtime_validations.runtime_adapter_digest - AS runtime_validation_runtime_adapter_digest, - project_runtime_validations.agent_installation_id_at_validation - AS runtime_validation_agent_installation_id_at_validation, - project_runtime_validations.validated_at - AS runtime_validation_validated_at, - project_runtime_validations.work_mode - AS runtime_validation_work_mode + AS execution_space_ssh_host_id FROM projects LEFT JOIN project_execution_spaces ON project_execution_spaces.project_id = projects.id - LEFT JOIN project_runtime_validations - ON project_runtime_validations.project_id = projects.id ` type TaskRow = { @@ -467,34 +412,16 @@ export type ClaimedHeartbeatRun = { acquired: boolean } -export type ValidatedSshProjectExecutionSpace = { +export type SshProjectExecutionSpace = { kind: 'ssh' hostId: string remoteRootPath: string - validation: SshExecutionValidation } -export type ValidatedSshHostPrecondition = Readonly<{ - hostId: string - hostRevision: number - hostKeyGeneration: number - remoteUsername: string - remoteRootPath: string - workspaceIdentity: string - agentProtocolMajor: number - agentInstallationId: string - agentBinaryDigest: string - agentVersion: string - agentArchitecture: 'x64' | 'arm64' -}> - -export type ValidatedSshProjectWrite = { +export type SshProjectWrite = { project: ProjectCreateInput - executionSpace: ValidatedSshProjectExecutionSpace - runtimeValidation: ProjectRuntimeValidation - assertSshHostCurrent: ( - expected: ValidatedSshHostPrecondition - ) => void + executionSpace: SshProjectExecutionSpace + assertCurrent: () => void } export type HeartbeatInputSnapshot = { @@ -531,95 +458,35 @@ function toProject(row: ProjectRow): AssistantProject { if (!row.execution_space_project_id) { throw new Error(`项目 ${row.id} 缺少执行空间配置`) } - const runtimeValidation = - row.runtime_validation_project_id && - row.runtime_validation_work_mode !== null - ? projectRuntimeValidationSchema.safeParse({ - runtimeSelectionKey: - row.runtime_validation_runtime_selection_key, - runtimeBundleDigest: - row.runtime_validation_runtime_bundle_digest, - runtimeAdapterDigest: - row.runtime_validation_runtime_adapter_digest, - agentInstallationIdAtValidation: - row.runtime_validation_agent_installation_id_at_validation, - validatedAt: row.runtime_validation_validated_at, - workMode: row.runtime_validation_work_mode - }) - : undefined - if (runtimeValidation && !runtimeValidation.success) { - throw new Error(`项目 ${row.id} 的 Runtime 验证配置无效`) - } - const hasExecutionValidation = [ - row.execution_space_host_revision, - row.execution_space_host_key_generation, - row.execution_space_remote_username, - row.execution_space_workspace_identity, - row.execution_space_agent_installation_id_at_validation, - row.execution_space_agent_binary_digest_at_validation, - row.execution_space_agent_version_at_validation, - row.execution_space_agent_architecture_at_validation, - row.execution_space_agent_protocol_major, - row.execution_space_validated_at - ].some((value) => value !== null) - if ( - row.execution_space_kind === 'ssh' && - !hasExecutionValidation - ) { - throw new Error(`项目 ${row.id} 的执行空间配置无效`) - } - const persistedExecutionValidation = hasExecutionValidation - ? { - validation: { - hostRevision: row.execution_space_host_revision, - hostKeyGeneration: - row.execution_space_host_key_generation, - remoteUsername: row.execution_space_remote_username, - workspaceIdentity: - row.execution_space_workspace_identity, - agentInstallationIdAtValidation: - row.execution_space_agent_installation_id_at_validation, - agentBinaryDigestAtValidation: - row.execution_space_agent_binary_digest_at_validation, - agentVersionAtValidation: - row.execution_space_agent_version_at_validation, - agentArchitectureAtValidation: - row.execution_space_agent_architecture_at_validation, - agentProtocolMajor: - row.execution_space_agent_protocol_major, - validatedAt: row.execution_space_validated_at - } - } - : {} const executionSpace = persistedProjectExecutionSpaceSchema.safeParse( row.execution_space_kind === 'local' ? { kind: 'local', - rootPath: row.execution_space_root_path, - ...persistedExecutionValidation + rootPath: row.execution_space_root_path } : row.execution_space_kind === 'ssh' ? { kind: 'ssh', hostId: row.execution_space_ssh_host_id, - remoteRootPath: row.execution_space_root_path, - ...persistedExecutionValidation + remoteRootPath: row.execution_space_root_path } : { kind: row.execution_space_kind } ) if (!executionSpace.success) { throw new Error(`项目 ${row.id} 的执行空间配置无效`) } + const runtimeSelection = + row.kind === 'channel' + ? parseRuntimeSelection(row.runtime_selection_json) ?? { + provider: 'auto' as const + } + : parseRuntimeSelection(row.runtime_selection_json) if ( - runtimeValidation?.success && executionSpace.data.kind === 'ssh' && - executionSpace.data.validation !== undefined && - runtimeValidation.data.agentInstallationIdAtValidation !== - executionSpace.data.validation - .agentInstallationIdAtValidation + runtimeSelection === undefined ) { - throw new Error(`项目 ${row.id} 的执行空间与 Runtime 验证不匹配`) + throw new Error(`项目 ${row.id} 缺少远程 Runtime 选择`) } const rootPath = executionSpace.data.kind === 'local' @@ -631,18 +498,10 @@ function toProject(row: ProjectRow): AssistantProject { description: row.description, rootPath, executionSpace: executionSpace.data, - ...(runtimeValidation?.success - ? { runtimeValidation: runtimeValidation.data } - : {}), defaultWorkMode: normalizeInteractiveWorkMode( row.default_work_mode ), - runtimeSelection: - row.kind === 'channel' - ? parseRuntimeSelection(row.runtime_selection_json) ?? { - provider: 'auto' - } - : parseRuntimeSelection(row.runtime_selection_json), + runtimeSelection, kind: row.kind, channel: row.channel ?? undefined, builtInDefault: row.built_in_default === 1, @@ -1187,50 +1046,30 @@ function serializeConversationMessageMetadata( }) } -type NormalizedValidatedSshProjectWrite = Omit< - ValidatedSshProjectWrite, - 'project' | 'executionSpace' | 'runtimeValidation' +type NormalizedSshProjectWrite = Omit< + SshProjectWrite, + 'project' | 'executionSpace' > & { project: ProjectCreateInput - executionSpace: ValidatedSshProjectExecutionSpace - runtimeValidation: ProjectRuntimeValidation + executionSpace: SshProjectExecutionSpace } -function normalizeValidatedSshProjectWrite( - write: ValidatedSshProjectWrite -): NormalizedValidatedSshProjectWrite { +function normalizeSshProjectWrite( + write: SshProjectWrite +): NormalizedSshProjectWrite { const project = projectCreateSchema.parse(write.project) const executionSpace = persistedProjectExecutionSpaceSchema.parse(write.executionSpace) - if ( - executionSpace.kind !== 'ssh' || - executionSpace.validation === undefined - ) { + if (executionSpace.kind !== 'ssh') { throw new Error('远程项目必须使用 SSH 执行空间') } - const runtimeValidation = projectRuntimeValidationSchema.parse( - write.runtimeValidation - ) - if (project.defaultWorkMode !== runtimeValidation.workMode) { - throw new Error('Runtime 验证与项目默认工作模式不匹配') - } if (project.rootPath !== executionSpace.remoteRootPath) { throw new Error('项目目录与远程执行空间目录不匹配') } - if ( - project.runtimeSelection && - runtimeValidation.runtimeSelectionKey !== - agentRuntimeSelectionKey(project.runtimeSelection) - ) { - throw new Error('Runtime 验证与项目 Runtime 选择不匹配') + if (project.runtimeSelection === undefined) { + throw new Error('远程项目必须选择 Runtime') } - if ( - runtimeValidation.agentInstallationIdAtValidation !== - executionSpace.validation.agentInstallationIdAtValidation - ) { - throw new Error('执行空间与 Runtime 验证的 Agent 身份不匹配') - } - if (typeof write.assertSshHostCurrent !== 'function') { + if (typeof write.assertCurrent !== 'function') { throw new TypeError('缺少 SSH Host 事务前置校验') } return { @@ -1238,146 +1077,50 @@ function normalizeValidatedSshProjectWrite( executionSpace: { kind: 'ssh', hostId: executionSpace.hostId, - remoteRootPath: executionSpace.remoteRootPath, - validation: executionSpace.validation + remoteRootPath: executionSpace.remoteRootPath }, - runtimeValidation, - assertSshHostCurrent: write.assertSshHostCurrent + assertCurrent: write.assertCurrent } } -function assertValidatedSshHostCurrent( - write: NormalizedValidatedSshProjectWrite -): void { - const { executionSpace } = write - write.assertSshHostCurrent({ - hostId: executionSpace.hostId, - hostRevision: executionSpace.validation.hostRevision, - hostKeyGeneration: - executionSpace.validation.hostKeyGeneration, - remoteUsername: executionSpace.validation.remoteUsername, - remoteRootPath: executionSpace.remoteRootPath, - workspaceIdentity: - executionSpace.validation.workspaceIdentity, - agentProtocolMajor: - executionSpace.validation.agentProtocolMajor, - agentInstallationId: - executionSpace.validation.agentInstallationIdAtValidation, - agentBinaryDigest: - executionSpace.validation.agentBinaryDigestAtValidation, - agentVersion: - executionSpace.validation.agentVersionAtValidation, - agentArchitecture: - executionSpace.validation.agentArchitectureAtValidation - }) -} - -function insertValidatedSshExecutionSpace( +function insertSshExecutionSpace( database: DatabaseSync, projectId: string, - executionSpace: ValidatedSshProjectExecutionSpace + executionSpace: SshProjectExecutionSpace ): void { - const validation = executionSpace.validation database .prepare( `INSERT INTO project_execution_spaces - (project_id, kind, root_path, ssh_host_id, host_revision, - host_key_generation, remote_username, workspace_identity, - agent_installation_id_at_validation, - agent_binary_digest_at_validation, - agent_version_at_validation, - agent_architecture_at_validation, agent_protocol_major, - trust_attestation_revision, validated_at) - VALUES (?, 'ssh', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (project_id, kind, root_path, ssh_host_id) + VALUES (?, 'ssh', ?, ?)` ) .run( projectId, executionSpace.remoteRootPath, - executionSpace.hostId, - validation.hostRevision, - validation.hostKeyGeneration, - validation.remoteUsername, - validation.workspaceIdentity, - validation.agentInstallationIdAtValidation, - validation.agentBinaryDigestAtValidation, - validation.agentVersionAtValidation, - validation.agentArchitectureAtValidation, - validation.agentProtocolMajor, - 0, - validation.validatedAt + executionSpace.hostId ) } -function updateValidatedSshExecutionSpace( +function updateSshExecutionSpace( database: DatabaseSync, projectId: string, - executionSpace: ValidatedSshProjectExecutionSpace + executionSpace: SshProjectExecutionSpace ): number { - const validation = executionSpace.validation return Number( database .prepare( `UPDATE project_execution_spaces - SET root_path = ?, ssh_host_id = ?, host_revision = ?, - host_key_generation = ?, remote_username = ?, - workspace_identity = ?, - agent_installation_id_at_validation = ?, - agent_binary_digest_at_validation = ?, - agent_version_at_validation = ?, - agent_architecture_at_validation = ?, - agent_protocol_major = ?, - trust_attestation_revision = ?, validated_at = ? + SET root_path = ?, ssh_host_id = ? WHERE project_id = ? AND kind = 'ssh'` ) .run( executionSpace.remoteRootPath, executionSpace.hostId, - validation.hostRevision, - validation.hostKeyGeneration, - validation.remoteUsername, - validation.workspaceIdentity, - validation.agentInstallationIdAtValidation, - validation.agentBinaryDigestAtValidation, - validation.agentVersionAtValidation, - validation.agentArchitectureAtValidation, - validation.agentProtocolMajor, - 0, - validation.validatedAt, projectId ).changes ) } -function insertProjectRuntimeValidation( - database: DatabaseSync, - projectId: string, - validation: ProjectRuntimeValidation -): void { - database - .prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, runtime_bundle_digest, - runtime_adapter_digest, confinement_policy_digest, - approval_bridge_version, - agent_installation_id_at_validation, validated_at, work_mode, - trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - projectId, - validation.runtimeSelectionKey, - validation.runtimeBundleDigest, - validation.runtimeAdapterDigest, - validation.runtimeAdapterDigest, - LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION, - validation.agentInstallationIdAtValidation, - validation.validatedAt, - validation.workMode, - null, - null - ) -} - function nextProjectUpdatedAt(current: string): string { const now = Date.now() const currentTimestamp = Date.parse(current) @@ -1388,6 +1131,92 @@ function nextProjectUpdatedAt(current: string): string { ).toISOString() } +function prepareProjectDeletionStatements(database: DatabaseSync) { + return { + notifications: database.prepare( + `DELETE FROM notifications + WHERE task_id IN ( + SELECT id FROM tasks WHERE project_id = ? + ) OR schedule_id IN ( + SELECT id FROM schedules WHERE project_id = ? + )` + ), + delegationOutbox: database.prepare( + `DELETE FROM delegation_outbox + WHERE task_id IN ( + SELECT id FROM tasks WHERE project_id = ? + )` + ), + memories: database.prepare( + `DELETE FROM memory_items + WHERE (scope = 'project' AND scope_id = ?) + OR (scope = 'conversation' AND scope_id IN ( + SELECT id FROM conversations WHERE project_id = ? + ))` + ), + heartbeatArtifacts: database.prepare( + `DELETE FROM artifacts + WHERE id IN ( + SELECT e.artifact_id + FROM heartbeat_entries e + JOIN heartbeat_configs c ON c.id = e.config_id + JOIN heartbeat_config_projects hp ON hp.config_id = c.id + WHERE hp.project_id = ? + AND NOT EXISTS ( + SELECT 1 FROM heartbeat_config_projects other + WHERE other.config_id = c.id + AND other.project_id <> ? + ) + )` + ), + heartbeatConfigs: database.prepare( + `DELETE FROM heartbeat_configs + WHERE scope_kind = 'projects' + AND EXISTS ( + SELECT 1 FROM heartbeat_config_projects hp + WHERE hp.config_id = heartbeat_configs.id + AND hp.project_id = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM heartbeat_config_projects other + WHERE other.config_id = heartbeat_configs.id + AND other.project_id <> ? + )` + ), + artifacts: database.prepare( + 'DELETE FROM artifacts WHERE project_id = ?' + ), + tasks: database.prepare('DELETE FROM tasks WHERE project_id = ?'), + conversations: database.prepare( + 'DELETE FROM conversations WHERE project_id = ?' + ), + schedules: database.prepare( + 'DELETE FROM schedules WHERE project_id = ?' + ), + project: database.prepare('DELETE FROM projects WHERE id = ?') + } +} + +function deleteProjectRecords( + database: DatabaseSync, + projectId: string, + statements = prepareProjectDeletionStatements(database) +): void { + statements.notifications.run(projectId, projectId) + statements.delegationOutbox.run(projectId) + statements.memories.run(projectId, projectId) + statements.heartbeatArtifacts.run(projectId, projectId) + statements.heartbeatConfigs.run(projectId, projectId) + statements.artifacts.run(projectId) + statements.tasks.run(projectId) + statements.conversations.run(projectId) + statements.schedules.run(projectId) + const result = statements.project.run(projectId) + if (result.changes !== 1) { + throw new Error('项目不存在') + } +} + export class AssistantDatabase { private database?: DatabaseSync private channelEventWrites = 0 @@ -1854,17 +1683,17 @@ export class AssistantDatabase { return this.getProject(projectId) } - createValidatedSshProject( - write: ValidatedSshProjectWrite + createSshProject( + write: SshProjectWrite ): AssistantProject { - const normalized = normalizeValidatedSshProjectWrite(write) + const normalized = normalizeSshProjectWrite(write) const database = this.requireDatabase() const id = randomUUID() const now = new Date().toISOString() database.exec('BEGIN IMMEDIATE') let transactionActive = true try { - assertValidatedSshHostCurrent(normalized) + normalized.assertCurrent() database .prepare( `INSERT INTO projects @@ -1885,16 +1714,11 @@ export class AssistantDatabase { now, now ) - insertValidatedSshExecutionSpace( + insertSshExecutionSpace( database, id, normalized.executionSpace ) - insertProjectRuntimeValidation( - database, - id, - normalized.runtimeValidation - ) const project = readProject(database, id) database.exec('COMMIT') transactionActive = false @@ -1907,12 +1731,12 @@ export class AssistantDatabase { } } - updateValidatedSshProject( + updateSshProject( projectId: string, expectedUpdatedAt: string, - write: ValidatedSshProjectWrite + write: SshProjectWrite ): AssistantProject { - const normalized = normalizeValidatedSshProjectWrite(write) + const normalized = normalizeSshProjectWrite(write) const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') let transactionActive = true @@ -1961,7 +1785,7 @@ export class AssistantDatabase { if (current.updated_at !== expectedUpdatedAt) { throw new Error('项目已被其他操作更新,请重新加载后重试') } - assertValidatedSshHostCurrent(normalized) + normalized.assertCurrent() const updatedAt = nextProjectUpdatedAt(current.updated_at) const projectResult = database .prepare( @@ -1987,7 +1811,7 @@ export class AssistantDatabase { if (projectResult.changes !== 1) { throw new Error('项目已被其他操作更新,请重新加载后重试') } - const executionResult = updateValidatedSshExecutionSpace( + const executionResult = updateSshExecutionSpace( database, projectId, normalized.executionSpace @@ -1995,45 +1819,6 @@ export class AssistantDatabase { if (executionResult !== 1) { throw new Error('项目执行空间配置无效') } - database - .prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, runtime_bundle_digest, - runtime_adapter_digest, confinement_policy_digest, - approval_bridge_version, - agent_installation_id_at_validation, validated_at, - work_mode, trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id) DO UPDATE SET - runtime_selection_key = excluded.runtime_selection_key, - runtime_bundle_digest = excluded.runtime_bundle_digest, - runtime_adapter_digest = excluded.runtime_adapter_digest, - confinement_policy_digest = - excluded.confinement_policy_digest, - approval_bridge_version = - excluded.approval_bridge_version, - agent_installation_id_at_validation = - excluded.agent_installation_id_at_validation, - validated_at = excluded.validated_at, - work_mode = excluded.work_mode, - trust_tier = excluded.trust_tier, - trust_attestation_revision = - excluded.trust_attestation_revision` - ) - .run( - projectId, - normalized.runtimeValidation.runtimeSelectionKey, - normalized.runtimeValidation.runtimeBundleDigest, - normalized.runtimeValidation.runtimeAdapterDigest, - normalized.runtimeValidation.runtimeAdapterDigest, - LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION, - normalized.runtimeValidation - .agentInstallationIdAtValidation, - normalized.runtimeValidation.validatedAt, - normalized.runtimeValidation.workMode, - null, - null - ) const project = readProject(database, projectId) database.exec('COMMIT') transactionActive = false @@ -2046,11 +1831,13 @@ export class AssistantDatabase { } } - listProjectIdsReferencingSshHost(sshHostId: string): string[] { + listProjectsReferencingSshHost( + sshHostId: string + ): SshHostProjectReference[] { return ( this.requireDatabase() .prepare( - `SELECT project_execution_spaces.project_id + `SELECT projects.id, projects.name FROM project_execution_spaces INNER JOIN projects ON projects.id = project_execution_spaces.project_id @@ -2058,8 +1845,77 @@ export class AssistantDatabase { AND project_execution_spaces.ssh_host_id = ? ORDER BY projects.created_at ASC, projects.rowid ASC` ) - .all(sshHostId) as Array<{ project_id: string }> - ).map((row) => row.project_id) + .all(sshHostId) as SshHostProjectReference[] + ) + } + + listSshHostProjectReferences(): Array<{ + hostId: string + } & SshHostProjectReference> { + return this.requireDatabase() + .prepare( + `SELECT project_execution_spaces.ssh_host_id AS "hostId", + projects.id, projects.name + FROM project_execution_spaces + INNER JOIN projects + ON projects.id = project_execution_spaces.project_id + WHERE project_execution_spaces.kind = 'ssh' + AND project_execution_spaces.ssh_host_id IS NOT NULL + ORDER BY projects.created_at ASC, projects.rowid ASC` + ) + .all() as Array<{ hostId: string } & SshHostProjectReference> + } + + deleteProjectsReferencingSshHost( + sshHostId: string + ): SshHostProjectReference[] { + const database = this.requireDatabase() + database.exec('BEGIN IMMEDIATE') + try { + const projects = this.listProjectsReferencingSshHost(sshHostId) + if (projects.length === 0) { + database.exec('COMMIT') + return [] + } + const activeLocalProjectCount = database + .prepare( + `SELECT COUNT(*) AS count + FROM projects + INNER JOIN project_execution_spaces + ON project_execution_spaces.project_id = projects.id + WHERE projects.kind = 'user' + AND projects.status = 'active' + AND project_execution_spaces.kind = 'local'` + ) + .get() as { count: number } + const deletedActiveProjectCount = database + .prepare( + `SELECT COUNT(*) AS count + FROM projects + INNER JOIN project_execution_spaces + ON project_execution_spaces.project_id = projects.id + WHERE projects.kind = 'user' + AND projects.status = 'active' + AND project_execution_spaces.kind = 'ssh' + AND project_execution_spaces.ssh_host_id = ?` + ) + .get(sshHostId) as { count: number } + if ( + deletedActiveProjectCount.count > 0 && + activeLocalProjectCount.count < 1 + ) { + throw new Error('至少需要保留一个可用项目') + } + const statements = prepareProjectDeletionStatements(database) + for (const project of projects) { + deleteProjectRecords(database, project.id, statements) + } + database.exec('COMMIT') + return projects + } catch (error) { + database.exec('ROLLBACK') + throw error + } } setProjectArchived(projectId: string, archived: boolean): void { @@ -2083,7 +1939,11 @@ export class AssistantDatabase { } } - deleteProject(projectId: string, confirmation: string): void { + deleteProject( + projectId: string, + confirmation: string, + options: { allowActiveTasks?: boolean } = {} + ): void { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') try { @@ -2105,13 +1965,30 @@ export class AssistantDatabase { if (confirmation !== project.name) { throw new Error('项目名称确认不匹配') } - const activeProjectCount = database + const activeLocalProjectCount = database .prepare( - `SELECT COUNT(*) AS count FROM projects - WHERE kind = 'user' AND status = 'active'` + `SELECT COUNT(*) AS count + FROM projects + INNER JOIN project_execution_spaces + ON project_execution_spaces.project_id = projects.id + WHERE projects.kind = 'user' + AND projects.status = 'active' + AND project_execution_spaces.kind = 'local'` ) .get() as { count: number } - if (project.status === 'active' && activeProjectCount.count <= 1) { + const projectIsActiveLocal = + project.status === 'active' && + database + .prepare( + `SELECT kind FROM project_execution_spaces + WHERE project_id = ?` + ) + .get(projectId)?.kind === 'local' + if ( + project.status === 'active' && + activeLocalProjectCount.count < + (projectIsActiveLocal ? 2 : 1) + ) { throw new Error('至少需要保留一个可用项目') } const activeTaskCount = database @@ -2128,88 +2005,14 @@ export class AssistantDatabase { )` ) .get(projectId) as { count: number } - if (activeTaskCount.count > 0) { + if ( + activeTaskCount.count > 0 && + options.allowActiveTasks !== true + ) { throw new Error('项目仍有进行中的任务,请先停止任务') } - database - .prepare( - `DELETE FROM notifications - WHERE task_id IN ( - SELECT id FROM tasks WHERE project_id = ? - ) OR schedule_id IN ( - SELECT id FROM schedules WHERE project_id = ? - )` - ) - .run(projectId, projectId) - database - .prepare( - `DELETE FROM delegation_outbox - WHERE task_id IN ( - SELECT id FROM tasks WHERE project_id = ? - )` - ) - .run(projectId) - database - .prepare( - `DELETE FROM memory_items - WHERE (scope = 'project' AND scope_id = ?) - OR (scope = 'conversation' AND scope_id IN ( - SELECT id FROM conversations WHERE project_id = ? - ))` - ) - .run(projectId, projectId) - database - .prepare( - `DELETE FROM artifacts - WHERE id IN ( - SELECT e.artifact_id - FROM heartbeat_entries e - JOIN heartbeat_configs c ON c.id = e.config_id - JOIN heartbeat_config_projects hp ON hp.config_id = c.id - WHERE hp.project_id = ? - AND NOT EXISTS ( - SELECT 1 FROM heartbeat_config_projects other - WHERE other.config_id = c.id - AND other.project_id <> ? - ) - )` - ) - .run(projectId, projectId) - database - .prepare( - `DELETE FROM heartbeat_configs - WHERE scope_kind = 'projects' - AND EXISTS ( - SELECT 1 FROM heartbeat_config_projects hp - WHERE hp.config_id = heartbeat_configs.id - AND hp.project_id = ? - ) - AND NOT EXISTS ( - SELECT 1 FROM heartbeat_config_projects other - WHERE other.config_id = heartbeat_configs.id - AND other.project_id <> ? - )` - ) - .run(projectId, projectId) - database - .prepare('DELETE FROM artifacts WHERE project_id = ?') - .run(projectId) - database - .prepare('DELETE FROM tasks WHERE project_id = ?') - .run(projectId) - database - .prepare('DELETE FROM conversations WHERE project_id = ?') - .run(projectId) - database - .prepare('DELETE FROM schedules WHERE project_id = ?') - .run(projectId) - const result = database - .prepare('DELETE FROM projects WHERE id = ?') - .run(projectId) - if (result.changes !== 1) { - throw new Error('项目不存在') - } + deleteProjectRecords(database, projectId) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') @@ -8181,506 +7984,57 @@ export class AssistantDatabase { throw error } } - if (version.user_version < 29) { - const executionSpaceColumns = new Set( - ( - database - .prepare('PRAGMA table_info(project_execution_spaces)') - .all() as Array<{ name: string }> - ).map((column) => column.name) - ) - const runtimeValidationColumns = new Set( - ( - database - .prepare( - 'PRAGMA table_info(project_runtime_validations)' - ) - .all() as Array<{ name: string }> - ).map((column) => column.name) - ) + if (version.user_version < 31) { database.exec('BEGIN IMMEDIATE') try { - if ( - !executionSpaceColumns.has( - 'agent_binary_digest_at_validation' - ) - ) { - database.exec(` - ALTER TABLE project_execution_spaces - ADD COLUMN agent_binary_digest_at_validation TEXT; - `) - } - if ( - !executionSpaceColumns.has( - 'agent_version_at_validation' - ) - ) { - database.exec(` - ALTER TABLE project_execution_spaces - ADD COLUMN agent_version_at_validation TEXT; - `) - } - if ( - !executionSpaceColumns.has( - 'agent_architecture_at_validation' - ) - ) { - database.exec(` - ALTER TABLE project_execution_spaces - ADD COLUMN agent_architecture_at_validation TEXT; - `) - } - if (!runtimeValidationColumns.has('work_mode')) { - database.exec(` - ALTER TABLE project_runtime_validations - ADD COLUMN work_mode TEXT; - `) - } - if (!runtimeValidationColumns.has('trust_tier')) { - database.exec(` - ALTER TABLE project_runtime_validations - ADD COLUMN trust_tier TEXT; - `) - } - if ( - !runtimeValidationColumns.has( - 'trust_attestation_revision' - ) - ) { - database.exec(` - ALTER TABLE project_runtime_validations - ADD COLUMN trust_attestation_revision INTEGER; - `) - } database.exec(` DROP TRIGGER IF EXISTS project_execution_spaces_validate_insert; DROP TRIGGER IF EXISTS project_execution_spaces_validate_update; - CREATE TRIGGER project_execution_spaces_validate_insert - BEFORE INSERT ON project_execution_spaces - WHEN COALESCE(NOT ( - ( - NEW.kind = 'local' AND - length(NEW.root_path) <= 4096 AND - NEW.ssh_host_id IS NULL AND - NEW.host_revision IS NULL AND - NEW.host_key_generation IS NULL AND - NEW.remote_username IS NULL AND - NEW.workspace_identity IS NULL AND - NEW.agent_installation_id_at_validation IS NULL AND - NEW.agent_binary_digest_at_validation IS NULL AND - NEW.agent_version_at_validation IS NULL AND - NEW.agent_architecture_at_validation IS NULL AND - NEW.agent_protocol_major IS NULL AND - NEW.trust_attestation_revision IS NULL AND - NEW.validated_at IS NULL - ) OR ( - NEW.kind = 'ssh' AND - length(NEW.root_path) <= 4096 AND - length(trim(NEW.root_path)) > 0 AND - NEW.ssh_host_id IS NOT NULL AND - length(NEW.ssh_host_id) = 36 AND - typeof(NEW.host_revision) = 'integer' AND - NEW.host_revision > 0 AND - typeof(NEW.host_key_generation) = 'integer' AND - NEW.host_key_generation > 0 AND - length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND - length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.agent_binary_digest_at_validation) = 71 AND - substr(NEW.agent_binary_digest_at_validation, 1, 7) = - 'sha256:' AND - substr(NEW.agent_binary_digest_at_validation, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND - substr(NEW.agent_version_at_validation, 1, 1) - NOT GLOB '[^0-9A-Za-z]' AND - NEW.agent_version_at_validation - NOT GLOB '*[^0-9A-Za-z.+_-]*' AND - NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND - typeof(NEW.agent_protocol_major) = 'integer' AND - NEW.agent_protocol_major > 0 AND - typeof(NEW.trust_attestation_revision) = 'integer' AND - NEW.trust_attestation_revision > 0 AND - length(NEW.validated_at) > 0 - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project execution space validation' - ); - END; - CREATE TRIGGER project_execution_spaces_validate_update - BEFORE UPDATE ON project_execution_spaces - WHEN COALESCE(NOT ( - ( - NEW.kind = 'local' AND - length(NEW.root_path) <= 4096 AND - NEW.ssh_host_id IS NULL AND - NEW.host_revision IS NULL AND - NEW.host_key_generation IS NULL AND - NEW.remote_username IS NULL AND - NEW.workspace_identity IS NULL AND - NEW.agent_installation_id_at_validation IS NULL AND - NEW.agent_binary_digest_at_validation IS NULL AND - NEW.agent_version_at_validation IS NULL AND - NEW.agent_architecture_at_validation IS NULL AND - NEW.agent_protocol_major IS NULL AND - NEW.trust_attestation_revision IS NULL AND - NEW.validated_at IS NULL - ) OR ( - NEW.kind = 'ssh' AND - length(NEW.root_path) <= 4096 AND - length(trim(NEW.root_path)) > 0 AND - NEW.ssh_host_id IS NOT NULL AND - length(NEW.ssh_host_id) = 36 AND - typeof(NEW.host_revision) = 'integer' AND - NEW.host_revision > 0 AND - typeof(NEW.host_key_generation) = 'integer' AND - NEW.host_key_generation > 0 AND - length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND - length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.agent_binary_digest_at_validation) = 71 AND - substr(NEW.agent_binary_digest_at_validation, 1, 7) = - 'sha256:' AND - substr(NEW.agent_binary_digest_at_validation, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND - substr(NEW.agent_version_at_validation, 1, 1) - NOT GLOB '[^0-9A-Za-z]' AND - NEW.agent_version_at_validation - NOT GLOB '*[^0-9A-Za-z.+_-]*' AND - NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND - typeof(NEW.agent_protocol_major) = 'integer' AND - NEW.agent_protocol_major > 0 AND - typeof(NEW.trust_attestation_revision) = 'integer' AND - NEW.trust_attestation_revision > 0 AND - length(NEW.validated_at) > 0 - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project execution space validation' - ); - END; DROP TRIGGER IF EXISTS project_runtime_validations_validate_insert; DROP TRIGGER IF EXISTS project_runtime_validations_validate_update; - CREATE TRIGGER project_runtime_validations_validate_insert - BEFORE INSERT ON project_runtime_validations - WHEN COALESCE(NOT ( - length(trim(NEW.runtime_selection_key)) - BETWEEN 1 AND 1000 AND - length(NEW.runtime_bundle_digest) = 71 AND - substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_bundle_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.runtime_adapter_digest) = 71 AND - substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_adapter_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.confinement_policy_digest) = 71 AND - substr(NEW.confinement_policy_digest, 1, 7) = - 'sha256:' AND - substr(NEW.confinement_policy_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(trim(NEW.approval_bridge_version)) - BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.validated_at) > 0 AND - ( - (NEW.work_mode = 'ask' AND NEW.trust_tier = 'T2') OR - (NEW.work_mode = 'execute' AND NEW.trust_tier = 'T3') - ) AND - typeof(NEW.trust_attestation_revision) = 'integer' AND - NEW.trust_attestation_revision > 0 AND - EXISTS ( - SELECT 1 FROM projects - WHERE projects.id = NEW.project_id - AND projects.default_work_mode = NEW.work_mode - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project runtime authorization' - ); - END; - CREATE TRIGGER project_runtime_validations_validate_update - BEFORE UPDATE ON project_runtime_validations - WHEN COALESCE(NOT ( - ( - OLD.work_mode IS NULL AND - OLD.trust_tier IS NULL AND - OLD.trust_attestation_revision IS NULL AND - NEW.work_mode IS NULL AND - NEW.trust_tier IS NULL AND - NEW.trust_attestation_revision IS NULL - ) OR ( - length(trim(NEW.runtime_selection_key)) - BETWEEN 1 AND 1000 AND - length(NEW.runtime_bundle_digest) = 71 AND - substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_bundle_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.runtime_adapter_digest) = 71 AND - substr(NEW.runtime_adapter_digest, 1, 7) = - 'sha256:' AND - substr(NEW.runtime_adapter_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.confinement_policy_digest) = 71 AND - substr(NEW.confinement_policy_digest, 1, 7) = - 'sha256:' AND - substr(NEW.confinement_policy_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(trim(NEW.approval_bridge_version)) - BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.validated_at) > 0 AND + DROP INDEX IF EXISTS + project_execution_spaces_ssh_host_idx; + + ALTER TABLE project_execution_spaces + RENAME TO legacy_project_execution_spaces; + + CREATE TABLE project_execution_spaces ( + project_id TEXT PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('local', 'ssh')), + root_path TEXT NOT NULL + CHECK(length(root_path) <= 4096), + ssh_host_id TEXT, + CHECK( ( - (NEW.work_mode = 'ask' AND NEW.trust_tier = 'T2') OR - (NEW.work_mode = 'execute' AND NEW.trust_tier = 'T3') - ) AND - typeof(NEW.trust_attestation_revision) = 'integer' AND - NEW.trust_attestation_revision > 0 AND - EXISTS ( - SELECT 1 FROM projects - WHERE projects.id = NEW.project_id - AND projects.default_work_mode = NEW.work_mode + kind = 'local' AND + ssh_host_id IS NULL + ) OR ( + kind = 'ssh' AND + ssh_host_id IS NOT NULL AND + length(ssh_host_id) = 36 AND + length(trim(root_path)) > 0 ) ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project runtime authorization' - ); - END; - PRAGMA user_version = 29; - COMMIT; - `) - } catch (error) { - database.exec('ROLLBACK') - throw error - } - } - if (version.user_version < ASSISTANT_DATABASE_SCHEMA_VERSION) { - database.exec('BEGIN IMMEDIATE') - try { - database.exec(` - DROP TRIGGER IF EXISTS - project_execution_spaces_validate_insert; - DROP TRIGGER IF EXISTS - project_execution_spaces_validate_update; - DROP TRIGGER IF EXISTS - project_runtime_validations_validate_insert; - DROP TRIGGER IF EXISTS - project_runtime_validations_validate_update; + ); - UPDATE project_execution_spaces - SET trust_attestation_revision = 0 - WHERE kind = 'ssh' - AND host_revision IS NOT NULL; + INSERT INTO project_execution_spaces + (project_id, kind, root_path, ssh_host_id) + SELECT project_id, kind, root_path, ssh_host_id + FROM legacy_project_execution_spaces; - UPDATE project_runtime_validations - SET confinement_policy_digest = runtime_adapter_digest, - approval_bridge_version = - '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}', - trust_tier = NULL, - trust_attestation_revision = NULL; + DROP TABLE project_runtime_validations; + DROP TABLE legacy_project_execution_spaces; - CREATE TRIGGER project_execution_spaces_validate_insert - BEFORE INSERT ON project_execution_spaces - WHEN COALESCE(NOT ( - ( - NEW.kind = 'local' AND - length(NEW.root_path) <= 4096 AND - NEW.ssh_host_id IS NULL AND - NEW.host_revision IS NULL AND - NEW.host_key_generation IS NULL AND - NEW.remote_username IS NULL AND - NEW.workspace_identity IS NULL AND - NEW.agent_installation_id_at_validation IS NULL AND - NEW.agent_binary_digest_at_validation IS NULL AND - NEW.agent_version_at_validation IS NULL AND - NEW.agent_architecture_at_validation IS NULL AND - NEW.agent_protocol_major IS NULL AND - NEW.trust_attestation_revision IS NULL AND - NEW.validated_at IS NULL - ) OR ( - NEW.kind = 'ssh' AND - length(NEW.root_path) <= 4096 AND - length(trim(NEW.root_path)) > 0 AND - length(NEW.ssh_host_id) = 36 AND - typeof(NEW.host_revision) = 'integer' AND - NEW.host_revision > 0 AND - typeof(NEW.host_key_generation) = 'integer' AND - NEW.host_key_generation > 0 AND - length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND - length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.agent_binary_digest_at_validation) = 71 AND - substr(NEW.agent_binary_digest_at_validation, 1, 7) = - 'sha256:' AND - substr(NEW.agent_binary_digest_at_validation, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND - substr(NEW.agent_version_at_validation, 1, 1) - NOT GLOB '[^0-9A-Za-z]' AND - NEW.agent_version_at_validation - NOT GLOB '*[^0-9A-Za-z.+_-]*' AND - NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND - typeof(NEW.agent_protocol_major) = 'integer' AND - NEW.agent_protocol_major > 0 AND - NEW.trust_attestation_revision = 0 AND - length(NEW.validated_at) > 0 - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project execution space validation' - ); - END; + CREATE INDEX project_execution_spaces_ssh_host_idx + ON project_execution_spaces(ssh_host_id, project_id) + WHERE kind = 'ssh'; - CREATE TRIGGER project_execution_spaces_validate_update - BEFORE UPDATE ON project_execution_spaces - WHEN COALESCE(NOT ( - ( - NEW.kind = 'local' AND - length(NEW.root_path) <= 4096 AND - NEW.ssh_host_id IS NULL AND - NEW.host_revision IS NULL AND - NEW.host_key_generation IS NULL AND - NEW.remote_username IS NULL AND - NEW.workspace_identity IS NULL AND - NEW.agent_installation_id_at_validation IS NULL AND - NEW.agent_binary_digest_at_validation IS NULL AND - NEW.agent_version_at_validation IS NULL AND - NEW.agent_architecture_at_validation IS NULL AND - NEW.agent_protocol_major IS NULL AND - NEW.trust_attestation_revision IS NULL AND - NEW.validated_at IS NULL - ) OR ( - NEW.kind = 'ssh' AND - length(NEW.root_path) <= 4096 AND - length(trim(NEW.root_path)) > 0 AND - length(NEW.ssh_host_id) = 36 AND - typeof(NEW.host_revision) = 'integer' AND - NEW.host_revision > 0 AND - typeof(NEW.host_key_generation) = 'integer' AND - NEW.host_key_generation > 0 AND - length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND - length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.agent_binary_digest_at_validation) = 71 AND - substr(NEW.agent_binary_digest_at_validation, 1, 7) = - 'sha256:' AND - substr(NEW.agent_binary_digest_at_validation, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND - substr(NEW.agent_version_at_validation, 1, 1) - NOT GLOB '[^0-9A-Za-z]' AND - NEW.agent_version_at_validation - NOT GLOB '*[^0-9A-Za-z.+_-]*' AND - NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND - typeof(NEW.agent_protocol_major) = 'integer' AND - NEW.agent_protocol_major > 0 AND - NEW.trust_attestation_revision = 0 AND - length(NEW.validated_at) > 0 - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project execution space validation' - ); - END; - - CREATE TRIGGER project_runtime_validations_validate_insert - BEFORE INSERT ON project_runtime_validations - WHEN COALESCE(NOT ( - length(trim(NEW.runtime_selection_key)) - BETWEEN 1 AND 1000 AND - length(NEW.runtime_bundle_digest) = 71 AND - substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_bundle_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.runtime_adapter_digest) = 71 AND - substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_adapter_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - NEW.confinement_policy_digest = - NEW.runtime_adapter_digest AND - NEW.approval_bridge_version = - '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}' AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.validated_at) > 0 AND - NEW.work_mode IN ('ask', 'execute') AND - NEW.trust_tier IS NULL AND - NEW.trust_attestation_revision IS NULL AND - EXISTS ( - SELECT 1 FROM projects - WHERE projects.id = NEW.project_id - AND projects.default_work_mode = NEW.work_mode - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project runtime validation' - ); - END; - - CREATE TRIGGER project_runtime_validations_validate_update - BEFORE UPDATE ON project_runtime_validations - WHEN COALESCE(NOT ( - length(trim(NEW.runtime_selection_key)) - BETWEEN 1 AND 1000 AND - length(NEW.runtime_bundle_digest) = 71 AND - substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_bundle_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - length(NEW.runtime_adapter_digest) = 71 AND - substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND - substr(NEW.runtime_adapter_digest, 8) - NOT GLOB '*[^0-9a-f]*' AND - NEW.confinement_policy_digest = - NEW.runtime_adapter_digest AND - NEW.approval_bridge_version = - '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}' AND - length(trim(NEW.agent_installation_id_at_validation)) - BETWEEN 1 AND 1000 AND - length(NEW.validated_at) > 0 AND - NEW.work_mode IN ('ask', 'execute') AND - NEW.trust_tier IS NULL AND - NEW.trust_attestation_revision IS NULL AND - EXISTS ( - SELECT 1 FROM projects - WHERE projects.id = NEW.project_id - AND projects.default_work_mode = NEW.work_mode - ) - ), 1) - BEGIN - SELECT RAISE( - ABORT, - 'invalid project runtime validation' - ); - END; - - PRAGMA user_version = ${ASSISTANT_DATABASE_SCHEMA_VERSION}; + PRAGMA user_version = 31; COMMIT; `) } catch (error) { diff --git a/src/main/execution-space/execution-space-resolver.test.ts b/src/main/execution-space/execution-space-resolver.test.ts index f6a08f3e..bb58dd38 100644 --- a/src/main/execution-space/execution-space-resolver.test.ts +++ b/src/main/execution-space/execution-space-resolver.test.ts @@ -1,10 +1,5 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' import { describe, expect, it, vi } from 'vitest' import type { AssistantProject } from '../../shared/assistant-contracts' -import { AssistantDatabase } from '../assistant/assistant-database' import { ExecutionSpaceResolver, REMOTE_EXECUTION_SPACE_UNAVAILABLE @@ -42,35 +37,18 @@ describe('ExecutionSpaceResolver', () => { expect(first.workspaceAccess).not.toBe(second.workspaceAccess) }) - it('keeps every remote workspace operation fail closed', async () => { - const resolver = new ExecutionSpaceResolver() - const descriptor = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project' - } - }) + it('keeps remote workspace operations unavailable without a factory', async () => { + const descriptor = new ExecutionSpaceResolver().resolveProject( + remoteProject() ) expect(descriptor.kind).toBe('ssh') await expect( descriptor.workspaceAccess.listDirectory({ path: '' }) ).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE) - await expect( - descriptor.workspaceAccess.getChanges({}) - ).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE) - await expect( - descriptor.workspaceAccess.writeTextAtomic({ - path: 'file.txt', - content: 'no' - }) - ).rejects.toThrow(REMOTE_EXECUTION_SPACE_UNAVAILABLE) }) - it('uses an injected remote factory only for complete matching validation', async () => { + it('binds remote access only to the configured Host and path', () => { const workspaceAccess = { getIdentity: vi.fn(), listDirectory: vi.fn(), @@ -84,289 +62,54 @@ describe('ExecutionSpaceResolver', () => { const factory = { create: vi.fn(() => workspaceAccess) } - const validation = { - hostRevision: 1, - hostKeyGeneration: 2, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: 'agent-1', - agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64' as const, - validatedAt: '2026-08-01T00:00:00.000Z' - } - const runtimeValidation = { - runtimeSelectionKey: 'opencode', - runtimeBundleDigest: `sha256:${'b'.repeat(64)}`, - runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`, - agentInstallationIdAtValidation: 'agent-1', - validatedAt: '2026-08-01T00:00:00.000Z', - workMode: 'ask' as const - } const resolver = new ExecutionSpaceResolver(factory) - const descriptor = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation - }, - runtimeValidation - }) - ) + const first = resolver.resolveProject(remoteProject()) + const second = resolver.resolveProject(remoteProject()) - expect(descriptor.workspaceAccess).toBe(workspaceAccess) - expect(descriptor).toMatchObject({ - kind: 'ssh', - validation: { - agentInstallationIdAtValidation: 'agent-1', - agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64' - }, - runtimeValidation - }) + expect(first.workspaceAccess).toBe(workspaceAccess) + expect(first.cacheIdentity).toBe(second.cacheIdentity) expect(factory.create).toHaveBeenCalledWith({ hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - hostRevision: 1, - hostKeyGeneration: 2, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 1, - agentInstallationId: 'agent-1', - agentBinaryDigest: `sha256:${'a'.repeat(64)}`, - agentVersion: '0.11.0', - agentArchitecture: 'x64' + remoteRootPath: '/srv/project' }) + }) - const stale = resolver.resolveProject( - project({ - rootPath: '/srv/project', + it('changes remote identities only with stable project configuration', () => { + const resolver = new ExecutionSpaceResolver() + const first = resolver.resolveProject(remoteProject()) + const nextPath = resolver.resolveProject( + remoteProject({ + rootPath: '/srv/other', executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation - }, - runtimeValidation: { - ...runtimeValidation, - agentInstallationIdAtValidation: 'agent-2' + remoteRootPath: '/srv/other' } }) ) - await expect(stale.workspaceAccess.getIdentity()).rejects.toThrow( - REMOTE_EXECUTION_SPACE_UNAVAILABLE - ) - - expect(factory.create).toHaveBeenCalledOnce() - }) - - it('includes remote Host, Agent, Runtime, and work-mode identities in the cache identity', () => { - const resolver = new ExecutionSpaceResolver() - const baseValidation = { - hostRevision: 1, - hostKeyGeneration: 2, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: 'agent-1', - agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, - agentVersionAtValidation: '0.11.0', - agentArchitectureAtValidation: 'x64' as const, - validatedAt: '2026-08-01T00:00:00.000Z' - } - const runtimeValidation = { - runtimeSelectionKey: 'opencode', - runtimeBundleDigest: `sha256:${'b'.repeat(64)}`, - runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`, - agentInstallationIdAtValidation: 'agent-1', - validatedAt: '2026-08-01T00:00:00.000Z', - workMode: 'ask' as const - } - const first = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation: baseValidation - }, - runtimeValidation - }) - ) - const second = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation: { - ...baseValidation, - hostRevision: 2 - } - }, - runtimeValidation + const nextProject = resolver.resolveProject( + remoteProject({ + id: '00000000-0000-4000-8000-000000000102' }) ) - expect(second.cacheIdentity).not.toBe(first.cacheIdentity) - - for (const validation of [ - { - ...baseValidation, - agentBinaryDigestAtValidation: `sha256:${'b'.repeat(64)}` - }, - { - ...baseValidation, - agentVersionAtValidation: '0.11.1' - }, - { - ...baseValidation, - agentArchitectureAtValidation: 'arm64' as const - } - ]) { - const changed = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation - }, - runtimeValidation - }) - ) - expect(changed.cacheIdentity).not.toBe(first.cacheIdentity) - } - - for (const changedRuntimeValidation of [ - { - ...runtimeValidation, - workMode: 'execute' as const - } - ]) { - const changed = resolver.resolveProject( - project({ - rootPath: '/srv/project', - executionSpace: { - kind: 'ssh', - hostId: '00000000-0000-4000-8000-000000000201', - remoteRootPath: '/srv/project', - validation: baseValidation - }, - runtimeValidation: changedRuntimeValidation - }) - ) - expect(changed.cacheIdentity).not.toBe(first.cacheIdentity) - } - }) - - it('changes DB-backed cache identity for host and Runtime generations', async () => { - const directory = await mkdtemp( - join(tmpdir(), 'goodbuddy-execution-resolver-') - ) - const databasePath = join(directory, 'assistant.sqlite') - const database = new AssistantDatabase(databasePath) - const raw = new DatabaseSync(databasePath) - try { - database.initialize('C:\\Workspace') - const projectId = database.listProjects()[0]!.id - raw - .prepare( - `UPDATE project_execution_spaces - SET kind = 'ssh', root_path = ?, ssh_host_id = ?, - host_revision = ?, host_key_generation = ?, - remote_username = ?, workspace_identity = ?, - agent_installation_id_at_validation = ?, - agent_binary_digest_at_validation = ?, - agent_version_at_validation = ?, - agent_architecture_at_validation = ?, - agent_protocol_major = ?, - trust_attestation_revision = ?, validated_at = ? - WHERE project_id = ?` - ) - .run( - '/srv/project', - '00000000-0000-4000-8000-000000000201', - 1, - 1, - 'builder', - 'workspace-1', - 'agent-1', - `sha256:${'a'.repeat(64)}`, - '0.11.0', - 'x64', - 1, - 0, - '2026-08-01T00:00:00.000Z', - projectId - ) - raw - .prepare( - `INSERT INTO project_runtime_validations - (project_id, runtime_selection_key, - runtime_bundle_digest, runtime_adapter_digest, - confinement_policy_digest, approval_bridge_version, - agent_installation_id_at_validation, validated_at, - work_mode, trust_tier, trust_attestation_revision) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - projectId, - 'opencode', - `sha256:${'b'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - `sha256:${'c'.repeat(64)}`, - 'unused', - 'agent-1', - '2026-08-01T00:00:00.000Z', - 'ask', - null, - null - ) - - const resolver = new ExecutionSpaceResolver() - const baseline = resolver.resolveProject( - database.getProject(projectId) - ) - raw - .prepare( - `UPDATE project_execution_spaces - SET host_key_generation = 2 - WHERE project_id = ?` - ) - .run(projectId) - const nextHostGeneration = resolver.resolveProject( - database.getProject(projectId) - ) - raw - .prepare( - `UPDATE project_runtime_validations - SET runtime_bundle_digest = ? - WHERE project_id = ?` - ) - .run(`sha256:${'e'.repeat(64)}`, projectId) - const nextRuntimeGeneration = resolver.resolveProject( - database.getProject(projectId) - ) - - expect(nextHostGeneration.cacheIdentity).not.toBe( - baseline.cacheIdentity - ) - expect(nextRuntimeGeneration.cacheIdentity).not.toBe( - nextHostGeneration.cacheIdentity - ) - } finally { - raw.close() - database.close() - await rm(directory, { recursive: true, force: true }) - } + expect(nextPath.cacheIdentity).not.toBe(first.cacheIdentity) + expect(nextProject.cacheIdentity).toBe(first.cacheIdentity) + expect(nextProject.routeIdentity).not.toBe(first.routeIdentity) }) }) + +function remoteProject( + overrides: Partial = {} +): AssistantProject { + return project({ + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh', + hostId: '00000000-0000-4000-8000-000000000201', + remoteRootPath: '/srv/project' + }, + runtimeSelection: { provider: 'opencode' }, + ...overrides + }) +} diff --git a/src/main/execution-space/execution-space-resolver.ts b/src/main/execution-space/execution-space-resolver.ts index acb91ea0..1017dbc5 100644 --- a/src/main/execution-space/execution-space-resolver.ts +++ b/src/main/execution-space/execution-space-resolver.ts @@ -1,9 +1,5 @@ import { isAbsolute, normalize, resolve } from 'node:path' -import type { - AssistantProject, - ProjectRuntimeValidation, - SshExecutionValidation -} from '../../shared/assistant-contracts' +import type { AssistantProject } from '../../shared/assistant-contracts' import { LocalWorkspaceAccess, UnsupportedRemoteWorkspaceAccess, @@ -27,8 +23,6 @@ export type SshExecutionSpaceDescriptor = { kind: 'ssh' hostId: string remoteRootPath: string - validation?: SshExecutionValidation - runtimeValidation?: ProjectRuntimeValidation cacheIdentity: string routeIdentity: string workspaceAccess: WorkspaceAccess @@ -79,78 +73,27 @@ export class ExecutionSpaceResolver { return this.resolveLocal(executionSpace.rootPath) } - const sshValidation = executionSpace.validation - const runtimeValidation = project.runtimeValidation - const identity = { + const binding: RemoteWorkspaceProjectBinding = { hostId: executionSpace.hostId, - remoteRootPath: executionSpace.remoteRootPath, - validation: sshValidation, - runtimeValidation + remoteRootPath: executionSpace.remoteRootPath } - const remoteBinding: RemoteWorkspaceProjectBinding | undefined = - sshValidation && - runtimeValidation && - sshValidation.agentInstallationIdAtValidation === - runtimeValidation.agentInstallationIdAtValidation - ? { - hostId: executionSpace.hostId, - remoteRootPath: executionSpace.remoteRootPath, - hostRevision: sshValidation.hostRevision, - hostKeyGeneration: sshValidation.hostKeyGeneration, - remoteUsername: sshValidation.remoteUsername, - workspaceIdentity: sshValidation.workspaceIdentity, - agentProtocolMajor: sshValidation.agentProtocolMajor, - agentInstallationId: - sshValidation.agentInstallationIdAtValidation, - agentBinaryDigest: - sshValidation.agentBinaryDigestAtValidation, - agentVersion: sshValidation.agentVersionAtValidation, - agentArchitecture: - sshValidation.agentArchitectureAtValidation - } - : undefined return { kind: 'ssh', - ...identity, + ...binding, cacheIdentity: JSON.stringify([ 'ssh', - identity.hostId, - identity.remoteRootPath, - identity.validation - ? [ - identity.validation.hostRevision, - identity.validation.hostKeyGeneration, - identity.validation.remoteUsername, - identity.validation.workspaceIdentity, - identity.validation.agentProtocolMajor, - identity.validation.agentInstallationIdAtValidation, - identity.validation.agentBinaryDigestAtValidation, - identity.validation.agentVersionAtValidation, - identity.validation.agentArchitectureAtValidation, - identity.validation.validatedAt - ] - : null, - identity.runtimeValidation - ? [ - identity.runtimeValidation.runtimeSelectionKey, - identity.runtimeValidation.runtimeBundleDigest, - identity.runtimeValidation.runtimeAdapterDigest, - identity.runtimeValidation - .agentInstallationIdAtValidation, - identity.runtimeValidation.validatedAt, - identity.runtimeValidation.workMode - ] - : null + binding.hostId, + binding.remoteRootPath ]), routeIdentity: JSON.stringify([ 'ssh', project.id, - identity.hostId, - identity.remoteRootPath + binding.hostId, + binding.remoteRootPath ]), workspaceAccess: - remoteBinding && this.remoteWorkspaceAccessFactory - ? this.remoteWorkspaceAccessFactory.create(remoteBinding) + this.remoteWorkspaceAccessFactory + ? this.remoteWorkspaceAccessFactory.create(binding) : new UnsupportedRemoteWorkspaceAccess( REMOTE_EXECUTION_SPACE_UNAVAILABLE ) diff --git a/src/main/execution-space/remote-runtime-request-validation.test.ts b/src/main/execution-space/remote-runtime-request-validation.test.ts deleted file mode 100644 index 1862ebb7..00000000 --- a/src/main/execution-space/remote-runtime-request-validation.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SshExecutionSpaceDescriptor } from './execution-space-resolver' -import { assertRemoteRuntimeRequestValidated } from './remote-runtime-request-validation' - -const digest = `sha256:${'a'.repeat(64)}` - -function descriptor(): SshExecutionSpaceDescriptor { - return { - kind: 'ssh', - hostId: 'host-1', - remoteRootPath: '/srv/project', - validation: { - hostRevision: 1, - hostKeyGeneration: 1, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: 'installation-1', - agentBinaryDigestAtValidation: digest, - agentVersionAtValidation: '1.0.0', - agentArchitectureAtValidation: 'x64', - validatedAt: '2026-08-21T00:00:00.000Z' - }, - runtimeValidation: { - runtimeSelectionKey: 'opencode:default', - runtimeBundleDigest: digest, - runtimeAdapterDigest: digest, - agentInstallationIdAtValidation: 'installation-1', - validatedAt: '2026-08-21T00:00:00.000Z', - workMode: 'ask' - }, - cacheIdentity: 'ssh-cache', - routeIdentity: 'ssh-route', - workspaceAccess: {} as SshExecutionSpaceDescriptor['workspaceAccess'] - } -} - -describe('assertRemoteRuntimeRequestValidated', () => { - it('accepts complete evidence from the current Agent installation', () => { - expect(() => - assertRemoteRuntimeRequestValidated(descriptor()) - ).not.toThrow() - }) - - it('does not couple current requests to a persisted model profile or work mode', () => { - const changedRequestEvidence = descriptor() - changedRequestEvidence.runtimeValidation = { - ...changedRequestEvidence.runtimeValidation!, - runtimeSelectionKey: - 'opencode:00000000-0000-4000-8000-000000000099', - workMode: 'execute' - } - - expect(() => - assertRemoteRuntimeRequestValidated(changedRequestEvidence) - ).not.toThrow() - }) - - it('rejects missing or cross-installation evidence', () => { - expect(() => - assertRemoteRuntimeRequestValidated( - { ...descriptor(), runtimeValidation: undefined } - ) - ).toThrow('缺少完整') - expect(() => - assertRemoteRuntimeRequestValidated( - { - ...descriptor(), - runtimeValidation: { - ...descriptor().runtimeValidation!, - agentInstallationIdAtValidation: 'installation-2' - } - } - ) - ).toThrow('缺少完整') - }) -}) diff --git a/src/main/execution-space/remote-runtime-request-validation.ts b/src/main/execution-space/remote-runtime-request-validation.ts deleted file mode 100644 index 800f00b6..00000000 --- a/src/main/execution-space/remote-runtime-request-validation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { SshExecutionSpaceDescriptor } from './execution-space-resolver' - -/** - * Prevents a request from combining persisted remote Runtime evidence with a - * different Agent installation. Runtime provider, model profile, and Ask / - * Execute behavior are validated by the live Runtime path for each request. - */ -export function assertRemoteRuntimeRequestValidated( - executionSpace: SshExecutionSpaceDescriptor -): void { - const executionValidation = executionSpace.validation - const runtimeValidation = executionSpace.runtimeValidation - if ( - !executionValidation || - !runtimeValidation || - executionValidation.agentInstallationIdAtValidation !== - runtimeValidation.agentInstallationIdAtValidation - ) { - throw new Error('远程 Project 缺少完整的当前验证,请重新验证') - } -} diff --git a/src/main/index.ts b/src/main/index.ts index 03d4dc65..9cb6c89b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,7 +11,7 @@ import { utilityProcess } from 'electron' import { homedir } from 'node:os' -import { mkdir } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import spawn from 'cross-spawn' @@ -132,7 +132,8 @@ import { } from './execution-space' import { RemoteAgentServices } from './remote-agent/remote-agent-services' import { - resolveBundledAgentResourcePaths + resolveBundledAgentResourcePaths, + resolveControlPlanePackageInstallerPath } from './remote-agent/bundled-agent-resources' import { AgentPackageManager @@ -141,6 +142,12 @@ import { ManagedRemoteExecutionServices } from './remote-agent/managed-remote-ex import { RemoteEnvironmentUpdateService } from './remote-agent/remote-environment-update-service' +import { + RemoteEnvironmentPreparer +} from './remote-agent/remote-environment-preparer' +import { + RemoteEnvironmentOperationStore +} from './remote-agent/remote-environment-operation-store' import type { RuntimeSettings, RuntimeSettingsInput @@ -651,6 +658,11 @@ if (hasSingleInstanceLock) { resourcesPath: process.resourcesPath, packaged: app.isPackaged }) + const controlPlanePackageInstaller = await readFile( + resolveControlPlanePackageInstallerPath({ + appPath: app.getAppPath() + }) + ) const agentPackageManager = new AgentPackageManager({ userDataPath: app.getPath('userData'), desktopVersion: app.getVersion(), @@ -665,7 +677,7 @@ if (hasSingleInstanceLock) { appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged, - agentPackageManager + controlPlanePackageInstaller }) remoteAgentServices = startupRemoteAgentServices const startupManagedRemoteExecutionServices = @@ -676,7 +688,6 @@ if (hasSingleInstanceLock) { appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged, - agentPackageManager, resolveRuntimeSelection: async (selection) => resolveConfiguredAgentRuntimeSelection( await settingsStore.getResolvedSettings(), @@ -695,6 +706,7 @@ if (hasSingleInstanceLock) { const invalidations = [ startupRemoteAgentServices.invalidateHost(hostId) ] + startupManagedRemoteExecutionServices.invalidateHost(hostId) if (selectedRuntimeManager) { invalidations.push( selectedRuntimeManager.invalidateHost(hostId) @@ -729,8 +741,11 @@ if (hasSingleInstanceLock) { remoteRuntimeLockPath: startupManagedRemoteExecutionServices.runtimeResourcePaths .runtimeLockPath, - loadExpectedCatalog: (architecture) => - agentPackageManager.getExpectedCatalog(architecture) + loadRemoteEnvironmentCatalog: (architecture, options) => + agentPackageManager.getRemoteEnvironmentCatalog( + architecture, + options + ) }) const [initialRuntimeSettings, initialResolvedSettings] = await Promise.all([ @@ -1240,12 +1255,26 @@ if (hasSingleInstanceLock) { database: startupAssistantDatabase, notify: sendRemoteProjectSaveProgress }) + const remoteEnvironmentPreparer = + new RemoteEnvironmentPreparer({ + resolver: startupRemoteAgentServices.targetResolver, + sshPool: startupRemoteAgentServices.sshPool, + agentPackageManager, + agentInstallationManager: + startupRemoteAgentServices.installationManager, + runtimeInstallationManager: + startupManagedRemoteExecutionServices + .runtimeInstallationManager, + operationStore: + new RemoteEnvironmentOperationStore( + app.getPath('userData') + ) + }) const remoteEnvironmentUpdateService = new RemoteEnvironmentUpdateService( - startupRemoteAgentServices.installationManager, - startupManagedRemoteExecutionServices - .runtimeInstallationManager, + remoteEnvironmentPreparer, async (hostId) => { + startupManagedRemoteExecutionServices.invalidateHost(hostId) const invalidations = [ startupRemoteAgentServices.invalidateHost(hostId) ] diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 0c128cb9..432ecdeb 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -980,7 +980,12 @@ describe('registerIpcHandlers SSH hosts', () => { } const sshHostService = { getSnapshot: vi.fn(async () => snapshot), - remove: vi.fn(async () => undefined), + remove: vi.fn( + async ( + _hostId: string, + commitLocalDeletion?: () => unknown + ) => commitLocalDeletion?.() + ), inspectDraftHostKey: vi.fn(async () => ({ candidateId, hostId, @@ -1003,13 +1008,15 @@ describe('registerIpcHandlers SSH hosts', () => { } })) } - const listProjectIdsReferencingSshHost = vi.fn((): string[] => []) + const deleteProjectsReferencingSshHost = vi.fn( + (): Array<{ id: string; name: string }> => [] + ) + const listSshHostProjectReferences = vi.fn(() => []) const remoteProject = { id: '00000000-0000-4000-8000-000000000107', name: 'Remote project' } const remoteProjectSaveService = { - activate: vi.fn(async () => remoteProject), save: vi.fn(async () => remoteProject), cancelCurrent: vi.fn(), dispose: vi.fn(async () => undefined) @@ -1061,7 +1068,12 @@ describe('registerIpcHandlers SSH hosts', () => { version: '1.18.9', architecture: 'x64' } - }] + }], + remoteDownload: { + available: true, + source: 'mirror', + packageSize: 1024 + } } as const const inspectRemoteEnvironment = vi.fn( async () => remoteEnvironment @@ -1073,16 +1085,21 @@ describe('registerIpcHandlers SSH hosts', () => { update: vi.fn( async ( owner: typeof webContents, - updateHostId: string, + request: { + hostId: string + method: 'remote-download' | 'goodbuddy-transfer' + }, onProgress?: (progress: { hostId: string - phase: 'agent' + method: 'remote-download' | 'goodbuddy-transfer' + phase: 'verifying' }) => void ) => { expect(owner).toBe(webContents) onProgress?.({ - hostId: updateHostId, - phase: 'agent' + hostId: request.hostId, + method: request.method, + phase: 'verifying' }) } ), @@ -1162,7 +1179,8 @@ describe('registerIpcHandlers SSH hosts', () => { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), - listProjectIdsReferencingSshHost + listSshHostProjectReferences, + deleteProjectsReferencingSshHost } as never, { clear: vi.fn() } as never, {} as never, @@ -1214,7 +1232,10 @@ describe('registerIpcHandlers SSH hosts', () => { await expect( electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event) - ).resolves.toEqual(snapshot) + ).resolves.toEqual({ + ...snapshot, + projectReferences: {} + }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageInventory @@ -1328,17 +1349,41 @@ describe('registerIpcHandlers SSH hosts', () => { event, { hostId } ) - ).resolves.toBeUndefined() - listProjectIdsReferencingSshHost.mockReturnValue([ - '00000000-0000-4000-8000-000000000107' + ).resolves.toEqual({ + hostId, + deletedProjects: [] + }) + deleteProjectsReferencingSshHost.mockReturnValue([ + { + id: '00000000-0000-4000-8000-000000000107', + name: 'Remote project' + } ]) await expect( electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( event, { hostId } ) - ).rejects.toThrow('SSH 主机仍被项目引用,无法删除') - expect(sshHostService.remove).toHaveBeenCalledOnce() + ).resolves.toEqual({ + hostId, + deletedProjects: [ + { + id: '00000000-0000-4000-8000-000000000107', + name: 'Remote project' + } + ] + }) + expect(sshHostService.remove).toHaveBeenCalledTimes(2) + sshHostService.remove.mockRejectedValueOnce( + new Error('Host store unavailable') + ) + await expect( + electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( + event, + { hostId } + ) + ).rejects.toThrow('Host store unavailable') + expect(deleteProjectsReferencingSshHost).toHaveBeenCalledTimes(2) await expect( electronMocks.handlers.get( @@ -1356,28 +1401,36 @@ describe('registerIpcHandlers SSH hosts', () => { await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment - )?.(event, { hostId }) + )?.(event, { hostId, method: 'remote-download' }) ).resolves.toBeUndefined() expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledWith( webContents, - hostId, + { hostId, method: 'remote-download' }, expect.any(Function) ) expect(webContents.send).toHaveBeenCalledWith( ipcChannels.sshHostsRemoteEnvironmentUpdateProgress, - { hostId, phase: 'agent' } + { + hostId, + method: 'remote-download', + phase: 'verifying' + } ) await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment - )?.(event, { hostId, command: 'whoami' }) + )?.(event, { + hostId, + method: 'remote-download', + command: 'whoami' + }) ).rejects.toThrow() await expect( electronMocks.handlers.get( ipcChannels.sshHostsUpdateRemoteEnvironment )?.( { sender: {}, senderFrame: webContents.mainFrame }, - { hostId } + { hostId, method: 'remote-download' } ) ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') expect(remoteEnvironmentUpdateService.update).toHaveBeenCalledOnce() @@ -1491,15 +1544,6 @@ describe('registerIpcHandlers SSH hosts', () => { defaultWorkMode: 'ask' } } - await expect( - electronMocks.handlers.get( - ipcChannels.remoteProjectActivate - )?.(event, remoteProject.id) - ).resolves.toEqual(remoteProject) - expect(remoteProjectSaveService.activate).toHaveBeenCalledWith( - webContents, - remoteProject.id - ) await expect( electronMocks.handlers.get( ipcChannels.remoteProjectSave @@ -1518,20 +1562,6 @@ describe('registerIpcHandlers SSH hosts', () => { remoteProjectSaveService.cancelCurrent ).toHaveBeenCalledWith(webContents) expect(selectedRuntimes.reset).not.toHaveBeenCalled() - await expect( - electronMocks.handlers.get( - ipcChannels.remoteProjectActivate - )?.(event, 'not-a-project-id') - ).rejects.toThrow() - expect(remoteProjectSaveService.activate).toHaveBeenCalledOnce() - remoteProjectSaveService.activate.mockRejectedValueOnce( - new Error('activation failed') - ) - await expect( - electronMocks.handlers.get( - ipcChannels.remoteProjectActivate - )?.(event, remoteProject.id) - ).rejects.toThrow('activation failed') expect(selectedRuntimes.reset).not.toHaveBeenCalled() await expect( electronMocks.handlers.get( @@ -1604,7 +1634,8 @@ describe('registerIpcHandlers SSH hosts', () => { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), - listProjectIdsReferencingSshHost: vi.fn(() => []), + listSshHostProjectReferences: vi.fn(() => []), + deleteProjectsReferencingSshHost: vi.fn(() => []), getProject: vi.fn(() => remoteProject), updateProject: vi.fn(), setProjectArchived: vi.fn(), @@ -1705,12 +1736,14 @@ describe('registerIpcHandlers SSH hosts', () => { { candidateId, fingerprintSha256: fingerprint, input: createInput } ], [ipcChannels.sshHostsRemoteEnvironment, { hostId }], - [ipcChannels.sshHostsUpdateRemoteEnvironment, { hostId }], + [ + ipcChannels.sshHostsUpdateRemoteEnvironment, + { hostId, method: 'remote-download' } + ], [ ipcChannels.sshHostsBrowseDirectories, { hostId, path: '/srv' } ], - [ipcChannels.remoteProjectActivate, projectId], [ipcChannels.remoteProjectSave, remoteDraft] ] for (const [channel, input] of disabledRequests) { @@ -1786,7 +1819,10 @@ describe('registerIpcHandlers SSH hosts', () => { webContents ) expect( - assistantDatabase.listProjectIdsReferencingSshHost + assistantDatabase.listSshHostProjectReferences + ).not.toHaveBeenCalled() + expect( + assistantDatabase.deleteProjectsReferencingSshHost ).not.toHaveBeenCalled() expect(assistantDatabase.updateProject).not.toHaveBeenCalled() expect(assistantDatabase.setProjectArchived).not.toHaveBeenCalled() @@ -1833,7 +1869,8 @@ describe('registerIpcHandlers SSH hosts', () => { } const progress = { hostId: '00000000-0000-4000-8000-000000000105', - phase: 'runtime' as const + method: 'remote-download' as const, + phase: 'installing-runtime' as const } sendRemoteEnvironmentUpdateProgress(owner as never, progress) @@ -7887,7 +7924,6 @@ describe('registerIpcHandlers agent terminal state', () => { selectedRuntimes ) const projectId = '00000000-0000-4000-8000-000000000451' - const agentInstallationId = 'agent-installation-1' harness.getApplicationSettings.mockResolvedValue({ magicNotesEnabled: false, remoteProjectsEnabled: true @@ -7900,30 +7936,10 @@ describe('registerIpcHandlers agent terminal state', () => { executionSpace: { kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000452', - remoteRootPath: '/srv/project', - validation: { - hostRevision: 1, - hostKeyGeneration: 1, - remoteUsername: 'builder', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 2, - agentInstallationIdAtValidation: agentInstallationId, - agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, - agentVersionAtValidation: '0.11.2-e2e.12', - agentArchitectureAtValidation: 'x64', - validatedAt: '2026-08-25T00:00:00.000Z' - } + remoteRootPath: '/srv/project' }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'opencode' }, - runtimeValidation: { - runtimeSelectionKey: 'opencode:default', - runtimeBundleDigest: `sha256:${'b'.repeat(64)}`, - runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`, - agentInstallationIdAtValidation: agentInstallationId, - validatedAt: '2026-08-25T00:00:00.000Z', - workMode: 'execute' - }, kind: 'user', status: 'active', createdAt: '2026-08-25T00:00:00.000Z', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1682ee34..849e2ce7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -227,9 +227,11 @@ import { sshHostDraftInspectionRequestSchema, sshHostRequestSchema, remoteEnvironmentUpdateProgressSchema, + remoteEnvironmentUpdateRequestSchema, sshHostRemoteEnvironmentSchema, sshHostValidationRequestSchema, - type RemoteEnvironmentUpdateProgress + type RemoteEnvironmentUpdateProgress, + type SshHostProjectReference } from '../shared/ssh-host-contracts' import type { SshHostService } from './ssh/ssh-host-service' import type { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser' @@ -241,9 +243,9 @@ import { remoteProjectSaveRequestSchema, type RemoteProjectSaveProgress } from '../shared/remote-project-candidate-contracts' -import type { - RemoteProjectSaveOwner, - RemoteProjectSaveService +import { + type RemoteProjectSaveOwner, + type RemoteProjectSaveService } from './remote-agent/remote-project-save-service' import type { RemoteEnvironmentUpdateOwner, @@ -326,7 +328,6 @@ import { ExecutionSpaceResolver, REMOTE_EXECUTION_SPACE_UNAVAILABLE } from './execution-space' -import { assertRemoteRuntimeRequestValidated } from './execution-space/remote-runtime-request-validation' const requestIdSchema = z.string().uuid() const BACKGROUND_QUESTION_REJECTION_TIMEOUT_MS = 1_000 @@ -1133,9 +1134,6 @@ export function registerIpcHandlers( selection ) } - if (executionSpace?.kind === 'ssh') { - assertRemoteRuntimeRequestValidated(executionSpace) - } return selectedRuntimes.getRuntime(selection, executionSpace) } const channels = Object.values(ipcChannels).filter( @@ -4112,7 +4110,24 @@ export function registerIpcHandlers( if (!sshHostService) { throw new Error('SSH 主机设置服务不可用') } - return sshHostService.getSnapshot() + const snapshot = await sshHostService.getSnapshot() + const projectReferences: Record< + string, + SshHostProjectReference[] + > = {} + for (const reference of + assistantDatabase.listSshHostProjectReferences()) { + const projects = projectReferences[reference.hostId] ?? [] + projects.push({ + id: reference.id, + name: reference.name + }) + projectReferences[reference.hostId] = projects + } + return { + ...snapshot, + projectReferences + } }) registerHandler( @@ -4222,12 +4237,15 @@ export function registerIpcHandlers( throw new Error('SSH 主机设置服务不可用') } const hostId = sshHostRequestSchema.parse(input).hostId - const referencingProjectIds = - assistantDatabase.listProjectIdsReferencingSshHost(hostId) - if (referencingProjectIds.length > 0) { - throw new Error('SSH 主机仍被项目引用,无法删除') + const deletedProjects = await sshHostService.remove( + hostId, + () => + assistantDatabase.deleteProjectsReferencingSshHost(hostId) + ) + return { + hostId, + deletedProjects } - return sshHostService.remove(hostId) } ) @@ -4296,10 +4314,11 @@ export function registerIpcHandlers( if (!remoteEnvironmentUpdateService) { throw new Error('SSH 远端运行环境更新服务不可用') } - const hostId = sshHostRequestSchema.parse(input).hostId + const request = + remoteEnvironmentUpdateRequestSchema.parse(input) await remoteEnvironmentUpdateService.update( event.sender, - hostId, + request, (progress) => sendRemoteEnvironmentUpdateProgress( event.sender, @@ -5175,22 +5194,6 @@ export function registerIpcHandlers( } ) - registerHandler( - ipcChannels.remoteProjectActivate, - async (event, input: unknown) => { - assertTrustedSender(event, window) - await requireRemoteProjectsEnabled() - if (!remoteProjectSaveService) { - throw new Error('远程项目验证服务不可用') - } - const result = await remoteProjectSaveService.activate( - event.sender, - assistantIdSchema.parse(input) - ) - return result - } - ) - registerHandler( ipcChannels.remoteProjectSave, async (event, input: unknown) => { @@ -5262,7 +5265,10 @@ export function registerIpcHandlers( } assistantDatabase.deleteProject( value.projectId, - value.confirmation + value.confirmation, + { + allowActiveTasks: project.executionSpace?.kind === 'ssh' + } ) await selectedRuntimes?.reset?.() } diff --git a/src/main/model-download-transport.ts b/src/main/model-download-transport.ts index 12557f27..5fa73cc3 100644 --- a/src/main/model-download-transport.ts +++ b/src/main/model-download-transport.ts @@ -3,7 +3,10 @@ import { ensureModelOperationNotAborted } from './model-package-utils' const MAX_REDIRECTS = 3 const redirectStatuses = new Set([301, 302, 303, 307, 308]) -function validateDownloadUrl(value: string, modelLabel: string): URL { +export function validateSecureDownloadUrl( + value: string, + downloadLabel: string +): URL { const url = new URL(value) if ( url.protocol !== 'https:' || @@ -13,12 +16,16 @@ function validateDownloadUrl(value: string, modelLabel: string): URL { url.hash ) { throw new Error( - `${modelLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL` + `${downloadLabel}下载地址必须是使用标准端口、无凭据和 Fragment 的 HTTPS URL` ) } return url } +export function isDownloadRedirectStatus(status: number): boolean { + return redirectStatuses.has(status) +} + export async function fetchModelDownloadResponse(options: { transport: typeof fetch initialUrl: string @@ -26,7 +33,10 @@ export async function fetchModelDownloadResponse(options: { signal: AbortSignal modelLabel: string }): Promise { - let url = validateDownloadUrl(options.initialUrl, options.modelLabel) + let url = validateSecureDownloadUrl( + options.initialUrl, + options.modelLabel + ) const initialHost = url.hostname const allowedRedirectHosts = new Set(options.redirectHosts) for (let redirectCount = 0; ; redirectCount += 1) { @@ -38,7 +48,7 @@ export async function fetchModelDownloadResponse(options: { cache: 'no-store', signal: options.signal }) - if (!redirectStatuses.has(response.status)) { + if (!isDownloadRedirectStatus(response.status)) { return response } if (redirectCount >= MAX_REDIRECTS) { @@ -54,7 +64,7 @@ export async function fetchModelDownloadResponse(options: { `${options.modelLabel}下载重定向缺少地址` ) } - const nextUrl = validateDownloadUrl( + const nextUrl = validateSecureDownloadUrl( new URL(location, url).toString(), options.modelLabel ) diff --git a/src/main/remote-agent/agent-attach-transport.test.ts b/src/main/remote-agent/agent-attach-transport.test.ts index 9d6cbab7..69c69b0c 100644 --- a/src/main/remote-agent/agent-attach-transport.test.ts +++ b/src/main/remote-agent/agent-attach-transport.test.ts @@ -248,10 +248,37 @@ describe('AgentAttachTransport', () => { await expect(connect(channel)).rejects.toMatchObject({ code: 'closed', message: - 'Agent attach closed during handshake with diagnostic output' + 'Agent attach closed during handshake with diagnostic output', + diagnostic: undefined }) }) + it('classifies installation integrity stderr without exposing its contents', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + queueMicrotask(() => { + channel.stderr.emit( + 'data', + 'Agent manifest uses an unknown signing key: private-key-id\n' + ) + channel.emit('close') + }) + } + + let error: unknown + try { + await connect(channel) + } catch (reason) { + error = reason + } + expect(error).toBeInstanceOf(AgentAttachTransportError) + expect(error).toMatchObject({ + code: 'closed', + diagnostic: 'installation-repair-required' + }) + expect((error as Error).message).not.toContain('private-key-id') + }) + it('writes every frame in caller FIFO order', async () => { const channel = new FakeChannel() channel.onWrite = () => { diff --git a/src/main/remote-agent/agent-attach-transport.ts b/src/main/remote-agent/agent-attach-transport.ts index 42e3179d..8c731fad 100644 --- a/src/main/remote-agent/agent-attach-transport.ts +++ b/src/main/remote-agent/agent-attach-transport.ts @@ -523,15 +523,19 @@ async function readWelcome( } const onAbort = (): void => fail(signal.reason) const onError = (error: Error): void => fail(error) - const onClose = (): void => + const onClose = (): void => { + const diagnostic = handshakeFailureDiagnostic(stderr) fail( new AgentAttachTransportError( stderr.byteLength > 0 ? 'Agent attach closed during handshake with diagnostic output' : 'Agent attach closed during handshake', - 'closed' + 'closed', + undefined, + diagnostic ) ) + } const onStderr = (chunk: Buffer | string): void => { const bytes = Buffer.from(chunk) if (stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) { @@ -633,6 +637,20 @@ async function readWelcome( }) } +function handshakeFailureDiagnostic( + stderr: Uint8Array +): string | undefined { + const text = Buffer.from(stderr).toString('utf8') + if ( + /(?:unknown signing key|signature verification|manifest .*mismatch|manifest does not match|authorized registry role|payload (?:size|hash|mode) mismatch)/iu.test( + text + ) + ) { + return 'installation-repair-required' + } + return protocolFailureDiagnostic(stderr) +} + function encodedFrameLength(frame: AgentFrame): number { return ( AGENT_FRAME_FIXED_HEADER_BYTES + diff --git a/src/main/remote-agent/agent-installation-manager.test.ts b/src/main/remote-agent/agent-installation-manager.test.ts index 272b3ccc..e4850ff4 100644 --- a/src/main/remote-agent/agent-installation-manager.test.ts +++ b/src/main/remote-agent/agent-installation-manager.test.ts @@ -4,7 +4,6 @@ import { sign } from 'node:crypto' import { - chmod, mkdtemp, mkdir, readFile, @@ -30,7 +29,6 @@ import { type VerifiedAgentBundle } from './agent-bundle-verifier' import { - AgentInstallationError, AgentInstallationManager } from './agent-installation-manager' @@ -79,6 +77,10 @@ class MemorySftp implements StagedSftp { } } + async uploadFile(): Promise { + throw new Error('uploadFile is unused in this fixture') + } + async mkdir(path: string): Promise { const absolute = this.absolute(path) this.operations.push(`mkdir:${path}`) @@ -239,19 +241,6 @@ class MemorySftp implements StagedSftp { } } -const registry: AgentReleaseKeyRegistry = { - formatVersion: 1, - keys: [{ - keyId: 'test-key', - publicKeySpkiBase64: Buffer.from( - '302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', - 'hex' - ).toString('base64'), - environment: 'test' - }], - revocations: [] -} - function target(revision = 1): SshConnectionPoolTarget { return { host: { @@ -337,133 +326,6 @@ async function bundleFixture(): Promise { } } -function elfFixture(architecture: 'x64' | 'arm64'): Buffer { - const header = Buffer.alloc(64) - header[0] = 0x7f - header.write('ELF', 1, 'ascii') - header[4] = 2 - header[5] = 1 - header.writeUInt16LE( - architecture === 'x64' ? 62 : 183, - 18 - ) - return header -} - -async function harness(options: { - sftp?: MemorySftp - lifecycleFailure?: 'bootstrap' | 'health' - lifecycleCancellation?: 'health' - emulateInstallationRegistry?: boolean - resolverTargets?: SshConnectionPoolTarget[] - bootstrapGate?: Promise -} = {}) { - const bundle = await bundleFixture() - const sftp = options.sftp ?? new MemorySftp() - const release = vi.fn() - const lifecycleActions: string[] = [] - const installationId = - `agent-${bundle.manifestSha256}` - const lease = { - identity: { - hostId: 'host-1', - hostRevision: 1, - hostKeyGeneration: 1, - authenticationIdentity: 'b'.repeat(64) - }, - isUsable: () => true, - runAgentBootstrapProbe: vi.fn(async (signal?: AbortSignal) => { - if (options.bootstrapGate) { - await waitForGate(options.bootstrapGate, signal) - } - return { - ready: true, - platform: 'linux', - architecture: 'x64', - canonicalHomeDirectory: home, - uid, - shell: '/bin/bash', - procfs: 'ready' - } as const - }), - openStagedSftp: vi.fn(async () => sftp), - runAgentLifecycleAction: vi.fn( - async ( - _installationId, - action: string, - signal?: AbortSignal - ) => { - lifecycleActions.push(action) - if ( - options.emulateInstallationRegistry && - action === 'bootstrap' - ) { - stageEmulatedCandidate( - sftp, - bundle, - installationId - ) - } - if (options.lifecycleCancellation === action) { - await waitForGate( - new Promise(() => undefined), - signal - ) - } - const failed = options.lifecycleFailure === action - if ( - options.emulateInstallationRegistry && - action === 'health' && - !failed - ) { - promoteEmulatedCandidate( - sftp, - bundle, - installationId - ) - } - return { - exitCode: failed ? 1 : 0, - stdout: '', - stderr: '' - } - } - ), - release - } as unknown as SshConnectionLease - let resolveCount = 0 - const targets = options.resolverTargets ?? [target()] - const resolver = { - resolve: vi.fn(async () => - targets[Math.min(resolveCount++, targets.length - 1)]! - ) - } - const sshPool = { - acquire: vi.fn(async () => lease) - } - const manager = new AgentInstallationManager({ - resolver, - sshPool, - resourcePaths: { - keyRegistryPath: 'unused', - runtimeLockPath: 'unused', - bundleDirectories: { x64: 'unused', arm64: 'unused' } - }, - verificationEnvironment: 'test', - loadVerifiedBundle: async () => ({ bundle, registry }) - }) - return { - manager, - bundle, - sftp, - release, - lifecycleActions, - resolver, - sshPool, - installationId - } -} - function stageEmulatedCandidate( sftp: MemorySftp, bundle: VerifiedAgentBundle, @@ -547,52 +409,6 @@ function legacyRegistryBytes(): Buffer { `) } -function populateManagedRegistries( - fixture: Awaited>, - installationRegistry: Buffer, - releaseKeyRegistry: Buffer -): void { - for (const path of [ - '.goodbuddy', - '.goodbuddy/agent', - '.goodbuddy/agent/staging', - '.goodbuddy/agent/installations' - ]) { - fixture.sftp.add(path, { - type: 'directory', - mode: 0o700, - uid - }) - } - fixture.sftp.add('.goodbuddy/agent/registry.json', { - type: 'file', - mode: 0o600, - uid, - contents: Buffer.from(installationRegistry) - }) - fixture.sftp.add('.goodbuddy/agent/release-keys.json', { - type: 'file', - mode: 0o600, - uid, - contents: Buffer.from(releaseKeyRegistry) - }) - fixture.sftp.add( - '.goodbuddy/agent/installations/old-current', - { type: 'directory', mode: 0o700, uid } - ) -} - -function managedBytes( - fixture: Awaited>, - path: string -): Buffer | undefined { - return fixture.sftp.entries.get(`${home}/${path}`)?.contents -} - -function bundledRegistryBytes(): Buffer { - return Buffer.from(`${JSON.stringify(registry, null, 2)}\n`) -} - async function waitForGate( gate: Promise, signal?: AbortSignal @@ -623,73 +439,21 @@ async function waitForGate( }) } -function populateInstallation( - fixture: Awaited> -): void { - const root = - `.goodbuddy/agent/installations/${fixture.installationId}` - fixture.sftp.add(root, { - type: 'directory', - mode: 0o700, - uid - }) - const directories = new Set() - for (const file of fixture.bundle.manifest.files) { - const parts = file.path.split('/') - let directory = root - for (const part of parts.slice(0, -1)) { - directory = `${directory}/${part}` - if (!directories.has(directory)) { - fixture.sftp.add(directory, { - type: 'directory', - mode: 0o700, - uid - }) - directories.add(directory) - } - } - fixture.sftp.add(`${root}/${file.path}`, { - type: 'file', - mode: file.mode === '0755' ? 0o755 : 0o644, - uid, - contents: Buffer.from( - file.path === 'node' - ? 'node' - : file.path === 'goodbuddy-agent' - ? 'agent' - : file.path === 'lib/agent.cjs' - ? 'script' - : 'license' - ) - }) - } - const manifestBytes = Buffer.from( - `${JSON.stringify(fixture.bundle.manifest, null, 2)}\n` - ) - fixture.sftp.add(`${root}/manifest.json`, { - type: 'file', - mode: 0o644, - uid, - contents: manifestBytes - }) - fixture.sftp.add(`${root}/manifest.sig`, { - type: 'file', - mode: 0o644, - uid, - contents: Buffer.from('fixture-signature\n') - }) -} - async function metadataOnlyHarness(options: { remoteRegistryBytes?: Buffer remoteReleaseKeyBytes?: Buffer hostRegistrySubset?: boolean corruptPath?: string resolverTargets?: SshConnectionPoolTarget[] - candidateResources?: boolean currentAgentVersion?: string currentNodeContents?: Buffer trustCurrentSignature?: boolean + agentStopped?: boolean + bootstrapGate?: Promise + publishedLifecycleFailure?: 'bootstrap' | 'health' + emulatePublishedRegistry?: boolean + remoteRegistryAbsent?: boolean + remoteReleaseKeysAbsent?: boolean } = {}) { const bundle = await bundleFixture() const keyPair = generateKeyPairSync('ed25519') @@ -824,27 +588,31 @@ async function metadataOnlyHarness(options: { arch: currentManifest.arch } }, null, 2)}\n`) - sftp.add('.goodbuddy/agent/registry.json', { - type: 'file', - mode: 0o600, - uid, - contents: persistedRegistry - }) - sftp.add('.goodbuddy/agent/release-keys.json', { - type: 'file', - mode: 0o600, - uid, - contents: - options.remoteReleaseKeyBytes ?? - ( - options.hostRegistrySubset - ? Buffer.from(`${JSON.stringify({ - ...signedRegistry, - keys: [signedRegistry.keys[0]] - }, null, 2)}\n`) - : registryBytes - ) - }) + if (!options.remoteRegistryAbsent) { + sftp.add('.goodbuddy/agent/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: persistedRegistry + }) + } + if (!options.remoteReleaseKeysAbsent) { + sftp.add('.goodbuddy/agent/release-keys.json', { + type: 'file', + mode: 0o600, + uid, + contents: + options.remoteReleaseKeyBytes ?? + ( + options.hostRegistrySubset + ? Buffer.from(`${JSON.stringify({ + ...signedRegistry, + keys: [signedRegistry.keys[0]] + }, null, 2)}\n`) + : registryBytes + ) + }) + } for (const file of bundle.manifest.files) { const contents = await readFile( join(bundle.bundleDirectory, ...file.path.split('/')) @@ -879,6 +647,7 @@ async function metadataOnlyHarness(options: { }) const lifecycleActions: string[] = [] + let healthCalls = 0 const release = vi.fn() const lease = { identity: { @@ -888,7 +657,11 @@ async function metadataOnlyHarness(options: { authenticationIdentity: 'b'.repeat(64) }, isUsable: () => true, - runAgentBootstrapProbe: vi.fn(async () => ({ + runAgentBootstrapProbe: vi.fn(async (signal?: AbortSignal) => { + if (options.bootstrapGate) { + await waitForGate(options.bootstrapGate, signal) + } + return { ready: true, platform: 'linux', architecture: 'x64', @@ -896,16 +669,58 @@ async function metadataOnlyHarness(options: { uid, shell: '/bin/bash', procfs: 'ready' - }) as const), + } as const + }), openStagedSftp: vi.fn(async () => sftp), runAgentLifecycleAction: vi.fn( async (_installationId, action: string) => { lifecycleActions.push(action) + if ( + options.emulatePublishedRegistry && + action === 'bootstrap' + ) { + stageEmulatedCandidate( + sftp, + { + ...bundle, + manifest: currentManifest, + manifestSha256: currentManifestSha256 + }, + currentInstallationId + ) + } + if (action === 'health') { + healthCalls += 1 + } + const publishedFailure = + options.publishedLifecycleFailure === action + if ( + options.emulatePublishedRegistry && + action === 'health' && + !publishedFailure + ) { + promoteEmulatedCandidate( + sftp, + { + ...bundle, + manifest: currentManifest, + manifestSha256: currentManifestSha256 + }, + currentInstallationId + ) + } return { exitCode: - options.corruptPath === 'node' && - !options.candidateResources && - action === 'health' + publishedFailure || + ( + action === 'health' && + ( + ( + options.corruptPath === 'node' + ) || + (options.agentStopped === true && healthCalls === 1) + ) + ) ? 1 : 0, stdout: '', @@ -933,21 +748,23 @@ async function metadataOnlyHarness(options: { arm64: join(resourceRoot, 'missing-arm64') } }, - verificationEnvironment: 'test', - ...(options.candidateResources - ? { - loadVerifiedBundle: async () => ({ - bundle, - registry: signedRegistry - }) - } - : {}) + verificationEnvironment: 'test' }) return { manager, bundle, installationId, currentInstallationId, + publishedIdentity: { + installationId: currentInstallationId, + agentVersion: currentManifest.agentVersion, + manifestSha256: currentManifestSha256, + binaryDigest: `sha256:${currentManifestSha256}`, + platform: 'linux' as const, + architecture: currentManifest.arch, + protocol: { ...currentManifest.protocol }, + supervisor: 'detached-on-demand' as const + }, lifecycleActions, resolver, sftp, @@ -956,30 +773,11 @@ async function metadataOnlyHarness(options: { } describe('AgentInstallationManager', () => { - it('requires the independently verified package loader in production', () => { - expect(() => - new AgentInstallationManager({ - resolver: { resolve: vi.fn() }, - sshPool: { acquire: vi.fn() }, - resourcePaths: { - keyRegistryPath: 'unused', - runtimeLockPath: 'unused', - bundleDirectories: { - x64: 'unused', - arm64: 'unused' - } - } - }) - ).toThrow( - 'Production Agent installation requires a verified package loader' - ) - }) - it('cryptographically reuses an exact current Host Agent when bundle resources are absent', async () => { const fixture = await metadataOnlyHarness() await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).resolves.toMatchObject({ installationId: fixture.installationId, agentVersion: fixture.bundle.manifest.agentVersion, @@ -994,13 +792,275 @@ describe('AgentInstallationManager', () => { expect(fixture.release).toHaveBeenCalledOnce() }) + it('reuses the current Agent identity until the Host is invalidated', async () => { + const fixture = await metadataOnlyHarness() + + const first = await fixture.manager.activateInstalled('host-1') + await expect( + fixture.manager.activateInstalled('host-1') + ).resolves.toEqual(first) + expect(fixture.lifecycleActions).toEqual(['health']) + expect(fixture.release).toHaveBeenCalledOnce() + + fixture.manager.invalidateHost('host-1') + await expect( + fixture.manager.activateInstalled('host-1') + ).resolves.toEqual(first) + expect(fixture.lifecycleActions).toEqual(['health', 'health']) + expect(fixture.release).toHaveBeenCalledTimes(2) + }) + + it('activates an exact installed Agent without loading or publishing installable payloads', async () => { + const fixture = await metadataOnlyHarness({}) + + await expect( + fixture.manager.activateInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId, + architecture: 'x64' + }) + + expect(fixture.lifecycleActions).toEqual(['health']) + expect( + fixture.sftp.operations.filter((operation) => + /^(?:mkdir|write|chmod|rename|replace|link|unlink|rmdir):/u + .test(operation) + ) + ).toEqual([]) + }) + + it('bootstraps a stopped installed Agent without SFTP mutation', async () => { + const fixture = await metadataOnlyHarness({ + agentStopped: true + }) + + await expect( + fixture.manager.activateInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId + }) + + expect(fixture.lifecycleActions).toEqual([ + 'health', + 'bootstrap', + 'health' + ]) + expect( + fixture.sftp.operations.filter((operation) => + /^(?:mkdir|write|chmod|rename|replace|link|unlink|rmdir):/u + .test(operation) + ) + ).toEqual([]) + }) + + it('adopts the exact package-published Agent without loading or mutating payloads', async () => { + const fixture = await metadataOnlyHarness({}) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId, + agentVersion: fixture.bundle.manifest.agentVersion, + architecture: 'x64' + }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + expect( + fixture.sftp.operations.filter((operation) => + /^(?:mkdir|write|chmod|rename|replace|link|unlink|rmdir):/u + .test(operation) + ) + ).toEqual([]) + }) + + it('adopts a package-published Agent when all global Agent metadata is absent', async () => { + const fixture = await metadataOnlyHarness({ + remoteRegistryAbsent: true, + remoteReleaseKeysAbsent: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId, + architecture: 'x64' + }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/agent/release-keys.json` + )?.contents + ).toBeDefined() + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/registry.json` + ) + ).toBe(false) + }) + + it('verifies a published Agent with packaged trust instead of stale Host keys', async () => { + const fixture = await metadataOnlyHarness({ + remoteReleaseKeyBytes: Buffer.from( + '{"formatVersion":1,"keys":[],"revocations":[]}\n' + ) + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId + }) + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + }) + + it('rejects a package-published Agent identity mismatch before activation', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activatePublished('host-1', { + ...fixture.publishedIdentity, + agentVersion: '9.9.9' + }) + ).rejects.toMatchObject({ reason: 'corrupt' }) + + expect(fixture.lifecycleActions).toEqual(['stop']) + }) + + it('does not transfer installer-verified Agent payloads back through SFTP', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + installationId: fixture.currentInstallationId + }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + for (const file of fixture.bundle.manifest.files) { + expect(fixture.sftp.operations).not.toContain( + `read:.goodbuddy/agent/installations/` + + `${fixture.currentInstallationId}/${file.path}` + ) + } + }) + + it('rejects package-published Agent adoption after Host identity changes', async () => { + const fixture = await metadataOnlyHarness({ + resolverTargets: [target(), target(2)] + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + + expect(fixture.lifecycleActions).toEqual(['stop']) + }) + + it('stops a failed package-published Agent and restores registry bytes exactly', async () => { + const oldRegistry = legacyRegistryBytes() + const oldReleaseKeys = Buffer.from( + '{ "legacy": "release keys", "spacing": true }\n' + ) + const fixture = await metadataOnlyHarness({ + remoteRegistryBytes: oldRegistry, + remoteReleaseKeyBytes: oldReleaseKeys, + publishedLifecycleFailure: 'health', + emulatePublishedRegistry: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ reason: 'lifecycle' }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health', + 'stop' + ]) + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/agent/registry.json` + )?.contents + ).toEqual(oldRegistry) + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/agent/release-keys.json` + )?.contents + ).toEqual(oldReleaseKeys) + }) + + it('stops a failed package-published Agent and restores global metadata absence', async () => { + const fixture = await metadataOnlyHarness({ + remoteRegistryAbsent: true, + remoteReleaseKeysAbsent: true, + publishedLifecycleFailure: 'health', + emulatePublishedRegistry: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ reason: 'lifecycle' }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health', + 'stop' + ]) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/registry.json` + ) + ).toBe(false) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/release-keys.json` + ) + ).toBe(false) + }) + it('reuses a canonical older Host key subset containing the current signing key', async () => { const fixture = await metadataOnlyHarness({ hostRegistrySubset: true }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).resolves.toMatchObject({ installationId: fixture.installationId, architecture: 'x64' @@ -1017,7 +1077,7 @@ describe('AgentInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'incompatible', message: expect.stringContaining( @@ -1038,7 +1098,7 @@ describe('AgentInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'corrupt' }) expect(fixture.lifecycleActions).toEqual([]) }) @@ -1049,7 +1109,7 @@ describe('AgentInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'corrupt' }) expect(fixture.lifecycleActions).toEqual([]) }) @@ -1060,7 +1120,7 @@ describe('AgentInstallationManager', () => { const fixture = await metadataOnlyHarness({ corruptPath }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'corrupt' }) expect(fixture.lifecycleActions).toEqual([]) } @@ -1072,7 +1132,7 @@ describe('AgentInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'lifecycle' }) expect(fixture.lifecycleActions).toEqual([ 'health', @@ -1087,819 +1147,70 @@ describe('AgentInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'host-identity-changed' }) expect(fixture.lifecycleActions).toEqual([]) }) - it('installs in manifest order with exact modes and verified readback', async () => { - const fixture = await harness() - const phases: string[] = [] - const identity = await fixture.manager.ensureInstalled( - 'host-1', - { onProgress: (phase) => phases.push(phase) } - ) - - expect(identity).toMatchObject({ - installationId: fixture.installationId, - binaryDigest: `sha256:${fixture.bundle.manifestSha256}`, - architecture: 'x64', - supervisor: 'detached-on-demand' - }) - expect(fixture.lifecycleActions).toEqual(['bootstrap', 'health']) - const payloadWrites = fixture.sftp.operations - .filter((operation) => operation.startsWith('write:') && - !operation.includes('.release-keys-')) - .map((operation) => - operation.slice(operation.lastIndexOf('/') + 1) - ) - expect(payloadWrites).toEqual([ - 'node', - 'goodbuddy-agent', - 'agent.cjs', - 'LICENSE.txt', - 'manifest.json', - 'manifest.sig' - ]) - expect( - fixture.sftp.operations.some((operation) => - /chmod:.*goodbuddy-agent:755/u.test(operation) - ) - ).toBe(true) - expect(phases.at(-1)).toBe('complete') - expect(fixture.release).toHaveBeenCalledOnce() - expect(fixture.sftp.closed).toBe(true) - }) - - it('dispatches an arm64 Host through the normal arm64 bundle-directory loader', async () => { - const resourceRoot = await mkdtemp( - join(tmpdir(), 'agent-normal-loader-') - ) - const x64Directory = join(resourceRoot, 'linux-x64') - const arm64Directory = join(resourceRoot, 'linux-arm64') - await Promise.all([ - mkdir(x64Directory, { recursive: true }), - mkdir(arm64Directory, { recursive: true }) - ]) - await writeFile( - join(x64Directory, 'manifest.json'), - 'not the selected architecture\n' - ) - - const payloads = [ - { - path: 'node', - contents: elfFixture('arm64'), - mode: '0755' as const - }, - { - path: 'goodbuddy-agent', - contents: Buffer.from('agent'), - mode: '0755' as const - }, - { - path: 'lib/agent.cjs', - contents: Buffer.from('script'), - mode: '0644' as const - }, - ...[ - 'lib/node_modules/koffi/package.json', - 'lib/node_modules/koffi/index.js', - 'lib/node_modules/koffi/src/koffi/index.js', - 'lib/node_modules/koffi/src/koffi/src/static.js', - 'lib/node_modules/@koromix/koffi-linux-arm64/package.json', - 'lib/node_modules/@koromix/koffi-linux-arm64/index.js' - ].map((path) => ({ - path, - contents: Buffer.from(path), - mode: '0644' as const - })), - ...[ - 'lib/node_modules/@koromix/koffi-linux-arm64/linux_arm64/koffi.node', - 'lib/node_modules/@koromix/koffi-linux-arm64/musl_arm64/koffi.node' - ].map((path) => ({ - path, - contents: elfFixture('arm64'), - mode: '0644' as const - })), - { - path: 'licenses/koffi-MIT.txt', - contents: Buffer.from('koffi license'), - mode: '0644' as const - }, - { - path: 'licenses/koffi-native-MIT.txt', - contents: Buffer.from('native license'), - mode: '0644' as const - } - ] - const manifest: AgentBundleManifest = { - formatVersion: 1, - product: 'GoodBuddy', - agentVersion: '1.0.0', - platform: 'linux', - arch: 'arm64', - protocol: { major: 1, minor: 0 }, - signingKeyId: 'test-key', - entrypoint: { - path: 'goodbuddy-agent', - runtimePath: 'node', - scriptPath: 'lib/agent.cjs' - }, - files: payloads.map((file) => ({ - path: file.path, - size: file.contents.byteLength, - sha256: sha256(file.contents), - mode: file.mode - })), - licenses: [ - { - package: 'koffi', - version: '3.1.4', - spdx: 'MIT', - path: 'licenses/koffi-MIT.txt' - }, - { - package: '@koromix/koffi-linux-arm64', - version: '3.1.4', - spdx: 'MIT', - path: 'licenses/koffi-native-MIT.txt' - } - ] - } - for (const payload of payloads) { - const destination = join( - arm64Directory, - ...payload.path.split('/') - ) - await mkdir(join(destination, '..'), { recursive: true }) - await writeFile(destination, payload.contents) - await chmod( - destination, - payload.mode === '0755' ? 0o755 : 0o644 - ) - } - const keyPair = generateKeyPairSync('ed25519') - const signedRegistry: AgentReleaseKeyRegistry = { - formatVersion: 1, - keys: [{ - keyId: 'test-key', - publicKeySpkiBase64: keyPair.publicKey.export({ - format: 'der', - type: 'spki' - }).toString('base64'), - environment: 'test' - }], - revocations: [] - } - const manifestBytes = Buffer.from( - `${JSON.stringify(manifest, null, 2)}\n` - ) - await Promise.all([ - writeFile( - join(arm64Directory, 'manifest.json'), - manifestBytes - ), - writeFile( - join(arm64Directory, 'manifest.sig'), - Buffer.from(`${sign( - null, - agentManifestSignaturePayload(manifestBytes), - keyPair.privateKey - ).toString('base64')}\n`) - ) - ]) - await Promise.all([ - chmod(join(arm64Directory, 'manifest.json'), 0o644), - chmod(join(arm64Directory, 'manifest.sig'), 0o644) - ]) - const rootLock = JSON.parse( - await readFile( - join(process.cwd(), 'agent-runtime-lock.json'), - 'utf8' - ) - ) as Record - const keyRegistryPath = join( - resourceRoot, - 'agent-release-keys.json' - ) - const runtimeLockPath = join( - resourceRoot, - 'agent-runtime-lock.json' - ) - await Promise.all([ - writeFile( - keyRegistryPath, - Buffer.from(`${JSON.stringify( - signedRegistry, - null, - 2 - )}\n`) - ), - writeFile( - runtimeLockPath, - Buffer.from(`${JSON.stringify({ - ...rootLock, - agentVersion: manifest.agentVersion, - protocol: manifest.protocol - }, null, 2)}\n`) - ) - ]) - - const sftp = new MemorySftp() - const release = vi.fn() - const lease = { - identity: { - hostId: 'host-1', - hostRevision: 1, - hostKeyGeneration: 1, - authenticationIdentity: 'b'.repeat(64) - }, - isUsable: () => true, - runAgentBootstrapProbe: vi.fn(async () => ({ - ready: true, - platform: 'linux', - architecture: 'arm64', - canonicalHomeDirectory: home, - uid, - shell: '/bin/bash', - procfs: 'ready' - }) as const), - openStagedSftp: vi.fn(async () => sftp), - runAgentLifecycleAction: vi.fn(async () => ({ - exitCode: 0, - stdout: '', - stderr: '' - })), - release - } as unknown as SshConnectionLease - const manager = new AgentInstallationManager({ - resolver: { resolve: vi.fn(async () => target()) }, - sshPool: { acquire: vi.fn(async () => lease) }, - resourcePaths: { - keyRegistryPath, - runtimeLockPath, - bundleDirectories: { - x64: x64Directory, - arm64: arm64Directory - } - }, - verificationEnvironment: 'test' - }) - - await expect( - manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ - architecture: 'arm64', - agentVersion: manifest.agentVersion - }) - expect( - sftp.operations.some((operation) => - operation.endsWith('/node') - ) - ).toBe(true) - expect(release).toHaveBeenCalledOnce() - }) - - it('upgrades an unverifiable current Agent when candidate resources exist', async () => { - const fixture = await metadataOnlyHarness({ - candidateResources: true, - currentAgentVersion: '0.9.0', - trustCurrentSignature: false - }) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ - installationId: fixture.installationId, - agentVersion: fixture.bundle.manifest.agentVersion - }) - expect(fixture.lifecycleActions).toEqual([ - 'bootstrap', - 'health' - ]) - expect( - fixture.sftp.operations.some((operation) => - operation.startsWith('write:') && - operation.endsWith('/node') - ) - ).toBe(true) - expect( - fixture.sftp.entries.has( - `${home}/.goodbuddy/agent/installations/${fixture.currentInstallationId}` - ) - ).toBe(true) - }) - - it('reuses an exactly verified current Node without uploading it', async () => { - const fixture = await metadataOnlyHarness({ - candidateResources: true, - currentAgentVersion: '0.9.0' - }) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ - installationId: fixture.installationId - }) - - expect( - fixture.sftp.operations.some((operation) => - /^link:\.goodbuddy\/agent\/installations\/agent-[a-f0-9]{64}\/node:\.goodbuddy\/agent\/staging\/op-[^/]+\/node$/u - .test(operation) - ) - ).toBe(true) - expect( - fixture.sftp.operations.some((operation) => - operation.startsWith('write:') && - operation.endsWith('/node') - ) - ).toBe(false) - }) - - it.each([ - { - condition: 'untrusted current manifest', - options: { trustCurrentSignature: false } - }, - { - condition: 'corrupt current Node', - options: { corruptPath: 'node' } - }, - { - condition: 'different verified current Node', - options: { currentNodeContents: Buffer.from('old-node') } - } - ])( - 'uploads candidate Node for $condition', - async ({ options }) => { - const fixture = await metadataOnlyHarness({ - candidateResources: true, - currentAgentVersion: '0.9.0', - ...options - }) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ - installationId: fixture.installationId - }) - expect( - fixture.sftp.operations.some((operation) => - operation.startsWith('link:') - ) - ).toBe(false) - expect( - fixture.sftp.operations.some((operation) => - operation.startsWith('write:') && - operation.endsWith('/node') - ) - ).toBe(true) - } - ) - - it('bootstraps a stopped exact installation and reuses it without upload', async () => { - const fixture = await harness() - populateInstallation(fixture) - await fixture.manager.ensureInstalled('host-1') - - expect(fixture.lifecycleActions).toEqual(['bootstrap', 'health']) - expect( - fixture.sftp.operations.some((entry) => - entry.startsWith('rename:.goodbuddy/agent/staging/') - ) - ).toBe(false) - }) - - it('reports lifecycle recovery failure without marking a valid bundle corrupt', async () => { - const fixture = await harness({ lifecycleFailure: 'health' }) - populateInstallation(fixture) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ reason: 'lifecycle' }) - expect(fixture.lifecycleActions).toEqual([ - 'bootstrap', - 'health', - 'stop' - ]) - expect( - fixture.sftp.operations.some((entry) => - entry.startsWith('rename:.goodbuddy/agent/staging/') - ) - ).toBe(false) - }) - - it('rejects corrupt readback and removes operation-owned staging', async () => { - const sftp = new MemorySftp() - const fixture = await harness({ sftp }) - const originalRead = sftp.readFile.bind(sftp) - sftp.readFile = vi.fn(async (path: string) => { - const value = await originalRead(path) - if (path.endsWith('/node')) { - return Buffer.from('bad') - } - return value - }) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toThrow('readback mismatch') - expect( - [...sftp.entries.keys()].some((path) => - path.includes('/staging/op-') - ) - ).toBe(false) - expect(fixture.release).toHaveBeenCalledOnce() - }) - - it('atomically replaces the managed release-key registry', async () => { - const fixture = await harness() - fixture.sftp.add('.goodbuddy', { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add('.goodbuddy/agent', { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add('.goodbuddy/agent/staging', { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add('.goodbuddy/agent/installations', { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add('.goodbuddy/agent/release-keys.json', { - type: 'file', - mode: 0o600, - uid, - contents: Buffer.from('old') - }) - await fixture.manager.ensureInstalled('host-1') - - expect( - fixture.sftp.operations.some((operation) => - /^replace:\.goodbuddy\/agent\/\.release-keys-.*\.tmp:\.goodbuddy\/agent\/release-keys\.json$/u - .test(operation) - ) - ).toBe(true) - }) - - it('deduplicates concurrent ensures for the same full host identity', async () => { + it('deduplicates installed activation and lets one waiter cancel', async () => { let releaseGate!: () => void const gate = new Promise((resolve) => { releaseGate = resolve }) - const fixture = await harness({ bootstrapGate: gate }) - const first = fixture.manager.ensureInstalled('host-1') - const second = fixture.manager.ensureInstalled('host-1', { - force: true - }) - await vi.waitFor(() => - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - ) - releaseGate() - const [left, right] = await Promise.all([first, second]) - expect(left).toEqual(right) - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - }) - - it('reuses a successful installation for the same Host identity', async () => { - const fixture = await harness() - const first = await fixture.manager.ensureInstalled('host-1') - const phases: string[] = [] - const second = await fixture.manager.ensureInstalled('host-1', { - onProgress: (phase) => phases.push(phase) - }) - - expect(second).toEqual(first) - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - expect(phases).toEqual(['inspecting-host', 'complete']) - }) - - it('force bypasses the settled installation cache and refreshes it after verification', async () => { - const fixture = await harness() - const first = await fixture.manager.ensureInstalled('host-1') - - const refreshed = await fixture.manager.ensureInstalled( - 'host-1', - { force: true } - ) - const cached = await fixture.manager.ensureInstalled('host-1') - - expect(refreshed).toEqual(first) - expect(cached).toEqual(refreshed) - expect(fixture.sshPool.acquire).toHaveBeenCalledTimes(2) - expect(fixture.lifecycleActions).toEqual([ - 'bootstrap', - 'health', - 'bootstrap', - 'health' - ]) - }) - - it('keeps a shared install running when the first waiter cancels', async () => { - let releaseGate!: () => void - const gate = new Promise((resolve) => { - releaseGate = resolve - }) - const fixture = await harness({ bootstrapGate: gate }) + const fixture = await metadataOnlyHarness({ bootstrapGate: gate }) const controller = new AbortController() - const canceledPhases: string[] = [] - const first = fixture.manager.ensureInstalled('host-1', { - signal: controller.signal, - onProgress: (phase) => canceledPhases.push(phase) + const canceled = fixture.manager.activateInstalled('host-1', { + signal: controller.signal }) - const survivor = fixture.manager.ensureInstalled('host-1') + const survivor = fixture.manager.activateInstalled('host-1') await vi.waitFor(() => - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(fixture.resolver.resolve).toHaveBeenCalledTimes(2) ) controller.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + await expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) releaseGate() - await expect(survivor).resolves.toMatchObject({ - installationId: fixture.installationId + installationId: fixture.currentInstallationId }) - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - expect(canceledPhases).toEqual(['inspecting-host']) + expect(fixture.lifecycleActions).toEqual(['health']) expect(fixture.release).toHaveBeenCalledOnce() }) - it('aborts a shared install after its last waiter cancels', async () => { - const gate = new Promise(() => undefined) - const fixture = await harness({ bootstrapGate: gate }) + it('cancels installed activation when its final waiter leaves', async () => { + const fixture = await metadataOnlyHarness({ + bootstrapGate: new Promise(() => undefined) + }) const controller = new AbortController() - const installation = fixture.manager.ensureInstalled('host-1', { + const activation = fixture.manager.activateInstalled('host-1', { signal: controller.signal }) await vi.waitFor(() => - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(fixture.resolver.resolve).toHaveBeenCalledOnce() ) controller.abort() - - await expect(installation).rejects.toMatchObject({ - name: 'AbortError' - }) - await vi.waitFor(() => - expect(fixture.release).toHaveBeenCalledOnce() - ) + await expect(activation).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(fixture.release).toHaveBeenCalledOnce()) }) - it('dispose aborts active installs, cleans up, and rejects new calls', async () => { - const sftp = new MemorySftp() - let startedNodeWrite!: () => void - const nodeWriteStarted = new Promise((resolve) => { - startedNodeWrite = resolve + it('dispose aborts active activation and rejects future calls', async () => { + const fixture = await metadataOnlyHarness({ + bootstrapGate: new Promise(() => undefined) }) - const originalWrite = sftp.writeFile.bind(sftp) - sftp.writeFile = vi.fn(async ( - path: string, - contents: Buffer, - signal?: AbortSignal - ) => { - await originalWrite(path, contents) - if (path.endsWith('/node')) { - startedNodeWrite() - await waitForGate( - new Promise(() => undefined), - signal - ) - } - }) - const fixture = await harness({ sftp }) - const installation = fixture.manager.ensureInstalled('host-1') - await nodeWriteStarted + const activation = fixture.manager.activateInstalled('host-1') + await vi.waitFor(() => + expect(fixture.resolver.resolve).toHaveBeenCalledOnce() + ) - const firstDispose = fixture.manager.dispose() - const secondDispose = fixture.manager.dispose() - expect(secondDispose).toBe(firstDispose) - await expect(installation).rejects.toMatchObject({ - name: 'AbortError' - }) - await firstDispose - - expect(fixture.release).toHaveBeenCalledOnce() - expect(fixture.sftp.closed).toBe(true) - expect( - [...sftp.entries.keys()].some((path) => - path.includes('/staging/op-') - ) - ).toBe(false) + const first = fixture.manager.dispose() + expect(fixture.manager.dispose()).toBe(first) + await expect(activation).rejects.toMatchObject({ name: 'AbortError' }) + await first await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toThrow('Agent installation manager is disposed') - expect(fixture.resolver.resolve).toHaveBeenCalledOnce() - }) - - it('cleans tracked staging and releases the lease after abort', async () => { - const controller = new AbortController() - const sftp = new MemorySftp() - sftp.abortController = controller - const fixture = await harness({ sftp }) - const originalWrite = sftp.writeFile.bind(sftp) - sftp.writeFile = vi.fn(async (path: string, contents: Buffer) => { - await originalWrite(path, contents) - if (path.endsWith('/node')) { - controller.abort() - throw controller.signal.reason - } - }) - - await expect( - fixture.manager.ensureInstalled('host-1', { - signal: controller.signal - }) - ).rejects.toMatchObject({ name: 'AbortError' }) - await vi.waitFor(() => - expect(fixture.release).toHaveBeenCalledOnce() - ) - expect( - [...sftp.entries.keys()].some((path) => - path.includes('/staging/op-') - ) - ).toBe(false) - }) - - it('stops before activation when the host identity changes', async () => { - const fixture = await harness({ - resolverTargets: [target(1), target(2)] - }) - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ - reason: 'host-identity-changed' - }) - expect(fixture.lifecycleActions).toEqual([]) - }) - - it('reports an existing bad destination as corrupt without replacing it', async () => { - const fixture = await harness() - const destination = - `.goodbuddy/agent/installations/${fixture.installationId}` - fixture.sftp.add(destination, { - type: 'directory', - mode: 0o700, - uid - }) - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ - reason: 'corrupt' - }) - expect(fixture.sftp.entries.has(`${home}/${destination}`)).toBe(true) - expect( - fixture.sftp.operations.some((entry) => - entry.startsWith('rename:.goodbuddy/agent/staging/') - ) - ).toBe(false) - }) - - it('leaves an old installation untouched when candidate bootstrap fails', async () => { - const fixture = await harness({ lifecycleFailure: 'bootstrap' }) - fixture.sftp.add( - '.goodbuddy/agent/installations/old-current', - { type: 'directory', mode: 0o700, uid } - ) - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toBeInstanceOf(AgentInstallationError) - expect( - fixture.sftp.entries.has( - `${home}/.goodbuddy/agent/installations/old-current` - ) - ).toBe(true) - expect(fixture.lifecycleActions).toEqual(['bootstrap', 'stop']) - }) - - it('restores legacy registry and release-key bytes after upgrade health failure', async () => { - const oldRegistry = legacyRegistryBytes() - const oldReleaseKeys = Buffer.from( - '{ "legacy": true, "spacing": "preserved" }\n' - ) - const fixture = await harness({ - lifecycleFailure: 'health', - emulateInstallationRegistry: true - }) - populateManagedRegistries( - fixture, - oldRegistry, - oldReleaseKeys - ) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ reason: 'lifecycle' }) - - expect(fixture.lifecycleActions).toEqual([ - 'bootstrap', - 'health', - 'stop' - ]) - expect( - managedBytes(fixture, '.goodbuddy/agent/registry.json') - ).toEqual(oldRegistry) - expect( - managedBytes( - fixture, - '.goodbuddy/agent/release-keys.json' - ) - ).toEqual(oldReleaseKeys) - expect( - fixture.sftp.entries.has( - `${home}/.goodbuddy/agent/installations/old-current` - ) - ).toBe(true) - }) - - it('stops the candidate and restores exact registry bytes after cancellation', async () => { - const oldRegistry = legacyRegistryBytes() - const oldReleaseKeys = Buffer.from('old release key bytes\n') - const fixture = await harness({ - lifecycleCancellation: 'health', - emulateInstallationRegistry: true - }) - populateManagedRegistries( - fixture, - oldRegistry, - oldReleaseKeys - ) - const controller = new AbortController() - const installation = fixture.manager.ensureInstalled('host-1', { - signal: controller.signal - }) - await vi.waitFor(() => - expect(fixture.lifecycleActions).toContain('health') - ) - - controller.abort() - - await expect(installation).rejects.toMatchObject({ - name: 'AbortError' - }) - await vi.waitFor(() => - expect(fixture.release).toHaveBeenCalledOnce() - ) - expect(fixture.lifecycleActions).toContain('stop') - expect( - managedBytes(fixture, '.goodbuddy/agent/registry.json') - ).toEqual(oldRegistry) - expect( - managedBytes( - fixture, - '.goodbuddy/agent/release-keys.json' - ) - ).toEqual(oldReleaseKeys) - }) - - it('keeps only the promoted current registry after successful upgrade health', async () => { - const oldRegistry = legacyRegistryBytes() - const fixture = await harness({ - emulateInstallationRegistry: true - }) - populateManagedRegistries( - fixture, - oldRegistry, - Buffer.from('old release keys\n') - ) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ - installationId: fixture.installationId - }) - - expect(fixture.lifecycleActions).toEqual([ - 'bootstrap', - 'health' - ]) - expect(JSON.parse( - managedBytes( - fixture, - '.goodbuddy/agent/registry.json' - )!.toString('utf8') - )).toEqual({ - formatVersion: 1, - current: registryEntry( - fixture.bundle, - fixture.installationId - ) - }) - expect( - managedBytes( - fixture, - '.goodbuddy/agent/release-keys.json' - ) - ).toEqual(bundledRegistryBytes()) }) }) diff --git a/src/main/remote-agent/agent-installation-manager.ts b/src/main/remote-agent/agent-installation-manager.ts index e9c59e80..2249547b 100644 --- a/src/main/remote-agent/agent-installation-manager.ts +++ b/src/main/remote-agent/agent-installation-manager.ts @@ -1,9 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' -import { lstat, readFile } from 'node:fs/promises' import { type AgentArchitecture, - type AgentBundleManifest, - type AgentReleaseKeyRegistry + type AgentBundleManifest } from '../../shared/agent-installation-contracts' import type { AgentBootstrapIncompatibleReason } from '../../shared/ssh-host-contracts' import type { @@ -20,27 +18,26 @@ import type { SshConnectionPool, SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' +import type { + SshRemotePackageAgentIdentity +} from '../ssh/ssh-remote-package-bootstrap' import { parseInstallationRegistryState } from '../../shared/remote-environment-registry-contracts' -import { - getBundledAgentDirectory, - type BundledAgentResourcePaths -} from './bundled-agent-resources' +import type { BundledAgentResourcePaths } from './bundled-agent-resources' import { assertAgentManifestMatchesRuntimeLock, canonicalAgentReleaseKeyRegistryBytes, - parseAgentReleaseKeyRegistry, parseAgentReleaseKeyRegistryBytes, readAgentReleaseKeyRegistry, readAgentRuntimeLock, verifyAgentManifestSignature, - verifyAgentBundleDirectory, type AgentVerificationEnvironment, type VerifiedAgentBundle } from './agent-bundle-verifier' import { settleBoundedly } from './bounded-settlement' import { isMissingPathError } from './path-errors' +import { remoteHostTargetIdentityKey } from './remote-host-target-identity' const MAXIMUM_INSTALLATION_FILES = 300 const MAXIMUM_INSTALLATION_BYTES = 512 * 1024 * 1024 @@ -56,8 +53,6 @@ const MAXIMUM_METADATA_BYTES = 1024 * 1024 export type AgentInstallationPhase = | 'inspecting-host' | 'verifying-bundle' - | 'preparing-installation' - | 'uploading-bundle' | 'starting-agent' | 'checking-health' | 'complete' @@ -79,12 +74,14 @@ export interface AgentInstallationTargetResolver { resolve(hostId: string): Promise } -export type AgentInstallationRequestOptions = { +export type AgentActivationRequestOptions = { signal?: AbortSignal onProgress?: (phase: AgentInstallationPhase) => void - force?: boolean } +export type PublishedAgentInstallationIdentity = + SshRemotePackageAgentIdentity + export class AgentInstallationError extends Error { constructor( message: string, @@ -120,14 +117,6 @@ type ManagedFileSnapshot = { uid: number } -export type AgentInstallationBundleLoader = ( - architecture: AgentArchitecture -) => Promise<{ - bundle: VerifiedAgentBundle - registry: AgentReleaseKeyRegistry - release?: () => void -}> - export class AgentInstallationManager { readonly #resolver: AgentInstallationTargetResolver readonly #sshPool: Pick @@ -135,9 +124,12 @@ export class AgentInstallationManager { readonly #verificationEnvironment: AgentVerificationEnvironment readonly #sftpLimits?: BoundedSftpLimits readonly #maximumConcurrentHosts: number - readonly #loadBundle?: AgentInstallationBundleLoader readonly #active = new Map() - readonly #installed = new Map() + readonly #current = new Map< + string, + { targetKey: string; identity: AgentInstallationIdentity } + >() + readonly #cacheGenerations = new Map() #closed = false #disposePromise: Promise | undefined @@ -148,8 +140,6 @@ export class AgentInstallationManager { verificationEnvironment?: AgentVerificationEnvironment sftpLimits?: BoundedSftpLimits maximumConcurrentHosts?: number - loadVerifiedBundle?: AgentInstallationBundleLoader - packageBundleLoader?: AgentInstallationBundleLoader }) { this.#resolver = options.resolver this.#sshPool = options.sshPool @@ -159,32 +149,6 @@ export class AgentInstallationManager { this.#sftpLimits = options.sftpLimits this.#maximumConcurrentHosts = options.maximumConcurrentHosts ?? 8 - if ( - options.loadVerifiedBundle && - options.packageBundleLoader - ) { - throw new Error( - 'Agent installation accepts only one bundle loader' - ) - } - this.#loadBundle = - options.packageBundleLoader ?? options.loadVerifiedBundle - if ( - options.loadVerifiedBundle && - this.#verificationEnvironment !== 'test' - ) { - throw new Error( - 'Injected Agent bundles are allowed only in test verification' - ) - } - if ( - this.#verificationEnvironment === 'production' && - options.packageBundleLoader === undefined - ) { - throw new Error( - 'Production Agent installation requires a verified package loader' - ) - } if ( !Number.isSafeInteger(this.#maximumConcurrentHosts) || this.#maximumConcurrentHosts <= 0 || @@ -194,9 +158,49 @@ export class AgentInstallationManager { } } - async ensureInstalled( + /** + * Verifies and starts the exact Agent selected by packaged trust metadata. + * This path never loads an installable Agent bundle or mutates SFTP state. + */ + async activateInstalled( hostId: string, - options: AgentInstallationRequestOptions = {} + options: AgentActivationRequestOptions = {} + ): Promise { + return this.#request(hostId, 'activate', options) + } + + /** + * Adopts an exact Agent directory already published by the authenticated + * package installer. Packaged trust verifies the remote contents before + * canonical metadata and fixed lifecycle actions promote the candidate. + */ + async activatePublished( + hostId: string, + expectedIdentity: PublishedAgentInstallationIdentity, + options: AgentActivationRequestOptions = {} + ): Promise { + assertPublishedAgentIdentity(expectedIdentity) + return this.#request( + hostId, + 'adopt', + options, + expectedIdentity + ) + } + + invalidateHost(hostId: string): void { + this.#current.delete(hostId) + this.#cacheGenerations.set( + hostId, + (this.#cacheGenerations.get(hostId) ?? 0) + 1 + ) + } + + async #request( + hostId: string, + mode: 'activate' | 'adopt', + options: AgentActivationRequestOptions, + expectedIdentity?: PublishedAgentInstallationIdentity ): Promise { this.#throwIfClosed() options.signal?.throwIfAborted() @@ -213,20 +217,29 @@ export class AgentInstallationManager { if (target.host.id !== hostId) { throw new Error('Resolved SSH host does not match the request') } - const operationKey = targetIdentityKey(target) + const targetKey = remoteHostTargetIdentityKey(target) + const cacheGeneration = + this.#cacheGenerations.get(hostId) ?? 0 + if (mode === 'activate') { + const current = this.#current.get(hostId) + if (current?.targetKey === targetKey) { + try { + options.onProgress?.('complete') + } catch { + // Progress observers cannot alter the cached result. + } + return current.identity + } + } + const operationKey = mode === 'adopt' + ? `${mode}:${targetKey}:${publishedAgentIdentityKey( + expectedIdentity! + )}` + : `${mode}:${targetKey}` const existing = this.#active.get(operationKey) if (existing) { return this.#waitForActive(existing, options) } - const installed = this.#installed.get(operationKey) - if (!options.force && installed !== undefined) { - try { - options.onProgress?.('complete') - } catch { - // Progress observers cannot alter the installation outcome. - } - return installed - } if (this.#active.size >= this.#maximumConcurrentHosts) { throw new AgentInstallationError( 'Too many Agent installations are already running', @@ -249,14 +262,27 @@ export class AgentInstallationManager { } } const controller = new AbortController() - const promise = this.#run( - target, - controller.signal, - emitProgress - ).then((identity) => { - this.#installed.set(operationKey, identity) - return identity - }) + const promise = (mode === 'activate' + ? this.#activateInstalled( + target, + controller.signal, + emitProgress + ) + : this.#activatePublished( + target, + expectedIdentity!, + controller.signal, + emitProgress + )).then((identity) => { + if ( + mode === 'activate' && + (this.#cacheGenerations.get(hostId) ?? 0) === + cacheGeneration + ) { + this.#current.set(hostId, { targetKey, identity }) + } + return identity + }) const active = { promise, controller, @@ -279,6 +305,8 @@ export class AgentInstallationManager { return this.#disposePromise } this.#closed = true + this.#current.clear() + this.#cacheGenerations.clear() const operations = [...this.#active.values()] const reason = new DOMException( 'Agent installation manager was disposed', @@ -291,7 +319,6 @@ export class AgentInstallationManager { operations.map((operation) => operation.promise), DISPOSE_WAIT_TIMEOUT_MS ) - this.#installed.clear() return this.#disposePromise } @@ -303,7 +330,7 @@ export class AgentInstallationManager { async #waitForActive( active: ActiveInstallation, - options: AgentInstallationRequestOptions + options: AgentActivationRequestOptions ): Promise { active.waiters += 1 if (options.onProgress) { @@ -334,20 +361,12 @@ export class AgentInstallationManager { } } - async #run( + async #activateInstalled( target: SshConnectionPoolTarget, signal: AbortSignal, progress: (phase: AgentInstallationPhase) => void ): Promise { let lease: SshConnectionLease | undefined - let sftp: StagedSftp | undefined - let cleanupHomeDirectory: string | undefined - const cleanup: CleanupEntry[] = [] - let stagingPublished = false - let activationStarted = false - let installationId: VerifiedAgentInstallationId | undefined - let registrySnapshots: readonly ManagedFileSnapshot[] | undefined - let releaseBundle: (() => void) | undefined try { lease = await this.#sshPool.acquire(target, signal) assertLeaseMatchesTarget(lease, target) @@ -359,50 +378,130 @@ export class AgentInstallationManager { probe.reason ) } - progress('verifying-bundle') + const identity = await this.#reuseInstalledBundle( + target, + lease, + probe, + signal + ) + progress('complete') + return identity + } finally { + lease?.release() + } + } + + async #activatePublished( + target: SshConnectionPoolTarget, + expected: PublishedAgentInstallationIdentity, + signal: AbortSignal, + progress: (phase: AgentInstallationPhase) => void + ): Promise { + let lease: SshConnectionLease | undefined + let sftp: StagedSftp | undefined + let registrySnapshots: readonly ManagedFileSnapshot[] | undefined + const cleanup: CleanupEntry[] = [] + const installationId = verifyAgentInstallationId( + expected.installationId + ) + try { + lease = await this.#sshPool.acquire(target, signal) + assertLeaseMatchesTarget(lease, target) + const probe = await lease.runAgentBootstrapProbe(signal) + if (!probe.ready) { + throw new AgentInstallationError( + `Remote host cannot run the GoodBuddy Agent: ${probe.reason}`, + 'incompatible', + probe.reason + ) + } if ( - this.#loadBundle === undefined && - !(await bundledAgentDirectoryAvailable( - getBundledAgentDirectory( - this.#resourcePaths, - probe.architecture - ) - )) + expected.platform !== 'linux' || + expected.architecture !== probe.architecture ) { - const reused = await this.#reuseInstalledBundle( - target, - lease, - probe, - signal + throw new AgentInstallationError( + 'Published Agent does not match the Host platform or architecture', + 'incompatible' ) - progress('complete') - return reused } - const loaded = - await this.#loadVerifiedBundle(probe.architecture) - const { bundle, registry, registryBytes } = loaded - releaseBundle = loaded.release - assertBundleCapacity(bundle.manifest, registryBytes.byteLength) - const candidateInstallationId = installationIdFor(bundle) - installationId = candidateInstallationId - const identity = publicIdentity( - candidateInstallationId, - bundle - ) - progress('preparing-installation') + progress('verifying-bundle') + const [registry, runtimeLock] = await Promise.all([ + readAgentReleaseKeyRegistry( + this.#resourcePaths.keyRegistryPath + ), + readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath) + ]) + const canonicalRegistryBytes = + canonicalAgentReleaseKeyRegistryBytes(registry) sftp = await lease.openStagedSftp( probe.canonicalHomeDirectory, - installationSftpLimits( - bundle.manifest, - registryBytes.byteLength, - this.#sftpLimits - ), + { + maximumFileBytes: Math.max( + MAXIMUM_AGENT_FILE_BYTES, + this.#sftpLimits?.maximumFileBytes ?? 0 + ), + maximumTotalBytes: Math.max( + MAXIMUM_INSTALLATION_BYTES + + MAXIMUM_METADATA_BYTES * 4, + this.#sftpLimits?.maximumTotalBytes ?? 0 + ), + maximumOperations: + this.#sftpLimits?.maximumOperations ?? 2_048, + operationTimeoutMs: + this.#sftpLimits?.operationTimeoutMs + }, + signal + ) + const destination = + `.goodbuddy/agent/installations/${installationId}` + const manifestBytes = await sftp.readFile( + `${destination}/manifest.json`, + signal + ) + const signatureFile = await sftp.readFile( + `${destination}/manifest.sig`, + signal + ) + const signatureBytes = decodeDetachedSignature( + signatureFile, + 'Agent detached signature' + ) + const manifest = verifyAgentManifestSignature( + manifestBytes, + signatureBytes, + registry, + this.#verificationEnvironment + ) + assertAgentManifestMatchesRuntimeLock( + manifest, + runtimeLock, + probe.architecture + ) + const bundle: VerifiedAgentBundle = { + bundleDirectory: destination, + manifest, + manifestSha256: sha256(manifestBytes) + } + assertPublishedAgentMatches(expected, bundle) + assertBundleCapacity( + manifest, + canonicalRegistryBytes.byteLength + ) + // The fixed SSH installer has already verified the complete published + // tree against this signed manifest. Adoption rechecks its immutable + // identity and metadata without transferring the payload back to Main. + await verifyRemoteInstallationFromMetadata( + sftp, + destination, + bundle, + manifestBytes, + signatureFile, + probe.uid, + false, signal ) - cleanupHomeDirectory = probe.canonicalHomeDirectory - await ensureManagedHierarchy(sftp, probe.uid, signal) registrySnapshots = await snapshotManagedRegistries( sftp, probe.uid, @@ -410,232 +509,72 @@ export class AgentInstallationManager { ) await installReleaseKeyRegistry( sftp, - registryBytes, + canonicalRegistryBytes, probe.uid, cleanup, signal ) - - const destination = - `.goodbuddy/agent/installations/${candidateInstallationId}` - const existingDestination = await pathMetadata(sftp, destination) - if (existingDestination !== undefined) { - await verifyRemoteInstallation( - sftp, - destination, - bundle, - probe.uid, - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw corruptInstallationError( - candidateInstallationId, - error - ) - }) - progress('checking-health') - activationStarted = true - await expectLifecycleSuccess( - lease, - candidateInstallationId, - 'bootstrap', - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw new AgentInstallationError( - 'Existing Agent installation could not be bootstrapped', - 'lifecycle' - ) - }) - await expectLifecycleSuccess( - lease, - candidateInstallationId, - 'health', - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw new AgentInstallationError( - 'Existing Agent installation is not healthy', - 'lifecycle' - ) - }) - progress('complete') - return identity - } - - progress('uploading-bundle') - const staging = - `.goodbuddy/agent/staging/op-${randomUUID()}` - await sftp.mkdir(staging, signal) - cleanup.push({ path: staging, type: 'directory' }) - await assertRemoteMetadata( - sftp, - staging, - 'directory', - probe.uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - const reusedFiles = new Set() - if ( - await tryReuseVerifiedCurrentNode( - sftp, - staging, - bundle, - registry, - this.#verificationEnvironment, - probe.uid, - signal - ) - ) { - reusedFiles.add(bundle.manifest.entrypoint.runtimePath) - cleanup.push({ - path: - `${staging}/${bundle.manifest.entrypoint.runtimePath}`, - type: 'file' - }) - } - await uploadAndVerifyBundle( - sftp, - staging, - bundle, - probe.uid, - cleanup, - reusedFiles, - signal - ) - const currentTarget = await this.#resolver.resolve( target.host.id ) if ( - targetIdentityKey(currentTarget) !== targetIdentityKey(target) + remoteHostTargetIdentityKey(currentTarget) !== + remoteHostTargetIdentityKey(target) ) { throw new AgentInstallationError( - 'SSH host identity changed before Agent activation', + 'SSH host identity changed before Agent adoption', 'host-identity-changed' ) } - signal?.throwIfAborted() - await sftp.rename(staging, destination, signal) - stagingPublished = true progress('starting-agent') - activationStarted = true await expectLifecycleSuccess( lease, - candidateInstallationId, + installationId, 'bootstrap', signal ) progress('checking-health') await expectLifecycleSuccess( lease, - candidateInstallationId, + installationId, 'health', signal ) progress('complete') - return identity + return publicIdentity(installationId, bundle) } catch (error) { - if (sftp && registrySnapshots) { - if (activationStarted && lease && installationId) { - await stopFailedCandidate(lease, installationId) - } + if (lease) { + await stopFailedCandidate(lease, installationId) + } + if (registrySnapshots && sftp) { await restoreManagedRegistries( sftp, registrySnapshots ).catch((rollbackError: unknown) => { throw new AggregateError( [error, rollbackError], - 'Agent installation failed and its previous registry state could not be restored' + 'Published Agent activation failed and its previous metadata state could not be restored' ) }) } - throw error - } finally { - let cleanupIncomplete = false - if (sftp && !stagingPublished) { - cleanupIncomplete = !(await cleanupOwnedStaging(sftp, cleanup)) - } - sftp?.close() if ( - cleanupIncomplete && - lease?.isUsable() && - cleanupHomeDirectory + error instanceof AgentInstallationError || + isAbortError(error) ) { - let cleanupSftp: StagedSftp | undefined - try { - cleanupSftp = await lease.openStagedSftp( - cleanupHomeDirectory, - this.#sftpLimits - ) - await cleanupOwnedStaging(cleanupSftp, cleanup) - } catch { - // The lease may already be unavailable after cancellation. - } finally { - cleanupSftp?.close() - } - } - lease?.release() - releaseBundle?.() - } - } - - async #loadVerifiedBundle( - architecture: AgentArchitecture - ): Promise<{ - bundle: VerifiedAgentBundle - registry: AgentReleaseKeyRegistry - registryBytes: Buffer - release?: () => void - }> { - if (this.#loadBundle) { - const loaded = await this.#loadBundle(architecture) - try { - if (loaded.bundle.manifest.arch !== architecture) { - throw new Error( - 'Verified Agent bundle architecture does not match the host' - ) - } - const canonicalRegistry = - parseAgentReleaseKeyRegistry(loaded.registry) - return { - bundle: loaded.bundle, - registry: canonicalRegistry, - registryBytes: - canonicalAgentReleaseKeyRegistryBytes(canonicalRegistry), - ...(loaded.release ? { release: loaded.release } : {}) - } - } catch (error) { - loaded.release?.() throw error } - } - const [registry, runtimeLock] = await Promise.all([ - readAgentReleaseKeyRegistry( - this.#resourcePaths.keyRegistryPath - ), - readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath) - ]) - const bundle = await verifyAgentBundleDirectory( - getBundledAgentDirectory(this.#resourcePaths, architecture), - { - architecture, - registry, - runtimeLock, - verificationEnvironment: this.#verificationEnvironment + throw new AgentInstallationError( + 'The published Host Agent could not be verified', + 'corrupt', + error + ) + } finally { + if (sftp) { + await cleanupOwnedStaging(sftp, cleanup) } - ) - const canonicalRegistry = parseAgentReleaseKeyRegistry( - registry - ) - return { - bundle, - registry: canonicalRegistry, - registryBytes: - canonicalAgentReleaseKeyRegistryBytes(canonicalRegistry) + sftp?.close() + lease?.release() } } @@ -782,15 +721,15 @@ export class AgentInstallationManager { manifestBytes, signatureFile, probe.uid, - 'critical-contents', + true, signal ) const currentTarget = await this.#resolver.resolve( target.host.id ) if ( - targetIdentityKey(currentTarget) !== - targetIdentityKey(target) + remoteHostTargetIdentityKey(currentTarget) !== + remoteHostTargetIdentityKey(target) ) { throw new AgentInstallationError( 'SSH host identity changed before Agent activation', @@ -826,19 +765,6 @@ export class AgentInstallationManager { } } -async function bundledAgentDirectoryAvailable( - directory: string -): Promise { - try { - return (await lstat(directory)).isDirectory() - } catch (error) { - if (isMissingPathError(error)) { - return false - } - throw error - } -} - function parseJsonBytes( value: Buffer, description: string @@ -867,29 +793,6 @@ function decodeDetachedSignature( return signature } -function targetIdentityKey(target: SshConnectionPoolTarget): string { - const hostKey = target.host.hostKey - return createHash('sha256') - .update(JSON.stringify([ - 'goodbuddy-agent-installation-target-v1', - target.host.id, - target.host.name, - target.host.hostname, - target.host.port, - target.host.username, - target.host.authentication, - target.host.password ?? null, - target.hostRevision, - target.hostKeyGeneration, - hostKey?.algorithm ?? null, - hostKey?.publicKeyBase64 ?? null, - hostKey?.fingerprintSha256 ?? null, - hostKey?.acceptedAt ?? null, - hostKey?.generation ?? null - ])) - .digest('hex') -} - function assertLeaseMatchesTarget( lease: SshConnectionLease, target: SshConnectionPoolTarget @@ -906,14 +809,6 @@ function assertLeaseMatchesTarget( } } -function installationIdFor( - bundle: VerifiedAgentBundle -): VerifiedAgentInstallationId { - return verifyAgentInstallationId( - `agent-${bundle.manifestSha256}` - ) -} - function publicIdentity( installationId: VerifiedAgentInstallationId, bundle: VerifiedAgentBundle @@ -929,6 +824,66 @@ function publicIdentity( } } +function assertPublishedAgentIdentity( + identity: PublishedAgentInstallationIdentity +): void { + const installationId = verifyAgentInstallationId( + identity.installationId + ) + if ( + !/^[a-f0-9]{64}$/u.test(identity.manifestSha256) || + installationId !== `agent-${identity.manifestSha256}` || + identity.binaryDigest !== + `sha256:${identity.manifestSha256}` || + identity.agentVersion.length < 1 || + identity.platform !== 'linux' || + (identity.architecture !== 'x64' && + identity.architecture !== 'arm64') || + !Number.isSafeInteger(identity.protocol.major) || + identity.protocol.major < 0 || + !Number.isSafeInteger(identity.protocol.minor) || + identity.protocol.minor < 0 || + identity.supervisor !== 'detached-on-demand' + ) { + throw new AgentInstallationError( + 'Published Agent identity is invalid', + 'corrupt' + ) + } +} + +function assertPublishedAgentMatches( + expected: PublishedAgentInstallationIdentity, + bundle: VerifiedAgentBundle +): void { + if ( + expected.installationId !== + `agent-${bundle.manifestSha256}` || + expected.agentVersion !== bundle.manifest.agentVersion || + expected.manifestSha256 !== bundle.manifestSha256 || + expected.binaryDigest !== + `sha256:${bundle.manifestSha256}` || + expected.platform !== bundle.manifest.platform || + expected.architecture !== bundle.manifest.arch || + expected.protocol.major !== bundle.manifest.protocol.major || + expected.protocol.minor !== bundle.manifest.protocol.minor || + expected.supervisor !== 'detached-on-demand' + ) { + throw new AgentInstallationError( + 'Published Agent does not match the authenticated package result', + 'corrupt' + ) + } +} + +function publishedAgentIdentityKey( + identity: PublishedAgentInstallationIdentity +): string { + return createHash('sha256') + .update(JSON.stringify(identity)) + .digest('hex') +} + function assertBundleCapacity( manifest: AgentBundleManifest, registryBytes: number @@ -982,45 +937,6 @@ function installationSftpLimits( } } -async function ensureManagedHierarchy( - sftp: StagedSftp, - uid: number, - signal?: AbortSignal -): Promise { - for (const path of [ - '.goodbuddy', - '.goodbuddy/agent', - '.goodbuddy/agent/staging', - '.goodbuddy/agent/installations' - ]) { - const existing = await pathMetadata(sftp, path, signal) - if (existing === undefined) { - await sftp.mkdir(path, signal) - } else { - assertMetadata( - existing, - path, - 'directory', - uid, - undefined, - undefined - ) - if (existing.mode !== PRIVATE_DIRECTORY_MODE) { - await sftp.chmod(path, PRIVATE_DIRECTORY_MODE, signal) - } - } - await assertRemoteMetadata( - sftp, - path, - 'directory', - uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - } -} - async function installReleaseKeyRegistry( sftp: StagedSftp, contents: Buffer, @@ -1182,271 +1098,6 @@ async function stopFailedCandidate( } } -async function uploadAndVerifyBundle( - sftp: StagedSftp, - staging: string, - bundle: VerifiedAgentBundle, - uid: number, - cleanup: CleanupEntry[], - reusedFiles: ReadonlySet, - signal?: AbortSignal -): Promise { - const createdDirectories = new Set() - for (const file of bundle.manifest.files) { - const parts = file.path.split('/') - let relativeDirectory = '' - for (const part of parts.slice(0, -1)) { - relativeDirectory = relativeDirectory - ? `${relativeDirectory}/${part}` - : part - if (!createdDirectories.has(relativeDirectory)) { - const directory = `${staging}/${relativeDirectory}` - await sftp.mkdir(directory, signal) - cleanup.push({ path: directory, type: 'directory' }) - createdDirectories.add(relativeDirectory) - await assertRemoteMetadata( - sftp, - directory, - 'directory', - uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - } - } - if (reusedFiles.has(file.path)) { - continue - } - const contents = await readFile( - `${bundle.bundleDirectory}/${file.path}` - ) - if ( - contents.byteLength !== file.size || - sha256(contents) !== file.sha256 - ) { - throw new Error( - `Agent bundle changed after local verification: ${file.path}` - ) - } - const destination = `${staging}/${file.path}` - cleanup.push({ path: destination, type: 'file' }) - await sftp.writeFile(destination, contents, signal) - await sftp.chmod( - destination, - file.mode === '0755' ? 0o755 : 0o644, - signal - ) - await verifyRemoteFile( - sftp, - destination, - file.size, - file.sha256, - uid, - file.mode === '0755' ? 0o755 : 0o644, - signal - ) - } - - for (const metadataName of ['manifest.json', 'manifest.sig']) { - const contents = await readFile( - `${bundle.bundleDirectory}/${metadataName}` - ) - if ( - metadataName === 'manifest.json' && - sha256(contents) !== bundle.manifestSha256 - ) { - throw new Error('Agent manifest changed after local verification') - } - const destination = `${staging}/${metadataName}` - cleanup.push({ path: destination, type: 'file' }) - await sftp.writeFile(destination, contents, signal) - await sftp.chmod(destination, METADATA_MODE, signal) - await verifyRemoteFile( - sftp, - destination, - contents.byteLength, - sha256(contents), - uid, - METADATA_MODE, - signal - ) - } -} - -async function tryReuseVerifiedCurrentNode( - sftp: StagedSftp, - staging: string, - candidate: VerifiedAgentBundle, - registry: AgentReleaseKeyRegistry, - verificationEnvironment: AgentVerificationEnvironment, - uid: number, - signal?: AbortSignal -): Promise { - const runtimePath = candidate.manifest.entrypoint.runtimePath - const candidateNode = candidate.manifest.files.find( - (file) => file.path === runtimePath - ) - if ( - candidateNode === undefined || - sftp.hardLink === undefined - ) { - return false - } - const destination = `${staging}/${runtimePath}` - try { - const registryBytes = await sftp.readFile( - AGENT_REGISTRY_PATH, - signal - ) - const current = parseInstallationRegistryState( - parseJsonBytes(registryBytes, 'Installed Agent registry') - ).current - if ( - current === undefined || - current.arch !== candidate.manifest.arch || - current.installationId !== - `agent-${current.manifestSha256}` - ) { - return false - } - const installationId = verifyAgentInstallationId( - current.installationId - ) - const root = - `.goodbuddy/agent/installations/${installationId}` - await assertRemoteMetadata( - sftp, - root, - 'directory', - uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - const manifestPath = `${root}/manifest.json` - const signaturePath = `${root}/manifest.sig` - const manifestBytes = await sftp.readFile( - manifestPath, - signal - ) - const signatureFile = await sftp.readFile( - signaturePath, - signal - ) - const manifest = verifyAgentManifestSignature( - manifestBytes, - decodeDetachedSignature( - signatureFile, - 'Agent detached signature' - ), - registry, - verificationEnvironment - ) - if ( - sha256(manifestBytes) !== current.manifestSha256 || - manifest.agentVersion !== current.agentVersion || - manifest.arch !== current.arch - ) { - return false - } - await verifyRemoteFile( - sftp, - manifestPath, - manifestBytes.byteLength, - sha256(manifestBytes), - uid, - METADATA_MODE, - signal - ) - await verifyRemoteFile( - sftp, - signaturePath, - signatureFile.byteLength, - sha256(signatureFile), - uid, - METADATA_MODE, - signal - ) - const currentNode = manifest.files.find( - (file) => file.path === manifest.entrypoint.runtimePath - ) - if ( - currentNode === undefined || - currentNode.path !== candidateNode.path || - currentNode.size !== candidateNode.size || - currentNode.sha256 !== candidateNode.sha256 || - currentNode.mode !== candidateNode.mode - ) { - return false - } - const source = `${root}/${currentNode.path}` - const mode = currentNode.mode === '0755' ? 0o755 : 0o644 - await verifyRemoteFile( - sftp, - source, - currentNode.size, - currentNode.sha256, - uid, - mode, - signal - ) - await sftp.hardLink(source, destination, signal) - await assertRemoteMetadata( - sftp, - destination, - 'file', - uid, - mode, - candidateNode.size, - signal - ) - return true - } catch (error) { - rethrowAbort(error) - try { - const linked = await pathMetadata(sftp, destination, signal) - if (linked !== undefined) { - assertMetadata( - linked, - destination, - 'file', - uid, - undefined, - undefined - ) - await sftp.unlink(destination, signal) - } - } catch (cleanupError) { - rethrowAbort(cleanupError) - } - return false - } -} - -async function verifyRemoteInstallation( - sftp: StagedSftp, - destination: string, - bundle: VerifiedAgentBundle, - uid: number, - signal?: AbortSignal -): Promise { - const [manifestBytes, signatureBytes] = await Promise.all([ - readFile(`${bundle.bundleDirectory}/manifest.json`), - readFile(`${bundle.bundleDirectory}/manifest.sig`) - ]) - await verifyRemoteInstallationFromMetadata( - sftp, - destination, - bundle, - manifestBytes, - signatureBytes, - uid, - 'contents', - signal - ) -} - async function verifyRemoteInstallationFromMetadata( sftp: StagedSftp, destination: string, @@ -1454,7 +1105,7 @@ async function verifyRemoteInstallationFromMetadata( manifestBytes: Buffer, signatureBytes: Buffer, uid: number, - verification: 'contents' | 'critical-contents', + verifyCriticalContents: boolean, signal?: AbortSignal ): Promise { await assertRemoteMetadata( @@ -1488,7 +1139,7 @@ async function verifyRemoteInstallationFromMetadata( const path = `${destination}/${file.path}` const mode = file.mode === '0755' ? 0o755 : 0o644 if ( - verification === 'contents' || + verifyCriticalContents && file.path !== bundle.manifest.entrypoint.runtimePath ) { await verifyRemoteFile( @@ -1516,15 +1167,28 @@ async function verifyRemoteInstallationFromMetadata( ['manifest.json', manifestBytes], ['manifest.sig', signatureBytes] ] as const) { - await verifyRemoteFile( - sftp, - `${destination}/${metadataName}`, - contents.byteLength, - sha256(contents), - uid, - METADATA_MODE, - signal - ) + const path = `${destination}/${metadataName}` + if (!verifyCriticalContents) { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + METADATA_MODE, + contents.byteLength, + signal + ) + } else { + await verifyRemoteFile( + sftp, + path, + contents.byteLength, + sha256(contents), + uid, + METADATA_MODE, + signal + ) + } } } @@ -1681,17 +1345,6 @@ async function ensureCurrentAgentHealthy( ) } -function corruptInstallationError( - installationId: VerifiedAgentInstallationId, - cause: unknown -): AgentInstallationError { - return new AgentInstallationError( - `Existing Agent installation is corrupt: ${installationId}`, - 'corrupt', - cause - ) -} - async function cleanupOwnedStaging( sftp: StagedSftp, entries: readonly CleanupEntry[] @@ -1748,8 +1401,8 @@ function waitForOperation( }) } -function rethrowAbort(error: unknown): void { - if ( +function isAbortError(error: unknown): boolean { + return ( ( error instanceof DOMException && error.name === 'AbortError' @@ -1760,9 +1413,7 @@ function rethrowAbort(error: unknown): void { 'name' in error && (error as { name?: unknown }).name === 'AbortError' ) - ) { - throw error - } + ) } function sha256(contents: Uint8Array): string { diff --git a/src/main/remote-agent/agent-package-manager.test.ts b/src/main/remote-agent/agent-package-manager.test.ts new file mode 100644 index 00000000..63156adf --- /dev/null +++ b/src/main/remote-agent/agent-package-manager.test.ts @@ -0,0 +1,756 @@ +import { + createHash, + generateKeyPairSync, + sign +} from 'node:crypto' +import { + mkdir, + lstat, + mkdtemp, + readFile, + rm, + writeFile +} from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentPackageCatalogSchema, + type AgentPackageCatalog +} from '../../shared/agent-package-contracts' +import { + AgentPackageManager, + type VerifiedRemoteAgentInstallCandidate +} from './agent-package-manager' + +vi.mock('./agent-package-verifier', async (importOriginal) => { + const actual = await importOriginal< + typeof import('./agent-package-verifier') + >() + const node = Buffer.from('verified-node') + const verified = ( + rootDirectory: string, + version = '2.0.0' + ) => ({ + rootDirectory, + descriptor: { + format: 'goodbuddy-agent-package', + formatVersion: 1, + product: 'GoodBuddy', + component: 'agent', + version, + minimumDesktopVersion: '1.0.0', + platform: 'linux', + architecture: 'x64', + agentProtocol: { major: 2, minor: 0 }, + remoteRuntime: { + runtimeId: 'opencode', + provider: 'opencode', + version: '1.2.3', + bundleDigest: `sha256:${'a'.repeat(64)}`, + protocol: { major: 1, minor: 0 } + }, + contentDigest: `sha256:${'b'.repeat(64)}`, + signingKeyId: 'production-test', + files: [{ + path: 'agent/node', + size: node.byteLength, + sha256: createHash('sha256').update(node).digest('hex'), + mode: '0755' + }] + }, + agentBundle: {}, + runtimeBundle: {}, + runtimeMetadata: {} + }) + return { + ...actual, + extractAndVerifyAgentPackage: vi.fn( + async (options: { + archivePath: string + destinationDirectory: string + }) => { + const { readFile: readFixture } = + await import('node:fs/promises') + const archive = await readFixture(options.archivePath) + const version = archive.toString('utf8') === '2.1.0' + ? '2.1.0' + : '2.0.0' + await mkdir(join(options.destinationDirectory, 'agent'), { + recursive: true + }) + await writeFile( + join(options.destinationDirectory, 'agent', 'node'), + node + ) + await writeFile( + join(options.destinationDirectory, '.mock-version'), + version + ) + return verified( + options.destinationDirectory, + version + ) as never + } + ), + verifyExtractedAgentPackage: vi.fn( + async (options: { rootDirectory: string }) => { + const { readFile: readFixture } = + await import('node:fs/promises') + const version = ( + await readFixture( + join(options.rootDirectory, '.mock-version'), + 'utf8' + ) + ).trim() + return verified(options.rootDirectory, version) as never + } + ) + } +}) + +const MIRROR_ROOT = + 'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/' +const GITHUB_ROOT = + 'https://github.com/mesalogo/goodbuddy/releases/download/' +const SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Agent Package Catalog Signature v1\0', + 'utf8' +) +const SHA256 = 'a'.repeat(64) +const KEY_ID = 'production-test' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +async function createFixture( + source: 'mirror' | 'github', + mutateCatalog?: (catalog: AgentPackageCatalog) => void, + archiveBytes?: Buffer +) { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-agent-catalog-')) + temporaryDirectories.push(root) + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const keyRegistryPath = join(root, 'registry.json') + await writeFile(keyRegistryPath, JSON.stringify({ + formatVersion: 1, + keys: [{ + keyId: KEY_ID, + publicKeySpkiBase64: publicKey.export({ + format: 'der', + type: 'spki' + }).toString('base64'), + environment: 'production' + }], + revocations: [] + })) + + const archive = 'goodbuddy-agent-2.0.0-linux-x64.gbagent' + const catalog = agentPackageCatalogSchema.parse({ + formatVersion: 1, + product: 'GoodBuddy', + component: 'agent', + signingKeyId: KEY_ID, + generatedAt: '2026-01-01T00:00:00.000Z', + entries: [{ + format: 'goodbuddy-agent-package', + formatVersion: 1, + product: 'GoodBuddy', + component: 'agent', + version: '2.0.0', + minimumDesktopVersion: '1.0.0', + platform: 'linux', + architecture: 'x64', + agentProtocol: { major: 2, minor: 0 }, + remoteRuntime: { + runtimeId: 'opencode', + provider: 'opencode', + version: '1.2.3', + bundleDigest: `sha256:${SHA256}`, + protocol: { major: 1, minor: 0 } + }, + archive, + size: archiveBytes?.byteLength ?? 123, + sha256: archiveBytes + ? createHash('sha256').update(archiveBytes).digest('hex') + : SHA256, + downloads: { + github: { + url: `${GITHUB_ROOT}agent-v2.0.0/${archive}` + }, + mirror: { + url: `${MIRROR_ROOT}v2.0.0/${archive}` + } + } + }] + }) + mutateCatalog?.(catalog) + const catalogBytes = Buffer.from( + `${JSON.stringify(catalog, null, 2)}\n` + ) + const signature = sign( + null, + Buffer.concat([SIGNATURE_DOMAIN, catalogBytes]), + privateKey + ).toString('base64') + const pointer = Buffer.from(`${JSON.stringify({ + formatVersion: 1, + version: '2.0.0', + catalog: 'v2.0.0/agent-catalog.json', + signature: 'v2.0.0/agent-catalog.sig' + }, null, 2)}\n`) + const releases = [{ + tag_name: 'agent-v2.0.0', + draft: false, + prerelease: false, + assets: [ + { + name: 'agent-catalog.json', + browser_download_url: + `${GITHUB_ROOT}agent-v2.0.0/agent-catalog.json` + }, + { + name: 'agent-catalog.sig', + browser_download_url: + `${GITHUB_ROOT}agent-v2.0.0/agent-catalog.sig` + } + ] + }] + + const manager = (transport: typeof fetch) => + new AgentPackageManager({ + userDataPath: root, + desktopVersion: '9.0.0', + keyRegistryPath, + getUpdateSource: async () => source, + fetch: transport + }) + return { + root, + archive, + catalog, + catalogBytes, + signature, + pointer, + releases, + archiveBytes, + manager + } +} + +function response( + body: string | Uint8Array | null, + init: ResponseInit = {} +): Response { + return new Response(body as never, init) +} + +describe('AgentPackageManager remote install candidates', () => { + it('removes interrupted manager-owned staging on startup', async () => { + const fixture = await createFixture('mirror') + const packageRoot = join( + fixture.root, + 'remote-components', + 'agent-packages' + ) + const stage = join( + packageRoot, + '.stage-linux-x64-00000000-0000-4000-8000-000000000001' + ) + const backup = join( + packageRoot, + 'linux-x64', + '.backup-00000000-0000-4000-8000-000000000002' + ) + const unrelated = join(packageRoot, '.keep') + await Promise.all([ + mkdir(stage, { recursive: true }), + mkdir(backup, { recursive: true }), + mkdir(unrelated, { recursive: true }) + ]) + + await fixture.manager(vi.fn()).getInventory() + + await expect(lstat(stage)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(lstat(backup)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(lstat(unrelated)).resolves.toMatchObject({}) + }) + + it('downloads, verifies, publishes, and leases an exact install archive', async () => { + const archiveBytes = Buffer.alloc(3 * 64 * 1024 + 11, 0x5a) + const fixture = await createFixture( + 'mirror', + undefined, + archiveBytes + ) + let archiveRequests = 0 + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + if (url.endsWith(fixture.archive)) { + archiveRequests += 1 + return response(archiveBytes, { + headers: { + 'content-length': String(archiveBytes.byteLength) + } + }) + } + throw new Error(`Unexpected URL ${url}`) + }) + const entry = fixture.catalog.entries[0]! + const expectedCandidate: VerifiedRemoteAgentInstallCandidate = { + source: 'mirror', + platform: 'linux', + architecture: 'x64', + version: entry.version, + minimumDesktopVersion: entry.minimumDesktopVersion, + agentProtocol: { ...entry.agentProtocol }, + remoteRuntime: { + ...entry.remoteRuntime, + protocol: { ...entry.remoteRuntime.protocol } + }, + archive: entry.archive, + size: entry.size, + sha256: entry.sha256, + urls: [entry.downloads.mirror.url] + } + + const manager = fixture.manager(transport) + const lease = await manager.acquireInstallArchive( + 'x64', + expectedCandidate + ) + expect(lease).toMatchObject({ + size: archiveBytes.byteLength, + sha256: entry.sha256, + nodeSize: Buffer.byteLength('verified-node'), + nodeSha256: createHash('sha256') + .update('verified-node') + .digest('hex') + }) + await expect(readFile(lease.path)).resolves.toEqual(archiveBytes) + await expect(readFile(lease.nodePath)).resolves.toEqual( + Buffer.from('verified-node') + ) + expect(archiveRequests).toBe(1) + + const cachedLease = await manager.acquireInstallArchive( + 'x64', + expectedCandidate + ) + expect(cachedLease.path).toBe(lease.path) + expect(archiveRequests).toBe(1) + + const importDirectory = await mkdtemp( + join(tmpdir(), 'goodbuddy-agent-import-') + ) + temporaryDirectories.push(importDirectory) + const replacementArchive = join( + importDirectory, + 'goodbuddy-agent-2.1.0-linux-x64.gbagent' + ) + await writeFile(replacementArchive, '2.1.0') + await manager.importArchive(replacementArchive) + await expect(readFile(lease.path)).resolves.toEqual(archiveBytes) + + cachedLease.release() + cachedLease.release() + await expect(readFile(lease.path)).resolves.toEqual(archiveBytes) + lease.release() + lease.release() + await vi.waitFor(async () => { + await expect(readFile(lease.path)).rejects.toBeDefined() + }) + }) + + it('rejects a stale expected candidate before downloading an archive', async () => { + const archiveBytes = Buffer.from('package') + const fixture = await createFixture( + 'mirror', + undefined, + archiveBytes + ) + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + throw new Error(`Archive must not be requested: ${url}`) + }) + const entry = fixture.catalog.entries[0]! + + await expect( + fixture.manager(transport).acquireInstallArchive( + 'x64', + { + source: 'mirror', + platform: 'linux', + architecture: 'x64', + version: entry.version, + minimumDesktopVersion: entry.minimumDesktopVersion, + agentProtocol: { ...entry.agentProtocol }, + remoteRuntime: { + ...entry.remoteRuntime, + protocol: { ...entry.remoteRuntime.protocol } + }, + archive: entry.archive, + size: entry.size + 1, + sha256: entry.sha256, + urls: [entry.downloads.mirror.url] + } + ) + ).rejects.toThrow('current signed catalog') + expect( + transport.mock.calls.some(([input]) => + input.toString().endsWith(fixture.archive) + ) + ).toBe(false) + }) + + it('leases an imported or cached package without an online catalog', async () => { + const archiveBytes = Buffer.from('offline-package') + const fixture = await createFixture( + 'mirror', + undefined, + archiveBytes + ) + const online = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + if (url.endsWith(fixture.archive)) { + return response(archiveBytes, { + headers: { + 'content-length': String(archiveBytes.byteLength) + } + }) + } + throw new Error(`Unexpected URL ${url}`) + }) + const entry = fixture.catalog.entries[0]! + const installed = await fixture.manager(online) + .acquireInstallArchive('x64', { + source: 'mirror', + platform: 'linux', + architecture: entry.architecture, + version: entry.version, + minimumDesktopVersion: entry.minimumDesktopVersion, + agentProtocol: { ...entry.agentProtocol }, + remoteRuntime: { + ...entry.remoteRuntime, + protocol: { ...entry.remoteRuntime.protocol } + }, + archive: entry.archive, + size: entry.size, + sha256: entry.sha256, + urls: [entry.downloads.mirror.url] + }) + installed.release() + + const offline = vi.fn(async () => { + throw new Error('offline') + }) + const lease = await fixture.manager(offline) + .acquireGoodBuddyInstallArchive('x64') + + expect(offline).not.toHaveBeenCalled() + expect(lease.candidate).toMatchObject({ + architecture: 'x64', + version: entry.version, + sha256: entry.sha256, + urls: [] + }) + await expect(readFile(lease.path)).resolves.toEqual(archiveBytes) + lease.release() + }) + + it('propagates cancellation into an active archive download', async () => { + const archiveBytes = Buffer.from('package') + const fixture = await createFixture( + 'mirror', + undefined, + archiveBytes + ) + const transport = vi.fn(async (input, init) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ) + }) + }) + const controller = new AbortController() + const download = fixture.manager(transport).download( + 'x64', + undefined, + controller.signal + ) + await vi.waitFor(() => + expect( + transport.mock.calls.some(([input]) => + input.toString().endsWith(fixture.archive) + ) + ).toBe(true) + ) + + controller.abort() + await expect(download).rejects.toMatchObject({ + name: 'AbortError' + }) + }) + + it('returns a signed, compatible mirror candidate without a local package', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input, init) => { + const url = input.toString() + if (url === `${MIRROR_ROOT}latest.json`) { + return response(fixture.pointer) + } + if (url === `${MIRROR_ROOT}v2.0.0/agent-catalog.json`) { + return response(fixture.catalogBytes) + } + if (url === `${MIRROR_ROOT}v2.0.0/agent-catalog.sig`) { + return response(fixture.signature) + } + expect(init?.method).toBe('HEAD') + return response(null, { + status: 200, + headers: { 'content-length': '123' } + }) + }) + + const manager = fixture.manager(transport) + await expect( + manager.getRemoteEnvironmentCatalog('x64') + ).resolves.toMatchObject({ + expected: { + agent: { version: '2.0.0' }, + runtimes: [{ + runtimeId: 'opencode', + provider: 'opencode', + version: '1.2.3' + }] + }, + candidate: { + source: 'mirror', + architecture: 'x64', + version: '2.0.0', + minimumDesktopVersion: '1.0.0', + agentProtocol: { major: 2, minor: 0 }, + remoteRuntime: { version: '1.2.3' }, + size: 123, + sha256: SHA256, + urls: [`${MIRROR_ROOT}v2.0.0/${fixture.archive}`] + } + }) + expect( + transport.mock.calls.filter(([input]) => + input.toString().endsWith('/agent-catalog.json') + ) + ).toHaveLength(1) + }) + + it('rejects a catalog whose signature does not match its canonical bytes', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + return response(Buffer.from('not-a-signature')) + }) + + await expect( + fixture.manager(transport).getRemoteInstallCandidate('x64') + ).rejects.toThrow('签名校验失败') + }) + + it('rejects mirror redirects even when they stay on the mirror host', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + return response(null, { + status: 302, + headers: { location: `${url}?redirected=1` } + }) + }) + + await expect( + fixture.manager(transport).getRemoteInstallCandidate('x64') + ).rejects.toThrow('镜像下载地址不允许重定向') + }) + + it('preserves a candidate source probe failure separately from package absence', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + return response(null, { status: 503 }) + }) + + await expect( + fixture.manager(transport).getRemoteEnvironmentCatalog('x64') + ).resolves.toMatchObject({ + candidate: null, + candidateFailure: { + reason: 'probe-failed', + source: 'mirror', + packageSize: 123 + } + }) + }) + + it('records only the canonical GitHub URL and approved asset redirects', async () => { + const fixture = await createFixture('github') + const assetUrl = + `https://release-assets.githubusercontent.com/download/${fixture.archive}` + const transport = vi.fn(async (input, init) => { + const url = input.toString() + if (url.startsWith('https://api.github.com/')) { + return response(JSON.stringify(fixture.releases)) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + if (url.startsWith(GITHUB_ROOT)) { + expect(init?.method).toBe('HEAD') + return response(null, { + status: 302, + headers: { location: assetUrl } + }) + } + if (url === assetUrl) { + return response(null, { + status: 200, + headers: { 'content-length': '123' } + }) + } + throw new Error(`Unexpected URL ${url}`) + }) + + const candidate = await fixture.manager(transport) + .getRemoteInstallCandidate('x64') + expect(candidate.urls).toEqual([ + `${GITHUB_ROOT}agent-v2.0.0/${fixture.archive}`, + assetUrl + ]) + }) + + it('rejects GitHub redirects to unapproved hosts', async () => { + const fixture = await createFixture('github') + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.startsWith('https://api.github.com/')) { + return response(JSON.stringify(fixture.releases)) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + return response(null, { + status: 302, + headers: { location: 'https://example.com/package.gbagent' } + }) + }) + + await expect( + fixture.manager(transport).getRemoteInstallCandidate('x64') + ).rejects.toThrow('不受信任的主机') + }) + + it('returns and probes the highest compatible signed entry', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input, init) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + expect(init?.method).toBe('HEAD') + return response(null, { + status: 200, + headers: { 'content-length': '123' } + }) + }) + + await expect( + fixture.manager(transport).getRemoteInstallCandidate('x64') + ).resolves.toMatchObject({ + source: 'mirror', + architecture: 'x64', + version: '2.0.0', + size: 123, + sha256: SHA256, + urls: [`${MIRROR_ROOT}v2.0.0/${fixture.archive}`] + }) + }) +}) diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts index 7ce4f9f1..e1aa74d2 100644 --- a/src/main/remote-agent/agent-package-manager.ts +++ b/src/main/remote-agent/agent-package-manager.ts @@ -36,7 +36,9 @@ import { type AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts' import { - fetchModelDownloadResponse + fetchModelDownloadResponse, + isDownloadRedirectStatus, + validateSecureDownloadUrl } from '../model-download-transport' import { compareSemanticVersions, @@ -44,16 +46,9 @@ import { verifyExtractedAgentPackage, type VerifiedAgentPackage } from './agent-package-verifier' -import type { - AgentInstallationBundleLoader -} from './agent-installation-manager' import { readAgentReleaseKeyRegistry } from './agent-bundle-verifier' -import type { - RemoteRuntimeInstallationBundleLoader, - RemoteRuntimeInstallationVerificationMetadataLoader -} from './remote-runtime-installation-manager' import { isMissingPathError } from './path-errors' const CATALOG_NAME = 'agent-catalog.json' @@ -71,6 +66,9 @@ const GITHUB_RELEASE_ROOT = const MAXIMUM_CATALOG_BYTES = 1024 * 1024 const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024 const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000 +const REMOTE_CANDIDATE_TIMEOUT_MS = 30_000 +const MAXIMUM_REMOTE_REDIRECTS = 3 +const MAXIMUM_REMOTE_URL_BYTES = 4_096 const GITHUB_REDIRECT_HOSTS = [ 'objects.githubusercontent.com', 'release-assets.githubusercontent.com' @@ -104,6 +102,132 @@ export type AgentPackageManagerOptions = { now?: () => Date } +export type VerifiedRemoteAgentInstallCandidate = { + source: UpdateSource + platform: 'linux' + architecture: AgentArchitecture + version: string + minimumDesktopVersion: string + agentProtocol: { + major: number + minor: number + } + remoteRuntime: { + runtimeId: 'opencode' + provider: 'opencode' + version: string + bundleDigest: string + protocol: { + major: number + minor: number + } + } + archive: string + size: number + sha256: string + /** + * The canonical signed URL followed by each manually verified redirect. + * The remote bootstrap must require this exact sequence. + */ + urls: readonly string[] +} + +export type VerifiedRemoteAgentEnvironmentCatalog = { + expected: { + agent: { version: string } + runtimes: Array<{ + runtimeId: 'opencode' + provider: 'opencode' + version: string + }> + } + candidate: VerifiedRemoteAgentInstallCandidate | null + candidateFailure?: { + reason: 'package-unavailable' | 'probe-failed' + source: UpdateSource | null + packageSize: number | null + } +} + +export type AgentPackageArchiveLease = { + candidate: VerifiedRemoteAgentInstallCandidate + path: string + size: number + sha256: string + nodePath: string + nodeSize: number + nodeSha256: string + release: () => void +} + +export type AcquireInstallArchiveOptions = { + signal?: AbortSignal + onProgress?: (progress: AgentPackageDownloadProgress) => void +} + +function expectedCatalog( + agentVersion: string, + runtimeVersion: string +): VerifiedRemoteAgentEnvironmentCatalog['expected'] { + return { + agent: { version: agentVersion }, + runtimes: [{ + runtimeId: 'opencode', + provider: 'opencode', + version: runtimeVersion + }] + } +} + +function candidateFromCatalogEntry( + entry: AgentPackageCatalogEntry, + source: UpdateSource +): VerifiedRemoteAgentInstallCandidate { + return { + source, + platform: 'linux', + architecture: entry.architecture, + version: entry.version, + minimumDesktopVersion: entry.minimumDesktopVersion, + agentProtocol: { ...entry.agentProtocol }, + remoteRuntime: { + ...entry.remoteRuntime, + protocol: { ...entry.remoteRuntime.protocol } + }, + archive: entry.archive, + size: entry.size, + sha256: entry.sha256, + urls: [] + } +} + +function candidateFromVerifiedRecord( + record: InstalledRecord, + source: UpdateSource, + size: number +): VerifiedRemoteAgentInstallCandidate { + const descriptor = record.verified.descriptor + return { + source, + platform: 'linux', + architecture: descriptor.architecture, + version: descriptor.version, + minimumDesktopVersion: descriptor.minimumDesktopVersion, + agentProtocol: { ...descriptor.agentProtocol }, + remoteRuntime: { + ...descriptor.remoteRuntime, + protocol: { ...descriptor.remoteRuntime.protocol } + }, + archive: agentPackageArchiveName( + descriptor.version, + descriptor.architecture + ), + size, + sha256: record.archiveSha256, + urls: [] + } +} + export class AgentPackageManager { readonly #rootDirectory: string readonly #desktopVersion: string @@ -118,10 +242,11 @@ export class AgentPackageManager { >() readonly #operations = new Map< AgentArchitecture, - Promise + Promise >() readonly #leaseCounts = new Map() readonly #pendingRemovals = new Set() + readonly #startupCleanup: Promise #catalogState?: CatalogState constructor(options: AgentPackageManagerOptions) { @@ -135,35 +260,9 @@ export class AgentPackageManager { this.#getUpdateSource = options.getUpdateSource this.#fetch = options.fetch ?? fetch this.#now = options.now ?? (() => new Date()) + this.#startupCleanup = this.#cleanupInterruptedArtifacts() } - readonly loadAgentBundle: AgentInstallationBundleLoader = - async (architecture) => { - const record = await this.#requireInstalled(architecture) - const release = this.#lease(record) - return { - bundle: record.verified.agentBundle, - registry: - record.verified.runtimeMetadata.releaseKeyRegistry, - release - } - } - - readonly loadRuntimeBundle: RemoteRuntimeInstallationBundleLoader = - async (architecture) => { - const record = await this.#requireInstalled(architecture) - return { - ...record.verified.runtimeBundle, - release: this.#lease(record) - } - } - - readonly loadRuntimeMetadata: - RemoteRuntimeInstallationVerificationMetadataLoader = - async (architecture) => - (await this.#requireInstalled(architecture)) - .verified.runtimeMetadata - async getInventory( options: { refresh?: boolean } = {} ): Promise { @@ -233,34 +332,125 @@ export class AgentPackageManager { }) } - async getExpectedCatalog( - architecture: AgentArchitecture - ): Promise<{ - agent: { version: string } - runtimes: Array<{ - runtimeId: 'opencode' - provider: 'opencode' - version: string - }> - }> { - const descriptor = - (await this.#requireInstalled(architecture)) - .verified.descriptor + async getRemoteEnvironmentCatalog( + architecture: AgentArchitecture, + options: { signal?: AbortSignal } = {} + ): Promise { + options.signal?.throwIfAborted() + let entry: AgentPackageCatalogEntry + let source: UpdateSource | undefined + try { + source = await this.#getUpdateSource() + options.signal?.throwIfAborted() + const catalog = await this.#loadCatalogAndRemember( + source, + options.signal + ) + entry = selectLatestCompatibleEntry( + catalog, + architecture, + this.#desktopVersion + ) + } catch (onlineError) { + options.signal?.throwIfAborted() + const installed = await this.#loadInstalled(architecture) + if (!installed) { + throw onlineError + } + return { + expected: expectedCatalog( + installed.verified.descriptor.version, + installed.verified.descriptor.remoteRuntime.version + ), + candidate: null, + candidateFailure: { + reason: + onlineError instanceof NoCompatibleAgentPackageError + ? 'package-unavailable' + : 'probe-failed', + source: source ?? null, + packageSize: null + } + } + } + const expected = expectedCatalog( + entry.version, + entry.remoteRuntime.version + ) + try { + return { + expected, + candidate: await this.#remoteInstallCandidate( + entry, + source, + options.signal + ) + } + } catch { + options.signal?.throwIfAborted() + return { + expected, + candidate: null, + candidateFailure: { + reason: 'probe-failed', + source, + packageSize: entry.size + } + } + } + } + + /** + * Returns a Main-only, signed and compatibility-checked remote installation + * candidate. No local .gbagent cache is required. + */ + async getRemoteInstallCandidate( + architecture: AgentArchitecture, + options: { signal?: AbortSignal } = {} + ): Promise { + options.signal?.throwIfAborted() + const source = await this.#getUpdateSource() + options.signal?.throwIfAborted() + const catalog = await this.#loadCatalogAndRemember( + source, + options.signal + ) + const entry = selectLatestCompatibleEntry( + catalog, + architecture, + this.#desktopVersion + ) + return this.#remoteInstallCandidate( + entry, + source, + options.signal + ) + } + + async #remoteInstallCandidate( + entry: AgentPackageCatalogEntry, + source: UpdateSource, + signal?: AbortSignal + ): Promise { + assertCanonicalDownload(entry, source) + const urls = await this.#resolveRemoteDownloadUrls( + entry, + source, + signal + ) return { - agent: { version: descriptor.version }, - runtimes: [{ - runtimeId: 'opencode', - provider: 'opencode', - version: descriptor.remoteRuntime.version - }] + ...candidateFromCatalogEntry(entry, source), + urls } } download( architecture: AgentArchitecture, - onProgress?: (progress: AgentPackageDownloadProgress) => void + onProgress?: (progress: AgentPackageDownloadProgress) => void, + signal?: AbortSignal ): Promise { return this.#runExclusive(architecture, async () => { + signal?.throwIfAborted() this.#emit(onProgress, { architecture, phase: 'catalog', @@ -268,75 +458,341 @@ export class AgentPackageManager { totalBytes: null }) const source = await this.#getUpdateSource() - const catalog = await this.#loadCatalogAndRemember(source) + signal?.throwIfAborted() + const catalog = await this.#loadCatalogAndRemember( + source, + signal + ) const entry = selectLatestCompatibleEntry( catalog, architecture, this.#desktopVersion ) - const current = await this.#loadInstalled(architecture) - const comparison = current - ? compareSemanticVersions( - entry.version, - current.verified.descriptor.version - ) - : 1 - if ( - current && - comparison < 0 - ) { - throw new Error( - 'Agent 发布目录不能降级已安装的兼容版本' - ) - } - if ( - current && - comparison === 0 && - current.archiveSha256 === entry.sha256 - ) { - return this.getInventory() - } - const staging = await this.#createStaging(architecture) - const archivePath = join(staging, entry.archive) - try { - await this.#downloadArchive( - entry, - source, - archivePath, - onProgress - ) - this.#emit(onProgress, { - architecture, - phase: 'verifying', - completedBytes: entry.size, - totalBytes: entry.size - }) - const installed = await this.#installArchive( - archivePath, - architecture, - staging - ) + await this.#ensureInstalledCandidate( + architecture, + entry, + source, + onProgress, + signal + ) + return this.getInventory() + }) + } + + async acquireInstallArchive( + architecture: AgentArchitecture, + expectedCandidate: VerifiedRemoteAgentInstallCandidate, + options: AcquireInstallArchiveOptions = {} + ): Promise { + return this.#runExclusive( + architecture, + async () => { + options.signal?.throwIfAborted() + const current = await this.#loadInstalled(architecture) if ( - installed.verified.descriptor.version !== entry.version || - installed.verified.descriptor.remoteRuntime.version !== - entry.remoteRuntime.version + current && + await this.#recordMatchesRemoteCandidate( + current, + expectedCandidate + ) ) { - throw new Error( - 'Downloaded Agent package does not match its catalog entry' + return this.#archiveLease( + current, + expectedCandidate ) } - this.#emit(onProgress, { + const source = await this.#getUpdateSource() + options.signal?.throwIfAborted() + const catalog = await this.#loadCatalogAndRemember( + source, + options.signal + ) + const entry = selectLatestCompatibleEntry( + catalog, architecture, - phase: 'installing', - completedBytes: entry.size, - totalBytes: entry.size - }) - await this.#publishInstalled(installed, architecture) - return this.getInventory({ refresh: true }) - } finally { - await rm(staging, { recursive: true, force: true }) + this.#desktopVersion + ) + assertCandidateMatchesCatalogEntry( + expectedCandidate, + entry, + source + ) + const record = await this.#ensureInstalledCandidate( + architecture, + entry, + source, + options.onProgress, + options.signal + ) + options.signal?.throwIfAborted() + return this.#archiveLease(record, expectedCandidate) } - }) + ) + } + + async acquireGoodBuddyInstallArchive( + architecture: AgentArchitecture, + options: AcquireInstallArchiveOptions = {} + ): Promise { + return this.#runExclusive( + architecture, + async () => { + options.signal?.throwIfAborted() + const source = await this.#getUpdateSource() + options.signal?.throwIfAborted() + let entry: AgentPackageCatalogEntry | undefined + if (this.#catalogState?.state === 'available') { + entry = selectLatestCompatibleEntry( + this.#catalogState.catalog, + architecture, + this.#desktopVersion + ) + } else { + const record = await this.#loadInstalled(architecture) + if (record) { + const status = await lstat(record.archivePath) + return this.#archiveLease( + record, + candidateFromVerifiedRecord( + record, + source, + status.size + ) + ) + } + if (this.#catalogState?.state === 'unavailable') { + throw new Error(this.#catalogState.error) + } + const catalog = await this.#loadCatalogAndRemember( + source, + options.signal + ) + entry = selectLatestCompatibleEntry( + catalog, + architecture, + this.#desktopVersion + ) + } + const candidate = candidateFromCatalogEntry( + entry, + source + ) + const record = await this.#ensureInstalledCandidate( + architecture, + entry, + source, + options.onProgress, + options.signal + ) + return this.#archiveLease(record, candidate) + } + ) + } + + async #archiveLease( + record: InstalledRecord, + candidate: VerifiedRemoteAgentInstallCandidate + ): Promise { + const archiveStatus = await lstat(record.archivePath) + if ( + !archiveStatus.isFile() || + archiveStatus.isSymbolicLink() || + archiveStatus.size !== candidate.size || + record.archiveSha256 !== candidate.sha256 + ) { + throw new Error( + 'Cached Agent archive does not match the signed installation candidate' + ) + } + const node = record.verified.descriptor.files.find( + (file) => file.path === 'agent/node' + ) + if (!node) { + throw new Error( + 'Verified Agent package does not contain agent/node' + ) + } + const release = this.#lease(record) + return { + candidate, + path: record.archivePath, + size: archiveStatus.size, + sha256: record.archiveSha256, + nodePath: join( + record.verified.rootDirectory, + ...node.path.split('/') + ), + nodeSize: node.size, + nodeSha256: node.sha256, + release + } + } + + async #recordMatchesRemoteCandidate( + record: InstalledRecord, + candidate: VerifiedRemoteAgentInstallCandidate + ): Promise { + const descriptor = record.verified.descriptor + if ( + descriptor.architecture === candidate.architecture && + descriptor.version === candidate.version && + descriptor.minimumDesktopVersion === + candidate.minimumDesktopVersion && + descriptor.agentProtocol.major === + candidate.agentProtocol.major && + descriptor.agentProtocol.minor === + candidate.agentProtocol.minor && + descriptor.remoteRuntime.runtimeId === + candidate.remoteRuntime.runtimeId && + descriptor.remoteRuntime.provider === + candidate.remoteRuntime.provider && + descriptor.remoteRuntime.version === + candidate.remoteRuntime.version && + descriptor.remoteRuntime.bundleDigest === + candidate.remoteRuntime.bundleDigest && + descriptor.remoteRuntime.protocol.major === + candidate.remoteRuntime.protocol.major && + descriptor.remoteRuntime.protocol.minor === + candidate.remoteRuntime.protocol.minor && + record.archiveSha256 === candidate.sha256 + ) { + try { + const status = await lstat(record.archivePath) + return ( + status.isFile() && + !status.isSymbolicLink() && + status.size === candidate.size + ) + } catch { + return false + } + } + return false + } + + async #ensureInstalledCandidate( + architecture: AgentArchitecture, + entry: AgentPackageCatalogEntry, + source: UpdateSource, + onProgress?: (progress: AgentPackageDownloadProgress) => void, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + const current = await this.#loadInstalled(architecture) + const comparison = current + ? compareSemanticVersions( + entry.version, + current.verified.descriptor.version + ) + : 1 + if (current && comparison < 0) { + throw new Error( + 'Agent 发布目录不能降级已安装的兼容版本' + ) + } + if ( + current && + comparison === 0 && + await this.#recordMatchesCatalogEntry(current, entry) + ) { + return current + } + + const staging = await this.#createStaging(architecture) + const archivePath = join(staging, entry.archive) + try { + await this.#downloadArchive( + entry, + source, + archivePath, + onProgress, + signal + ) + signal?.throwIfAborted() + this.#emit(onProgress, { + architecture, + phase: 'verifying', + completedBytes: entry.size, + totalBytes: entry.size + }) + const installed = await this.#installArchive( + archivePath, + architecture, + staging, + entry.sha256 + ) + if (!await this.#recordMatchesCatalogEntry( + installed, + entry + )) { + throw new Error( + 'Downloaded Agent package does not match its catalog entry' + ) + } + signal?.throwIfAborted() + this.#emit(onProgress, { + architecture, + phase: 'installing', + completedBytes: entry.size, + totalBytes: entry.size + }) + const published = await this.#publishInstalled( + installed, + architecture, + entry + ) + if (!await this.#recordMatchesCatalogEntry(published, entry)) { + throw new Error( + 'Published Agent package does not match its catalog entry' + ) + } + return published + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + + async #recordMatchesCatalogEntry( + record: InstalledRecord, + entry: AgentPackageCatalogEntry + ): Promise { + const descriptor = record.verified.descriptor + if ( + descriptor.version !== entry.version || + descriptor.minimumDesktopVersion !== + entry.minimumDesktopVersion || + descriptor.platform !== entry.platform || + descriptor.architecture !== entry.architecture || + descriptor.agentProtocol.major !== + entry.agentProtocol.major || + descriptor.agentProtocol.minor !== + entry.agentProtocol.minor || + descriptor.remoteRuntime.runtimeId !== + entry.remoteRuntime.runtimeId || + descriptor.remoteRuntime.provider !== + entry.remoteRuntime.provider || + descriptor.remoteRuntime.version !== + entry.remoteRuntime.version || + descriptor.remoteRuntime.bundleDigest !== + entry.remoteRuntime.bundleDigest || + descriptor.remoteRuntime.protocol.major !== + entry.remoteRuntime.protocol.major || + descriptor.remoteRuntime.protocol.minor !== + entry.remoteRuntime.protocol.minor || + record.archiveSha256 !== entry.sha256 + ) { + return false + } + try { + const status = await lstat(record.archivePath) + return ( + status.isFile() && + !status.isSymbolicLink() && + status.size === entry.size + ) + } catch { + return false + } } async importArchive( @@ -345,7 +801,7 @@ export class AgentPackageManager { const staging = await this.#createStaging('import') try { const copiedArchive = join(staging, basename(archivePath)) - await copyAndHashBoundedRegularFile( + const archiveSha256 = await copyAndHashBoundedRegularFile( resolve(archivePath), copiedArchive, MAXIMUM_PACKAGE_BYTES @@ -353,7 +809,8 @@ export class AgentPackageManager { const installed = await this.#installArchive( copiedArchive, undefined, - staging + staging, + archiveSha256 ) const architecture = installed.verified.descriptor.architecture @@ -361,9 +818,8 @@ export class AgentPackageManager { architecture, async () => { await this.#publishInstalled(installed, architecture) - return this.getInventory({ refresh: true }) - }, - false + return this.getInventory() + } ) } finally { await rm(staging, { recursive: true, force: true }) @@ -461,9 +917,10 @@ export class AgentPackageManager { return installed } - #loadInstalled( + async #loadInstalled( architecture: AgentArchitecture ): Promise { + await this.#startupCleanup let promise = this.#installed.get(architecture) if (!promise) { promise = this.#scanInstalled(architecture) @@ -542,7 +999,8 @@ export class AgentPackageManager { async #installArchive( archivePath: string, architecture: AgentArchitecture | undefined, - staging: string + staging: string, + verifiedArchiveSha256?: string ): Promise { const content = join(staging, 'content') const verified = await extractAndVerifyAgentPackage({ @@ -556,7 +1014,8 @@ export class AgentPackageManager { if (resolve(archivePath) !== resolve(canonicalArchive)) { await rename(archivePath, canonicalArchive) } - const archiveSha256 = await sha256File(canonicalArchive) + const archiveSha256 = + verifiedArchiveSha256 ?? await sha256File(canonicalArchive) await writeFile( join(staging, 'archive.sha256'), `${archiveSha256}\n`, @@ -572,8 +1031,9 @@ export class AgentPackageManager { async #publishInstalled( installed: InstalledRecord, - architecture: AgentArchitecture - ): Promise { + architecture: AgentArchitecture, + catalogEntry?: AgentPackageCatalogEntry + ): Promise { const destination = join( this.#architectureRoot(architecture), installed.verified.descriptor.version @@ -591,11 +1051,20 @@ export class AgentPackageManager { installed.verified.descriptor.contentDigest || current.archiveSha256 !== installed.archiveSha256 ) { - throw new Error( - 'Agent package version identity is immutable' - ) + if ( + !catalogEntry || + !await this.#recordMatchesCatalogEntry( + installed, + catalogEntry + ) + ) { + throw new Error( + 'Agent package version identity is immutable' + ) + } + } else { + return current } - return } const backup = join( this.#architectureRoot(architecture), @@ -624,7 +1093,19 @@ export class AgentPackageManager { } throw error } - this.#installed.delete(architecture) + const published: InstalledRecord = { + ...installed, + directory: destination, + archivePath: join(destination, 'package.gbagent'), + verified: { + ...installed.verified, + rootDirectory: join(destination, 'content') + } + } + this.#installed.set( + architecture, + Promise.resolve(published) + ) if (replacedExisting) { await rm(backup, { recursive: true, force: true }).catch( () => undefined @@ -634,6 +1115,7 @@ export class AgentPackageManager { architecture, installed.verified.descriptor.version ) + return published } async #removeOtherVersions( @@ -661,12 +1143,27 @@ export class AgentPackageManager { entry: AgentPackageCatalogEntry, source: UpdateSource, destination: string, - onProgress?: (progress: AgentPackageDownloadProgress) => void + onProgress?: (progress: AgentPackageDownloadProgress) => void, + signal?: AbortSignal ): Promise { assertCanonicalDownload(entry, source) const controller = new AbortController() + const abortFromCaller = (): void => { + controller.abort(signal?.reason) + } + signal?.addEventListener('abort', abortFromCaller, { + once: true + }) + if (signal?.aborted) { + abortFromCaller() + } const timeout = setTimeout( - () => controller.abort(), + () => controller.abort( + new DOMException( + 'Agent package download timed out', + 'TimeoutError' + ) + ), DOWNLOAD_TIMEOUT_MS ) timeout.unref?.() @@ -699,6 +1196,7 @@ export class AgentPackageManager { const handle = await open(destination, 'wx') try { while (true) { + controller.signal.throwIfAborted() const result = await reader.read() if (result.done) { break @@ -734,17 +1232,21 @@ export class AgentPackageManager { } } finally { clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromCaller) } } async #loadCatalog( - source: UpdateSource + source: UpdateSource, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const registry = await this.#loadTrustedRegistry() + signal?.throwIfAborted() const [catalogBytes, signatureBytes] = source === 'mirror' - ? await this.#fetchMirrorCatalog() - : await this.#fetchGithubCatalog() + ? await this.#fetchMirrorCatalog(signal) + : await this.#fetchGithubCatalog(signal) const catalog = parseCanonicalCatalog(catalogBytes) verifyCatalogSignature( catalog, @@ -760,11 +1262,12 @@ export class AgentPackageManager { } async #loadCatalogAndRemember( - source: UpdateSource + source: UpdateSource, + signal?: AbortSignal ): Promise { const checkedAt = this.#now().toISOString() try { - const catalog = await this.#loadCatalog(source) + const catalog = await this.#loadCatalog(source, signal) this.#catalogState = { state: 'available', checkedAt, @@ -843,23 +1346,39 @@ export class AgentPackageManager { } } - async #fetchMirrorCatalog(): Promise<[Buffer, Buffer]> { + async #fetchMirrorCatalog( + signal?: AbortSignal + ): Promise<[Buffer, Buffer]> { const pointerBytes = await this.#fetchBounded( - `${MIRROR_ROOT}latest.json` + `${MIRROR_ROOT}latest.json`, + [], + signal, + true ) const pointer = parseCanonicalMirrorPointer(pointerBytes) return Promise.all([ this.#fetchBounded( - new URL(pointer.catalog, MIRROR_ROOT).href + new URL(pointer.catalog, MIRROR_ROOT).href, + [], + signal, + true ), this.#fetchBounded( - new URL(pointer.signature, MIRROR_ROOT).href + new URL(pointer.signature, MIRROR_ROOT).href, + [], + signal, + true ) ]) } - async #fetchGithubCatalog(): Promise<[Buffer, Buffer]> { - const releases = await this.#fetchJson(GITHUB_RELEASES_API) + async #fetchGithubCatalog( + signal?: AbortSignal + ): Promise<[Buffer, Buffer]> { + const releases = await this.#fetchJson( + GITHUB_RELEASES_API, + signal + ) if (!Array.isArray(releases)) { throw new Error('GitHub Agent 发布目录无效') } @@ -907,16 +1426,32 @@ export class AgentPackageManager { continue } return Promise.all([ - this.#fetchBounded(catalogUrl, GITHUB_REDIRECT_HOSTS), - this.#fetchBounded(signatureUrl, GITHUB_REDIRECT_HOSTS) + this.#fetchBounded( + catalogUrl, + GITHUB_REDIRECT_HOSTS, + signal + ), + this.#fetchBounded( + signatureUrl, + GITHUB_REDIRECT_HOSTS, + signal + ) ]) } } throw new Error('GitHub 中没有可用的 Agent 发布目录') } - async #fetchJson(url: string): Promise { - const bytes = await this.#fetchBounded(url) + async #fetchJson( + url: string, + signal?: AbortSignal + ): Promise { + const bytes = await this.#fetchBounded( + url, + [], + signal, + true + ) return JSON.parse( new TextDecoder('utf-8', { fatal: true }).decode(bytes) ) as unknown @@ -924,22 +1459,40 @@ export class AgentPackageManager { async #fetchBounded( url: string, - redirectHosts: readonly string[] = [] + redirectHosts: readonly string[] = [], + signal?: AbortSignal, + rejectRedirects = false ): Promise { const controller = new AbortController() + const abortFromCaller = (): void => { + controller.abort(signal?.reason) + } + signal?.addEventListener('abort', abortFromCaller, { + once: true + }) + if (signal?.aborted) { + abortFromCaller() + } const timeout = setTimeout( - () => controller.abort(), + () => controller.abort( + new DOMException( + 'Agent catalog request timed out', + 'TimeoutError' + ) + ), 30_000 ) timeout.unref?.() try { - const response = await fetchModelDownloadResponse({ - transport: this.#fetch, - initialUrl: url, - redirectHosts, - signal: controller.signal, - modelLabel: 'Agent 发布目录' - }) + const response = rejectRedirects + ? await this.#fetchExactResponse(url, controller.signal) + : await fetchModelDownloadResponse({ + transport: this.#fetch, + initialUrl: url, + redirectHosts, + signal: controller.signal, + modelLabel: 'Agent 发布目录' + }) if (!response.ok) { await response.body?.cancel().catch(() => undefined) throw new Error( @@ -970,6 +1523,142 @@ export class AgentPackageManager { return Buffer.concat(chunks, total) } finally { clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromCaller) + } + } + + async #fetchExactResponse( + url: string, + signal: AbortSignal + ): Promise { + validateSecureDownloadUrl(url, 'Agent 发布目录') + const response = await this.#fetch(url, { + method: 'GET', + redirect: 'manual', + credentials: 'omit', + cache: 'no-store', + signal + }) + if (isDownloadRedirectStatus(response.status)) { + await response.body?.cancel().catch(() => undefined) + throw new Error('Agent 发布目录不允许重定向') + } + return response + } + + async #resolveRemoteDownloadUrls( + entry: AgentPackageCatalogEntry, + source: UpdateSource, + signal?: AbortSignal + ): Promise { + const controller = new AbortController() + const abortFromCaller = (): void => { + controller.abort(signal?.reason) + } + signal?.addEventListener('abort', abortFromCaller, { + once: true + }) + if (signal?.aborted) { + abortFromCaller() + } + const timeout = setTimeout( + () => controller.abort( + new DOMException( + 'Agent remote candidate resolution timed out', + 'TimeoutError' + ) + ), + REMOTE_CANDIDATE_TIMEOUT_MS + ) + timeout.unref?.() + const urls: string[] = [] + let current = validateSecureDownloadUrl( + entry.downloads[source].url, + 'Agent 包' + ) + try { + for ( + let redirectCount = 0; + redirectCount <= MAXIMUM_REMOTE_REDIRECTS; + redirectCount += 1 + ) { + controller.signal.throwIfAborted() + const currentUrl = current.href + if ( + Buffer.byteLength(currentUrl, 'utf8') > + MAXIMUM_REMOTE_URL_BYTES + ) { + throw new Error('Agent 包下载地址超出长度限制') + } + urls.push(currentUrl) + let response = await this.#fetch(current, { + method: 'HEAD', + redirect: 'manual', + credentials: 'omit', + cache: 'no-store', + signal: controller.signal + }) + if (response.status === 405 || response.status === 501) { + await response.body?.cancel().catch(() => undefined) + response = await this.#fetch(current, { + method: 'GET', + redirect: 'manual', + credentials: 'omit', + cache: 'no-store', + signal: controller.signal + }) + } + if (!isDownloadRedirectStatus(response.status)) { + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + throw new Error( + `Agent 包下载地址检查失败:HTTP ${response.status}` + ) + } + const declared = response.headers.get('content-length') + await response.body?.cancel().catch(() => undefined) + if ( + declared !== null && + (!/^(?:0|[1-9]\d*)$/u.test(declared) || + Number(declared) !== entry.size) + ) { + throw new Error('Agent 包下载大小与目录不一致') + } + return Object.freeze([...urls]) + } + if ( + source === 'mirror' || + redirectCount === MAXIMUM_REMOTE_REDIRECTS + ) { + await response.body?.cancel().catch(() => undefined) + throw new Error( + source === 'mirror' + ? 'Agent 镜像下载地址不允许重定向' + : 'Agent 包下载重定向次数过多' + ) + } + const location = response.headers.get('location') + await response.body?.cancel().catch(() => undefined) + if (!location) { + throw new Error('Agent 包下载重定向缺少地址') + } + const next = validateSecureDownloadUrl( + new URL(location, current).href, + 'Agent 包' + ) + if ( + !GITHUB_REDIRECT_HOSTS.includes( + next.hostname as (typeof GITHUB_REDIRECT_HOSTS)[number] + ) + ) { + throw new Error('Agent 包下载重定向到不受信任的主机') + } + current = next + } + throw new Error('Agent 包下载重定向次数过多') + } finally { + clearTimeout(timeout) + signal?.removeEventListener('abort', abortFromCaller) } } @@ -991,6 +1680,7 @@ export class AgentPackageManager { async #createStaging( architecture: AgentArchitecture | 'import' ): Promise { + await this.#startupCleanup await mkdir(this.#rootDirectory, { recursive: true }) const path = join( this.#rootDirectory, @@ -1000,6 +1690,52 @@ export class AgentPackageManager { return path } + async #cleanupInterruptedArtifacts(): Promise { + await this.#removeOwnedTemporaryDirectories( + this.#rootDirectory, + /^\.stage-linux-(?:x64|arm64|import)-[0-9a-f-]{36}$/u + ) + await Promise.all( + agentArchitectureSchema.options.map((architecture) => + this.#removeOwnedTemporaryDirectories( + this.#architectureRoot(architecture), + /^\.backup-[0-9a-f-]{36}$/u + ) + ) + ) + } + + async #removeOwnedTemporaryDirectories( + root: string, + pattern: RegExp + ): Promise { + let entries + try { + entries = await readdir(root, { withFileTypes: true }) + } catch (error) { + if (isMissingPathError(error)) { + return + } + throw error + } + await Promise.all( + entries + .filter( + (entry) => + entry.isDirectory() && + !entry.isSymbolicLink() && + pattern.test(entry.name) + ) + .slice(0, 512) + .map((entry) => + rm(join(root, entry.name), { + recursive: true, + force: true + }).catch(() => undefined) + ) + ) + } + #architectureRoot( architecture: AgentArchitecture ): string { @@ -1048,31 +1784,18 @@ export class AgentPackageManager { await rm(directory, { recursive: true, force: true }) } - #runExclusive( + #runExclusive( architecture: AgentArchitecture, - operation: () => Promise, - joinExisting = true - ): Promise { + operation: () => Promise + ): Promise { const existing = this.#operations.get(architecture) if (existing) { - return joinExisting - ? existing - : existing.then( - () => - this.#runExclusive( - architecture, - operation, - false - ), - () => - this.#runExclusive( - architecture, - operation, - false - ) - ) + return existing.then( + () => this.#runExclusive(architecture, operation), + () => this.#runExclusive(architecture, operation) + ) } - const promise = operation().finally(() => { + const promise: Promise = operation().finally(() => { if (this.#operations.get(architecture) === promise) { this.#operations.delete(architecture) } @@ -1117,13 +1840,20 @@ export function selectLatestCompatibleEntry( compareSemanticVersions(right.version, left.version) )[0] if (!entry) { - throw new Error( + throw new NoCompatibleAgentPackageError( `没有与当前 GoodBuddy 兼容的 Linux ${architecture} Agent 包` ) } return entry } +class NoCompatibleAgentPackageError extends Error { + constructor(message: string) { + super(message) + this.name = 'NoCompatibleAgentPackageError' + } +} + function assertCanonicalDownload( entry: AgentPackageCatalogEntry, source: UpdateSource @@ -1143,6 +1873,47 @@ function assertCanonicalDownload( } } +function assertCandidateMatchesCatalogEntry( + candidate: VerifiedRemoteAgentInstallCandidate, + entry: AgentPackageCatalogEntry, + source: UpdateSource +): void { + const canonicalUrl = entry.downloads[source].url + if ( + candidate.source !== source || + candidate.platform !== entry.platform || + candidate.architecture !== entry.architecture || + candidate.version !== entry.version || + candidate.minimumDesktopVersion !== + entry.minimumDesktopVersion || + candidate.agentProtocol.major !== + entry.agentProtocol.major || + candidate.agentProtocol.minor !== + entry.agentProtocol.minor || + candidate.remoteRuntime.runtimeId !== + entry.remoteRuntime.runtimeId || + candidate.remoteRuntime.provider !== + entry.remoteRuntime.provider || + candidate.remoteRuntime.version !== + entry.remoteRuntime.version || + candidate.remoteRuntime.bundleDigest !== + entry.remoteRuntime.bundleDigest || + candidate.remoteRuntime.protocol.major !== + entry.remoteRuntime.protocol.major || + candidate.remoteRuntime.protocol.minor !== + entry.remoteRuntime.protocol.minor || + candidate.archive !== entry.archive || + candidate.size !== entry.size || + candidate.sha256 !== entry.sha256 || + candidate.urls.length < 1 || + candidate.urls[0] !== canonicalUrl + ) { + throw new Error( + 'Agent install candidate does not match the current signed catalog' + ) + } +} + function parseCanonicalCatalog(bytes: Buffer): AgentPackageCatalog { const catalog = agentPackageCatalogSchema.parse( JSON.parse( diff --git a/src/main/remote-agent/agent-package-verifier.ts b/src/main/remote-agent/agent-package-verifier.ts index fb76bbce..c427b378 100644 --- a/src/main/remote-agent/agent-package-verifier.ts +++ b/src/main/remote-agent/agent-package-verifier.ts @@ -45,20 +45,14 @@ import { parseAgentReleaseKeyRegistry, parseAgentReleaseKeyRegistryBytes, verifyAgentBundleDirectory, - type AgentVerificationEnvironment, - type VerifiedAgentBundle + type AgentVerificationEnvironment } from './agent-bundle-verifier' import { - loadVerifiedRemoteRuntimeResourceBundle, - loadRemoteRuntimeVerificationMetadata, - type RemoteRuntimeVerificationMetadata + loadVerifiedRemoteRuntimeResourceBundle } from './remote-runtime-resource-loader' import type { BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources' -import type { - VerifiedRemoteRuntimeInstallationBundle -} from './remote-runtime-installation-manager' const DESCRIPTOR_NAME = 'agent-package.json' const SIGNATURE_NAME = 'agent-package.sig' @@ -76,9 +70,6 @@ const SIGNATURE_DOMAIN = Buffer.from( export type VerifiedAgentPackage = { rootDirectory: string descriptor: AgentPackageDescriptor - agentBundle: VerifiedAgentBundle - runtimeBundle: VerifiedRemoteRuntimeInstallationBundle - runtimeMetadata: RemoteRuntimeVerificationMetadata } export async function extractAndVerifyAgentPackage(options: { @@ -239,8 +230,6 @@ export async function verifyExtractedAgentPackage(options: { options.verificationEnvironment ?? 'production' } ) - const runtimeMetadata = - await loadRemoteRuntimeVerificationMetadata(runtimePaths) if ( runtimeBundle.manifest.runtimeVersion !== descriptor.remoteRuntime.version || @@ -258,9 +247,6 @@ export async function verifyExtractedAgentPackage(options: { return { rootDirectory, descriptor, - agentBundle, - runtimeBundle, - runtimeMetadata } } diff --git a/src/main/remote-agent/bundled-agent-resources.test.ts b/src/main/remote-agent/bundled-agent-resources.test.ts index 25d55d90..aad71c8a 100644 --- a/src/main/remote-agent/bundled-agent-resources.test.ts +++ b/src/main/remote-agent/bundled-agent-resources.test.ts @@ -2,7 +2,8 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getBundledAgentDirectory, - resolveBundledAgentResourcePaths + resolveBundledAgentResourcePaths, + resolveControlPlanePackageInstallerPath } from './bundled-agent-resources' describe('bundled Agent resource paths', () => { @@ -55,6 +56,24 @@ describe('bundled Agent resource paths', () => { expect(paths.runtimeLockPath).toBe( join('installed', 'resources', 'agent-runtime-lock.json') ) + expect( + resolveControlPlanePackageInstallerPath({ + appPath: join( + 'installed', + 'resources', + 'app.asar' + ) + }) + ).toBe( + join( + 'installed', + 'resources', + 'app.asar', + 'out', + 'main', + 'remote-package-installer.mjs' + ) + ) expect(getBundledAgentDirectory(paths, 'x64')).toBe( join('installed', 'resources', 'agents', 'linux-x64') ) diff --git a/src/main/remote-agent/bundled-agent-resources.ts b/src/main/remote-agent/bundled-agent-resources.ts index b3cdc321..ac512238 100644 --- a/src/main/remote-agent/bundled-agent-resources.ts +++ b/src/main/remote-agent/bundled-agent-resources.ts @@ -35,6 +35,17 @@ export function resolveBundledAgentResourcePaths( } } +export function resolveControlPlanePackageInstallerPath( + environment: Pick +): string { + return join( + environment.appPath, + 'out', + 'main', + 'remote-package-installer.mjs' + ) +} + export function getBundledAgentDirectory( paths: BundledAgentResourcePaths, architecture: AgentArchitecture diff --git a/src/main/remote-agent/control-plane-package-installer-cli.ts b/src/main/remote-agent/control-plane-package-installer-cli.ts new file mode 100644 index 00000000..9141a321 --- /dev/null +++ b/src/main/remote-agent/control-plane-package-installer-cli.ts @@ -0,0 +1,3 @@ +import { runPackageInstallerCli } from './control-plane-package-installer' + +process.exitCode = runPackageInstallerCli() diff --git a/src/main/remote-agent/control-plane-package-installer.ts b/src/main/remote-agent/control-plane-package-installer.ts new file mode 100644 index 00000000..82b93157 --- /dev/null +++ b/src/main/remote-agent/control-plane-package-installer.ts @@ -0,0 +1,2508 @@ +import { + createHash, + createPublicKey, + verify +} from 'node:crypto' +import { + chmodSync, + closeSync, + constants, + existsSync, + fstatSync, + fsyncSync, + lstatSync, + linkSync, + mkdirSync, + mkdtempSync, + openSync, + readSync, + readdirSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeSync +} from 'node:fs' +import { homedir } from 'node:os' +import { + basename, + dirname, + join, + relative, + resolve, + sep +} from 'node:path' +import { crc32 } from 'node:zlib' + +const PACKAGE_DESCRIPTOR = 'agent-package.json' +const PACKAGE_SIGNATURE = 'agent-package.sig' +const NODE_PATH = 'agent/node' +const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024 +const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024 +const MAXIMUM_ENTRY_BYTES = 384 * 1024 * 1024 +const MAXIMUM_METADATA_BYTES = 1024 * 1024 +const MAXIMUM_ENTRIES = 50_002 +const MAXIMUM_CENTRAL_BYTES = 32 * 1024 * 1024 +const ZIP_LOCAL = 0x04034b50 +const ZIP_CENTRAL = 0x02014b50 +const ZIP_END = 0x06054b50 +const PACKAGE_SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Agent Package Descriptor Signature v1\0', + 'utf8' +) +const AGENT_SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Agent Bundle Manifest Signature v1\0', + 'utf8' +) +const RUNTIME_SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Remote Runtime Bundle Manifest Signature v1\0', + 'utf8' +) +const sha256Pattern = /^[a-f0-9]{64}$/u +const digestPattern = /^sha256:[a-f0-9]{64}$/u +const versionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u +const windowsReservedNamePattern = + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu + +type JsonRecord = Record +type Architecture = 'x64' | 'arm64' +type Protocol = { major: number; minor: number } +type FileRecord = { + path: string + size: number + sha256: string + mode: '0644' | '0755' +} +type ZipEntry = { + name: string + size: number + checksum: number + mode: '0644' | '0755' + dataOffset: number + recordStart: number + recordEnd: number +} +type TrustedRegistry = { + formatVersion: 1 + keys: Array<{ + keyId: string + publicKeySpkiBase64: string + environment: 'production' | 'test' + }> + revocations: Array<{ keyId: string }> +} + +export type PackageInstallerAgentIdentity = { + installationId: string + agentVersion: string + manifestSha256: string + binaryDigest: string + platform: 'linux' + architecture: Architecture + protocol: Protocol + supervisor: 'detached-on-demand' +} + +export type PackageInstallerRuntimeIdentity = { + runtimeId: 'opencode' + runtimeVersion: string + bundleDigest: string + manifestDigest: string + runtimeAdapterDigest: string + acpCapabilitiesDigest: string + platform: 'linux' + architecture: Architecture + protocol: Protocol +} + +export type PackageInstallerResult = { + type: 'result' + command: 'prepare' | 'commit' + status: 'prepared' | 'committed' + archiveSha256: string + agent: PackageInstallerAgentIdentity + runtime: PackageInstallerRuntimeIdentity +} + +export type PackageInstallerEvent = + | { + type: 'progress' + command: 'prepare' | 'commit' + phase: string + } + | PackageInstallerResult + | { + type: 'error' + command: 'prepare' | 'commit' + status: 'failed' | 'rollback-incomplete' + message: string + } + +class PackageInstallerRollbackIncompleteError + extends AggregateError {} + +type InstallerOptions = { + operationRoot: string + archive: string + expectedSha256: string + homeDirectory?: string + emit?: (event: PackageInstallerEvent) => void +} + +type VerifiedPackage = { + archiveSha256: string + entries: Map + descriptor: JsonRecord + registry: TrustedRegistry + agentManifest: JsonRecord + runtimeManifest: JsonRecord + agentManifestSha256: string + runtimeManifestDigest: string + agent: PackageInstallerAgentIdentity + runtime: PackageInstallerRuntimeIdentity +} + +type PreparedState = { + formatVersion: 1 + archiveSha256: string + agent: PackageInstallerAgentIdentity + runtime: PackageInstallerRuntimeIdentity +} + +function canonicalJson(value: unknown): string { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return JSON.stringify(value) + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('Canonical JSON contains a non-finite number') + } + return JSON.stringify(Object.is(value, -0) ? 0 : value) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]` + } + if (!isRecord(value)) { + throw new Error('Canonical JSON contains a non-JSON value') + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}` +} + +function prettyBytes(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +function sha256(bytes: Buffer | string): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function digestCanonicalOperation( + method: string, + scope: unknown, + payload: unknown +): string { + return `sha256:${sha256( + Buffer.from(canonicalJson({ method, payload, scope }), 'utf8') + )}` +} + +function isRecord(value: unknown): value is JsonRecord { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +function exactKeys( + value: JsonRecord, + keys: readonly string[], + label: string +): void { + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} has unsupported or missing fields`) + } +} + +function protocol(value: unknown, label: string): Protocol { + if (!isRecord(value)) { + throw new Error(`${label} is invalid`) + } + exactKeys(value, ['major', 'minor'], label) + if ( + !Number.isSafeInteger(value.major) || + Number(value.major) < 0 || + !Number.isSafeInteger(value.minor) || + Number(value.minor) < 0 + ) { + throw new Error(`${label} is invalid`) + } + return { + major: Number(value.major), + minor: Number(value.minor) + } +} + +function safePath(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 512 && + !value.startsWith('/') && + !value.endsWith('/') && + !value.includes('\\') && + value.split('/').every( + (part) => + part.length > 0 && + part !== '.' && + part !== '..' && + !part.endsWith('.') && + !part.endsWith(' ') && + !part.includes(':') && + [...part].every((character) => character.charCodeAt(0) > 0x1f) && + !windowsReservedNamePattern.test(part) + ) + ) +} + +function safeManifestPath(value: unknown): value is string { + return ( + safePath(value) && + value.length <= 240 + ) +} + +function readExact( + handle: number, + length: number, + position: number, + label: string +): Buffer { + const output = Buffer.allocUnsafe(length) + let offset = 0 + while (offset < length) { + const count = readSync( + handle, + output, + offset, + length - offset, + position + offset + ) + if (count <= 0) { + throw new Error(`${label} is truncated`) + } + offset += count + } + return output +} + +function parseZip(handle: number, archiveSize: number): Map { + if (archiveSize < 22) { + throw new Error('Agent package ZIP end record is missing') + } + const endSize = Math.min(archiveSize, 65_557) + const endStart = archiveSize - endSize + const tail = readExact(handle, endSize, endStart, 'Agent package ZIP end record') + let endOffset = -1 + for (let offset = tail.length - 22; offset >= 0; offset -= 1) { + if ( + tail.readUInt32LE(offset) === ZIP_END && + offset + 22 + tail.readUInt16LE(offset + 20) === tail.length + ) { + endOffset = offset + break + } + } + if (endOffset < 0 || tail.readUInt16LE(endOffset + 20) !== 0) { + throw new Error('Agent package ZIP end record is invalid') + } + const count = tail.readUInt16LE(endOffset + 10) + const centralSize = tail.readUInt32LE(endOffset + 12) + const centralOffset = tail.readUInt32LE(endOffset + 16) + if ( + tail.readUInt16LE(endOffset + 4) !== 0 || + tail.readUInt16LE(endOffset + 6) !== 0 || + tail.readUInt16LE(endOffset + 8) !== count || + count < 1 || + count > MAXIMUM_ENTRIES || + centralSize < 46 || + centralSize > MAXIMUM_CENTRAL_BYTES || + centralOffset + centralSize !== endStart + endOffset + ) { + throw new Error('Agent package ZIP central directory is invalid') + } + const central = readExact( + handle, + centralSize, + centralOffset, + 'Agent package ZIP central directory' + ) + const entries = new Map() + let offset = 0 + let expanded = 0 + let previousName: string | undefined + for (let index = 0; index < count; index += 1) { + if ( + offset + 46 > central.length || + central.readUInt32LE(offset) !== ZIP_CENTRAL + ) { + throw new Error('Agent package ZIP central entry is invalid') + } + const nameLength = central.readUInt16LE(offset + 28) + const extraLength = central.readUInt16LE(offset + 30) + const commentLength = central.readUInt16LE(offset + 32) + const recordLength = 46 + nameLength + extraLength + commentLength + const flags = central.readUInt16LE(offset + 8) + const compressed = central.readUInt32LE(offset + 20) + const size = central.readUInt32LE(offset + 24) + const external = central.readUInt32LE(offset + 38) + const unixMode = external >>> 16 + const permissions = unixMode & 0o777 + const mode = + permissions === 0o644 + ? '0644' + : permissions === 0o755 + ? '0755' + : undefined + if ( + offset + recordLength > central.length || + central.readUInt16LE(offset + 4) !== 0x0314 || + central.readUInt16LE(offset + 6) !== 20 || + flags !== 0x0800 || + central.readUInt16LE(offset + 10) !== 0 || + central.readUInt16LE(offset + 30) !== 0 || + central.readUInt16LE(offset + 32) !== 0 || + central.readUInt16LE(offset + 34) !== 0 || + compressed !== size || + size > MAXIMUM_ENTRY_BYTES || + (unixMode & 0o170000) !== 0o100000 || + mode === undefined + ) { + throw new Error('Agent package ZIP entry is unsupported') + } + const nameBytes = central.subarray(offset + 46, offset + 46 + nameLength) + const name = new TextDecoder('utf-8', { fatal: true }).decode(nameBytes) + if ( + !safePath(name) || + entries.has(name) || + ( + previousName !== undefined && + Buffer.compare( + Buffer.from(previousName, 'utf8'), + Buffer.from(name, 'utf8') + ) >= 0 + ) + ) { + throw new Error('Agent package contains an unsafe, duplicate, or unordered path') + } + previousName = name + expanded += size + if (expanded > MAXIMUM_EXPANDED_BYTES) { + throw new Error('Agent package expanded payload exceeds its limit') + } + const localOffset = central.readUInt32LE(offset + 42) + const local = readExact(handle, 30, localOffset, 'Agent package ZIP local header') + const localNameLength = local.readUInt16LE(26) + const localExtraLength = local.readUInt16LE(28) + if ( + local.readUInt32LE(0) !== ZIP_LOCAL || + local.readUInt16LE(4) !== 20 || + local.readUInt16LE(6) !== flags || + local.readUInt16LE(8) !== 0 || + local.readUInt32LE(14) !== central.readUInt32LE(offset + 16) || + local.readUInt32LE(18) !== size || + local.readUInt32LE(22) !== size || + localNameLength !== nameLength || + localExtraLength !== 0 || + !readExact( + handle, + nameLength, + localOffset + 30, + 'Agent package ZIP local name' + ).equals(nameBytes) + ) { + throw new Error('Agent package ZIP local header is invalid') + } + const dataOffset = localOffset + 30 + nameLength + entries.set(name, { + name, + size, + checksum: central.readUInt32LE(offset + 16), + mode, + dataOffset, + recordStart: localOffset, + recordEnd: dataOffset + size + }) + offset += recordLength + } + if (offset !== central.length) { + throw new Error('Agent package ZIP central count is invalid') + } + const ordered = [...entries.values()].sort( + (left, right) => left.recordStart - right.recordStart + ) + let end = 0 + for (const entry of ordered) { + if (entry.recordStart !== end || entry.recordEnd > centralOffset) { + throw new Error('Agent package ZIP entries overlap or contain gaps') + } + end = entry.recordEnd + } + if (end !== centralOffset) { + throw new Error('Agent package ZIP data region is invalid') + } + return entries +} + +function streamEntry( + handle: number, + entry: ZipEntry, + destination?: string +): { sha256: string; crc32: number; bytes?: Buffer } { + const wantedBytes = + destination === undefined && entry.size <= MAXIMUM_METADATA_BYTES + ? Buffer.allocUnsafe(entry.size) + : undefined + let output: number | undefined + if (destination !== undefined) { + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }) + output = openSync( + destination, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + entry.mode === '0755' ? 0o755 : 0o644 + ) + } + const hash = createHash('sha256') + const chunk = Buffer.allocUnsafe(1024 * 1024) + let checksum = 0 + let position = 0 + try { + while (position < entry.size) { + const count = readSync( + handle, + chunk, + 0, + Math.min(chunk.length, entry.size - position), + entry.dataOffset + position + ) + if (count <= 0) { + throw new Error(`Agent package entry is truncated: ${entry.name}`) + } + const bytes = chunk.subarray(0, count) + hash.update(bytes) + checksum = crc32(bytes, checksum) + if (wantedBytes !== undefined) { + bytes.copy(wantedBytes, position) + } + if (output !== undefined) { + let written = 0 + while (written < count) { + const amount = writeFileChunk(output, bytes, written) + written += amount + } + } + position += count + } + if (output !== undefined) { + fsyncSync(output) + } + } finally { + if (output !== undefined) { + closeSync(output) + chmodSync(destination!, entry.mode === '0755' ? 0o755 : 0o644) + } + } + return { + sha256: hash.digest('hex'), + crc32: checksum >>> 0, + ...(wantedBytes === undefined ? {} : { bytes: wantedBytes }) + } +} + +function writeFileChunk(handle: number, bytes: Buffer, offset: number): number { + const written = writeSync(handle, bytes, offset, bytes.length - offset) + if (written <= 0) { + throw new Error('Agent package extraction made no progress') + } + return written +} + +function readEntry(handle: number, entries: Map, name: string): Buffer { + const entry = entries.get(name) + if (entry === undefined || entry.size > MAXIMUM_METADATA_BYTES) { + throw new Error(`Agent package metadata is missing or too large: ${name}`) + } + return streamEntry(handle, entry).bytes! +} + +function parseJson(bytes: Buffer, label: string): unknown { + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) + } catch (error) { + throw new Error(`${label} is invalid JSON`, { cause: error }) + } +} + +function parseCanonicalJson(bytes: Buffer, label: string): JsonRecord { + const value = parseJson(bytes, label) + if (!isRecord(value) || !prettyBytes(value).equals(bytes)) { + throw new Error(`${label} is not canonical`) + } + return value +} + +function parseRegistry(bytes: Buffer): TrustedRegistry { + const value = parseJson(bytes, 'Agent package key registry') + if (!isRecord(value)) { + throw new Error('Agent package key registry is invalid') + } + exactKeys(value, ['formatVersion', 'keys', 'revocations'], 'Agent package key registry') + if ( + value.formatVersion !== 1 || + !Array.isArray(value.keys) || + !Array.isArray(value.revocations) || + value.keys.length < 1 || + value.keys.length > 1_000 || + value.revocations.length > 1_000 + ) { + throw new Error('Agent package key registry is invalid') + } + const ids = new Set() + const keys = value.keys.map((candidate) => { + if (!isRecord(candidate)) { + throw new Error('Agent package key registry is invalid') + } + exactKeys( + candidate, + ['keyId', 'publicKeySpkiBase64', 'environment'], + 'Agent package key' + ) + if ( + typeof candidate.keyId !== 'string' || + !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(candidate.keyId) || + ids.has(candidate.keyId) || + typeof candidate.publicKeySpkiBase64 !== 'string' || + !['production', 'test'].includes(String(candidate.environment)) + ) { + throw new Error('Agent package key registry is invalid') + } + const publicKey = createPublicKey({ + key: Buffer.from(candidate.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('Agent package registry key is not Ed25519') + } + ids.add(candidate.keyId) + return { + keyId: candidate.keyId, + publicKeySpkiBase64: candidate.publicKeySpkiBase64, + environment: candidate.environment as 'production' | 'test' + } + }) + const revoked = new Set() + const revocations = value.revocations.map((candidate) => { + if (!isRecord(candidate) || typeof candidate.keyId !== 'string') { + throw new Error('Agent package key revocation is invalid') + } + if (revoked.has(candidate.keyId)) { + throw new Error('Agent package key revocation is duplicated') + } + revoked.add(candidate.keyId) + return { keyId: candidate.keyId } + }) + if (!keys.some((key) => key.environment === 'production')) { + throw new Error('Agent package has no production trust root') + } + return { formatVersion: 1, keys, revocations } +} + +function trustedProductionKey(registry: TrustedRegistry, keyId: unknown) { + const key = registry.keys.find( + (candidate) => + candidate.keyId === keyId && + candidate.environment === 'production' + ) + if ( + key === undefined || + registry.revocations.some((item) => item.keyId === key.keyId) + ) { + throw new Error(`GoodBuddy signing key is not trusted: ${String(keyId)}`) + } + return createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) +} + +function verifySignature( + bytes: Buffer, + signatureBytes: Buffer, + publicKey: ReturnType, + domain: Buffer, + label: string +): void { + const text = signatureBytes.toString('utf8') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) { + throw new Error(`${label} detached signature encoding is invalid`) + } + const signature = Buffer.from(text.trim(), 'base64') + if ( + signature.length !== 64 || + !verify(null, Buffer.concat([domain, bytes]), publicKey, signature) + ) { + throw new Error(`${label} signature verification failed`) + } +} + +function parseFiles(value: unknown, label: string): FileRecord[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 50_000) { + throw new Error(`${label} file inventory is invalid`) + } + const paths = new Set() + return value.map((item) => { + if (!isRecord(item)) { + throw new Error(`${label} file inventory is invalid`) + } + exactKeys(item, ['path', 'size', 'sha256', 'mode'], `${label} file`) + if ( + !safePath(item.path) || + !Number.isSafeInteger(item.size) || + Number(item.size) < 0 || + !sha256Pattern.test(String(item.sha256)) || + !['0644', '0755'].includes(String(item.mode)) || + paths.has(item.path) + ) { + throw new Error(`${label} file inventory is invalid`) + } + paths.add(item.path) + return { + path: item.path, + size: Number(item.size), + sha256: String(item.sha256), + mode: item.mode as '0644' | '0755' + } + }) +} + +function validateLicenses( + value: unknown, + declared: Set, + label: string +): Array<{ + package: string + version: string + spdx: string + path: string +}> { + if (!Array.isArray(value) || value.length < 1 || value.length > 1_000) { + throw new Error(`${label} license inventory is invalid`) + } + const paths = new Set() + return value.map((item) => { + if (!isRecord(item)) { + throw new Error(`${label} license inventory is invalid`) + } + exactKeys( + item, + ['package', 'version', 'spdx', 'path'], + `${label} license` + ) + if ( + typeof item.package !== 'string' || + item.package.length < 1 || + typeof item.version !== 'string' || + item.version.length < 1 || + typeof item.spdx !== 'string' || + item.spdx.length < 1 || + !safeManifestPath(item.path) || + !declared.has(item.path) || + paths.has(item.path) + ) { + throw new Error(`${label} license inventory is invalid`) + } + paths.add(item.path) + return { + package: item.package, + version: item.version, + spdx: item.spdx, + path: item.path + } + }) +} + +function expectedAgentMode(path: string): '0644' | '0755' { + return ( + path === 'node' || + path === 'goodbuddy-agent' || + path.startsWith('helpers/') + ) + ? '0755' + : '0644' +} + +function assertOuterDescriptor( + descriptor: JsonRecord, + bytes: Buffer, + entries: Map, + registry: TrustedRegistry, + signature: Buffer +): FileRecord[] { + exactKeys( + descriptor, + [ + 'format', + 'formatVersion', + 'product', + 'component', + 'version', + 'minimumDesktopVersion', + 'platform', + 'architecture', + 'signingKeyId', + 'agentProtocol', + 'remoteRuntime', + 'contentDigest', + 'files' + ], + 'Agent package descriptor' + ) + const architecture = descriptor.architecture + const remoteRuntime = descriptor.remoteRuntime + if ( + descriptor.format !== 'goodbuddy-agent-package' || + descriptor.formatVersion !== 1 || + descriptor.product !== 'GoodBuddy' || + descriptor.component !== 'agent' || + !versionPattern.test(String(descriptor.version)) || + !versionPattern.test(String(descriptor.minimumDesktopVersion)) || + descriptor.platform !== 'linux' || + !['x64', 'arm64'].includes(String(architecture)) || + typeof descriptor.signingKeyId !== 'string' || + !digestPattern.test(String(descriptor.contentDigest)) || + !isRecord(remoteRuntime) + ) { + throw new Error('Agent package descriptor is invalid') + } + exactKeys( + remoteRuntime, + ['runtimeId', 'provider', 'version', 'bundleDigest', 'protocol'], + 'Agent package Runtime identity' + ) + if ( + remoteRuntime.runtimeId !== 'opencode' || + remoteRuntime.provider !== 'opencode' || + !versionPattern.test(String(remoteRuntime.version)) || + !digestPattern.test(String(remoteRuntime.bundleDigest)) + ) { + throw new Error('Agent package Runtime identity is invalid') + } + protocol(descriptor.agentProtocol, 'Agent package protocol') + protocol(remoteRuntime.protocol, 'Agent package Runtime protocol') + const { contentDigest: _contentDigest, ...content } = descriptor + void _contentDigest + if ( + descriptor.contentDigest !== + `sha256:${sha256(Buffer.from(canonicalJson(content), 'utf8'))}` + ) { + throw new Error('Agent package content identity is invalid') + } + verifySignature( + bytes, + signature, + trustedProductionKey(registry, descriptor.signingKeyId), + PACKAGE_SIGNATURE_DOMAIN, + 'Agent package' + ) + const files = parseFiles(descriptor.files, 'Agent package') + const expected = new Set([ + ...files.map((file) => file.path), + PACKAGE_DESCRIPTOR, + PACKAGE_SIGNATURE + ]) + if ( + entries.size !== expected.size || + [...entries.keys()].some((name) => !expected.has(name)) + ) { + throw new Error('Agent package contains undeclared or missing files') + } + for (const file of files) { + const entry = entries.get(file.path) + if ( + entry === undefined || + entry.size !== file.size || + entry.mode !== file.mode + ) { + throw new Error(`Agent package ZIP inventory mismatch: ${file.path}`) + } + } + for (const metadata of [PACKAGE_DESCRIPTOR, PACKAGE_SIGNATURE]) { + if (entries.get(metadata)?.mode !== '0644') { + throw new Error('Agent package metadata mode is invalid') + } + } + if ( + !files.some((file) => file.path === NODE_PATH && file.mode === '0755') + ) { + throw new Error('Agent package is not self-bootstrap capable') + } + return files +} + +function assertAgentManifest( + bytes: Buffer, + signature: Buffer, + registry: TrustedRegistry, + lock: JsonRecord, + descriptor: JsonRecord, + packageFiles: Map +): JsonRecord { + const manifest = parseCanonicalJson(bytes, 'Agent manifest') + exactKeys( + manifest, + [ + 'formatVersion', + 'product', + 'agentVersion', + 'platform', + 'arch', + 'protocol', + 'signingKeyId', + 'entrypoint', + 'files', + 'licenses' + ], + 'Agent manifest' + ) + if ( + manifest.formatVersion !== 1 || + manifest.product !== 'GoodBuddy' || + !versionPattern.test(String(manifest.agentVersion)) || + manifest.platform !== 'linux' || + !['x64', 'arm64'].includes(String(manifest.arch)) || + !isRecord(manifest.entrypoint) || + !Array.isArray(manifest.licenses) + ) { + throw new Error('Agent manifest contract is invalid') + } + exactKeys( + manifest.entrypoint, + ['path', 'runtimePath', 'scriptPath'], + 'Agent entrypoint' + ) + if ( + manifest.entrypoint.path !== 'goodbuddy-agent' || + manifest.entrypoint.runtimePath !== 'node' || + manifest.entrypoint.scriptPath !== 'lib/agent.cjs' + ) { + throw new Error('Agent entrypoint is invalid') + } + const agentProtocol = protocol(manifest.protocol, 'Agent manifest protocol') + if ( + lock.formatVersion !== 1 || + manifest.agentVersion !== lock.agentVersion || + manifest.arch !== descriptor.architecture || + manifest.agentVersion !== descriptor.version || + canonicalJson(agentProtocol) !== canonicalJson(descriptor.agentProtocol) || + canonicalJson(agentProtocol) !== canonicalJson(lock.protocol) + ) { + throw new Error('Agent manifest does not match package identity or lock') + } + verifySignature( + bytes, + signature, + trustedProductionKey(registry, manifest.signingKeyId), + AGENT_SIGNATURE_DOMAIN, + 'Agent manifest' + ) + const files = parseFiles(manifest.files, 'Agent manifest') + const declared = new Set() + for (const file of files) { + if ( + !safeManifestPath(file.path) || + file.mode !== expectedAgentMode(file.path) + ) { + throw new Error(`Agent manifest path or mode is invalid: ${file.path}`) + } + declared.add(file.path) + const outer = packageFiles.get(`agent/${file.path}`) + if ( + outer === undefined || + outer.size !== file.size || + outer.sha256 !== file.sha256 || + outer.mode !== file.mode + ) { + throw new Error(`Agent inner inventory mismatch: ${file.path}`) + } + } + const expectedAgentPaths = new Set([ + ...files.map((file) => `agent/${file.path}`), + 'agent/manifest.json', + 'agent/manifest.sig' + ]) + const actualAgentPaths = [...packageFiles.keys()].filter((path) => + path.startsWith('agent/') + ) + if ( + actualAgentPaths.length !== expectedAgentPaths.size || + actualAgentPaths.some((path) => !expectedAgentPaths.has(path)) + ) { + throw new Error('Agent bundle contains undeclared or missing files') + } + for (const required of [ + 'node', + 'goodbuddy-agent', + 'lib/agent.cjs' + ]) { + if (!declared.has(required)) { + throw new Error(`Agent required payload is missing: ${required}`) + } + } + const architecture = manifest.arch as Architecture + const koffiPackageRoot = 'lib/node_modules/koffi' + const koffiNativePackage = `@koromix/koffi-linux-${architecture}` + const koffiNativeRoot = + `lib/node_modules/${koffiNativePackage}` + for (const required of [ + `${koffiPackageRoot}/package.json`, + `${koffiPackageRoot}/index.js`, + `${koffiPackageRoot}/src/koffi/index.js`, + `${koffiPackageRoot}/src/koffi/src/static.js`, + `${koffiNativeRoot}/package.json`, + `${koffiNativeRoot}/index.js` + ]) { + if (!declared.has(required)) { + throw new Error(`Agent Koffi payload is missing: ${required}`) + } + } + for (const native of [ + `lib/node_modules/@koromix/koffi-linux-${manifest.arch}/linux_${manifest.arch}/koffi.node`, + `lib/node_modules/@koromix/koffi-linux-${manifest.arch}/musl_${manifest.arch}/koffi.node` + ]) { + if (!declared.has(native)) { + throw new Error(`Agent Koffi payload is missing: ${native}`) + } + } + if ( + !isRecord(lock.koffi) || + typeof lock.koffi.version !== 'string' + ) { + throw new Error('Agent runtime lock contract is invalid') + } + const licenses = validateLicenses( + manifest.licenses, + declared, + 'Agent manifest' + ) + const koffiLicense = licenses.find( + (license) => license.package === 'koffi' + ) + const nativeLicense = licenses.find( + (license) => license.package === koffiNativePackage + ) + if ( + koffiLicense?.version !== lock.koffi.version || + koffiLicense.spdx !== 'MIT' || + koffiLicense.path !== 'licenses/koffi-MIT.txt' || + nativeLicense?.version !== lock.koffi.version || + nativeLicense.spdx !== 'MIT' || + nativeLicense.path !== 'licenses/koffi-native-MIT.txt' + ) { + throw new Error('Agent Koffi license declaration is invalid') + } + return manifest +} + +function assertRuntimeManifest( + bytes: Buffer, + signature: Buffer, + registry: TrustedRegistry, + lock: JsonRecord, + descriptor: JsonRecord, + packageFiles: Map +): { manifest: JsonRecord; manifestDigest: string } { + const manifest = parseCanonicalJson(bytes, 'Runtime manifest') + exactKeys( + manifest, + [ + 'formatVersion', + 'product', + 'runtimeId', + 'runtimeVersion', + 'provider', + 'platform', + 'architecture', + 'signingKeyId', + 'bundleDigest', + 'adapterDigest', + 'sourcePackage', + 'entrypoint', + 'files', + 'licenses', + 'allowedEnvironmentNames', + 'protocol', + 'acpCapabilitiesDigest', + 'limits' + ], + 'Runtime manifest' + ) + const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode) + ? lock.runtimes.opencode + : undefined + const target = + runtime !== undefined && + isRecord(runtime.targets) && + isRecord(runtime.targets[String(descriptor.architecture)]) + ? runtime.targets[String(descriptor.architecture)] + : undefined + const remote = descriptor.remoteRuntime as JsonRecord + if ( + manifest.formatVersion !== 2 || + manifest.product !== 'GoodBuddy' || + manifest.runtimeId !== 'opencode' || + manifest.provider !== 'opencode' || + manifest.platform !== 'linux' || + manifest.architecture !== descriptor.architecture || + !versionPattern.test(String(manifest.runtimeVersion)) || + !digestPattern.test(String(manifest.bundleDigest)) || + !digestPattern.test(String(manifest.adapterDigest)) || + !digestPattern.test(String(manifest.acpCapabilitiesDigest)) || + !isRecord(manifest.entrypoint) || + !isRecord(manifest.sourcePackage) || + !isRecord(manifest.limits) || + !Array.isArray(manifest.files) || + !Array.isArray(manifest.licenses) || + !Array.isArray(manifest.allowedEnvironmentNames) || + runtime === undefined || + target === undefined + ) { + throw new Error('Runtime manifest contract or lock is invalid') + } + const lockedTarget = target as JsonRecord + const entrypoint = manifest.entrypoint as JsonRecord + const sourcePackage = manifest.sourcePackage as JsonRecord + const limits = manifest.limits as JsonRecord + exactKeys( + entrypoint, + ['identity', 'path', 'sha256', 'argvPrefix'], + 'Runtime entrypoint' + ) + exactKeys( + sourcePackage, + ['name', 'integrity'], + 'Runtime source package' + ) + exactKeys( + limits, + [ + 'maximumPromptRuntimeMilliseconds', + 'maximumPromptInputBytes', + 'maximumPromptOutputBytes' + ], + 'Runtime limits' + ) + const runtimeProtocol = protocol(manifest.protocol, 'Runtime manifest protocol') + if ( + manifest.runtimeVersion !== runtime.version || + manifest.runtimeVersion !== remote.version || + manifest.bundleDigest !== remote.bundleDigest || + canonicalJson(runtimeProtocol) !== canonicalJson(runtime.protocol) || + canonicalJson(runtimeProtocol) !== canonicalJson(remote.protocol) || + entrypoint.path !== runtime.entrypoint || + entrypoint.identity !== runtime.entrypointIdentity || + canonicalJson(entrypoint.argvPrefix) !== + canonicalJson(runtime.argvPrefix) || + canonicalJson(manifest.allowedEnvironmentNames) !== + canonicalJson(runtime.allowedEnvironmentNames) || + sourcePackage.name !== lockedTarget.package || + sourcePackage.integrity !== lockedTarget.integrity || + !sha256Pattern.test(String(entrypoint.sha256)) || + !Number.isSafeInteger(limits.maximumPromptRuntimeMilliseconds) || + Number(limits.maximumPromptRuntimeMilliseconds) < 1 || + !Number.isSafeInteger(limits.maximumPromptInputBytes) || + Number(limits.maximumPromptInputBytes) < 1 || + !Number.isSafeInteger(limits.maximumPromptOutputBytes) || + Number(limits.maximumPromptOutputBytes) < 0 + ) { + throw new Error('Runtime manifest does not match package identity or lock') + } + const { bundleDigest: _bundleDigest, ...identity } = manifest + void _bundleDigest + if ( + manifest.bundleDigest !== + digestCanonicalOperation( + 'runtime/bundleIdentity', + { kind: 'installation', installationId: manifest.runtimeId }, + identity + ) + ) { + throw new Error('Runtime bundle identity digest does not match') + } + verifySignature( + bytes, + signature, + trustedProductionKey(registry, manifest.signingKeyId), + RUNTIME_SIGNATURE_DOMAIN, + 'Runtime manifest' + ) + const files = parseFiles(manifest.files, 'Runtime manifest') + const digestDirectory = String(manifest.bundleDigest).slice('sha256:'.length) + const prefix = `runtime/opencode/${digestDirectory}/` + const expectedRuntimePaths = new Set([ + ...files.map((file) => `${prefix}${file.path}`), + `${prefix}manifest.json`, + `${prefix}manifest.sig` + ]) + const actualRuntimePaths = [...packageFiles.keys()].filter((path) => + path.startsWith('runtime/') + ) + if ( + actualRuntimePaths.length !== expectedRuntimePaths.size || + actualRuntimePaths.some((path) => !expectedRuntimePaths.has(path)) + ) { + throw new Error('Runtime bundle contains undeclared or missing files') + } + for (const file of files) { + if (!safeManifestPath(file.path)) { + throw new Error(`Runtime manifest path is invalid: ${file.path}`) + } + const expectedMode = file.path === 'bin/opencode' ? '0755' : '0644' + const outer = packageFiles.get(`${prefix}${file.path}`) + if ( + file.mode !== expectedMode || + outer === undefined || + outer.size !== file.size || + outer.sha256 !== file.sha256 || + outer.mode !== file.mode + ) { + throw new Error(`Runtime inner inventory mismatch: ${file.path}`) + } + } + const runtimeLicenses = validateLicenses( + manifest.licenses, + new Set(files.map((file) => file.path)), + 'Runtime manifest' + ) + const openCodeLicense = runtimeLicenses.find( + (license) => license.package === 'opencode-ai' + ) + if ( + openCodeLicense?.version !== manifest.runtimeVersion || + openCodeLicense?.spdx !== 'MIT' || + openCodeLicense?.path !== 'licenses/opencode-MIT.txt' + ) { + throw new Error('Runtime license declaration is invalid') + } + if ( + entrypoint.path !== 'bin/opencode' || + !files.some( + (file) => + file.path === 'bin/opencode' && + file.sha256 === entrypoint.sha256 + ) + ) { + throw new Error('Runtime entrypoint is missing') + } + return { + manifest, + manifestDigest: digestCanonicalOperation( + 'runtime/bundleManifest', + { kind: 'installation', installationId: manifest.runtimeId }, + manifest + ) + } +} + +function elfArchitecture(bytes: Buffer): Architecture | undefined { + if ( + bytes.length < 20 || + !bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + return undefined + } + const machine = + bytes[5] === 1 + ? bytes.readUInt16LE(18) + : bytes[5] === 2 + ? bytes.readUInt16BE(18) + : 0 + return machine === 62 ? 'x64' : machine === 183 ? 'arm64' : undefined +} + +function assertExtractedArchitectures( + preparedRoot: string, + architecture: Architecture +): void { + for (const path of [ + join(preparedRoot, 'agent', 'node'), + join( + preparedRoot, + 'agent', + 'lib', + 'node_modules', + `@koromix/koffi-linux-${architecture}`, + `linux_${architecture}`, + 'koffi.node' + ), + join( + preparedRoot, + 'agent', + 'lib', + 'node_modules', + `@koromix/koffi-linux-${architecture}`, + `musl_${architecture}`, + 'koffi.node' + ), + join(preparedRoot, 'runtime', 'bin', 'opencode') + ]) { + if ( + elfArchitecture(readFileHeader(path, 64)) !== architecture + ) { + throw new Error(`Installed executable architecture mismatch: ${basename(path)}`) + } + } +} + +function readFileHeader(path: string, maximumBytes: number): Buffer { + const handle = openSync( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + try { + const snapshot = fstatSync(handle) + if (!snapshot.isFile()) { + throw new Error(`Installed payload is not a regular file: ${path}`) + } + return readExact( + handle, + Math.min(snapshot.size, maximumBytes), + 0, + 'Installed payload header' + ) + } finally { + closeSync(handle) + } +} + +function hashArchive(handle: number, size: number): string { + const hash = createHash('sha256') + const chunk = Buffer.allocUnsafe(1024 * 1024) + let position = 0 + while (position < size) { + const count = readSync( + handle, + chunk, + 0, + Math.min(chunk.length, size - position), + position + ) + if (count <= 0) { + throw new Error('Agent package archive is truncated') + } + hash.update(chunk.subarray(0, count)) + position += count + } + return hash.digest('hex') +} + +function sameSnapshot( + before: ReturnType, + after: ReturnType +): boolean { + return ( + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size && + before.mtimeMs === after.mtimeMs && + before.ctimeMs === after.ctimeMs + ) +} + +function verifyArchive( + archivePath: string, + expectedSha256: string, + emit: (phase: string) => void +): VerifiedPackage { + const handle = openSync( + archivePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + try { + const snapshot = fstatSync(handle) + if ( + !snapshot.isFile() || + snapshot.size <= 0 || + snapshot.size > MAXIMUM_ARCHIVE_BYTES + ) { + throw new Error('Agent package archive is not a bounded regular file') + } + emit('hashing-archive') + const archiveSha256 = hashArchive(handle, snapshot.size) + if (archiveSha256 !== expectedSha256) { + throw new Error('Agent package archive SHA-256 does not match the authenticated catalog') + } + emit('verifying-zip') + const entries = parseZip(handle, snapshot.size) + const descriptorBytes = readEntry(handle, entries, PACKAGE_DESCRIPTOR) + const signatureBytes = readEntry(handle, entries, PACKAGE_SIGNATURE) + const registryBytes = readEntry(handle, entries, 'agent-release-keys.json') + const descriptor = parseCanonicalJson( + descriptorBytes, + 'Agent package descriptor' + ) + const registry = parseRegistry(registryBytes) + const declared = assertOuterDescriptor( + descriptor, + descriptorBytes, + entries, + registry, + signatureBytes + ) + const declaredMap = new Map(declared.map((file) => [file.path, file])) + emit('verifying-payload') + for (const entry of entries.values()) { + const actual = streamEntry(handle, entry) + if (actual.crc32 !== entry.checksum) { + throw new Error(`Agent package ZIP checksum mismatch: ${entry.name}`) + } + const expected = declaredMap.get(entry.name) + if (expected !== undefined && actual.sha256 !== expected.sha256) { + throw new Error(`Agent package payload digest mismatch: ${entry.name}`) + } + } + const agentManifestBytes = readEntry(handle, entries, 'agent/manifest.json') + const agentManifest = assertAgentManifest( + agentManifestBytes, + readEntry(handle, entries, 'agent/manifest.sig'), + registry, + parseCanonicalJson( + readEntry(handle, entries, 'agent-runtime-lock.json'), + 'Agent runtime lock' + ), + descriptor, + declaredMap + ) + const runtimeDigest = String( + (descriptor.remoteRuntime as JsonRecord).bundleDigest + ).slice('sha256:'.length) + const runtimeManifestBytes = readEntry( + handle, + entries, + `runtime/opencode/${runtimeDigest}/manifest.json` + ) + const runtime = assertRuntimeManifest( + runtimeManifestBytes, + readEntry( + handle, + entries, + `runtime/opencode/${runtimeDigest}/manifest.sig` + ), + registry, + parseCanonicalJson( + readEntry(handle, entries, 'remote-runtime-lock.json'), + 'Remote Runtime lock' + ), + descriptor, + declaredMap + ) + if (!sameSnapshot(snapshot, fstatSync(handle))) { + throw new Error('Agent package archive changed during verification') + } + const agentManifestSha256 = sha256(agentManifestBytes) + const architecture = descriptor.architecture as Architecture + return { + archiveSha256, + entries, + descriptor, + registry, + agentManifest, + runtimeManifest: runtime.manifest, + agentManifestSha256, + runtimeManifestDigest: runtime.manifestDigest, + agent: { + installationId: `agent-${agentManifestSha256}`, + agentVersion: String(agentManifest.agentVersion), + manifestSha256: agentManifestSha256, + binaryDigest: `sha256:${agentManifestSha256}`, + platform: 'linux', + architecture, + protocol: protocol(agentManifest.protocol, 'Agent protocol'), + supervisor: 'detached-on-demand' + }, + runtime: { + runtimeId: 'opencode', + runtimeVersion: String(runtime.manifest.runtimeVersion), + bundleDigest: String(runtime.manifest.bundleDigest), + manifestDigest: runtime.manifestDigest, + runtimeAdapterDigest: String(runtime.manifest.adapterDigest), + acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest), + platform: 'linux', + architecture, + protocol: protocol(runtime.manifest.protocol, 'Runtime protocol') + } + } + } finally { + closeSync(handle) + } +} + +function assertAbsoluteInputs( + options: InstallerOptions, + createOperationRoot = true +): { + operationRoot: string + archive: string + expectedSha256: string +} { + if ( + !sha256Pattern.test(options.expectedSha256) || + resolve(options.operationRoot) !== options.operationRoot || + resolve(options.archive) !== options.archive + ) { + throw new Error('Installer paths must be absolute and archive SHA-256 must be lowercase hexadecimal') + } + const operationRoot = resolve(options.operationRoot) + const archive = resolve(options.archive) + if ( + operationRoot === resolve(options.homeDirectory ?? homedir()) || + operationRoot === dirname(operationRoot) + ) { + throw new Error('Installer operation root is unsafe') + } + if (createOperationRoot) { + mkdirSync(operationRoot, { recursive: true, mode: 0o700 }) + } + if ( + !existsSync(operationRoot) || + !statSync(operationRoot).isDirectory() || + lstatSync(operationRoot).isSymbolicLink() + ) { + throw new Error('Installer operation root is not a real directory') + } + return { + operationRoot, + archive, + expectedSha256: options.expectedSha256 + } +} + +function persistPreparedState( + operationRoot: string, + verified: VerifiedPackage +): void { + const preparedRoot = join(operationRoot, 'prepared') + if (existsSync(preparedRoot)) { + const metadata = lstatSync(preparedRoot) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('Prepared package state directory is invalid') + } + } else { + mkdirSync(preparedRoot, { recursive: false, mode: 0o700 }) + } + const destination = join(preparedRoot, 'result.json') + const temporary = join( + preparedRoot, + `.result-${process.pid}-${Date.now().toString(36)}.tmp` + ) + const state: PreparedState = { + formatVersion: 1, + archiveSha256: verified.archiveSha256, + agent: verified.agent, + runtime: verified.runtime + } + const bytes = prettyBytes(state) + if (bytes.length > MAXIMUM_METADATA_BYTES) { + throw new Error('Prepared package state exceeds its limit') + } + const handle = openSync( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o600 + ) + try { + let offset = 0 + while (offset < bytes.length) { + offset += writeFileChunk(handle, bytes, offset) + } + fsyncSync(handle) + } finally { + closeSync(handle) + } + try { + chmodSync(temporary, 0o600) + renameSync(temporary, destination) + } finally { + if (existsSync(temporary)) { + unlinkSync(temporary) + } + } +} + +function extractPayload( + archive: string, + operationRoot: string, + verified: VerifiedPackage +): string { + const temporary = mkdtempSync(join(operationRoot, '.commit-payload-')) + try { + mkdirSync(join(temporary, 'agent'), { recursive: true, mode: 0o700 }) + mkdirSync(join(temporary, 'runtime'), { recursive: true, mode: 0o700 }) + const runtimePrefix = + `runtime/opencode/${verified.runtime.bundleDigest.slice('sha256:'.length)}/` + const declared = new Map( + parseFiles(verified.descriptor.files, 'Agent package') + .map((file) => [file.path, file]) + ) + const handle = openSync( + archive, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + try { + const snapshot = fstatSync(handle) + if ( + !snapshot.isFile() || + snapshot.size <= 0 || + snapshot.size > MAXIMUM_ARCHIVE_BYTES + ) { + throw new Error('Agent package archive is not a bounded regular file') + } + for (const [name, entry] of verified.entries) { + let target: string | undefined + if (name.startsWith('agent/')) { + target = join(temporary, 'agent', ...name.slice(6).split('/')) + } else if (name.startsWith(runtimePrefix)) { + target = join( + temporary, + 'runtime', + ...name.slice(runtimePrefix.length).split('/') + ) + } + if (target !== undefined) { + const actual = streamEntry(handle, entry, target) + const expected = declared.get(name) + if ( + expected === undefined || + actual.crc32 !== entry.checksum || + actual.sha256 !== expected.sha256 + ) { + throw new Error(`Extracted package payload verification failed: ${name}`) + } + } + } + if (!sameSnapshot(snapshot, fstatSync(handle))) { + throw new Error('Agent package archive changed during extraction') + } + } finally { + closeSync(handle) + } + assertExtractedArchitectures(temporary, verified.agent.architecture) + chmodSync(temporary, 0o700) + return temporary + } catch (error) { + rmSync(temporary, { recursive: true, force: true }) + throw error + } +} + +function eventEmitter( + command: 'prepare' | 'commit', + emit?: (event: PackageInstallerEvent) => void +): (phase: string) => void { + return (phase) => emit?.({ type: 'progress', command, phase }) +} + +export function preparePackage(options: InstallerOptions): PackageInstallerResult { + const inputs = assertAbsoluteInputs(options) + const progress = eventEmitter('prepare', options.emit) + progress('validating') + const verified = verifyArchive( + inputs.archive, + inputs.expectedSha256, + progress + ) + progress('persisting-prepared-state') + persistPreparedState(inputs.operationRoot, verified) + const result: PackageInstallerResult = { + type: 'result', + command: 'prepare', + status: 'prepared', + archiveSha256: verified.archiveSha256, + agent: verified.agent, + runtime: verified.runtime + } + options.emit?.(result) + return result +} + +function listFiles(root: string): string[] { + const output: string[] = [] + const pending = [root] + while (pending.length > 0) { + const current = pending.pop()! + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`Prepared payload contains a symbolic link: ${path}`) + } + if (entry.isDirectory()) { + pending.push(path) + } else if (entry.isFile()) { + output.push(path) + } else { + throw new Error(`Prepared payload contains an unsupported entry: ${path}`) + } + } + } + return output.sort((left, right) => + Buffer.compare( + Buffer.from(relative(root, left).split(sep).join('/'), 'utf8'), + Buffer.from(relative(root, right).split(sep).join('/'), 'utf8') + ) + ) +} + +function directoriesEqual(left: string, right: string): boolean { + const leftFiles = listFiles(left) + const rightFiles = listFiles(right) + if (leftFiles.length !== rightFiles.length) { + return false + } + for (let index = 0; index < leftFiles.length; index += 1) { + const leftName = relative(left, leftFiles[index]!).split(sep).join('/') + const rightName = relative(right, rightFiles[index]!).split(sep).join('/') + const leftMetadata = statSync(leftFiles[index]!) + const rightMetadata = statSync(rightFiles[index]!) + if ( + leftName !== rightName || + leftMetadata.size !== rightMetadata.size || + ( + process.platform !== 'win32' && + (leftMetadata.mode & 0o777) !== (rightMetadata.mode & 0o777) + ) || + hashFile(leftFiles[index]!) !== hashFile(rightFiles[index]!) + ) { + return false + } + } + return true +} + +type PublishedDirectory = { + destination: string + backupRoot?: string + backup?: string +} + +function publishDirectory( + source: string, + destination: string, + operationRoot: string +): PublishedDirectory { + assertOwnedManagedDirectory(dirname(destination)) + if (existsSync(destination)) { + const destinationMetadata = lstatSync(destination) + if ( + !destinationMetadata.isDirectory() || + destinationMetadata.isSymbolicLink() || + ( + typeof process.getuid === 'function' && + destinationMetadata.uid !== process.getuid() + ) + ) { + throw new Error(`Existing digest destination has conflicting content: ${destination}`) + } + if (directoriesEqual(source, destination)) { + rmSync(source, { recursive: true, force: true }) + return { destination } + } + const backupRoot = mkdtempSync( + join(operationRoot, '.replaced-destination-') + ) + const backup = join(backupRoot, 'payload') + renameSync(destination, backup) + try { + renameSync(source, destination) + } catch (error) { + renameSync(backup, destination) + rmSync(backupRoot, { recursive: true, force: true }) + throw new Error( + 'Prepared payload could not replace the conflicting managed destination', + { cause: error } + ) + } + return { destination, backupRoot, backup } + } + try { + renameSync(source, destination) + } catch (error) { + if ( + existsSync(destination) && + directoriesEqual(source, destination) + ) { + rmSync(source, { recursive: true, force: true }) + return { destination } + } + throw new Error( + 'Prepared payload could not be atomically published on the managed filesystem', + { cause: error } + ) + } + return { destination } +} + +function ensureOwnedManagedHierarchy( + home: string, + runtimeId: string +): void { + assertOwnedManagedDirectory(home, false) + const managedRoot = join(home, '.goodbuddy') + for (const directory of [ + managedRoot, + join(managedRoot, 'agent'), + join(managedRoot, 'agent', 'installations'), + join(managedRoot, 'runtimes'), + join(managedRoot, 'runtimes', runtimeId) + ]) { + ensureOwnedManagedDirectory(directory) + } +} + +function ensureOwnedManagedDirectory(path: string): void { + try { + mkdirSync(path, { recursive: false, mode: 0o700 }) + } catch (error) { + if (!isExistingPathError(error)) { + throw error + } + } + assertOwnedManagedDirectory(path) +} + +function assertOwnedManagedDirectory( + path: string, + requirePrivateMode = true +): void { + const metadata = lstatSync(path) + if ( + !metadata.isDirectory() || + metadata.isSymbolicLink() || + ( + typeof process.getuid === 'function' && + metadata.uid !== process.getuid() + ) || + ( + requirePrivateMode && + process.platform !== 'win32' && + (metadata.mode & 0o777) !== 0o700 + ) + ) { + throw new Error( + `Managed installation directory is unsafe: ${path}` + ) + } +} + +function isExistingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'EEXIST' + ) +} + +function restoreReplacedDirectory(published: PublishedDirectory): void { + if (!published.backup || !published.backupRoot) { + return + } + rmSync(published.destination, { recursive: true, force: true }) + renameSync(published.backup, published.destination) + rmSync(published.backupRoot, { recursive: true, force: true }) +} + +function discardReplacedDirectory(published: PublishedDirectory): void { + if (published.backupRoot) { + try { + rmSync(published.backupRoot, { recursive: true, force: true }) + } catch { + // The bootstrap removes the bounded operation root after commit. + } + } +} + +function readPreparedState(path: string): PreparedState { + const bytes = readBoundedRegularFile( + path, + MAXIMUM_METADATA_BYTES, + 'Prepared package state', + '0600' + ) + const value = parseCanonicalJson(bytes, 'Prepared package state') + exactKeys( + value, + ['formatVersion', 'archiveSha256', 'agent', 'runtime'], + 'Prepared package state' + ) + if ( + value.formatVersion !== 1 || + !sha256Pattern.test(String(value.archiveSha256)) || + !isRecord(value.agent) || + !isRecord(value.runtime) + ) { + throw new Error('Prepared package state is invalid') + } + const agent = value.agent + const runtime = value.runtime + exactKeys( + agent, + [ + 'installationId', + 'agentVersion', + 'manifestSha256', + 'binaryDigest', + 'platform', + 'architecture', + 'protocol', + 'supervisor' + ], + 'Prepared Agent identity' + ) + exactKeys( + runtime, + [ + 'runtimeId', + 'runtimeVersion', + 'bundleDigest', + 'manifestDigest', + 'runtimeAdapterDigest', + 'acpCapabilitiesDigest', + 'platform', + 'architecture', + 'protocol' + ], + 'Prepared Runtime identity' + ) + const agentProtocol = protocol(agent.protocol, 'Prepared Agent protocol') + const runtimeProtocol = protocol( + runtime.protocol, + 'Prepared Runtime protocol' + ) + if ( + typeof agent.installationId !== 'string' || + !versionPattern.test(String(agent.agentVersion)) || + !sha256Pattern.test(String(agent.manifestSha256)) || + agent.installationId !== `agent-${String(agent.manifestSha256)}` || + agent.binaryDigest !== `sha256:${String(agent.manifestSha256)}` || + agent.platform !== 'linux' || + !['x64', 'arm64'].includes(String(agent.architecture)) || + agent.supervisor !== 'detached-on-demand' || + runtime.runtimeId !== 'opencode' || + !versionPattern.test(String(runtime.runtimeVersion)) || + !digestPattern.test(String(runtime.bundleDigest)) || + !digestPattern.test(String(runtime.manifestDigest)) || + !digestPattern.test(String(runtime.runtimeAdapterDigest)) || + !digestPattern.test(String(runtime.acpCapabilitiesDigest)) || + runtime.platform !== 'linux' || + runtime.architecture !== agent.architecture + ) { + throw new Error('Prepared package state identity is invalid') + } + return { + formatVersion: 1, + archiveSha256: String(value.archiveSha256), + agent: { + installationId: agent.installationId, + agentVersion: String(agent.agentVersion), + manifestSha256: String(agent.manifestSha256), + binaryDigest: String(agent.binaryDigest), + platform: 'linux', + architecture: agent.architecture as Architecture, + protocol: agentProtocol, + supervisor: 'detached-on-demand' + }, + runtime: { + runtimeId: 'opencode', + runtimeVersion: String(runtime.runtimeVersion), + bundleDigest: String(runtime.bundleDigest), + manifestDigest: String(runtime.manifestDigest), + runtimeAdapterDigest: String(runtime.runtimeAdapterDigest), + acpCapabilitiesDigest: String(runtime.acpCapabilitiesDigest), + platform: 'linux', + architecture: runtime.architecture as Architecture, + protocol: runtimeProtocol + } + } +} + +function safeRegularFile( + path: string, + expectedMode: '0600' | '0644' | '0755', + expectedSize?: number +): boolean { + try { + const metadata = lstatSync(path) + const getuid = process.getuid + return ( + metadata.isFile() && + !metadata.isSymbolicLink() && + (getuid === undefined || metadata.uid === getuid.call(process)) && + (process.platform === 'win32' || + (metadata.mode & 0o777) === + ( + expectedMode === '0755' + ? 0o755 + : expectedMode === '0600' + ? 0o600 + : 0o644 + )) && + (expectedSize === undefined || metadata.size === expectedSize) + ) + } catch { + return false + } +} + +function readBoundedRegularFile( + path: string, + maximumBytes: number, + label: string, + expectedMode: '0600' | '0644' | '0755' +): Buffer { + const handle = openSync( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + try { + const snapshot = fstatSync(handle) + const getuid = process.getuid + const expectedPermissions = + expectedMode === '0755' + ? 0o755 + : expectedMode === '0600' + ? 0o600 + : 0o644 + if ( + !snapshot.isFile() || + snapshot.size <= 0 || + snapshot.size > maximumBytes || + (getuid !== undefined && snapshot.uid !== getuid.call(process)) || + ( + process.platform !== 'win32' && + (snapshot.mode & 0o777) !== expectedPermissions + ) + ) { + throw new Error(`${label} is not a bounded trusted regular file`) + } + const bytes = readExact(handle, snapshot.size, 0, label) + if (!sameSnapshot(snapshot, fstatSync(handle))) { + throw new Error(`${label} changed while it was read`) + } + return bytes + } finally { + closeSync(handle) + } +} + +function hashFile(path: string): string { + const handle = openSync( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + try { + const snapshot = fstatSync(handle) + if (!snapshot.isFile()) { + throw new Error(`Installed payload is not a regular file: ${path}`) + } + const digest = hashArchive(handle, snapshot.size) + if (!sameSnapshot(snapshot, fstatSync(handle))) { + throw new Error(`Installed payload changed while it was hashed: ${path}`) + } + return digest + } finally { + closeSync(handle) + } +} + +function verifyAgentTree( + root: string, + verified: VerifiedPackage +): void { + const files = parseFiles(verified.agentManifest.files, 'Agent manifest') + const expected = new Set([ + ...files.map((file) => file.path), + 'manifest.json', + 'manifest.sig' + ]) + const actual = listFiles(root).map((path) => + relative(root, path).split(sep).join('/') + ) + if ( + actual.length !== expected.size || + actual.some((path) => !expected.has(path)) + ) { + throw new Error('Prepared Agent payload contains undeclared or missing files') + } + for (const file of files) { + const path = join(root, ...file.path.split('/')) + if ( + !safeRegularFile(path, file.mode, file.size) || + hashFile(path) !== file.sha256 + ) { + throw new Error(`Prepared Agent payload verification failed: ${file.path}`) + } + } + for (const name of ['manifest.json', 'manifest.sig'] as const) { + const path = join(root, name) + const entry = verified.entries.get(`agent/${name}`) + if ( + entry === undefined || + !safeRegularFile(path, '0644', entry.size) || + hashFile(path) !== + streamVerifiedEntryDigest(verified, `agent/${name}`) + ) { + throw new Error(`Prepared Agent metadata verification failed: ${name}`) + } + } +} + +function verifyRuntimeTree( + root: string, + verified: VerifiedPackage +): void { + const files = parseFiles(verified.runtimeManifest.files, 'Runtime manifest') + const expected = new Set([ + ...files.map((file) => file.path), + 'manifest.json', + 'manifest.sig' + ]) + const actual = listFiles(root).map((path) => + relative(root, path).split(sep).join('/') + ) + if ( + actual.length !== expected.size || + actual.some((path) => !expected.has(path)) + ) { + throw new Error('Prepared Runtime payload contains undeclared or missing files') + } + for (const file of files) { + const path = join(root, ...file.path.split('/')) + if ( + !safeRegularFile(path, file.mode, file.size) || + hashFile(path) !== file.sha256 + ) { + throw new Error(`Prepared Runtime payload verification failed: ${file.path}`) + } + } + const runtimePrefix = + `runtime/opencode/${verified.runtime.bundleDigest.slice('sha256:'.length)}/` + for (const name of ['manifest.json', 'manifest.sig'] as const) { + const path = join(root, name) + const entry = verified.entries.get(`${runtimePrefix}${name}`) + if ( + entry === undefined || + !safeRegularFile(path, '0644', entry.size) || + hashFile(path) !== + streamVerifiedEntryDigest(verified, `${runtimePrefix}${name}`) + ) { + throw new Error(`Prepared Runtime metadata verification failed: ${name}`) + } + } +} + +function streamVerifiedEntryDigest( + verified: VerifiedPackage, + name: string +): string { + const file = parseFiles( + verified.descriptor.files, + 'Agent package' + ).find((candidate) => candidate.path === name) + if (file === undefined) { + throw new Error(`Agent package file is missing: ${name}`) + } + return file.sha256 +} + +function tryReuseCurrentNode( + preparedRoot: string, + managedRoot: string, + verified: VerifiedPackage +): void { + const candidateRuntimePath = isRecord(verified.agentManifest.entrypoint) + ? verified.agentManifest.entrypoint.runtimePath + : undefined + const candidateNode = parseFiles( + verified.agentManifest.files, + 'Agent manifest' + ).find((file) => file.path === candidateRuntimePath) + if (candidateNode === undefined) { + return + } + const destination = join( + preparedRoot, + 'agent', + ...candidateNode.path.split('/') + ) + const linked = join(preparedRoot, '.reused-node') + const backup = join(preparedRoot, '.candidate-node') + let movedCandidate = false + try { + const registryPath = join(managedRoot, 'agent', 'registry.json') + const registryValue = parseJson( + readBoundedRegularFile( + registryPath, + MAXIMUM_METADATA_BYTES, + 'Installed Agent registry', + '0600' + ), + 'Installed Agent registry' + ) + if (!isRecord(registryValue)) { + return + } + exactKeys( + registryValue, + ['formatVersion', 'current'], + 'Installed Agent registry' + ) + if (registryValue.formatVersion !== 1 || !isRecord(registryValue.current)) { + return + } + const current = registryValue.current + exactKeys( + current, + ['installationId', 'agentVersion', 'manifestSha256', 'arch'], + 'Installed Agent registry current entry' + ) + if ( + typeof current.installationId !== 'string' || + typeof current.agentVersion !== 'string' || + !versionPattern.test(current.agentVersion) || + !sha256Pattern.test(String(current.manifestSha256)) || + current.installationId !== `agent-${String(current.manifestSha256)}` || + current.arch !== verified.agent.architecture + ) { + return + } + const root = join( + managedRoot, + 'agent', + 'installations', + current.installationId + ) + const manifestPath = join(root, 'manifest.json') + const signaturePath = join(root, 'manifest.sig') + const manifestBytes = readBoundedRegularFile( + manifestPath, + MAXIMUM_METADATA_BYTES, + 'Installed Agent manifest', + '0644' + ) + const signatureBytes = readBoundedRegularFile( + signaturePath, + MAXIMUM_METADATA_BYTES, + 'Installed Agent manifest signature', + '0644' + ) + const manifest = parseCanonicalJson(manifestBytes, 'Installed Agent manifest') + exactKeys( + manifest, + [ + 'formatVersion', + 'product', + 'agentVersion', + 'platform', + 'arch', + 'protocol', + 'signingKeyId', + 'entrypoint', + 'files', + 'licenses' + ], + 'Installed Agent manifest' + ) + verifySignature( + manifestBytes, + signatureBytes, + trustedProductionKey(verified.registry, manifest.signingKeyId), + AGENT_SIGNATURE_DOMAIN, + 'Installed Agent manifest' + ) + if ( + manifest.formatVersion !== 1 || + manifest.product !== 'GoodBuddy' || + manifest.platform !== 'linux' || + manifest.agentVersion !== current.agentVersion || + manifest.arch !== current.arch || + sha256(manifestBytes) !== current.manifestSha256 || + !isRecord(manifest.entrypoint) + ) { + return + } + const entrypoint = manifest.entrypoint + exactKeys( + entrypoint, + ['path', 'runtimePath', 'scriptPath'], + 'Installed Agent entrypoint' + ) + const currentNode = parseFiles( + manifest.files, + 'Installed Agent manifest' + ).find((file) => file.path === entrypoint.runtimePath) + if ( + currentNode === undefined || + canonicalJson(currentNode) !== canonicalJson(candidateNode) + ) { + return + } + const source = join(root, ...currentNode.path.split('/')) + if ( + !safeRegularFile(source, currentNode.mode, currentNode.size) || + hashFile(source) !== currentNode.sha256 || + !safeRegularFile(destination, candidateNode.mode, candidateNode.size) || + hashFile(destination) !== candidateNode.sha256 + ) { + return + } + linkSync(source, linked) + if ( + !safeRegularFile(linked, currentNode.mode, currentNode.size) || + lstatSync(linked).ino !== lstatSync(source).ino + ) { + unlinkSync(linked) + return + } + renameSync(destination, backup) + movedCandidate = true + renameSync(linked, destination) + unlinkSync(backup) + movedCandidate = false + } catch { + try { + if (existsSync(linked)) { + unlinkSync(linked) + } + if (movedCandidate && existsSync(backup)) { + if (existsSync(destination)) { + unlinkSync(destination) + } + renameSync(backup, destination) + movedCandidate = false + } else if (existsSync(backup)) { + unlinkSync(backup) + } + } catch { + // The normal candidate remains authoritative unless a swap completed. + } + } +} + +export function commitPackage(options: InstallerOptions): PackageInstallerResult { + const inputs = assertAbsoluteInputs(options, false) + const progress = eventEmitter('commit', options.emit) + progress('validating-prepared-state') + const preparedRoot = join(inputs.operationRoot, 'prepared') + if ( + !existsSync(preparedRoot) || + !lstatSync(preparedRoot).isDirectory() || + lstatSync(preparedRoot).isSymbolicLink() + ) { + throw new Error('Prepared package state directory is invalid') + } + const state = readPreparedState(join(preparedRoot, 'result.json')) + if (state.archiveSha256 !== inputs.expectedSha256) { + throw new Error('Prepared package state does not match the requested archive') + } + progress('revalidating-archive') + const verified = verifyArchive( + inputs.archive, + inputs.expectedSha256, + progress + ) + if ( + state.archiveSha256 !== verified.archiveSha256 || + canonicalJson(state.agent) !== canonicalJson(verified.agent) || + canonicalJson(state.runtime) !== canonicalJson(verified.runtime) + ) { + throw new Error('Prepared package state does not match the authenticated archive') + } + progress('extracting-payload') + const extractedRoot = extractPayload( + inputs.archive, + inputs.operationRoot, + verified + ) + try { + const home = resolve(options.homeDirectory ?? homedir()) + const managedRoot = join(home, '.goodbuddy') + ensureOwnedManagedHierarchy( + home, + verified.runtime.runtimeId + ) + tryReuseCurrentNode(extractedRoot, managedRoot, verified) + verifyAgentTree(join(extractedRoot, 'agent'), verified) + verifyRuntimeTree(join(extractedRoot, 'runtime'), verified) + progress('publishing-content') + const agentDestination = join( + managedRoot, + 'agent', + 'installations', + verified.agent.installationId + ) + const runtimeDestination = join( + managedRoot, + 'runtimes', + verified.runtime.runtimeId, + verified.runtime.bundleDigest.slice('sha256:'.length) + ) + const published: PublishedDirectory[] = [] + try { + published.push( + publishDirectory( + join(extractedRoot, 'agent'), + agentDestination, + inputs.operationRoot + ) + ) + verifyAgentTree(agentDestination, verified) + published.push( + publishDirectory( + join(extractedRoot, 'runtime'), + runtimeDestination, + inputs.operationRoot + ) + ) + verifyRuntimeTree(runtimeDestination, verified) + } catch (error) { + const rollbackErrors: unknown[] = [] + for (const entry of [...published].reverse()) { + try { + restoreReplacedDirectory(entry) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (rollbackErrors.length > 0) { + throw new PackageInstallerRollbackIncompleteError( + [error, ...rollbackErrors], + 'Package commit failed and a replaced managed destination could not be restored', + { cause: error } + ) + } + throw error + } + for (const entry of published) { + discardReplacedDirectory(entry) + } + const result: PackageInstallerResult = { + type: 'result', + command: 'commit', + status: 'committed', + archiveSha256: verified.archiveSha256, + agent: verified.agent, + runtime: verified.runtime + } + options.emit?.(result) + return result + } finally { + rmSync(extractedRoot, { recursive: true, force: true }) + } +} + +function parseCli(argv: readonly string[]): { + command: 'prepare' | 'commit' + options: InstallerOptions +} { + const command = argv[0] + if (command !== 'prepare' && command !== 'commit') { + throw new Error( + 'Usage: remote-package-installer.mjs --operation-root --archive --expected-sha256 ' + ) + } + const values: Record = {} + const allowed = new Set([ + '--operation-root', + '--archive', + '--expected-sha256' + ]) + for (let index = 1; index < argv.length; index += 2) { + const flag = argv[index] + const value = argv[index + 1] + if ( + flag === undefined || + value === undefined || + value.startsWith('--') || + !allowed.has(flag) || + values[flag] !== undefined + ) { + throw new Error('Invalid package installer argument') + } + values[flag] = value + } + for (const flag of allowed) { + if (values[flag] === undefined) { + throw new Error(`Missing required package installer argument: ${flag}`) + } + } + return { + command, + options: { + operationRoot: values['--operation-root']!, + archive: values['--archive']!, + expectedSha256: values['--expected-sha256']! + } + } +} + +function boundedMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.replace(/[\r\n]+/gu, ' ').slice(0, 2_000) +} + +export function runPackageInstallerCli( + argv: readonly string[] = process.argv.slice(2) +): number { + let command: 'prepare' | 'commit' = argv[0] === 'commit' ? 'commit' : 'prepare' + const emit = (event: PackageInstallerEvent): void => { + process.stdout.write(`${JSON.stringify(event)}\n`) + } + try { + const parsed = parseCli(argv) + command = parsed.command + const options = { ...parsed.options, emit } + if (command === 'prepare') { + preparePackage(options) + } else { + commitPackage(options) + } + return 0 + } catch (error) { + emit({ + type: 'error', + command, + status: + error instanceof PackageInstallerRollbackIncompleteError + ? 'rollback-incomplete' + : 'failed', + message: boundedMessage(error) + }) + return 1 + } +} + +if (typeof require !== 'undefined' && require.main === module) { + process.exitCode = runPackageInstallerCli() +} diff --git a/src/main/remote-agent/index.ts b/src/main/remote-agent/index.ts index 40b982a3..57a56322 100644 --- a/src/main/remote-agent/index.ts +++ b/src/main/remote-agent/index.ts @@ -66,7 +66,6 @@ export { ProtocolRemoteWorkspaceTransportError } from './protocol-remote-workspace-transport' export type { - DurableRemoteWorkspaceValidation, RemoteWorkspaceInstallationIdentityResolver, RemoteWorkspaceInstallationResolution } from './protocol-remote-workspace-transport' @@ -75,10 +74,9 @@ export { AgentInstallationManager } from './agent-installation-manager' export type { - AgentInstallationBundleLoader, AgentInstallationIdentity, AgentInstallationPhase, - AgentInstallationRequestOptions, + AgentActivationRequestOptions, AgentInstallationTargetResolver } from './agent-installation-manager' export { RemoteAgentServices } from './remote-agent-services' @@ -90,7 +88,6 @@ export { export type { RemoteProjectSaveOwner, RemoteProjectSaveServiceOptions, - RemoteProjectRuntimeValidationEvidence, RemoteProjectRuntimeValidationInput, RemoteProjectRuntimeValidationLease, RemoteProjectRuntimeValidator diff --git a/src/main/remote-agent/managed-remote-acp-runtime.test.ts b/src/main/remote-agent/managed-remote-acp-runtime.test.ts index 8ad13ab4..15f289aa 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.test.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.test.ts @@ -86,26 +86,6 @@ function descriptor(): SshExecutionSpaceDescriptor { kind: 'ssh', hostId: 'host-1', remoteRootPath: '/srv/project', - validation: { - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'alice', - workspaceIdentity: 'workspace-1', - agentProtocolMajor: 1, - agentInstallationIdAtValidation: 'agent-1', - agentBinaryDigestAtValidation: digest('a'), - agentVersionAtValidation: '1.0.0', - agentArchitectureAtValidation: 'x64', - validatedAt: '2030-01-01T00:00:00.000Z' - }, - runtimeValidation: { - runtimeSelectionKey: 'opencode:default', - runtimeBundleDigest: digest('b'), - runtimeAdapterDigest: digest('c'), - agentInstallationIdAtValidation: 'agent-1', - validatedAt: '2030-01-01T00:00:00.000Z', - workMode: 'ask' - }, cacheIdentity: 'cache-1', routeIdentity: 'route-1', workspaceAccess: { @@ -127,10 +107,25 @@ function harness( remoteHarness.instances.length = 0 remoteHarness.runGate = undefined const releaseConnection = vi.fn() - const workspaceAccesses: Array<{ - getIdentity: ReturnType - dispose: ReturnType - }> = [] + const workspaceValidate = vi.fn(async () => ({ + handle: { + workspaceId: 'workspace-id', + workspaceIdentity: 'workspace-1', + canonicalDisplayPath: '/srv/project', + access: 'read-only' as const, + git: 'available' as const, + capabilities: ['list', 'stat', 'read-text', 'search'], + generation: 1 + }, + validatedAt: '2030-01-01T00:00:00.000Z' + })) + const workspaceClose = vi.fn(async (params: { + workspaceId: string + generation: number + }) => ({ + ...params, + closed: true as const + })) const connection = { identity: { cacheKey: 'connection-cache', @@ -156,7 +151,23 @@ function harness( supervisor: 'detached-on-demand' }, capabilities: capabilities(), - client: { generation: 5 }, + client: { + generation: 5, + request: vi.fn(async (method: string, params: unknown) => { + if (method === 'workspace/validate') { + return await workspaceValidate() + } + if (method === 'workspace/close') { + return await workspaceClose( + params as { + workspaceId: string + generation: number + } + ) + } + throw new Error(`Unexpected method: ${method}`) + }) + }, state: 'ready', refreshCapabilities: vi.fn(async () => capabilities()), reconnect: vi.fn(async () => undefined), @@ -166,62 +177,36 @@ function harness( executionSpace: descriptor(), selection: { provider: 'opencode' as const }, agentServices: { - installationManager: { - ensureInstalled: vi.fn(async () => ({ - installationId: 'agent-1', - binaryDigest: digest('a'), - agentVersion: '1.0.0', - protocol: { major: 1, minor: 0 }, - platform: 'linux' as const, - architecture: 'x64' as const, - supervisor: 'detached-on-demand' as const - })) - }, connectionManager: { acquire: vi.fn(async () => connection) }, controllerState: { getControllerId: vi.fn(async () => 'controller-1') + }, + installationManager: { + activateInstalled: vi.fn(async () => ({ + installationId: 'agent-1', + binaryDigest: digest('a'), + agentVersion: '1.0.0', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand' + })) } }, runtimeInstallationManager: { - ensureInstalled: vi.fn(async () => ({ + activateInstalled: vi.fn(async () => ({ runtimeId: 'opencode', runtimeVersion: '1.18.9', bundleDigest: digest('b'), - manifestDigest: digest('e'), + manifestDigest: digest('d'), runtimeAdapterDigest: digest('c'), acpCapabilitiesDigest: digest('f'), - platform: 'linux' as const, - architecture: 'x64' as const + platform: 'linux', + architecture: 'x64' })) }, - workspaceAccessFactory: { - create: vi.fn(() => { - const access = { - getIdentity: vi.fn(async () => ({ - kind: 'remote' as const, - id: 'host-1:workspace-1', - canonicalDisplayPath: '/srv/project', - capabilities: { - read: true, - write: true, - search: true, - changes: true - } - })), - listDirectory: vi.fn(), - stat: vi.fn(), - readText: vi.fn(), - writeTextAtomic: vi.fn(), - search: vi.fn(), - getChanges: vi.fn(), - dispose: vi.fn(async () => undefined) - } - workspaceAccesses.push(access) - return access - }) - }, bindingStore: new MemoryRuntimeSessionBindingStore(), modelBridge: { policy: { @@ -243,7 +228,8 @@ function harness( options, connection, releaseConnection, - workspaceAccesses + workspaceValidate, + workspaceClose } } @@ -274,19 +260,13 @@ describe('createManagedRemoteAcpRuntime', () => { await expect( createManagedRemoteAcpRuntime(fixture.options) - ).rejects.toThrow(/validated OpenCode project evidence/iu) - expect( - fixture.options.agentServices.installationManager.ensureInstalled - ).not.toHaveBeenCalled() - expect( - fixture.options.runtimeInstallationManager.ensureInstalled - ).not.toHaveBeenCalled() + ).rejects.toThrow(/OpenCode Runtime/iu) expect( fixture.options.agentServices.connectionManager.acquire ).not.toHaveBeenCalled() }) - it('binds exact persisted evidence and owns its trust and connection leases', async () => { + it('binds the current Agent and Runtime and owns its connection lease', async () => { const fixture = harness() const runtime = await createManagedRemoteAcpRuntime( fixture.options @@ -296,9 +276,15 @@ describe('createManagedRemoteAcpRuntime', () => { id: 'opencode', available: true }) + expect(fixture.workspaceValidate).toHaveBeenCalledOnce() expect( - fixture.options.workspaceAccessFactory.create - ).not.toHaveBeenCalled() + fixture.connection.refreshCapabilities + ).toHaveBeenCalledOnce() + expect( + fixture.options.runtimeInstallationManager.activateInstalled + ).toHaveBeenCalledWith('host-1', { + agentInstallationId: 'agent-1' + }) expect( fixture.options.agentServices.connectionManager.acquire ).toHaveBeenCalledWith('host-1', expect.objectContaining({ @@ -317,6 +303,23 @@ describe('createManagedRemoteAcpRuntime', () => { expect(fixture.releaseConnection).toHaveBeenCalledOnce() }) + it('uses the current Host-management transport identity after Host edits', async () => { + const fixture = harness() + fixture.connection.identity.hostRevision = 9 + fixture.connection.identity.hostKeyGeneration = 10 + fixture.connection.identity.remoteUsername = 'current-user' + + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + + expect(remoteHarness.instances[0]!.options.identity).toMatchObject({ + hostRevision: 9, + hostKeyGeneration: 10 + }) + await runtime.dispose() + }) + it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => { const fixture = harness() const runtime = await createManagedRemoteAcpRuntime( @@ -329,7 +332,7 @@ describe('createManagedRemoteAcpRuntime', () => { controllerGeneration: 5, daemonBootIdAtOpen: 'boot-1' }) - expect(fixture.workspaceAccesses).toHaveLength(1) + expect(fixture.workspaceValidate).toHaveBeenCalledTimes(1) fixture.connection.state = 'offline' vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => { @@ -347,13 +350,11 @@ describe('createManagedRemoteAcpRuntime', () => { controllerGeneration: 6, daemonBootIdAtOpen: 'boot-2' }) - expect(fixture.workspaceAccesses).toHaveLength(2) - expect( - fixture.workspaceAccesses[0]!.dispose - ).toHaveBeenCalledOnce() + expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2) + expect(fixture.workspaceClose).toHaveBeenCalledTimes(1) await runtime.dispose() - expect(fixture.workspaceAccesses[1]!.dispose).toHaveBeenCalledOnce() + expect(fixture.workspaceClose).toHaveBeenCalledTimes(2) expect(fixture.releaseConnection).toHaveBeenCalledOnce() }) @@ -371,7 +372,7 @@ describe('createManagedRemoteAcpRuntime', () => { await expect(staleFactory('binding-1')).rejects.toThrow( /transport lease is stale/iu ) - expect(fixture.workspaceAccesses).toHaveLength(1) + expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2) await runtime.dispose() }) @@ -390,7 +391,7 @@ describe('createManagedRemoteAcpRuntime', () => { controllerGeneration: 5, daemonBootIdAtOpen: 'boot-2' }) - expect(fixture.workspaceAccesses).toHaveLength(2) + expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2) await runtime.dispose() }) @@ -428,27 +429,24 @@ describe('createManagedRemoteAcpRuntime', () => { await runtime.dispose() }) - it('releases exact trust without connecting when Runtime evidence changed', async () => { + it('rejects capabilities that differ from the current Runtime', async () => { const fixture = harness() vi.mocked( - fixture.options.runtimeInstallationManager.ensureInstalled + fixture.connection.refreshCapabilities ).mockResolvedValueOnce({ - runtimeId: 'opencode', - runtimeVersion: '1.18.9', - bundleDigest: digest('9'), - manifestDigest: digest('e'), - runtimeAdapterDigest: digest('c'), - acpCapabilitiesDigest: digest('f'), - platform: 'linux', - architecture: 'x64' + ...capabilities(), + runtimes: [ + { + ...capabilities().runtimes[0]!, + bundleDigest: digest('9') + } + ] }) await expect( createManagedRemoteAcpRuntime(fixture.options) - ).rejects.toThrow(/does not match project validation/iu) - expect( - fixture.options.agentServices.connectionManager.acquire - ).not.toHaveBeenCalled() + ).rejects.toThrow(/current OpenCode Runtime/iu) + expect(fixture.releaseConnection).toHaveBeenCalledOnce() }) it('releases both leases when the live Agent identity is stale', async () => { @@ -457,7 +455,7 @@ describe('createManagedRemoteAcpRuntime', () => { await expect( createManagedRemoteAcpRuntime(fixture.options) - ).rejects.toThrow(/connection does not match/iu) + ).rejects.toThrow(/connection is not current/iu) expect(fixture.releaseConnection).toHaveBeenCalledOnce() }) diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts index d75c17a4..6a91a4e4 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -12,11 +12,20 @@ import type { import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store' import type { SshExecutionSpaceDescriptor } from '../execution-space' import type { RemoteWorkspaceProjectBinding } from '../workspace' +import { + REMOTE_WORKSPACE_READ_CAPABILITIES, + remoteWorkspaceCloseResultSchema, + remoteWorkspaceValidateResultSchema, + type RemoteWorkspaceHandle +} from '../../shared/remote-agent-contracts' import { createProtocolRemoteRuntimeChannel } from './protocol-remote-runtime-channel' import type { RemoteAgentServices } from './remote-agent-services' -import type { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory' import type { ManagedModelBridge } from './managed-model-bridge' -import type { RemoteRuntimeInstallationManager } from './remote-runtime-installation-manager' +import type { AgentInstallationIdentity } from './agent-installation-manager' +import type { + RemoteRuntimeInstallationIdentity, + RemoteRuntimeInstallationManager +} from './remote-runtime-installation-manager' const RUNTIME_ACP_CAPABILITY = { name: 'runtime/acp', @@ -34,20 +43,26 @@ type ManagedRemoteTransportIdentity = { daemonBootId: string } +type ManagedRemoteRuntimeIdentity = { + runtimeId: 'opencode' + runtimeVersion: string + bundleDigest: string + runtimeAdapterDigest: string + acpCapabilitiesDigest: string + platform: 'linux' + architecture: 'x64' | 'arm64' +} + export type ManagedRemoteAcpRuntimeOptions = { executionSpace: SshExecutionSpaceDescriptor selection: AgentRuntimeSelection agentServices: Pick< RemoteAgentServices, - 'installationManager' | 'connectionManager' | 'controllerState' + 'connectionManager' | 'controllerState' | 'installationManager' > runtimeInstallationManager: Pick< RemoteRuntimeInstallationManager, - 'ensureInstalled' - > - workspaceAccessFactory: Pick< - ManagedRemoteWorkspaceAccessFactory, - 'create' + 'activateInstalled' > bindingStore: RuntimeSessionBindingStore modelBridge: ManagedModelBridge @@ -58,7 +73,7 @@ export type ManagedRemoteAcpRuntimeOptions = { } /** - * Acquires every production lease needed by one validated remote Runtime. + * Acquires every production lease needed by the Host's current Runtime. * The returned Runtime owns those leases and releases them only after all ACP * conversation channels have been closed. */ @@ -66,15 +81,9 @@ export async function createManagedRemoteAcpRuntime( options: ManagedRemoteAcpRuntimeOptions ): Promise { const descriptor = options.executionSpace - const validation = descriptor.validation - const runtimeValidation = descriptor.runtimeValidation - if ( - options.selection.provider !== 'opencode' || - validation === undefined || - runtimeValidation === undefined - ) { + if (options.selection.provider !== 'opencode') { throw new Error( - 'Managed remote execution requires validated OpenCode project evidence' + 'Managed remote execution requires the OpenCode Runtime' ) } const workspaceBinding = workspaceProjectBinding(descriptor) @@ -88,17 +97,9 @@ export async function createManagedRemoteAcpRuntime( let retained = false try { const agent = - await options.agentServices.installationManager.ensureInstalled( + await options.agentServices.installationManager.activateInstalled( descriptor.hostId ) - assertAgentMatches(workspaceBinding, agent) - - const runtime = - await options.runtimeInstallationManager.ensureInstalled( - descriptor.hostId - ) - assertRuntimeMatches(runtimeValidation, agent.architecture, runtime) - const activeConnection = await options.agentServices.connectionManager.acquire( descriptor.hostId, @@ -111,39 +112,29 @@ export async function createManagedRemoteAcpRuntime( } ) connection = activeConnection - assertConnectionMatches(workspaceBinding, activeConnection) - const initialTransport = { - controllerGeneration: activeConnection.client.generation, - daemonBootId: activeConnection.status.daemonBootId - } + assertConnectionMatches(workspaceBinding, agent, activeConnection) + const runtimeInstallation = + await options.runtimeInstallationManager.activateInstalled( + descriptor.hostId, + { agentInstallationId: agent.installationId } + ) const capabilities = await activeConnection.refreshCapabilities() - assertConnectionMatches(workspaceBinding, activeConnection) - assertSameTransport( - initialTransport, - { - controllerGeneration: activeConnection.client.generation, - daemonBootId: activeConnection.status.daemonBootId - }, - 'Remote Agent transport changed while creating the Runtime' + assertConnectionMatches(workspaceBinding, agent, activeConnection) + const runtime = runtimeIdentityFromCapabilities( + runtimeInstallation, + capabilities ) - assertRuntimeAdvertised(runtime, capabilities) const controllerId = await options.agentServices.controllerState.getControllerId() - assertSameTransport( - initialTransport, - { - controllerGeneration: activeConnection.client.generation, - daemonBootId: activeConnection.status.daemonBootId - }, - 'Remote Agent transport changed while creating the Runtime' - ) const resources = new ManagedRemoteRuntimeResources( - options.workspaceAccessFactory, workspaceBinding, + agent, activeConnection, runtime, options.modelBridge ) + const initialTransport = + await resources.prepareTransport(capabilities) const createRemote = ( transport: ManagedRemoteTransportIdentity @@ -156,15 +147,14 @@ export async function createManagedRemoteAcpRuntime( controllerId, controllerGeneration: transport.controllerGeneration, hostId: workspaceBinding.hostId, - hostRevision: workspaceBinding.hostRevision, - hostKeyGeneration: workspaceBinding.hostKeyGeneration, - workspaceIdentity: workspaceBinding.workspaceIdentity, - agentInstallationId: workspaceBinding.agentInstallationId, + hostRevision: activeConnection.identity.hostRevision, + hostKeyGeneration: + activeConnection.identity.hostKeyGeneration, + workspaceIdentity: resources.workspaceIdentity, + agentInstallationId: agent.installationId, daemonBootIdAtOpen: transport.daemonBootId, - runtimeBundleDigest: - runtimeValidation.runtimeBundleDigest, - runtimeAdapterDigest: - runtimeValidation.runtimeAdapterDigest, + runtimeBundleDigest: runtime.bundleDigest, + runtimeAdapterDigest: runtime.runtimeAdapterDigest }, channelFactory: async (bindingId) => resources.openChannel(transport, bindingId), @@ -174,11 +164,12 @@ export async function createManagedRemoteAcpRuntime( if ( identity.controllerId !== controllerId || identity.hostId !== workspaceBinding.hostId || - identity.hostRevision !== workspaceBinding.hostRevision || + identity.hostRevision !== + activeConnection.identity.hostRevision || identity.hostKeyGeneration !== - workspaceBinding.hostKeyGeneration || + activeConnection.identity.hostKeyGeneration || identity.agentInstallationId !== - workspaceBinding.agentInstallationId + agent.installationId ) { throw new Error( 'Remote Runtime Host identity no longer matches its project' @@ -217,16 +208,14 @@ class ManagedRemoteRuntimeResources { #workspace: | { transport: ManagedRemoteTransportIdentity - access: ReturnType + handle: RemoteWorkspaceHandle } | undefined #workspaceTail: Promise = Promise.resolve() #disposePromise?: Promise - constructor( - private readonly workspaceFactory: - Pick, private readonly workspaceBinding: RemoteWorkspaceProjectBinding, + private readonly agent: AgentInstallationIdentity, private readonly connection: NonNullable< Awaited< ReturnType< @@ -234,12 +223,17 @@ class ManagedRemoteRuntimeResources { > > >, - private readonly runtime: Awaited< - ReturnType - >, + private readonly runtime: ManagedRemoteRuntimeIdentity, private readonly modelBridge: ManagedModelBridge ) {} + get workspaceIdentity(): string { + if (this.#workspace === undefined) { + throw new Error('Remote Workspace is not open') + } + return this.#workspace.handle.workspaceIdentity + } + currentTransportIdentity(): ManagedRemoteTransportIdentity { return { controllerGeneration: this.connection.client.generation, @@ -247,14 +241,30 @@ class ManagedRemoteRuntimeResources { } } - async prepareTransport(): Promise { + async prepareTransport( + preparedCapabilities?: Awaited< + ReturnType< + RemoteAgentServices['connectionManager']['acquire'] + > + >['capabilities'] + ): Promise { if (this.connection.state !== 'ready') { await this.connection.reconnect() } - assertConnectionMatches(this.workspaceBinding, this.connection) + assertConnectionMatches( + this.workspaceBinding, + this.agent, + this.connection + ) const beforeRefresh = this.currentTransportIdentity() - const capabilities = await this.connection.refreshCapabilities() - assertConnectionMatches(this.workspaceBinding, this.connection) + const capabilities = + preparedCapabilities ?? + await this.connection.refreshCapabilities() + assertConnectionMatches( + this.workspaceBinding, + this.agent, + this.connection + ) assertSameTransport( beforeRefresh, this.currentTransportIdentity(), @@ -286,7 +296,7 @@ class ManagedRemoteRuntimeResources { bindingId, runtimeId: 'opencode', runtimeBundleDigest: this.runtime.bundleDigest, - workspaceIdentity: this.workspaceBinding.workspaceIdentity + workspaceIdentity: this.workspaceIdentity }, modelBridge: this.modelBridge.channel }) @@ -311,7 +321,6 @@ class ManagedRemoteRuntimeResources { this.#workspace !== undefined && sameTransport(this.#workspace.transport, transport) ) { - await this.#workspace.access.getIdentity() assertSameTransport( transport, this.currentTransportIdentity(), @@ -322,32 +331,40 @@ class ManagedRemoteRuntimeResources { const previous = this.#workspace this.#workspace = undefined if (previous !== undefined) { - await previous.access.dispose().catch(() => undefined) + await this.#closeWorkspace(previous.handle).catch( + () => undefined + ) } - const access = this.workspaceFactory.create( - this.workspaceBinding + const validated = remoteWorkspaceValidateResultSchema.parse( + await this.connection.client.request( + 'workspace/validate', + { + remoteRootPath: this.workspaceBinding.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [ + ...REMOTE_WORKSPACE_READ_CAPABILITIES + ] + } + ) ) try { - const identity = await access.getIdentity() - if ( - identity.kind !== 'remote' || - identity.canonicalDisplayPath !== - this.workspaceBinding.remoteRootPath || - identity.id !== - `${this.workspaceBinding.hostId}:${this.workspaceBinding.workspaceIdentity}` - ) { - throw new Error( - 'Live remote Workspace does not match persisted validation' - ) - } + assertWorkspace( + this.workspaceBinding, + validated.handle + ) assertSameTransport( transport, this.currentTransportIdentity(), 'Remote Agent transport changed while opening the Workspace' ) - this.#workspace = { transport, access } + this.#workspace = { + transport, + handle: validated.handle + } } catch (error) { - await access.dispose().catch(() => undefined) + await this.#closeWorkspace(validated.handle).catch( + () => undefined + ) throw error } }) @@ -355,6 +372,21 @@ class ManagedRemoteRuntimeResources { return operation } + async #closeWorkspace(handle: RemoteWorkspaceHandle): Promise { + const result = remoteWorkspaceCloseResultSchema.parse( + await this.connection.client.request('workspace/close', { + workspaceId: handle.workspaceId, + generation: handle.generation + }) + ) + if ( + result.workspaceId !== handle.workspaceId || + result.generation !== handle.generation + ) { + throw new Error('Remote Workspace close confirmation is invalid') + } + } + dispose(): Promise { this.#disposePromise ??= this.#dispose() return this.#disposePromise @@ -364,7 +396,9 @@ class ManagedRemoteRuntimeResources { await this.#workspaceTail const errors: unknown[] = [] try { - await this.#workspace?.access.dispose() + if (this.#workspace !== undefined) { + await this.#closeWorkspace(this.#workspace.handle) + } } catch (error) { errors.push(error) } @@ -562,77 +596,61 @@ function assertSameTransport( function workspaceProjectBinding( descriptor: SshExecutionSpaceDescriptor ): RemoteWorkspaceProjectBinding { - const validation = descriptor.validation - const runtime = descriptor.runtimeValidation - if ( - validation === undefined || - runtime === undefined || - validation.agentInstallationIdAtValidation !== - runtime.agentInstallationIdAtValidation - ) { - throw new Error('Remote project validation evidence is inconsistent') - } return { hostId: descriptor.hostId, - hostRevision: validation.hostRevision, - hostKeyGeneration: validation.hostKeyGeneration, - remoteUsername: validation.remoteUsername, - remoteRootPath: descriptor.remoteRootPath, - workspaceIdentity: validation.workspaceIdentity, - agentInstallationId: - validation.agentInstallationIdAtValidation, - agentBinaryDigest: - validation.agentBinaryDigestAtValidation, - agentVersion: validation.agentVersionAtValidation, - agentArchitecture: - validation.agentArchitectureAtValidation, - agentProtocolMajor: validation.agentProtocolMajor + remoteRootPath: descriptor.remoteRootPath } } -function assertAgentMatches( +function assertWorkspace( binding: RemoteWorkspaceProjectBinding, - agent: Awaited< - ReturnType - > + handle: RemoteWorkspaceHandle ): void { if ( - agent.installationId !== binding.agentInstallationId || - agent.binaryDigest !== binding.agentBinaryDigest || - agent.agentVersion !== binding.agentVersion || - agent.architecture !== binding.agentArchitecture || - agent.protocol.major !== binding.agentProtocolMajor + handle.canonicalDisplayPath !== binding.remoteRootPath || + handle.access !== 'read-only' || + REMOTE_WORKSPACE_READ_CAPABILITIES.some( + (capability) => !handle.capabilities.includes(capability) + ) ) { throw new Error( - 'Installed remote Agent does not match project validation' + 'Live remote Workspace does not match the project path' ) } } -function assertRuntimeMatches( - validation: NonNullable< - SshExecutionSpaceDescriptor['runtimeValidation'] - >, - architecture: 'x64' | 'arm64', - runtime: Awaited< - ReturnType - > -): void { +function runtimeIdentityFromCapabilities( + installation: RemoteRuntimeInstallationIdentity, + capabilities: Awaited< + ReturnType< + RemoteAgentServices['connectionManager']['acquire'] + > + >['capabilities'] +): ManagedRemoteRuntimeIdentity { if ( - runtime.runtimeId !== 'opencode' || - runtime.architecture !== architecture || - runtime.bundleDigest !== validation.runtimeBundleDigest || - runtime.runtimeAdapterDigest !== - validation.runtimeAdapterDigest + installation.runtimeId !== 'opencode' || + installation.platform !== 'linux' ) { throw new Error( - 'Installed OpenCode Runtime does not match project validation' + 'Remote Agent does not advertise the current Runtime' ) } + const runtime: ManagedRemoteRuntimeIdentity = { + runtimeId: 'opencode', + runtimeVersion: installation.runtimeVersion, + bundleDigest: installation.bundleDigest, + runtimeAdapterDigest: installation.runtimeAdapterDigest, + acpCapabilitiesDigest: installation.acpCapabilitiesDigest, + platform: 'linux', + architecture: installation.architecture + } + assertRuntimeAdvertised(runtime, capabilities) + return runtime } function assertConnectionMatches( binding: RemoteWorkspaceProjectBinding, + agent: AgentInstallationIdentity, connection: Awaited< ReturnType > @@ -640,31 +658,24 @@ function assertConnectionMatches( if ( connection.state !== 'ready' || connection.identity.hostId !== binding.hostId || - connection.identity.hostRevision !== binding.hostRevision || - connection.identity.hostKeyGeneration !== - binding.hostKeyGeneration || - connection.identity.remoteUsername !== binding.remoteUsername || - connection.identity.installationId !== - binding.agentInstallationId || - connection.identity.binaryDigest !== binding.agentBinaryDigest || - connection.identity.protocolMajor !== binding.agentProtocolMajor || + connection.identity.installationId !== agent.installationId || + connection.identity.binaryDigest !== agent.binaryDigest || + connection.identity.protocolMajor !== agent.protocol.major || connection.status.state !== 'ready' || connection.status.draining || - connection.status.installationId !== binding.agentInstallationId || - connection.status.binaryDigest !== binding.agentBinaryDigest || - connection.status.agentVersion !== binding.agentVersion || - connection.status.architecture !== binding.agentArchitecture + connection.status.installationId !== agent.installationId || + connection.status.binaryDigest !== agent.binaryDigest || + connection.status.agentVersion !== agent.agentVersion || + connection.status.architecture !== agent.architecture ) { throw new Error( - 'Remote Agent connection does not match project validation' + 'Remote Agent connection is not current' ) } } function assertRuntimeAdvertised( - runtime: Awaited< - ReturnType - >, + runtime: ManagedRemoteRuntimeIdentity, capabilities: Awaited< ReturnType< RemoteAgentServices['connectionManager']['acquire'] @@ -698,7 +709,7 @@ function assertRuntimeAdvertised( !advertised[0]!.sessionResume ) { throw new Error( - 'Remote Agent does not advertise the validated OpenCode Runtime' + 'Remote Agent does not advertise the current OpenCode Runtime' ) } } diff --git a/src/main/remote-agent/managed-remote-execution-services.ts b/src/main/remote-agent/managed-remote-execution-services.ts index 1d50a35a..6977866d 100644 --- a/src/main/remote-agent/managed-remote-execution-services.ts +++ b/src/main/remote-agent/managed-remote-execution-services.ts @@ -21,13 +21,13 @@ import { RemoteRuntimeInstallationManager, type RemoteRuntimeActivator } from './remote-runtime-installation-manager' +import { + loadRemoteRuntimeVerificationMetadata +} from './remote-runtime-resource-loader' import { createManagedModelBridge, reconcileStartupModelCalls } from './managed-model-bridge' -import type { - AgentPackageManager -} from './agent-package-manager' const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite' const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite' @@ -46,10 +46,6 @@ export type ManagedRemoteExecutionServicesOptions = { appPath: string resourcesPath: string packaged: boolean - agentPackageManager: Pick< - AgentPackageManager, - 'loadRuntimeBundle' | 'loadRuntimeMetadata' - > resolveModelProfile( selection: AgentRuntimeSelection ): Promise @@ -93,13 +89,17 @@ export class ManagedRemoteExecutionServices { runtimeId, bundleDigest, architecture, + currentAgentInstallationId, signal ) => { - const agent = - await options.agentServices.installationManager.ensureInstalled( - lease.identity.hostId, - { signal } - ) + const agentInstallationId = + currentAgentInstallationId ?? + ( + await options.agentServices.installationManager.activateInstalled( + lease.identity.hostId, + { signal } + ) + ).installationId signal.throwIfAborted() const current = await options.agentServices.targetResolver.resolve( lease.identity.hostId @@ -110,15 +110,14 @@ export class ManagedRemoteExecutionServices { current.host.id !== lease.identity.hostId || current.hostRevision !== lease.identity.hostRevision || current.hostKeyGeneration !== - lease.identity.hostKeyGeneration || - agent.architecture !== architecture + lease.identity.hostKeyGeneration ) { throw new Error( 'Remote Agent or SSH host identity changed before Runtime activation' ) } const result = await lease.runAgentRuntimeAction( - agent.installationId, + agentInstallationId, { kind: 'runtime-activate', runtimeId, @@ -135,10 +134,10 @@ export class ManagedRemoteExecutionServices { new RemoteRuntimeInstallationManager({ resolver: options.agentServices.targetResolver, sshPool: options.agentServices.sshPool, - loadVerifiedBundle: - options.agentPackageManager.loadRuntimeBundle, - loadVerificationMetadata: - options.agentPackageManager.loadRuntimeMetadata, + loadVerificationMetadata: () => + loadRemoteRuntimeVerificationMetadata( + this.runtimeResourcePaths + ), activate }) this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({ @@ -147,9 +146,9 @@ export class ManagedRemoteExecutionServices { }) this.workspaceAccessFactory = new ManagedRemoteWorkspaceAccessFactory({ + connectionManager: options.agentServices.connectionManager, installationManager: - options.agentServices.installationManager, - connectionManager: options.agentServices.connectionManager + options.agentServices.installationManager }) this.bindingStore = new SqliteRuntimeSessionBindingStore( join(options.userDataPath, RUNTIME_BINDING_DATABASE_NAME) @@ -188,6 +187,10 @@ export class ManagedRemoteExecutionServices { await this.#readiness } + invalidateHost(hostId: string): void { + this.runtimeInstallationManager.invalidateHost(hostId) + } + async createRuntime( options: Pick< ManagedRemoteAcpRuntimeOptions, @@ -206,14 +209,12 @@ export class ManagedRemoteExecutionServices { ...options, modelBridge, agentServices: { - installationManager: - this.#agentServices.installationManager, connectionManager: this.#agentServices.connectionManager, - controllerState: this.#agentServices.controllerState + controllerState: this.#agentServices.controllerState, + installationManager: + this.#agentServices.installationManager }, - runtimeInstallationManager: - this.runtimeInstallationManager, - workspaceAccessFactory: this.workspaceAccessFactory, + runtimeInstallationManager: this.runtimeInstallationManager, bindingStore: this.bindingStore }) } diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts index d7b8f29e..2de0e28b 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts @@ -8,9 +8,12 @@ import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-r import type { RemoteProjectRuntimeValidationInput } from './remote-project-save-service' import type { RemoteRuntimeInstallationIdentity } from './remote-runtime-installation-manager' import type { ResolvedModelProfile } from '../runtime-settings-store' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' const digest = (character: string): string => `sha256:${character.repeat(64)}` +const agentInstallationId = + verifyAgentInstallationId('agent-installation') const installation: RemoteRuntimeInstallationIdentity = { runtimeId: 'opencode', @@ -60,7 +63,6 @@ function advertisedCapabilities(): DaemonCapabilities { function harness(options: { selection?: RemoteProjectRuntimeValidationInput['selection'] - workMode?: 'ask' | 'execute' architecture?: 'x64' | 'arm64' installed?: RemoteRuntimeInstallationIdentity refreshed?: DaemonCapabilities @@ -72,7 +74,6 @@ function harness(options: { ({ provider: 'opencode' } as const) - const workMode = options.workMode ?? 'ask' const architecture = options.architecture ?? 'x64' const installed = options.installed ?? installation let state: RemoteAgentConnection['state'] = 'ready' @@ -85,7 +86,7 @@ function harness(options: { } const status: DaemonStatus = { state: 'ready', - installationId: 'agent-installation', + installationId: agentInstallationId, binaryDigest: digest('a'), daemonBootId: 'daemon-boot', agentVersion: '2.0.0', @@ -127,8 +128,8 @@ function harness(options: { reconnect: vi.fn(), release: vi.fn() } as unknown as RemoteAgentConnection - const ensureInstalled = vi.fn(async () => { - calls.push('install') + const activateInstalled = vi.fn(async () => { + calls.push('activate') return installed }) const resolveModelProfile = vi.fn(async () => { @@ -138,19 +139,12 @@ function harness(options: { : options.modelProfile ?? modelProfile }) const validator = new ManagedRemoteProjectRuntimeValidator({ - installationManager: { ensureInstalled }, - resolveModelProfile, - now: () => new Date('2030-01-01T00:00:00.000Z') + installationManager: { activateInstalled }, + resolveModelProfile }) const signal = new AbortController().signal const input: RemoteProjectRuntimeValidationInput = { selection, - runtimeSelectionKey: `${selection.provider}:${ - 'profileId' in selection - ? selection.profileId ?? 'platform' - : 'default' - }`, - workMode, host: { hostId: 'host-1', hostRevision: 3, @@ -158,20 +152,12 @@ function harness(options: { remoteUsername: 'builder' }, agent: { - installationId: status.installationId, + installationId: agentInstallationId, binaryDigest: status.binaryDigest, version: status.agentVersion, architecture, protocolMajor: 1 }, - workspace: { - canonicalRemoteRoot: '/srv/project', - workspaceIdentity: 'workspace-identity', - workspaceId: 'workspace-id', - generation: 1, - access: 'read-only', - capabilities: ['read-text'] - }, connection, signal } @@ -180,7 +166,7 @@ function harness(options: { input, connection, calls, - ensureInstalled, + activateInstalled, refreshCapabilities, resolveModelProfile, setCapabilities: (value: DaemonCapabilities) => { @@ -193,22 +179,15 @@ function harness(options: { } describe('ManagedRemoteProjectRuntimeValidator', () => { - it('installs, refreshes, and leases exact Ask Runtime evidence', async () => { + it('installs, refreshes, and leases the current Runtime', async () => { const test = harness() const lease = await test.validator.validate(test.input) - expect(test.calls).toEqual(['model', 'install', 'refresh']) - expect(test.ensureInstalled).toHaveBeenCalledWith('host-1', { + expect(test.calls).toEqual(['model', 'activate', 'refresh']) + expect(test.activateInstalled).toHaveBeenCalledWith('host-1', { + agentInstallationId, signal: test.input.signal }) - expect(lease.evidence).toEqual({ - runtimeSelectionKey: 'opencode:default', - runtimeBundleDigest: installation.bundleDigest, - runtimeAdapterDigest: installation.runtimeAdapterDigest, - agentInstallationIdAtValidation: 'agent-installation', - validatedAt: '2030-01-01T00:00:00.000Z', - workMode: 'ask' - }) expect(() => lease.assertCurrent()).not.toThrow() test.setCapabilities({ @@ -221,13 +200,10 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { expect(() => lease.assertCurrent()).toThrow(/released/iu) }) - it('validates Execute without a second authorization tier', async () => { - const test = harness({ workMode: 'execute' }) + it('fails the live lease when the connection goes offline', async () => { + const test = harness() const lease = await test.validator.validate(test.input) - expect(lease.evidence).toMatchObject({ - workMode: 'execute' - }) test.setState('offline') expect(() => lease.assertCurrent()).toThrow(/not ready/iu) }) @@ -243,7 +219,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { await expect(test.validator.validate(test.input)).rejects.toThrow( /OpenCode Runtime/iu ) - expect(test.ensureInstalled).not.toHaveBeenCalled() + expect(test.activateInstalled).not.toHaveBeenCalled() expect(test.refreshCapabilities).not.toHaveBeenCalled() }) @@ -263,7 +239,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => { /model profile/iu ) expect(test.resolveModelProfile).toHaveBeenCalledOnce() - expect(test.ensureInstalled).not.toHaveBeenCalled() + expect(test.activateInstalled).not.toHaveBeenCalled() expect(test.refreshCapabilities).not.toHaveBeenCalled() }) diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.ts index 6dba39d9..4f92b333 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.ts @@ -1,10 +1,7 @@ import { isDeepStrictEqual } from 'node:util' -import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts' -import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' import type { ResolvedModelProfile } from '../runtime-settings-store' import type { RemoteAgentConnection } from './remote-agent-connection-manager' import type { - RemoteProjectRuntimeValidationEvidence, RemoteProjectRuntimeValidationInput, RemoteProjectRuntimeValidationLease, RemoteProjectRuntimeValidator @@ -24,9 +21,8 @@ export class ManagedRemoteProjectRuntimeValidator { readonly #installationManager: Pick< RemoteRuntimeInstallationManager, - 'ensureInstalled' + 'activateInstalled' > - readonly #now: () => Date readonly #resolveModelProfile: ( selection: RemoteProjectRuntimeValidationInput['selection'] ) => Promise @@ -34,16 +30,14 @@ export class ManagedRemoteProjectRuntimeValidator constructor(options: { installationManager: Pick< RemoteRuntimeInstallationManager, - 'ensureInstalled' + 'activateInstalled' > resolveModelProfile( selection: RemoteProjectRuntimeValidationInput['selection'] ): Promise - now?: () => Date }) { this.#installationManager = options.installationManager this.#resolveModelProfile = options.resolveModelProfile - this.#now = options.now ?? (() => new Date()) } async validate( @@ -51,9 +45,7 @@ export class ManagedRemoteProjectRuntimeValidator ): Promise { input.signal.throwIfAborted() if ( - input.selection.provider !== 'opencode' || - input.runtimeSelectionKey !== - agentRuntimeSelectionKey(input.selection) + input.selection.provider !== 'opencode' ) { throw new Error( 'Managed remote projects require the OpenCode Runtime' @@ -75,9 +67,12 @@ export class ManagedRemoteProjectRuntimeValidator ) } const installation = - await this.#installationManager.ensureInstalled( + await this.#installationManager.activateInstalled( input.host.hostId, - { signal: input.signal } + { + signal: input.signal, + agentInstallationId: input.agent.installationId + } ) input.signal.throwIfAborted() assertInstallationIdentity(input, installation) @@ -96,19 +91,9 @@ export class ManagedRemoteProjectRuntimeValidator } assertRuntimeCapability(refreshed, installation) - const validated = projectRuntimeValidationSchema.parse({ - runtimeSelectionKey: input.runtimeSelectionKey, - runtimeBundleDigest: installation.bundleDigest, - runtimeAdapterDigest: installation.runtimeAdapterDigest, - agentInstallationIdAtValidation: input.agent.installationId, - validatedAt: this.#now().toISOString(), - workMode: input.workMode - }) - const evidence: RemoteProjectRuntimeValidationEvidence = validated const capabilitySnapshot = structuredClone(refreshed) let released = false return { - evidence, assertCurrent: (): void => { if (released) { throw new Error( diff --git a/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts b/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts index 1aff84eb..7320e111 100644 --- a/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts +++ b/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts @@ -5,11 +5,6 @@ import { } from '../../shared/agent-protocol' import type { RemoteWorkspaceHandle } from '../../shared/remote-agent-contracts' import type { RemoteWorkspaceProjectBinding } from '../workspace/remote-workspace-access' -import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' -import type { - AgentInstallationIdentity, - AgentInstallationManager -} from './agent-installation-manager' import type { AgentProtocolClient } from './agent-protocol-client' import { ManagedRemoteWorkspaceAccessFactory, @@ -19,35 +14,26 @@ import type { RemoteAgentConnection, RemoteAgentConnectionManager } from './remote-agent-connection-manager' +import type { AgentInstallationIdentity } from './agent-installation-manager' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' const digest = `sha256:${'a'.repeat(64)}` const binding: RemoteWorkspaceProjectBinding = { hostId: '00000000-0000-4000-8000-000000000201', - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - remoteRootPath: '/srv/project', - workspaceIdentity: 'workspace-identity', - agentInstallationId: 'installation-test', - agentBinaryDigest: digest, - agentVersion: '0.11.0', - agentArchitecture: 'x64', - agentProtocolMajor: AGENT_PROTOCOL_VERSION.major + remoteRootPath: '/srv/project' } const installation: AgentInstallationIdentity = { - installationId: verifyAgentInstallationId( - binding.agentInstallationId - ), - binaryDigest: binding.agentBinaryDigest, - agentVersion: binding.agentVersion, - protocol: { ...AGENT_PROTOCOL_VERSION }, + installationId: verifyAgentInstallationId('installation-test'), + binaryDigest: digest, + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, platform: 'linux', - architecture: binding.agentArchitecture, + architecture: 'x64', supervisor: 'detached-on-demand' } const handle: RemoteWorkspaceHandle = { workspaceId: 'workspace-1', - workspaceIdentity: binding.workspaceIdentity, + workspaceIdentity: 'workspace-identity', canonicalDisplayPath: binding.remoteRootPath, access: 'read-only', git: 'available', @@ -63,21 +49,38 @@ const handle: RemoteWorkspaceHandle = { } describe('ManagedRemoteWorkspaceAccessFactory', () => { - it('verifies installation before network work and owns the connection lease', async () => { + it('opens with the Host current Agent and owns the connection lease', async () => { const test = createHarness() const access = test.factory.create(binding) await expect(access.getIdentity()).resolves.toEqual({ kind: 'remote', - id: `${binding.hostId}:${binding.workspaceIdentity}`, + id: `${binding.hostId}:${handle.workspaceIdentity}`, canonicalDisplayPath: binding.remoteRootPath, access: 'read-only' }) expect(test.events.slice(0, 2)).toEqual([ - 'agent:ensure', + 'installation:activate', 'connection:acquire' ]) + expect(test.activateInstalled).toHaveBeenCalledWith( + binding.hostId, + {} + ) + expect(test.acquire).toHaveBeenCalledWith( + binding.hostId, + expect.objectContaining({ + installationId: installation.installationId, + binaryDigest: installation.binaryDigest, + agentVersion: installation.agentVersion, + architecture: installation.architecture, + protocol: expect.objectContaining({ + major: installation.protocol.major + }) + }), + undefined + ) expect(test.protocolMethods).toContain('workspace/validate') expect(test.protocolMethods).not.toContain('workspace/open') expect(test.protocolMethods).not.toContain('workspace/resume') @@ -92,49 +95,6 @@ describe('ManagedRemoteWorkspaceAccessFactory', () => { ).toHaveLength(1) }) - it.each([ - [ - 'installation id', - { - ...installation, - installationId: - verifyAgentInstallationId('installation-other') - } - ], - [ - 'binary digest', - { - ...installation, - binaryDigest: `sha256:${'b'.repeat(64)}` - } - ], - ['version', { ...installation, agentVersion: '0.11.1' }], - ['architecture', { ...installation, architecture: 'arm64' as const }], - [ - 'protocol major', - { - ...installation, - protocol: { - ...installation.protocol, - major: installation.protocol.major + 1 - } - } - ] - ])( - 'rejects an installed Agent with a different persisted %s', - async (_field, changedInstallation) => { - const test = createHarness({ - ensureInstalled: async () => changedInstallation - }) - const access = test.factory.create(binding) - - await expect(access.getIdentity()).rejects.toThrow( - 'does not match the persisted project binding' - ) - expect(test.acquire).not.toHaveBeenCalled() - } - ) - it('propagates connection acquisition failures', async () => { const test = createHarness({ acquireError: new Error('network unavailable') @@ -150,25 +110,18 @@ describe('ManagedRemoteWorkspaceAccessFactory', () => { type HarnessOptions = { acquireError?: Error - ensureInstalled?: () => Promise } function createHarness(options: HarnessOptions = {}): { factory: ManagedRemoteWorkspaceAccessFactory events: string[] protocolMethods: string[] - ensureInstalled: ReturnType acquire: ReturnType + activateInstalled: ReturnType connectionRelease: ReturnType } { const events: string[] = [] const protocolMethods: string[] = [] - const ensureInstalled = vi.fn(async () => { - events.push('agent:ensure') - return options.ensureInstalled - ? await options.ensureInstalled() - : installation - }) const client = { request: vi.fn(async (method: string) => { protocolMethods.push(method) @@ -198,23 +151,23 @@ function createHarness(options: HarnessOptions = {}): { identity: { cacheKey: 'connection-cache', hostId: binding.hostId, - hostRevision: binding.hostRevision, - hostKeyGeneration: binding.hostKeyGeneration, - remoteUsername: binding.remoteUsername, - installationId: binding.agentInstallationId, - binaryDigest: binding.agentBinaryDigest, - protocolMajor: binding.agentProtocolMajor, - protocolMinor: installation.protocol.minor + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + installationId: installation.installationId, + binaryDigest: installation.binaryDigest, + protocolMajor: installation.protocol.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor }, status: { state: 'ready', - installationId: binding.agentInstallationId, - binaryDigest: binding.agentBinaryDigest, + installationId: installation.installationId, + binaryDigest: installation.binaryDigest, daemonBootId: 'boot-1', - agentVersion: binding.agentVersion, - protocol: { ...installation.protocol }, + agentVersion: installation.agentVersion, + protocol: { ...AGENT_PROTOCOL_VERSION }, platform: 'linux', - architecture: binding.agentArchitecture, + architecture: installation.architecture, supervisor: 'detached-on-demand', remoteUserIdentity: 'uid:1000', draining: false @@ -235,20 +188,24 @@ function createHarness(options: HarnessOptions = {}): { } return connection }) + const activateInstalled = vi.fn(async () => { + events.push('installation:activate') + return installation + }) const factoryOptions = { - installationManager: { - ensureInstalled - } as unknown as Pick, connectionManager: { acquire - } as unknown as Pick + } as unknown as Pick, + installationManager: { + activateInstalled + } } satisfies ManagedRemoteWorkspaceAccessFactoryOptions return { factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions), events, protocolMethods, - ensureInstalled, acquire, + activateInstalled, connectionRelease } } diff --git a/src/main/remote-agent/managed-remote-workspace-access-factory.ts b/src/main/remote-agent/managed-remote-workspace-access-factory.ts index fd9e058d..08b9f864 100644 --- a/src/main/remote-agent/managed-remote-workspace-access-factory.ts +++ b/src/main/remote-agent/managed-remote-workspace-access-factory.ts @@ -3,25 +3,21 @@ import { type RemoteWorkspaceAccessFactory, type RemoteWorkspaceProjectBinding } from '../workspace/remote-workspace-access' -import type { AgentInstallationManager } from './agent-installation-manager' import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager' +import type { AgentInstallationManager } from './agent-installation-manager' import { ProtocolRemoteWorkspaceTransport, type RemoteWorkspaceInstallationIdentityResolver } from './protocol-remote-workspace-transport' export type ManagedRemoteWorkspaceAccessFactoryOptions = { - installationManager: Pick< - AgentInstallationManager, - 'ensureInstalled' - > connectionManager: Pick + installationManager: Pick } /** - * Composes persisted project validation with fresh installation and - * connection leases. The resulting WorkspaceAccess remains strictly - * read-only and owns every acquired connection lease. + * Resolves the Host's current Agent before opening a read-only Workspace + * connection. Projects persist only the Host and remote path. */ export class ManagedRemoteWorkspaceAccessFactory implements RemoteWorkspaceAccessFactory @@ -33,25 +29,21 @@ export class ManagedRemoteWorkspaceAccessFactory create(bindingInput: RemoteWorkspaceProjectBinding): RemoteWorkspaceAccess { const binding = { ...bindingInput } const resolver: RemoteWorkspaceInstallationIdentityResolver = { - resolve: async (hostId, installationId, signal) => { - if ( - hostId !== binding.hostId || - installationId !== binding.agentInstallationId - ) { + resolve: async (hostId, signal) => { + if (hostId !== binding.hostId) { throw new Error( - 'Remote workspace installation request does not match its project binding' + 'Remote workspace Host does not match its project binding' ) } signal?.throwIfAborted() - const installation = - await this.options.installationManager.ensureInstalled( - binding.hostId, - { signal } - ) - signal?.throwIfAborted() - assertInstallationMatches(binding, installation) - return { installation } + return { + installation: + await this.options.installationManager.activateInstalled( + hostId, + { ...(signal === undefined ? {} : { signal }) } + ) + } } } return new RemoteWorkspaceAccess( @@ -63,22 +55,3 @@ export class ManagedRemoteWorkspaceAccessFactory ) } } - -function assertInstallationMatches( - binding: RemoteWorkspaceProjectBinding, - installation: Awaited< - ReturnType - > -): void { - if ( - installation.installationId !== binding.agentInstallationId || - installation.binaryDigest !== binding.agentBinaryDigest || - installation.agentVersion !== binding.agentVersion || - installation.architecture !== binding.agentArchitecture || - installation.protocol.major !== binding.agentProtocolMajor - ) { - throw new Error( - 'Installed Remote Agent does not match the persisted project binding' - ) - } -} diff --git a/src/main/remote-agent/protocol-remote-workspace-transport.test.ts b/src/main/remote-agent/protocol-remote-workspace-transport.test.ts index a0c699d6..c0034235 100644 --- a/src/main/remote-agent/protocol-remote-workspace-transport.test.ts +++ b/src/main/remote-agent/protocol-remote-workspace-transport.test.ts @@ -89,16 +89,7 @@ const digest = `sha256:${'a'.repeat(64)}` const timestamp = '2026-08-21T00:00:00.000Z' const binding: RemoteWorkspaceProjectBinding = { hostId: 'host-1', - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - remoteRootPath: '/srv/project', - workspaceIdentity: 'workspace-identity', - agentInstallationId: 'installation-test', - agentBinaryDigest: digest, - agentVersion: '0.11.0', - agentArchitecture: 'x64', - agentProtocolMajor: AGENT_PROTOCOL_VERSION.major + remoteRootPath: '/srv/project' } const installation: RemoteAgentInstallationIdentity = { installationId: verifyAgentInstallationId('installation-test'), @@ -111,7 +102,7 @@ const installation: RemoteAgentInstallationIdentity = { } const handle: RemoteWorkspaceHandle = { workspaceId: 'workspace-1', - workspaceIdentity: binding.workspaceIdentity, + workspaceIdentity: 'workspace-identity', canonicalDisplayPath: binding.remoteRootPath, access: 'read-only', git: 'available', @@ -139,16 +130,9 @@ describe('ProtocolRemoteWorkspaceTransport', () => { methods: workspaceMethods(calls) }) const durableBinding = transportBinding(test, 1) - test.resolution.durableWorkspaceValidation = { - binding: durableBinding, - workspaceIdentity: binding.workspaceIdentity, - handle, - validatedAt: timestamp - } const lease = await test.transport.acquireLease(binding) expect(lease.binding).toEqual(durableBinding) - expect(lease.resumableHandle).toEqual(handle) await expect( lease.validateWorkspace({ remoteRootPath: binding.remoteRootPath, @@ -156,19 +140,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => { requiredCapabilities: [...handle.capabilities] }) ).resolves.toEqual({ handle, validatedAt: timestamp }) - await expect( - lease.openWorkspace({ - workspaceIdentity: binding.workspaceIdentity, - requestedAccess: 'read-only' - }) - ).resolves.toEqual(handle) - await expect( - lease.resumeWorkspace({ - workspaceId: handle.workspaceId, - generation: handle.generation, - workspaceIdentity: binding.workspaceIdentity - }) - ).resolves.toEqual({ resumed: true, handle }) await expect( lease.listWorkspace({ workspaceId: handle.workspaceId, @@ -244,8 +215,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => { expect(calls).toEqual([ 'workspace/validate', - 'workspace/open', - 'workspace/resume', 'workspace/list', 'workspace/stat', 'workspace/readText', @@ -259,38 +228,49 @@ describe('ProtocolRemoteWorkspaceTransport', () => { test.close() }) - it('accepts a durable non-Git handle for read operations', async () => { - const nonGitHandle: RemoteWorkspaceHandle = { - ...handle, - git: 'not-a-repository', - capabilities: ['list', 'stat', 'read-text', 'search'] - } - const test = createHarness() - test.resolution.durableWorkspaceValidation = { - binding: transportBinding(test, 1), - workspaceIdentity: binding.workspaceIdentity, - handle: nonGitHandle, - validatedAt: timestamp - } + it('uses the current Host-management transport identity for workspace access', async () => { + const test = createHarness({ + identity: { + hostRevision: 99, + hostKeyGeneration: 100, + remoteUsername: 'current-user' + } + }) const lease = await test.transport.acquireLease(binding) + expect(test.release).not.toHaveBeenCalled() + lease.release() + expect(test.release).toHaveBeenCalledOnce() + test.close() + }) - expect(lease.resumableHandle).toEqual(nonGitHandle) + it('accepts an older compatible Agent protocol minor', async () => { + const test = createHarness({ + installation: { + ...installation, + protocol: { + major: AGENT_PROTOCOL_VERSION.major, + minor: AGENT_PROTOCOL_VERSION.minor + 1 + } + }, + identity: { + protocolMinor: AGENT_PROTOCOL_VERSION.minor + }, + connectionStatus: { + protocol: { + major: AGENT_PROTOCOL_VERSION.major, + minor: AGENT_PROTOCOL_VERSION.minor + } + } + }) + + const lease = await test.transport.acquireLease(binding) lease.release() expect(test.release).toHaveBeenCalledOnce() test.close() }) it('fails closed for project, installation, status, and capability mismatches', async () => { - const hostMismatch = createHarness({ - identity: { hostRevision: 99 } - }) - await expect( - hostMismatch.transport.acquireLease(binding) - ).rejects.toMatchObject({ reason: 'binding-mismatch' }) - expect(hostMismatch.release).toHaveBeenCalledOnce() - hostMismatch.close() - const installationMismatch = createHarness({ installation: { ...installation, @@ -301,7 +281,8 @@ describe('ProtocolRemoteWorkspaceTransport', () => { await expect( installationMismatch.transport.acquireLease(binding) ).rejects.toMatchObject({ reason: 'binding-mismatch' }) - expect(installationMismatch.acquire).not.toHaveBeenCalled() + expect(installationMismatch.acquire).toHaveBeenCalledOnce() + expect(installationMismatch.release).toHaveBeenCalledOnce() installationMismatch.close() const statusMismatch = createHarness({ @@ -333,7 +314,8 @@ describe('ProtocolRemoteWorkspaceTransport', () => { await expect( identityMismatch.transport.acquireLease(binding) ).rejects.toMatchObject({ reason: 'binding-mismatch' }) - expect(identityMismatch.acquire).not.toHaveBeenCalled() + expect(identityMismatch.acquire).toHaveBeenCalledOnce() + expect(identityMismatch.release).toHaveBeenCalledOnce() identityMismatch.close() } @@ -421,24 +403,6 @@ describe('ProtocolRemoteWorkspaceTransport', () => { test.close() }) - it('only exposes resumable state with a complete durable identity match', async () => { - const test = createHarness() - test.resolution.durableWorkspaceValidation = { - binding: { - ...transportBinding(test, 1), - capabilityGeneration: 2 - }, - workspaceIdentity: binding.workspaceIdentity, - handle, - validatedAt: timestamp - } - const lease = await test.transport.acquireLease(binding) - - expect(lease.resumableHandle).toBeUndefined() - lease.release() - test.close() - }) - it('keeps one connection reference per lease and makes close and release idempotent', async () => { const closeHandler = vi.fn( workspaceMethods()['workspace/close'] @@ -491,7 +455,7 @@ function createHarness(options: HarnessOptions = {}) { const events = new EventJournal(resolve(root, 'events.sqlite')) const status = { state: 'ready' as const, - installationId: binding.agentInstallationId, + installationId: installation.installationId, binaryDigest: digest, daemonBootId: 'boot-test', agentVersion: '0.11.0', @@ -544,10 +508,10 @@ function createHarness(options: HarnessOptions = {}) { identity: { cacheKey: 'connection-cache', hostId: binding.hostId, - hostRevision: binding.hostRevision, - hostKeyGeneration: binding.hostKeyGeneration, - remoteUsername: binding.remoteUsername, - installationId: binding.agentInstallationId, + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + installationId: installation.installationId, binaryDigest: digest, protocolMajor: AGENT_PROTOCOL_VERSION.major, protocolMinor: AGENT_PROTOCOL_VERSION.minor, @@ -572,12 +536,7 @@ function createHarness(options: HarnessOptions = {}) { } satisfies RemoteAgentConnection }) const resolution = { - installation: options.installation ?? installation, - durableWorkspaceValidation: undefined - } as { - installation: RemoteAgentInstallationIdentity - durableWorkspaceValidation?: - import('./protocol-remote-workspace-transport').DurableRemoteWorkspaceValidation + installation: options.installation ?? installation } const resolver: RemoteWorkspaceInstallationIdentityResolver = { resolve: vi.fn(async () => resolution) @@ -697,14 +656,14 @@ function transportBinding( ): RemoteWorkspaceTransportBinding { return { hostId: binding.hostId, - hostRevision: binding.hostRevision, - hostKeyGeneration: binding.hostKeyGeneration, - remoteUsername: binding.remoteUsername, - agentInstallationId: binding.agentInstallationId, - agentBinaryDigest: binding.agentBinaryDigest, - agentVersion: binding.agentVersion, - agentArchitecture: binding.agentArchitecture, - agentProtocolMajor: binding.agentProtocolMajor, + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + agentInstallationId: installation.installationId, + agentBinaryDigest: installation.binaryDigest, + agentVersion: '0.11.0', + agentArchitecture: installation.architecture, + agentProtocolMajor: AGENT_PROTOCOL_VERSION.major, capabilityGeneration } } @@ -762,7 +721,7 @@ class MemoryAgentTransport { protocol: AGENT_PROTOCOL_VERSION, connectionId: controller.connectionId, generation: controller.generation, - installationId: binding.agentInstallationId, + installationId: installation.installationId, binaryDigest: digest, daemonBootId: 'boot-test', serverNonce: 'server-test' diff --git a/src/main/remote-agent/protocol-remote-workspace-transport.ts b/src/main/remote-agent/protocol-remote-workspace-transport.ts index 3eb1d726..163d834a 100644 --- a/src/main/remote-agent/protocol-remote-workspace-transport.ts +++ b/src/main/remote-agent/protocol-remote-workspace-transport.ts @@ -1,8 +1,5 @@ -import { - REMOTE_WORKSPACE_READ_CAPABILITIES, - remoteWorkspaceHandleSchema, - type RemoteWorkspaceCloseResult, - type RemoteWorkspaceHandle +import type { + RemoteWorkspaceCloseResult } from '../../shared/remote-agent-contracts' import type { RemoteWorkspaceProjectBinding, @@ -23,26 +20,13 @@ import type { const WORKSPACE_READ_CAPABILITY = 'workspace/read' const WORKSPACE_READ_CAPABILITY_VERSION = 1 -export type DurableRemoteWorkspaceValidation = { - binding: RemoteWorkspaceTransportBinding - workspaceIdentity: string - handle: RemoteWorkspaceHandle - validatedAt: string -} - export type RemoteWorkspaceInstallationResolution = { installation: RemoteAgentInstallationIdentity - /** - * This value must come from durable validation storage. The transport never - * promotes an in-memory handle or an incomplete record into resume state. - */ - durableWorkspaceValidation?: DurableRemoteWorkspaceValidation } export interface RemoteWorkspaceInstallationIdentityResolver { resolve( hostId: string, - installationId: string, signal?: AbortSignal ): Promise } @@ -85,7 +69,6 @@ export class ProtocolRemoteWorkspaceTransport const projectBinding = { ...binding } const resolved = await this.installations.resolve( projectBinding.hostId, - projectBinding.agentInstallationId, signal ) let resolution: RemoteWorkspaceInstallationResolution | undefined @@ -94,15 +77,13 @@ export class ProtocolRemoteWorkspaceTransport try { resolution = snapshotResolution(resolved) signal?.throwIfAborted() - assertResolution(projectBinding, resolution) connection = await this.connections.acquire( projectBinding.hostId, requireWorkspaceReadCapability(resolution.installation), signal ) - const status = connection.status assertConnectionIdentity(projectBinding, resolution, connection) - assertConnectionStatus(projectBinding, resolution, status) + assertConnectionStatus(resolution, connection.status) const capabilities = await connection.client.request( 'agent/capabilities', {}, @@ -116,23 +97,11 @@ export class ProtocolRemoteWorkspaceTransport ) { throw staleGenerationError() } - const transportBinding = liveBinding( - projectBinding, - resolution, - connection, - capabilities.generation - ) - const resumableHandle = validatedResumableHandle( - projectBinding, - transportBinding, - resolution.durableWorkspaceValidation - ) const lease = createLease({ binding: projectBinding, resolution, connection, - acquiredCapabilityGeneration: capabilities.generation, - resumableHandle + acquiredCapabilityGeneration: capabilities.generation }) retained = true return lease @@ -148,7 +117,6 @@ function snapshotResolution( resolution: RemoteWorkspaceInstallationResolution ): RemoteWorkspaceInstallationResolution { const installation = resolution.installation - const durable = resolution.durableWorkspaceValidation return { installation: { ...installation, @@ -163,20 +131,7 @@ function snapshotResolution( ...entry })) }) - }, - ...(durable === undefined - ? {} - : { - durableWorkspaceValidation: { - binding: { ...durable.binding }, - workspaceIdentity: durable.workspaceIdentity, - handle: { - ...durable.handle, - capabilities: [...durable.handle.capabilities] - }, - validatedAt: durable.validatedAt - } - }) + } } } @@ -185,7 +140,6 @@ type LeaseState = { resolution: RemoteWorkspaceInstallationResolution connection: RemoteAgentConnection acquiredCapabilityGeneration: number - resumableHandle?: RemoteWorkspaceHandle } function createLease(state: LeaseState): RemoteWorkspaceTransportLease { @@ -218,7 +172,6 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease { state.connection ) assertConnectionStatus( - state.binding, state.resolution, state.connection.status ) @@ -264,15 +217,8 @@ function createLease(state: LeaseState): RemoteWorkspaceTransportLease { state.acquiredCapabilityGeneration ) }, - ...(state.resumableHandle === undefined - ? {} - : { resumableHandle: state.resumableHandle }), validateWorkspace: async (value, signal) => await request('workspace/validate', value, signal), - openWorkspace: async (value, signal) => - await request('workspace/open', value, signal), - resumeWorkspace: async (value, signal) => - await request('workspace/resume', value, signal), closeWorkspace: (value) => { currentClient() if ( @@ -337,8 +283,6 @@ function releaseLeaseResources( type WorkspaceReadProtocolMethod = | 'workspace/validate' - | 'workspace/open' - | 'workspace/resume' | 'workspace/close' | 'workspace/list' | 'workspace/stat' @@ -382,26 +326,6 @@ function requireWorkspaceReadCapability( } } -function assertResolution( - binding: RemoteWorkspaceProjectBinding, - resolution: RemoteWorkspaceInstallationResolution -): void { - const protocol = resolution.installation.protocol - if ( - resolution.installation.installationId !== - binding.agentInstallationId || - resolution.installation.binaryDigest !== - binding.agentBinaryDigest || - resolution.installation.agentVersion !== binding.agentVersion || - resolution.installation.architecture !== - binding.agentArchitecture || - protocol === undefined || - protocol.major !== binding.agentProtocolMajor - ) { - throw bindingMismatchError() - } -} - function assertConnectionIdentity( binding: RemoteWorkspaceProjectBinding, resolution: RemoteWorkspaceInstallationResolution, @@ -411,22 +335,18 @@ function assertConnectionIdentity( const protocol = resolution.installation.protocol if ( identity.hostId !== binding.hostId || - identity.hostRevision !== binding.hostRevision || - identity.hostKeyGeneration !== binding.hostKeyGeneration || - identity.remoteUsername !== binding.remoteUsername || - identity.installationId !== binding.agentInstallationId || - identity.binaryDigest !== binding.agentBinaryDigest || + identity.installationId !== + resolution.installation.installationId || identity.binaryDigest !== resolution.installation.binaryDigest || - identity.protocolMajor !== binding.agentProtocolMajor || protocol === undefined || - identity.protocolMinor !== protocol.minor + identity.protocolMajor !== protocol.major || + identity.protocolMinor > protocol.minor ) { throw bindingMismatchError() } } function assertConnectionStatus( - binding: RemoteWorkspaceProjectBinding, resolution: RemoteWorkspaceInstallationResolution, status: RemoteAgentConnection['status'] ): void { @@ -434,16 +354,13 @@ function assertConnectionStatus( if ( status.state !== 'ready' || status.draining || - status.installationId !== binding.agentInstallationId || - status.binaryDigest !== binding.agentBinaryDigest || + status.installationId !== installation.installationId || status.binaryDigest !== installation.binaryDigest || - status.agentVersion !== binding.agentVersion || status.agentVersion !== installation.agentVersion || - status.architecture !== binding.agentArchitecture || status.architecture !== installation.architecture || - status.protocol.major !== binding.agentProtocolMajor || installation.protocol === undefined || - status.protocol.minor !== installation.protocol.minor || + status.protocol.major !== installation.protocol.major || + status.protocol.minor > installation.protocol.minor || status.platform !== installation.platform || status.supervisor !== installation.supervisor || installation.agentVersion === undefined @@ -479,7 +396,7 @@ function liveBinding( capabilityGeneration: number ): RemoteWorkspaceTransportBinding { assertConnectionIdentity(binding, resolution, connection) - assertConnectionStatus(binding, resolution, connection.status) + assertConnectionStatus(resolution, connection.status) if ( connection.capabilities.generation !== capabilityGeneration ) { @@ -499,54 +416,6 @@ function liveBinding( } } -function validatedResumableHandle( - binding: RemoteWorkspaceProjectBinding, - transportBinding: RemoteWorkspaceTransportBinding, - validation: DurableRemoteWorkspaceValidation | undefined -): RemoteWorkspaceHandle | undefined { - if (validation === undefined) { - return undefined - } - const timestamp = Date.parse(validation.validatedAt) - if ( - !Number.isFinite(timestamp) || - validation.workspaceIdentity !== binding.workspaceIdentity || - !sameTransportBinding(validation.binding, transportBinding) - ) { - return undefined - } - const parsed = remoteWorkspaceHandleSchema.safeParse(validation.handle) - if ( - !parsed.success || - parsed.data.workspaceIdentity !== binding.workspaceIdentity || - parsed.data.access !== 'read-only' || - REMOTE_WORKSPACE_READ_CAPABILITIES.some( - (capability) => !parsed.data.capabilities.includes(capability) - ) - ) { - return undefined - } - return parsed.data -} - -function sameTransportBinding( - left: RemoteWorkspaceTransportBinding, - right: RemoteWorkspaceTransportBinding -): boolean { - return ( - left.hostId === right.hostId && - left.hostRevision === right.hostRevision && - left.hostKeyGeneration === right.hostKeyGeneration && - left.remoteUsername === right.remoteUsername && - left.agentInstallationId === right.agentInstallationId && - left.agentBinaryDigest === right.agentBinaryDigest && - left.agentVersion === right.agentVersion && - left.agentArchitecture === right.agentArchitecture && - left.agentProtocolMajor === right.agentProtocolMajor && - left.capabilityGeneration === right.capabilityGeneration - ) -} - function bindingMismatchError(): ProtocolRemoteWorkspaceTransportError { return new ProtocolRemoteWorkspaceTransportError( 'Remote workspace project binding does not match the live Agent connection', diff --git a/src/main/remote-agent/remote-agent-connection-manager.test.ts b/src/main/remote-agent/remote-agent-connection-manager.test.ts index 5b0fae21..6e559958 100644 --- a/src/main/remote-agent/remote-agent-connection-manager.test.ts +++ b/src/main/remote-agent/remote-agent-connection-manager.test.ts @@ -9,7 +9,10 @@ import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' -import type { AgentAttachTransport } from './agent-attach-transport' +import { + AgentAttachTransportError, + type AgentAttachTransport +} from './agent-attach-transport' import type { AgentProtocolClient, AgentProtocolMethod @@ -26,12 +29,14 @@ import { class MemoryStateFile implements ControllerStateFile { value?: unknown + writes = 0 async read(): Promise { return structuredClone(this.value) } async write(value: PersistedControllerState): Promise { + this.writes += 1 this.value = structuredClone(value) } } @@ -152,7 +157,8 @@ const installation = { function harness( connectGate?: Promise, - reconnectResumeGate?: Promise + reconnectResumeGate?: Promise, + idleTimeoutMs = 0 ) { let currentTarget = target() const release = vi.fn() @@ -199,7 +205,7 @@ function harness( sshPool, controllerState: new ControllerStateStore(stateFile), goodBuddyVersion: '0.11.0', - idleTimeoutMs: 0, + idleTimeoutMs, connectTransport, createProtocolClient: () => { const client = new FakeProtocolClient(clients.length) @@ -244,6 +250,109 @@ describe('RemoteAgentConnectionManager', () => { await test.manager.dispose() }) + it('shares one Host connection across caller capability requirements', async () => { + const test = harness() + const workspace = await test.manager.acquire( + 'host-1', + installation + ) + test.clients[0]!.capabilities = { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true } + ], + runtimes: [] + } + await workspace.refreshCapabilities() + + const runtime = await test.manager.acquire('host-1', { + ...installation, + requiredCapabilities: [ + { + name: 'runtime/acp', + exactVersion: 3, + critical: true + } + ] + }) + + expect(runtime.client).toBe(workspace.client) + expect(test.connectTransport).toHaveBeenCalledOnce() + runtime.release() + workspace.release() + await test.manager.dispose() + }) + + it('rejects only the caller when a shared connection lacks its capability', async () => { + const test = harness() + const workspace = await test.manager.acquire( + 'host-1', + installation + ) + + await expect( + test.manager.acquire('host-1', { + ...installation, + requiredCapabilities: [ + { + name: 'runtime/acp', + exactVersion: 3, + critical: true + } + ] + }) + ).rejects.toMatchObject({ + disposition: 'terminal', + reason: 'capability' + }) + expect(workspace.status.state).toBe('ready') + expect(test.connectTransport).toHaveBeenCalledOnce() + + workspace.release() + await test.manager.dispose() + }) + + it('does not bind shared connection viability to the first caller requirements', async () => { + const test = harness(undefined, undefined, 1_000) + await expect( + test.manager.acquire('host-1', { + ...installation, + requiredCapabilities: [ + { + name: 'runtime/acp', + exactVersion: 3, + critical: true + } + ] + }) + ).rejects.toMatchObject({ + disposition: 'terminal', + reason: 'capability' + }) + + const workspace = await test.manager.acquire( + 'host-1', + installation + ) + expect(test.connectTransport).toHaveBeenCalledOnce() + + workspace.release() + await test.manager.dispose() + }) + + it('does not persist an unchanged capability refresh', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + const writes = test.stateFile.writes + + await lease.refreshCapabilities() + + expect(test.stateFile.writes).toBe(writes) + lease.release() + await test.manager.dispose() + }) + it('isolates cache entries across Host revisions', async () => { const test = harness() const first = await test.manager.acquire('host-1', installation) @@ -256,6 +365,28 @@ describe('RemoteAgentConnectionManager', () => { await test.manager.dispose() }) + it('directs registered installation integrity failures to Host repair', async () => { + const test = harness() + test.connectTransport.mockRejectedValueOnce( + new AgentAttachTransportError( + 'Agent attach closed during handshake with diagnostic output', + 'closed', + undefined, + 'installation-repair-required' + ) + ) + + await expect( + test.manager.acquire('host-1', installation) + ).rejects.toMatchObject({ + message: 'GoodBuddy Agent installation needs repair', + disposition: 'terminal', + reason: 'protocol' + }) + expect(test.release).toHaveBeenCalledOnce() + await test.manager.dispose() + }) + it('cancels one waiter without canceling a shared connect', async () => { let openTransport!: () => void const gate = new Promise((resolve) => { @@ -352,11 +483,54 @@ describe('RemoteAgentConnectionManager', () => { reason: 'capability', disposition: 'terminal' }) - expect(lease.capabilities).toEqual(refreshed) + expect(lease.capabilities).toEqual( + test.clients[0]!.capabilities + ) lease.release() await test.manager.dispose() }) + it('deduplicates concurrent capability refreshes for shared leases', async () => { + const test = harness() + const [first, second] = await Promise.all([ + test.manager.acquire('host-1', installation), + test.manager.acquire('host-1', installation) + ]) + let finishRefresh!: () => void + test.clients[0]!.capabilitiesGate = new Promise((resolve) => { + finishRefresh = resolve + }) + test.clients[0]!.capabilities = { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + } + const requestsBefore = test.clients[0]!.requests.filter( + (request) => request.method === 'agent/capabilities' + ).length + + const refreshes = [ + first.refreshCapabilities(), + second.refreshCapabilities() + ] + finishRefresh() + + await expect(Promise.all(refreshes)).resolves.toEqual([ + test.clients[0]!.capabilities, + test.clients[0]!.capabilities + ]) + expect( + test.clients[0]!.requests.filter( + (request) => request.method === 'agent/capabilities' + ) + ).toHaveLength(requestsBefore + 1) + first.release() + second.release() + await test.manager.dispose() + }) + it('rejects versions above an exact capability requirement', async () => { const test = harness() const lease = await test.manager.acquire('host-1', { @@ -395,7 +569,7 @@ describe('RemoteAgentConnectionManager', () => { await test.manager.dispose() }) - it('does not publish a capability refresh after its lease is released in flight', async () => { + it('publishes a shared capability refresh for a retained lease', async () => { const test = harness() const [refreshing, retained] = await Promise.all([ test.manager.acquire('host-1', installation), @@ -418,7 +592,7 @@ describe('RemoteAgentConnectionManager', () => { finishRefresh() await expect(refresh).rejects.toThrow('lease is released') - expect(retained.capabilities.generation).toBe(1) + expect(retained.capabilities.generation).toBe(2) retained.release() await test.manager.dispose() }) diff --git a/src/main/remote-agent/remote-agent-connection-manager.ts b/src/main/remote-agent/remote-agent-connection-manager.ts index f2776505..50c8f7a8 100644 --- a/src/main/remote-agent/remote-agent-connection-manager.ts +++ b/src/main/remote-agent/remote-agent-connection-manager.ts @@ -1,4 +1,5 @@ import { createHash, createHmac, randomBytes } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' import { AGENT_PROTOCOL_VERSION, agentCapabilitySchema, @@ -18,7 +19,10 @@ import { type VerifiedAgentInstallationId } from '../ssh/ssh-agent-command' import { defaultSystemAgent } from '../ssh/ssh-transport' -import { AgentAttachTransport } from './agent-attach-transport' +import { + AgentAttachTransport, + AgentAttachTransportError +} from './agent-attach-transport' import { AgentProtocolClient, type AgentProtocolClientError @@ -141,6 +145,7 @@ type ConnectionEntry = { controller: AbortController connectPromise: Promise reconnectPromise?: Promise + capabilityRefreshPromise?: Promise connection?: ActiveConnection connectionState: RemoteAgentConnectionState failure?: RemoteAgentConnectionError @@ -283,8 +288,12 @@ export class RemoteAgentConnectionManager { 'network' ) } + this.#validateCallerCapabilities( + installation, + entry.connection!.capabilities + ) entry.refs += 1 - return this.#lease(entry) + return this.#lease(entry, installation) } finally { entry.waiters -= 1 if (entry.waiters === 0 && entry.refs === 0) { @@ -595,11 +604,22 @@ export class RemoteAgentConnectionManager { 'installation' ) } + if (capabilities.generation < 1) { + throw terminalError( + 'GoodBuddy Agent capability state is invalid', + 'capability' + ) + } + } + + #validateCallerCapabilities( + installation: RemoteAgentInstallationIdentity, + capabilities: DaemonCapabilities + ): void { if ( - capabilities.generation < 1 || !requiredCapabilitiesPresent( capabilities, - expected.requiredCapabilities ?? [] + installation.requiredCapabilities ?? [] ) ) { throw terminalError( @@ -609,7 +629,10 @@ export class RemoteAgentConnectionManager { } } - #lease(entry: ConnectionEntry): RemoteAgentConnection { + #lease( + entry: ConnectionEntry, + installation: RemoteAgentInstallationIdentity + ): RemoteAgentConnection { let released = false const assertRetained = (): void => { if ( @@ -650,50 +673,16 @@ export class RemoteAgentConnectionManager { return released ? 'disposed' : entry.connectionState }, refreshCapabilities: async (signal) => { - const active = assertActive() - const client = active.client - const generation = client.generation - const previousCapabilities = active.capabilities - const capabilities = daemonCapabilitiesSchema.parse( - await client.request( - 'agent/capabilities', - {}, - { signal } - ) + assertActive() + const capabilities = await waitForShared( + this.#refreshCapabilities(entry), + signal ) - signal?.throwIfAborted() - const current = assertActive() - if ( - this.#entries.get(entry.key) !== entry || - current !== active || - current.client !== client || - client.generation !== generation || - current.capabilities !== previousCapabilities - ) { - throw new RemoteAgentConnectionError( - 'Remote Agent capability refresh became stale', - 'transient', - 'network' - ) - } - this.#validateDaemon( - entry, - active.transport, - active.status, + assertRetained() + this.#validateCallerCapabilities( + installation, capabilities ) - active.capabilities = capabilities - try { - await this.#persistConnection( - entry, - active, - await this.#controllerState.getConnection(entry.stateKey), - true - ) - } catch (error) { - active.capabilities = previousCapabilities - throw error - } return capabilities }, reconnect: async (signal) => { @@ -734,6 +723,84 @@ export class RemoteAgentConnectionManager { } } + #refreshCapabilities( + entry: ConnectionEntry + ): Promise { + if (entry.capabilityRefreshPromise !== undefined) { + return entry.capabilityRefreshPromise + } + const refresh = (async () => { + const active = entry.connection + if ( + active === undefined || + entry.connectionState !== 'ready' + ) { + throw ( + entry.failure ?? + new RemoteAgentConnectionError( + 'Remote Agent connection is offline', + 'transient', + 'network' + ) + ) + } + const client = active.client + const generation = client.generation + const previousCapabilities = active.capabilities + const capabilities = daemonCapabilitiesSchema.parse( + await client.request( + 'agent/capabilities', + {}, + { signal: entry.controller.signal } + ) + ) + const current = entry.connection + if ( + this.#entries.get(entry.key) !== entry || + current !== active || + entry.connectionState !== 'ready' || + current.client !== client || + client.generation !== generation || + current.capabilities !== previousCapabilities + ) { + throw new RemoteAgentConnectionError( + 'Remote Agent capability refresh became stale', + 'transient', + 'network' + ) + } + this.#validateDaemon( + entry, + active.transport, + active.status, + capabilities + ) + if (isDeepStrictEqual(capabilities, previousCapabilities)) { + return previousCapabilities + } + active.capabilities = capabilities + try { + await this.#persistConnection( + entry, + active, + await this.#controllerState.getConnection(entry.stateKey), + true + ) + } catch (error) { + active.capabilities = previousCapabilities + throw error + } + return capabilities + })() + const shared = refresh.finally(() => { + if (entry.capabilityRefreshPromise === shared) { + entry.capabilityRefreshPromise = undefined + } + }) + entry.capabilityRefreshPromise = shared + return shared + } + async #reconnect( entry: ConnectionEntry, signal?: AbortSignal @@ -1034,6 +1101,16 @@ function classifyConnectionError(error: unknown): RemoteAgentConnectionError { if (error instanceof RemoteAgentConnectionError) { return error } + if ( + error instanceof AgentAttachTransportError && + error.diagnostic === 'installation-repair-required' + ) { + return terminalError( + 'GoodBuddy Agent installation needs repair', + 'protocol', + error + ) + } const message = error instanceof Error ? error.message : String(error) if ( /host key|主机密钥|authentication|认证|credential|凭据/iu.test( diff --git a/src/main/remote-agent/remote-agent-services.test.ts b/src/main/remote-agent/remote-agent-services.test.ts index 80c3b3bb..f7a51302 100644 --- a/src/main/remote-agent/remote-agent-services.test.ts +++ b/src/main/remote-agent/remote-agent-services.test.ts @@ -37,6 +37,8 @@ const target: SshConnectionPoolTarget = { hostRevision: 1, hostKeyGeneration: 1 } +const controlPlanePackageInstaller = + 'module.exports = { source: "GoodBuddy" }\n' function harness(overrides: { installationDispose?: () => Promise @@ -60,7 +62,8 @@ function harness(overrides: { dispose: vi.fn(overrides.controllerDispose ?? (() => undefined)) } const installationManager = { - ensureInstalled: vi.fn(), + activateInstalled: vi.fn(), + invalidateHost: vi.fn(), dispose: vi.fn( overrides.installationDispose ?? (async () => undefined) ) @@ -93,7 +96,6 @@ function harness(overrides: { return connectionManager as unknown as RemoteAgentConnectionManager } ) - const loadAgentBundle = vi.fn() const services = new RemoteAgentServices({ sshHostStore, goodBuddyVersion: '0.11.0', @@ -101,9 +103,7 @@ function harness(overrides: { appPath: join('workspace', 'goodbuddy'), resourcesPath: join('installed', 'resources'), packaged: false, - agentPackageManager: { - loadAgentBundle - } as never, + controlPlanePackageInstaller, factories: { createSshConnectionPool: createSshConnectionPool as never, @@ -123,8 +123,7 @@ function harness(overrides: { createSshConnectionPool, createControllerStateStore, createAgentInstallationManager, - createConnectionManager, - loadAgentBundle + createConnectionManager } } @@ -137,6 +136,9 @@ describe('RemoteAgentServices', () => { instance.createConnectionManager.mock.calls[0]?.[0] expect(instance.createSshConnectionPool).toHaveBeenCalledOnce() + expect(instance.createSshConnectionPool).toHaveBeenCalledWith({ + controlPlanePackageInstaller + }) expect(installerOptions?.resolver).toBe( connectionOptions?.resolver ) @@ -145,9 +147,6 @@ describe('RemoteAgentServices', () => { ) expect(installerOptions?.sshPool).toBe(connectionOptions?.sshPool) expect(installerOptions?.sshPool).toBe(instance.services.sshPool) - expect(installerOptions?.packageBundleLoader).toBe( - instance.loadAgentBundle - ) await expect( instance.services.targetResolver.resolve('host-1') @@ -162,7 +161,7 @@ describe('RemoteAgentServices', () => { expect(instance.resolveConnectionTarget).not.toHaveBeenCalled() expect(instance.sshPool.acquire).not.toHaveBeenCalled() - expect(instance.installationManager.ensureInstalled).not.toHaveBeenCalled() + expect(instance.installationManager.activateInstalled).not.toHaveBeenCalled() expect(instance.connectionManager.acquire).not.toHaveBeenCalled() expect(instance.services.resourcePaths).toEqual({ keyRegistryPath: join( @@ -213,9 +212,7 @@ describe('RemoteAgentServices', () => { appPath: join('workspace', 'goodbuddy'), resourcesPath: join('installed', 'resources'), packaged: false, - agentPackageManager: { - loadAgentBundle: vi.fn() - } as never + controlPlanePackageInstaller, }) expect(resolveConnectionTarget).not.toHaveBeenCalled() @@ -233,6 +230,9 @@ describe('RemoteAgentServices', () => { const invalidation = instance.services.invalidateHost('host-1') + expect( + instance.installationManager.invalidateHost + ).toHaveBeenCalledWith('host-1') expect(invalidateHost).toHaveBeenCalledWith('host-1') expect(invalidation).toBe(persistence) finishPersistence?.() diff --git a/src/main/remote-agent/remote-agent-services.ts b/src/main/remote-agent/remote-agent-services.ts index f65366c9..6bc4b780 100644 --- a/src/main/remote-agent/remote-agent-services.ts +++ b/src/main/remote-agent/remote-agent-services.ts @@ -1,10 +1,7 @@ import { join } from 'node:path' import { SshConnectionPool } from '../ssh/ssh-connection-pool' import type { SshHostStore } from '../ssh/ssh-host-store' -import { - AgentInstallationManager, - type AgentInstallationBundleLoader -} from './agent-installation-manager' +import { AgentInstallationManager } from './agent-installation-manager' import { resolveBundledAgentResourcePaths, type BundledAgentResourcePaths @@ -17,14 +14,13 @@ import { RemoteAgentConnectionManager, type RemoteAgentTargetResolver } from './remote-agent-connection-manager' -import type { - AgentPackageManager -} from './agent-package-manager' const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json' type RemoteAgentServiceFactories = { - createSshConnectionPool: () => SshConnectionPool + createSshConnectionPool: (options: { + controlPlanePackageInstaller: Buffer | string + }) => SshConnectionPool createControllerStateStore: ( filePath: string ) => ControllerStateStore @@ -32,7 +28,6 @@ type RemoteAgentServiceFactories = { resolver: RemoteAgentTargetResolver sshPool: SshConnectionPool resourcePaths: BundledAgentResourcePaths - packageBundleLoader: AgentInstallationBundleLoader }) => AgentInstallationManager createConnectionManager: (options: { resolver: RemoteAgentTargetResolver @@ -49,10 +44,7 @@ export type RemoteAgentServicesOptions = { appPath: string resourcesPath: string packaged: boolean - agentPackageManager: Pick< - AgentPackageManager, - 'loadAgentBundle' - > + controlPlanePackageInstaller: Buffer | string factories?: Partial } @@ -74,7 +66,8 @@ export class RemoteAgentServices { constructor(options: RemoteAgentServicesOptions) { const factories: RemoteAgentServiceFactories = { - createSshConnectionPool: () => new SshConnectionPool(), + createSshConnectionPool: (poolOptions) => + new SshConnectionPool(undefined, poolOptions), createControllerStateStore: (filePath) => new ControllerStateStore(new JsonControllerStateFile(filePath)), createAgentInstallationManager: (managerOptions) => @@ -93,7 +86,10 @@ export class RemoteAgentServices { resourcesPath: options.resourcesPath, packaged: options.packaged }) - this.sshPool = factories.createSshConnectionPool() + this.sshPool = factories.createSshConnectionPool({ + controlPlanePackageInstaller: + options.controlPlanePackageInstaller + }) this.controllerState = factories.createControllerStateStore( join(options.userDataPath, CONTROLLER_STATE_FILE_NAME) ) @@ -101,9 +97,7 @@ export class RemoteAgentServices { factories.createAgentInstallationManager({ resolver: this.targetResolver, sshPool: this.sshPool, - resourcePaths: this.resourcePaths, - packageBundleLoader: - options.agentPackageManager.loadAgentBundle + resourcePaths: this.resourcePaths }) this.connectionManager = factories.createConnectionManager({ resolver: this.targetResolver, @@ -119,6 +113,7 @@ export class RemoteAgentServices { * credential-free SSH host lifecycle hook starts fail-closed synchronously. */ invalidateHost(hostId: string): Promise { + this.installationManager.invalidateHost(hostId) return this.connectionManager.invalidateHost(hostId) } diff --git a/src/main/remote-agent/remote-environment-metadata.test.ts b/src/main/remote-agent/remote-environment-metadata.test.ts new file mode 100644 index 00000000..084e0605 --- /dev/null +++ b/src/main/remote-agent/remote-environment-metadata.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + SftpEntryMetadata, + StagedSftp +} from '../ssh/bounded-sftp' +import { + REMOTE_ENVIRONMENT_METADATA_PATHS, + restoreRemoteEnvironmentMetadata, + snapshotRemoteEnvironmentMetadata, + type RemoteEnvironmentMetadataSnapshot +} from './remote-environment-metadata' + +const UID = 1000 + +function missingPath(): Error & { code: string } { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +function fileMetadata( + contents: Buffer, + overrides: Partial = {} +): SftpEntryMetadata { + return { + type: 'file', + size: contents.byteLength, + mode: 0o600, + uid: UID, + gid: UID, + atime: 0, + mtime: 0, + ...overrides + } +} + +function createSftp( + initial: ReadonlyMap, + metadataOverrides: ReadonlyMap> = + new Map() +) { + const files = new Map( + [...initial].map(([path, contents]) => [path, Buffer.from(contents)]) + ) + const temporary = new Map() + const sftp: StagedSftp = { + stagingDirectory: '/home/goodbuddy', + mkdir: vi.fn(), + uploadFile: vi.fn(), + writeFile: vi.fn(async (path, contents) => { + temporary.set(path, Buffer.from(contents)) + }), + readFile: vi.fn(async (path) => { + const contents = files.get(path) ?? temporary.get(path) + if (!contents) throw missingPath() + return Buffer.from(contents) + }), + lstat: vi.fn(async (path) => { + const contents = files.get(path) ?? temporary.get(path) + if (!contents) throw missingPath() + return fileMetadata( + contents, + metadataOverrides.get(path) + ) + }), + stat: vi.fn(), + chmod: vi.fn(), + setExecutable: vi.fn(), + rename: vi.fn(), + replaceFile: vi.fn(async (source, destination) => { + const contents = temporary.get(source) + if (!contents) throw missingPath() + files.set(destination, Buffer.from(contents)) + temporary.delete(source) + }), + unlink: vi.fn(async (path) => { + if (!files.delete(path) && !temporary.delete(path)) { + throw missingPath() + } + }), + rmdir: vi.fn(), + close: vi.fn() + } + return { sftp, files, temporary } +} + +describe('remote environment metadata', () => { + it('snapshots original bytes and restores them exactly', async () => { + const originals = new Map( + REMOTE_ENVIRONMENT_METADATA_PATHS.map((path, index) => [ + path, + Buffer.from([0, index, 255, 10]) + ]) + ) + const value = createSftp(originals) + const snapshots = await snapshotRemoteEnvironmentMetadata( + value.sftp, + UID, + new AbortController().signal + ) + for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) { + value.files.set(path, Buffer.from('changed')) + } + + await restoreRemoteEnvironmentMetadata(value.sftp, snapshots) + + expect( + REMOTE_ENVIRONMENT_METADATA_PATHS.map((path) => + value.files.get(path) + ) + ).toEqual( + REMOTE_ENVIRONMENT_METADATA_PATHS.map((path) => + originals.get(path) + ) + ) + expect(value.sftp.chmod).toHaveBeenCalledTimes(5) + expect(value.sftp.replaceFile).toHaveBeenCalledTimes(5) + }) + + it('records missing metadata and removes files created after the snapshot', async () => { + const value = createSftp(new Map()) + const snapshots = await snapshotRemoteEnvironmentMetadata( + value.sftp, + UID, + new AbortController().signal + ) + for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) { + value.files.set(path, Buffer.from('new')) + } + + await restoreRemoteEnvironmentMetadata(value.sftp, snapshots) + + expect(value.files.size).toBe(0) + expect(value.sftp.unlink).toHaveBeenCalledTimes(5) + }) + + it('distinguishes an empty original file from a missing file', async () => { + const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0] + const value = createSftp( + new Map([[path, Buffer.alloc(0)]]) + ) + const snapshots = await snapshotRemoteEnvironmentMetadata( + value.sftp, + UID, + new AbortController().signal + ) + value.files.set(path, Buffer.from('changed')) + + await restoreRemoteEnvironmentMetadata(value.sftp, snapshots) + + expect(value.files.get(path)).toEqual(Buffer.alloc(0)) + }) + + it.each([ + ['permissions', { mode: 0o644 }], + ['owner', { uid: UID + 1 }], + ['symbolic link', { type: 'symbolic-link' as const }] + ])('rejects metadata with invalid %s', async (_label, override) => { + const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0] + const contents = Buffer.from('metadata') + const value = createSftp( + new Map([[path, contents]]), + new Map([[path, override]]) + ) + + await expect(snapshotRemoteEnvironmentMetadata( + value.sftp, + UID, + new AbortController().signal + )).rejects.toThrow('类型、所有者或权限无效') + expect(value.sftp.readFile).not.toHaveBeenCalled() + }) + + it('continues restoring remaining files and aggregates every error', async () => { + const first = REMOTE_ENVIRONMENT_METADATA_PATHS[0] + const second = REMOTE_ENVIRONMENT_METADATA_PATHS[1] + const third = REMOTE_ENVIRONMENT_METADATA_PATHS[2] + const value = createSftp(new Map([ + [first, Buffer.from('changed-1')], + [second, Buffer.from('changed-2')], + [third, Buffer.from('created')] + ])) + const firstError = new Error('first replace failed') + const secondError = new Error('second replace failed') + vi.mocked(value.sftp.replaceFile).mockImplementation( + async (_source, destination) => { + throw destination === first ? firstError : secondError + } + ) + const snapshots: readonly RemoteEnvironmentMetadataSnapshot[] = [ + { path: first, contents: Buffer.from('original-1'), uid: UID }, + { path: second, contents: Buffer.from('original-2'), uid: UID }, + { path: third, uid: UID } + ] + + const caught = await restoreRemoteEnvironmentMetadata( + value.sftp, + snapshots + ).catch((error: unknown) => error) + + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors).toEqual([ + firstError, + secondError + ]) + expect(value.sftp.replaceFile).toHaveBeenCalledTimes(2) + expect(value.files.has(third)).toBe(false) + }) +}) diff --git a/src/main/remote-agent/remote-environment-metadata.ts b/src/main/remote-agent/remote-environment-metadata.ts new file mode 100644 index 00000000..1240e9fc --- /dev/null +++ b/src/main/remote-agent/remote-environment-metadata.ts @@ -0,0 +1,142 @@ +import { randomUUID } from 'node:crypto' +import type { StagedSftp } from '../ssh/bounded-sftp' +import { isMissingPathError } from './path-errors' + +const PRIVATE_FILE_MODE = 0o600 + +export const REMOTE_ENVIRONMENT_METADATA_PATHS = [ + '.goodbuddy/agent/release-keys.json', + '.goodbuddy/agent/registry.json', + '.goodbuddy/runtimes/release-keys.json', + '.goodbuddy/runtimes/remote-runtime-lock.json', + '.goodbuddy/runtimes/registry.json' +] as const + +export type RemoteEnvironmentMetadataSnapshot = { + path: (typeof REMOTE_ENVIRONMENT_METADATA_PATHS)[number] + contents?: Buffer + uid: number +} + +export async function snapshotRemoteEnvironmentMetadata( + sftp: StagedSftp, + uid: number, + signal: AbortSignal +): Promise { + const snapshots: RemoteEnvironmentMetadataSnapshot[] = [] + for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) { + signal.throwIfAborted() + const metadata = await metadataIfPresent(sftp, path, signal) + if (!metadata) { + snapshots.push({ path, uid }) + continue + } + assertPrivateFile(metadata, path, uid) + snapshots.push({ + path, + contents: await sftp.readFile(path, signal), + uid + }) + } + return snapshots +} + +export async function restoreRemoteEnvironmentMetadata( + sftp: StagedSftp, + snapshots: readonly RemoteEnvironmentMetadataSnapshot[] +): Promise { + const errors: unknown[] = [] + for (const snapshot of snapshots) { + try { + await restoreRemoteEnvironmentMetadataFile(sftp, snapshot) + } catch (error) { + errors.push(error) + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + '远程运行环境元数据回滚不完整' + ) + } +} + +async function restoreRemoteEnvironmentMetadataFile( + sftp: StagedSftp, + snapshot: RemoteEnvironmentMetadataSnapshot +): Promise { + const existing = await metadataIfPresent(sftp, snapshot.path) + if (snapshot.contents === undefined) { + if (existing) { + assertPrivateFile(existing, snapshot.path, snapshot.uid) + await sftp.unlink(snapshot.path) + } + return + } + + if (existing) { + assertPrivateFile(existing, snapshot.path, snapshot.uid) + if ( + existing.size === snapshot.contents.byteLength && + (await sftp.readFile(snapshot.path)).equals(snapshot.contents) + ) { + return + } + } + + const temporary = + `.goodbuddy/.environment-metadata-rollback-${randomUUID()}.tmp` + try { + await sftp.writeFile(temporary, snapshot.contents) + await sftp.chmod(temporary, PRIVATE_FILE_MODE) + await sftp.replaceFile(temporary, snapshot.path) + const restored = await sftp.lstat(snapshot.path) + assertPrivateFile(restored, snapshot.path, snapshot.uid) + if ( + restored.size !== snapshot.contents.byteLength || + !(await sftp.readFile(snapshot.path)).equals(snapshot.contents) + ) { + throw new Error( + `远程运行环境元数据 ${snapshot.path} 回滚校验失败` + ) + } + } catch (error) { + await sftp.unlink(temporary).catch((cleanupError: unknown) => { + if (!isMissingPathError(cleanupError)) { + throw cleanupError + } + }) + throw error + } +} + +async function metadataIfPresent( + sftp: StagedSftp, + path: string, + signal?: AbortSignal +) { + try { + return await sftp.lstat(path, signal) + } catch (error) { + if (isMissingPathError(error)) { + return undefined + } + throw error + } +} + +function assertPrivateFile( + metadata: Awaited>, + path: string, + uid: number +): void { + if ( + metadata.type !== 'file' || + metadata.uid !== uid || + metadata.mode !== PRIVATE_FILE_MODE + ) { + throw new Error( + `远程运行环境元数据 ${path} 的类型、所有者或权限无效` + ) + } +} diff --git a/src/main/remote-agent/remote-environment-operation-store.test.ts b/src/main/remote-agent/remote-environment-operation-store.test.ts new file mode 100644 index 00000000..040536bc --- /dev/null +++ b/src/main/remote-agent/remote-environment-operation-store.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + RemoteEnvironmentOperationStore, + type PendingRemoteEnvironmentOperation +} from './remote-environment-operation-store' + +const temporaryDirectories: string[] = [] +const operation: PendingRemoteEnvironmentOperation = { + version: 1, + hostId: 'host-1', + targetIdentity: 'a'.repeat(64), + operationId: '00000000-0000-4000-8000-000000000777', + size: 123, + sha256: 'b'.repeat(64), + urls: [] +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => + rm(path, { recursive: true, force: true }) + ) + ) +}) + +describe('RemoteEnvironmentOperationStore', () => { + it('persists one bounded operation per Host and removes it', async () => { + const root = await mkdtemp( + join(tmpdir(), 'goodbuddy-environment-operation-') + ) + temporaryDirectories.push(root) + const store = new RemoteEnvironmentOperationStore(root) + + await store.save(operation) + await expect(store.load(operation.hostId)).resolves.toEqual( + operation + ) + await store.remove(operation.hostId) + await expect(store.load(operation.hostId)).resolves.toBeUndefined() + }) + + it('rejects a mismatched or oversized recovery record', async () => { + const root = await mkdtemp( + join(tmpdir(), 'goodbuddy-environment-operation-') + ) + temporaryDirectories.push(root) + const store = new RemoteEnvironmentOperationStore(root) + await store.save(operation) + const files = join( + root, + 'remote-components', + 'pending-environment-operations' + ) + const { readdir } = await import('node:fs/promises') + const [record] = await readdir(files) + await writeFile( + join(files, record!), + Buffer.alloc(64 * 1024 + 1) + ) + + await expect(store.load(operation.hostId)).rejects.toThrow( + '超过大小限制' + ) + }) +}) diff --git a/src/main/remote-agent/remote-environment-operation-store.ts b/src/main/remote-agent/remote-environment-operation-store.ts new file mode 100644 index 00000000..65159f0b --- /dev/null +++ b/src/main/remote-agent/remote-environment-operation-store.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' +import { readFile, rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { + isMissingFileError, + writeJsonFileAtomically +} from '../settings-file-utils' + +const MAXIMUM_RECORD_BYTES = 64 * 1024 +const OPERATION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u +const SHA256_PATTERN = /^[a-f0-9]{64}$/u + +export type PendingRemoteEnvironmentOperation = { + version: 1 + hostId: string + targetIdentity: string + operationId: string + size: number + sha256: string + urls: readonly string[] +} + +export class RemoteEnvironmentOperationStore { + readonly #directory: string + + constructor(userDataPath: string) { + this.#directory = resolve( + userDataPath, + 'remote-components', + 'pending-environment-operations' + ) + } + + async load( + hostId: string + ): Promise { + const path = this.#path(hostId) + let bytes: Buffer + try { + bytes = await readFile(path) + } catch (error) { + if (isMissingFileError(error)) { + return undefined + } + throw error + } + if (bytes.byteLength > MAXIMUM_RECORD_BYTES) { + throw new Error('远程环境恢复记录超过大小限制') + } + let value: unknown + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) + } catch (error) { + throw new Error('远程环境恢复记录无效', { + cause: error + }) + } + return parsePendingOperation(value, hostId) + } + + save(operation: PendingRemoteEnvironmentOperation): Promise { + return writeJsonFileAtomically( + this.#path(operation.hostId), + parsePendingOperation(operation, operation.hostId) + ) + } + + remove(hostId: string): Promise { + return rm(this.#path(hostId), { force: true }) + } + + #path(hostId: string): string { + if ( + !hostId || + hostId.length > 512 || + /[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(hostId) + ) { + throw new Error('远程环境恢复 Host ID 无效') + } + const name = createHash('sha256') + .update(hostId) + .digest('hex') + return join(this.#directory, `${name}.json`) + } +} + +function parsePendingOperation( + value: unknown, + expectedHostId: string +): PendingRemoteEnvironmentOperation { + if ( + typeof value !== 'object' || + value === null || + Object.keys(value).sort().join('\0') !== + [ + 'hostId', + 'operationId', + 'sha256', + 'size', + 'targetIdentity', + 'urls', + 'version' + ].sort().join('\0') + ) { + throw new Error('远程环境恢复记录无效') + } + const record = value as Record + if ( + record.version !== 1 || + record.hostId !== expectedHostId || + typeof record.targetIdentity !== 'string' || + !SHA256_PATTERN.test(record.targetIdentity) || + typeof record.operationId !== 'string' || + !OPERATION_ID_PATTERN.test(record.operationId) || + !Number.isSafeInteger(record.size) || + Number(record.size) <= 0 || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) || + !Array.isArray(record.urls) || + record.urls.length > 4 || + record.urls.some( + (url) => + typeof url !== 'string' || + Buffer.byteLength(url, 'utf8') > 4_096 + ) + ) { + throw new Error('远程环境恢复记录无效') + } + return { + version: 1, + hostId: expectedHostId, + targetIdentity: record.targetIdentity, + operationId: record.operationId, + size: Number(record.size), + sha256: record.sha256, + urls: [...record.urls] as string[] + } +} diff --git a/src/main/remote-agent/remote-environment-preparer.test.ts b/src/main/remote-agent/remote-environment-preparer.test.ts new file mode 100644 index 00000000..b02f9a24 --- /dev/null +++ b/src/main/remote-agent/remote-environment-preparer.test.ts @@ -0,0 +1,861 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' +import { + SshRemotePackageCommitIndeterminateError, + type SshRemotePackageIdentity +} from '../ssh/ssh-remote-package-bootstrap' +import type { StagedSftp } from '../ssh/bounded-sftp' +import type { + AgentPackageArchiveLease, + VerifiedRemoteAgentInstallCandidate +} from './agent-package-manager' +import { REMOTE_ENVIRONMENT_METADATA_PATHS } from './remote-environment-metadata' +import type { + PendingRemoteEnvironmentOperation +} from './remote-environment-operation-store' +import { RemoteEnvironmentPreparer } from './remote-environment-preparer' +import { + remoteHostRecoveryIdentityKey +} from './remote-host-target-identity' + +const HOST_ID = 'host-1' +const SHA256 = 'a'.repeat(64) +const NODE_SHA256 = 'b'.repeat(64) + +function target( + overrides: Partial = {} +): SshConnectionPoolTarget { + return { + host: { + id: HOST_ID, + name: 'Builder', + hostname: 'builder.example', + port: 22, + username: 'goodbuddy', + authentication: 'password', + password: 'secret', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'a2V5', + fingerprintSha256: `SHA256:${'b'.repeat(43)}`, + generation: 2 + } + }, + hostRevision: 3, + hostKeyGeneration: 2, + ...overrides + } +} + +const candidate: VerifiedRemoteAgentInstallCandidate = { + source: 'mirror', + platform: 'linux', + architecture: 'x64', + version: '2.0.0', + minimumDesktopVersion: '1.0.0', + agentProtocol: { major: 2, minor: 1 }, + remoteRuntime: { + runtimeId: 'opencode', + provider: 'opencode', + version: '1.2.3', + bundleDigest: `sha256:${'c'.repeat(64)}`, + protocol: { major: 1, minor: 0 } + }, + archive: 'goodbuddy-agent-2.0.0-linux-x64.gbagent', + size: 123, + sha256: SHA256, + urls: [ + 'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v2.0.0/package.gbagent' + ] +} + +const identity: SshRemotePackageIdentity = { + archiveSha256: SHA256, + agent: { + installationId: `agent-${'d'.repeat(64)}`, + agentVersion: '2.0.0', + manifestSha256: 'd'.repeat(64), + binaryDigest: `sha256:${'e'.repeat(64)}`, + platform: 'linux', + architecture: 'x64', + protocol: { major: 2, minor: 1 }, + supervisor: 'detached-on-demand' + }, + runtime: { + runtimeId: 'opencode', + runtimeVersion: '1.2.3', + bundleDigest: `sha256:${'c'.repeat(64)}`, + manifestDigest: `sha256:${'f'.repeat(64)}`, + runtimeAdapterDigest: `sha256:${'1'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'2'.repeat(64)}`, + platform: 'linux', + architecture: 'x64', + protocol: { major: 1, minor: 0 } + } +} + +type FixtureOptions = { + targets?: SshConnectionPoolTarget[] + probeAvailable?: boolean + prepare?: ReturnType + prepareUploaded?: ReturnType + commit?: ReturnType + uploadFile?: ReturnType + activateAgent?: ReturnType + activateRuntime?: ReturnType + metadataFiles?: Map + metadataReplaceFailure?: Error + pendingOperation?: PendingRemoteEnvironmentOperation +} + +function missingPath(): Error & { code: string } { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +function metadataSftp( + files: Map, + calls: string[], + replaceFailure?: Error +): StagedSftp { + const temporary = new Map() + return { + stagingDirectory: '/home/goodbuddy', + mkdir: vi.fn(), + uploadFile: vi.fn(), + writeFile: vi.fn(async (path: string, contents: Buffer) => { + temporary.set(path, Buffer.from(contents)) + }), + readFile: vi.fn(async (path: string) => { + const contents = files.get(path) ?? temporary.get(path) + if (!contents) throw missingPath() + return Buffer.from(contents) + }), + lstat: vi.fn(async (path: string) => { + const contents = files.get(path) ?? temporary.get(path) + if (!contents) throw missingPath() + return { + type: 'file' as const, + size: contents.byteLength, + mode: 0o600, + uid: 1000, + gid: 1000, + atime: 0, + mtime: 0 + } + }), + stat: vi.fn(), + chmod: vi.fn(), + setExecutable: vi.fn(), + rename: vi.fn(), + replaceFile: vi.fn(async (source: string, destination: string) => { + if (replaceFailure) throw replaceFailure + const contents = temporary.get(source) + if (!contents) throw missingPath() + files.set(destination, contents) + temporary.delete(source) + }), + unlink: vi.fn(async (path: string) => { + if (!files.delete(path) && !temporary.delete(path)) { + throw missingPath() + } + }), + rmdir: vi.fn(), + close: vi.fn(() => calls.push('close-metadata')) + } +} + +function fixture(options: FixtureOptions = {}) { + const calls: string[] = [] + const targets = options.targets ?? [target()] + let resolveIndex = 0 + const resolver = { + resolve: vi.fn(async () => { + calls.push('resolve') + return targets[Math.min(resolveIndex++, targets.length - 1)]! + }) + } + const leaseIdentity = { + hostId: targets[0]!.host.id, + hostRevision: targets[0]!.hostRevision, + hostKeyGeneration: targets[0]!.hostKeyGeneration, + authenticationIdentity: '3'.repeat(64) + } + const metadataFiles = options.metadataFiles ?? new Map() + const metadata = metadataSftp( + metadataFiles, + calls, + options.metadataReplaceFailure + ) + const uploadFile = options.uploadFile ?? vi.fn(async ( + path: string + ) => { + calls.push(`upload:${path}`) + }) + const uploadSftp = { + stagingDirectory: '/home/goodbuddy/.goodbuddy/install-operations/op', + uploadFile, + setExecutable: vi.fn(async () => { + calls.push('executable') + }), + close: vi.fn(() => calls.push('close-upload')) + } + const regularRelease = vi.fn(() => calls.push('release-probe')) + const stopCandidate = vi.fn(async () => { + calls.push('stop-candidate') + }) + const regularLease = { + identity: leaseIdentity, + isUsable: vi.fn(() => true), + runAgentBootstrapProbe: vi.fn(async () => { + calls.push('platform-probe') + return { + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: '/home/goodbuddy', + uid: 1000, + shell: '/bin/sh', + procfs: 'ready' + } + }), + openStagedSftp: vi.fn(async (root: string) => { + if (root.includes('install-operations')) { + calls.push('open-upload') + return uploadSftp + } + calls.push('open-metadata') + return metadata + }), + runAgentLifecycleAction: stopCandidate, + release: regularRelease + } + const prepare = options.prepare ?? vi.fn(async ( + input: { operationId: string }, + prepareOptions: { + onProgress?: (progress: { phase: 'downloading' | 'verifying' }) => void + } + ) => { + calls.push('prepare') + prepareOptions.onProgress?.({ phase: 'downloading' }) + prepareOptions.onProgress?.({ phase: 'verifying' }) + return { + prepared: true, + operationId: input.operationId, + identity + } + }) + const prepareUploaded = options.prepareUploaded ?? vi.fn(async ( + input: { operationId: string } + ) => { + calls.push('prepare-uploaded') + return { + prepared: true, + operationId: input.operationId, + identity + } + }) + const commit = options.commit ?? vi.fn(async ( + input: { operationId: string } + ) => { + calls.push('commit') + return { + committed: true, + operationId: input.operationId, + identity: structuredClone(identity) + } + }) + const cleanup = vi.fn(async (operationId: string) => { + calls.push('cleanup') + return { cleaned: true, operationId } + }) + const commitStatus = vi.fn(async ( + input: { operationId: string } + ) => ({ + committed: true, + operationId: input.operationId, + identity + })) + const bootstrapRelease = vi.fn(() => calls.push('release-bootstrap')) + const probe = vi.fn(async () => { + calls.push('method-probe') + return options.probeAvailable === false + ? { available: false, reason: 'curl-unavailable' } + : { available: true } + }) + const createUploadStaging = vi.fn(async ( + input: { operationId: string } + ) => { + calls.push('create-upload-staging') + const root = + `/home/goodbuddy/.goodbuddy/install-operations/${input.operationId}` + return { + created: true, + operationId: input.operationId, + archivePath: `${root}/package.gbagent`, + bootstrapNodePath: `${root}/bootstrap/agent/node` + } + }) + const bootstrapLease = { + identity: { ...leaseIdentity }, + isUsable: vi.fn(() => true), + probe, + createUploadStaging, + prepare, + prepareUploaded, + commit, + commitStatus, + cleanup, + release: bootstrapRelease + } + const archiveRelease = vi.fn(() => calls.push('release-archive')) + const archive: AgentPackageArchiveLease = { + candidate, + path: 'C:/cache/package.gbagent', + size: candidate.size, + sha256: candidate.sha256, + nodePath: 'C:/cache/node', + nodeSize: 42, + nodeSha256: NODE_SHA256, + release: archiveRelease + } + const acquireInstallArchive = vi.fn(async () => { + calls.push('acquire-archive') + return archive + }) + const acquireGoodBuddyInstallArchive = vi.fn(async () => { + calls.push('acquire-archive') + return archive + }) + const activateAgent = options.activateAgent ?? vi.fn(async () => { + calls.push('agent') + return identity.agent + }) + const activateRuntime = options.activateRuntime ?? vi.fn(async () => { + calls.push('runtime') + }) + let pendingOperation = options.pendingOperation + const operationStore = { + load: vi.fn(async () => pendingOperation), + save: vi.fn(async ( + operation: PendingRemoteEnvironmentOperation + ) => { + calls.push('save-operation') + pendingOperation = operation + }), + remove: vi.fn(async () => { + calls.push('remove-operation') + pendingOperation = undefined + }) + } + const preparer = new RemoteEnvironmentPreparer({ + resolver, + sshPool: { + acquire: vi.fn(async () => { + calls.push('acquire-probe') + return regularLease as never + }), + acquireRemotePackageBootstrap: vi.fn(async () => { + calls.push('acquire-bootstrap') + return bootstrapLease as never + }) + }, + agentPackageManager: { + getRemoteInstallCandidate: vi.fn(async () => { + calls.push('candidate') + return candidate + }), + acquireInstallArchive, + acquireGoodBuddyInstallArchive + }, + agentInstallationManager: { + activatePublished: activateAgent + } as never, + runtimeInstallationManager: { + activatePublished: activateRuntime + } as never, + operationStore + }) + return { + preparer, + calls, + resolver, + regularLease, + bootstrapLease, + metadata, + metadataFiles, + uploadFile, + probe, + createUploadStaging, + prepare, + prepareUploaded, + commit, + cleanup, + activateAgent, + activateRuntime, + acquireInstallArchive, + archiveRelease, + regularRelease, + bootstrapRelease, + stopCandidate, + commitStatus, + operationStore + } +} + +describe('RemoteEnvironmentPreparer', () => { + it('runs the remote compound graph in order, verifies identity, cleans explicitly, and emits the resolved method', async () => { + const value = fixture() + const progress: Array<{ method: string; phase: string }> = [] + const controller = new AbortController() + + await value.preparer.prepare( + HOST_ID, + 'remote-download', + (event) => progress.push(event), + controller.signal + ) + + expect(value.calls).toEqual([ + 'resolve', + 'acquire-probe', + 'platform-probe', + 'candidate', + 'resolve', + 'acquire-bootstrap', + 'save-operation', + 'prepare', + 'open-metadata', + 'resolve', + 'commit', + 'resolve', + 'agent', + 'resolve', + 'runtime', + 'resolve', + 'cleanup', + 'remove-operation', + 'close-metadata', + 'release-bootstrap', + 'release-probe' + ]) + const remoteCandidate = value.prepare.mock.calls[0]?.[0] + expect(remoteCandidate).toMatchObject({ + urls: candidate.urls, + size: candidate.size, + sha256: candidate.sha256 + }) + expect(remoteCandidate.operationId).toMatch(/^[0-9a-f-]{36}$/u) + expect(value.commit.mock.calls[0]?.[0]).toEqual(remoteCandidate) + expect(value.activateAgent).toHaveBeenCalledWith( + HOST_ID, + identity.agent, + expect.objectContaining({ signal: controller.signal }) + ) + expect(value.activateRuntime).toHaveBeenCalledWith( + HOST_ID, + identity.runtime, + expect.objectContaining({ + agentInstallationId: identity.agent.installationId, + signal: controller.signal + }) + ) + expect(value.cleanup).toHaveBeenCalledWith( + remoteCandidate.operationId, + { signal: controller.signal } + ) + expect(value.operationStore.save).toHaveBeenCalledOnce() + expect(value.operationStore.remove).toHaveBeenCalledOnce() + expect(progress.some((event) => + event.method === 'remote-download' && + event.phase === 'downloading' + )).toBe(true) + expect(progress.every((event) => + event.method === 'remote-download' + )).toBe(true) + }) + + it('GoodBuddy uploads only the compound archive and bootstrap node, releases the archive, and never invokes remote acquisition', async () => { + const value = fixture() + + await value.preparer.prepare( + HOST_ID, + 'goodbuddy-transfer', + undefined, + new AbortController().signal + ) + + expect(value.probe).not.toHaveBeenCalled() + expect(value.prepare).not.toHaveBeenCalled() + expect(value.createUploadStaging).toHaveBeenCalledOnce() + expect(value.uploadFile.mock.calls.map((call) => call[0])).toEqual([ + 'package.gbagent', + 'bootstrap/agent/node' + ]) + expect(value.prepareUploaded).toHaveBeenCalledOnce() + expect(value.archiveRelease).toHaveBeenCalledOnce() + }) + + it('releases the archive and cleans the operation when a GoodBuddy upload fails', async () => { + const uploadFailure = new Error('upload failed') + const value = fixture({ + uploadFile: vi.fn(async () => { + throw uploadFailure + }) + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'goodbuddy-transfer', + undefined, + new AbortController().signal + )).rejects.toBe(uploadFailure) + expect(value.cleanup).toHaveBeenCalledOnce() + expect(value.prepareUploaded).not.toHaveBeenCalled() + expect(value.archiveRelease).toHaveBeenCalledOnce() + }) + + it.each([ + { + available: true, + expectedPrepare: 1, + expectedUpload: 0, + resolved: 'remote-download' + }, + { + available: false, + expectedPrepare: 0, + expectedUpload: 2, + resolved: 'goodbuddy-transfer' + } + ] as const)('auto resolves an availability result to $resolved', async ({ + available, + expectedPrepare, + expectedUpload, + resolved + }) => { + const value = fixture({ probeAvailable: available }) + const methods: string[] = [] + + await value.preparer.prepare( + HOST_ID, + 'auto', + (event) => methods.push(event.method), + new AbortController().signal + ) + + expect(value.probe).toHaveBeenCalledOnce() + expect(value.prepare).toHaveBeenCalledTimes(expectedPrepare) + expect(value.uploadFile).toHaveBeenCalledTimes(expectedUpload) + expect(methods).toContain(resolved) + }) + + it('does not cross acquisition methods after auto selected remote download', async () => { + const value = fixture({ + probeAvailable: true, + prepare: vi.fn(async () => ({ + prepared: false, + unavailable: true, + reason: 'curl-unavailable' + })) + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'auto', + undefined, + new AbortController().signal + )).rejects.toThrow('不可用') + expect(value.acquireInstallArchive).not.toHaveBeenCalled() + expect(value.createUploadStaging).not.toHaveBeenCalled() + expect(value.cleanup).toHaveBeenCalledOnce() + }) + + it.each([ + { + label: 'prepared operation', + prepare: vi.fn(async () => ({ + prepared: true, + operationId: 'wrong-operation', + identity + })), + commit: undefined, + message: '准备操作身份不匹配' + }, + { + label: 'prepared package', + prepare: vi.fn(async (input: { operationId: string }) => ({ + prepared: true, + operationId: input.operationId, + identity: { ...identity, archiveSha256: '9'.repeat(64) } + })), + commit: undefined, + message: '签名候选不匹配' + }, + { + label: 'committed identity', + prepare: undefined, + commit: vi.fn(async (input: { operationId: string }) => ({ + committed: true, + operationId: input.operationId, + identity: { + ...identity, + runtime: { + ...identity.runtime, + runtimeVersion: 'different' + } + } + })), + message: '提交身份与准备身份不匹配' + } + ])('rejects a $label mismatch and cleans staging', async ({ + prepare, + commit, + message + }) => { + const value = fixture({ prepare, commit }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toThrow(message) + expect(value.cleanup).toHaveBeenCalledOnce() + expect(value.activateAgent).not.toHaveBeenCalled() + }) + + it('rejects a Host identity change immediately before commit and cleans staging', async () => { + const value = fixture({ + targets: [target(), target(), target({ hostRevision: 4 })] + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toThrow('身份') + expect(value.commit).not.toHaveBeenCalled() + expect(value.cleanup).toHaveBeenCalledOnce() + }) + + it('cleans deterministic cancellation and commit failures', async () => { + const controller = new AbortController() + const value = fixture({ + prepare: vi.fn(async () => { + controller.abort(new DOMException('cancelled', 'AbortError')) + throw controller.signal.reason + }) + }) + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + controller.signal + )).rejects.toMatchObject({ name: 'AbortError' }) + expect(value.cleanup).toHaveBeenCalledOnce() + + const failed = fixture({ + commit: vi.fn(async () => ({ + committed: false, + reason: 'installer-failed' + })) + }) + await expect(failed.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toThrow('提交失败') + expect(failed.cleanup).toHaveBeenCalledOnce() + }) + + it('does not clean or adopt after an indeterminate commit', async () => { + const value = fixture({ + commit: vi.fn(async () => { + throw new SshRemotePackageCommitIndeterminateError() + }) + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toBeInstanceOf(SshRemotePackageCommitIndeterminateError) + expect(value.cleanup).not.toHaveBeenCalled() + expect(value.operationStore.save).toHaveBeenCalledOnce() + expect(value.operationStore.remove).not.toHaveBeenCalled() + expect(value.activateAgent).not.toHaveBeenCalled() + expect(value.activateRuntime).not.toHaveBeenCalled() + }) + + it('keeps rollback-incomplete commits recoverable without cleanup', async () => { + const value = fixture({ + commit: vi.fn(async () => ({ + committed: false, + reason: 'installer-rollback-incomplete' + })) + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toBeInstanceOf( + SshRemotePackageCommitIndeterminateError + ) + expect(value.cleanup).not.toHaveBeenCalled() + expect(value.operationStore.remove).not.toHaveBeenCalled() + }) + + it('reconciles and cleans a pending commit before starting new work', async () => { + const pendingOperationId = + '00000000-0000-4000-8000-000000000777' + const originalTarget = target() + const renamedTarget = target({ + host: { + ...originalTarget.host, + name: 'Renamed builder' + }, + hostRevision: 99 + }) + const value = fixture({ + targets: [renamedTarget], + pendingOperation: { + version: 1, + hostId: HOST_ID, + targetIdentity: + remoteHostRecoveryIdentityKey(originalTarget), + operationId: pendingOperationId, + size: candidate.size, + sha256: candidate.sha256, + urls: candidate.urls + } + }) + + await value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + ) + + expect(value.commitStatus).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: pendingOperationId + }), + expect.any(Object) + ) + expect(value.cleanup.mock.calls[0]?.[0]).toBe( + pendingOperationId + ) + expect(value.prepare).toHaveBeenCalledOnce() + expect(value.operationStore.remove).toHaveBeenCalledTimes(2) + }) + + it('retains pending recovery evidence when the real SSH target changes', async () => { + const originalTarget = target() + const changedTarget = target({ + host: { + ...originalTarget.host, + hostname: 'different.example' + } + }) + const value = fixture({ + targets: [changedTarget], + pendingOperation: { + version: 1, + hostId: HOST_ID, + targetIdentity: + remoteHostRecoveryIdentityKey(originalTarget), + operationId: + '00000000-0000-4000-8000-000000000778', + size: candidate.size, + sha256: candidate.sha256, + urls: candidate.urls + } + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toBeInstanceOf( + SshRemotePackageCommitIndeterminateError + ) + expect(value.commitStatus).not.toHaveBeenCalled() + expect(value.operationStore.remove).not.toHaveBeenCalled() + }) + + it('stops the committed Agent and restores all five metadata files when Runtime adoption fails', async () => { + const originals = new Map() + for (const [index, path] of REMOTE_ENVIRONMENT_METADATA_PATHS.entries()) { + if (index !== 2) originals.set(path, Buffer.from(`original-${index}`)) + } + const files = new Map( + [...originals].map(([path, contents]) => [path, Buffer.from(contents)]) + ) + const runtimeFailure = new Error('runtime health failed') + const value = fixture({ + metadataFiles: files, + activateAgent: vi.fn(async () => { + value.metadataFiles.clear() + for (const path of REMOTE_ENVIRONMENT_METADATA_PATHS) { + value.metadataFiles.set(path, Buffer.from('new')) + } + return identity.agent + value.calls.push('agent') + }), + activateRuntime: vi.fn(async () => { + throw runtimeFailure + }) + }) + + await expect(value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + )).rejects.toBe(runtimeFailure) + expect(value.stopCandidate).toHaveBeenCalledWith( + identity.agent.installationId, + 'stop' + ) + expect([...value.metadataFiles]).toEqual([...originals]) + expect(value.cleanup).toHaveBeenCalledOnce() + }) + + it('throws AggregateError when adoption and metadata rollback both fail', async () => { + const path = REMOTE_ENVIRONMENT_METADATA_PATHS[0] + const files = new Map([[path, Buffer.from('original')]]) + const runtimeFailure = new Error('runtime failed') + const rollbackFailure = new Error('replace failed') + const value = fixture({ + metadataFiles: files, + metadataReplaceFailure: rollbackFailure, + activateAgent: vi.fn(async () => { + files.set(path, Buffer.from('changed')) + return identity.agent + }), + activateRuntime: vi.fn(async () => { + throw runtimeFailure + }) + }) + + const caught = await value.preparer.prepare( + HOST_ID, + 'remote-download', + undefined, + new AbortController().signal + ).catch((error: unknown) => error) + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors[0]).toBe(runtimeFailure) + expect((caught as AggregateError).errors[1]).toBeInstanceOf( + AggregateError + ) + expect(value.stopCandidate).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/remote-agent/remote-environment-preparer.ts b/src/main/remote-agent/remote-environment-preparer.ts new file mode 100644 index 00000000..a69f688a --- /dev/null +++ b/src/main/remote-agent/remote-environment-preparer.ts @@ -0,0 +1,825 @@ +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { + RemoteEnvironmentPreparationMethod, + RemoteEnvironmentUpdateProgress +} from '../../shared/ssh-host-contracts' +import type { StagedSftp } from '../ssh/bounded-sftp' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' +import type { + SshConnectionLease, + SshConnectionPool, + SshRemotePackageBootstrapLease +} from '../ssh/ssh-connection-pool' +import type { SshConnectionTarget } from '../ssh/ssh-host-store' +import { + assertSshLeaseMatchesTarget, + assertValidSshConnectionTarget +} from '../ssh/ssh-connection-target' +import { + SshRemotePackageCommitIndeterminateError, + type SshRemotePackageBootstrapPrepareResult, + type SshRemotePackageBootstrapProgress, + type SshRemotePackageCandidate, + type SshRemotePackageIdentity +} from '../ssh/ssh-remote-package-bootstrap' +import type { + AgentInstallationManager, + AgentInstallationTargetResolver +} from './agent-installation-manager' +import type { + AgentPackageArchiveLease, + AgentPackageManager, + VerifiedRemoteAgentInstallCandidate +} from './agent-package-manager' +import { + restoreRemoteEnvironmentMetadata, + snapshotRemoteEnvironmentMetadata, + type RemoteEnvironmentMetadataSnapshot +} from './remote-environment-metadata' +import { + mapAgentInstallationPhase, + mapRuntimeInstallationPhase +} from './remote-environment-progress' +import type { + RemoteEnvironmentOperationStore +} from './remote-environment-operation-store' +import type { + RemoteEnvironmentPreparer as Preparer, + RemoteEnvironmentUpdateProgressObserver +} from './remote-environment-update-service' +import type { + RemoteRuntimeInstallationManager +} from './remote-runtime-installation-manager' +import { + remoteHostRecoveryIdentityKey +} from './remote-host-target-identity' + +const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024 +const MAXIMUM_METADATA_BYTES = 1024 * 1024 + +export type RemoteEnvironmentPreparerOptions = { + resolver: AgentInstallationTargetResolver + sshPool: Pick< + SshConnectionPool, + 'acquire' | 'acquireRemotePackageBootstrap' + > + agentPackageManager: Pick< + AgentPackageManager, + | 'getRemoteInstallCandidate' + | 'acquireInstallArchive' + | 'acquireGoodBuddyInstallArchive' + > + agentInstallationManager: Pick< + AgentInstallationManager, + 'activatePublished' + > + runtimeInstallationManager: Pick< + RemoteRuntimeInstallationManager, + 'activatePublished' + > + operationStore: Pick< + RemoteEnvironmentOperationStore, + 'load' | 'save' | 'remove' + > +} + +/** + * Prepares one signed compound environment. The selected method controls only + * how the archive reaches the Host; both paths share prepare, commit, + * activation, rollback, health checks, and cleanup. + */ +export class RemoteEnvironmentPreparer implements Preparer { + readonly #resolver: AgentInstallationTargetResolver + readonly #sshPool: RemoteEnvironmentPreparerOptions['sshPool'] + readonly #agentPackageManager: + RemoteEnvironmentPreparerOptions['agentPackageManager'] + readonly #agentInstallationManager: + RemoteEnvironmentPreparerOptions['agentInstallationManager'] + readonly #runtimeInstallationManager: + RemoteEnvironmentPreparerOptions['runtimeInstallationManager'] + readonly #operationStore: + RemoteEnvironmentPreparerOptions['operationStore'] + + constructor(options: RemoteEnvironmentPreparerOptions) { + this.#resolver = options.resolver + this.#sshPool = options.sshPool + this.#agentPackageManager = options.agentPackageManager + this.#agentInstallationManager = + options.agentInstallationManager + this.#runtimeInstallationManager = + options.runtimeInstallationManager + this.#operationStore = options.operationStore + } + + async prepare( + hostId: string, + requestedMethod: RemoteEnvironmentPreparationMethod, + observer: RemoteEnvironmentUpdateProgressObserver | undefined, + signal: AbortSignal + ): Promise { + const emit = ( + method: RemoteEnvironmentPreparationMethod, + phase: RemoteEnvironmentUpdateProgress['phase'] + ): void => { + try { + observer?.({ hostId, method, phase }) + } catch { + // Progress observers cannot alter installation state. + } + } + + signal.throwIfAborted() + emit(requestedMethod, 'probing') + const target = await this.#resolver.resolve(hostId) + signal.throwIfAborted() + assertValidSshConnectionTarget(hostId, target) + await this.#reconcilePendingOperation( + hostId, + target, + emit, + signal + ) + + let probeLease: SshConnectionLease | undefined + let bootstrapLease: + | SshRemotePackageBootstrapLease + | undefined + let archiveLease: AgentPackageArchiveLease | undefined + let metadataSftp: StagedSftp | undefined + let metadataSnapshots: + | readonly RemoteEnvironmentMetadataSnapshot[] + | undefined + let operationId: string | undefined + let operationCleaned = false + let pendingOperationSaved = false + let committedIdentity: SshRemotePackageIdentity | undefined + try { + probeLease = await this.#sshPool.acquire(target, signal) + assertLeaseMatchesTarget(probeLease, target) + const probe = await probeLease.runAgentBootstrapProbe(signal) + signal.throwIfAborted() + assertLeaseMatchesTarget(probeLease, target) + if (!probe.ready) { + throw new Error( + `远端系统不能运行 GoodBuddy Agent:${probe.reason}` + ) + } + + let candidate: VerifiedRemoteAgentInstallCandidate + let method: + | 'remote-download' + | 'goodbuddy-transfer' + | undefined + if (requestedMethod === 'goodbuddy-transfer') { + archiveLease = + await this.#acquireGoodBuddyArchive( + probe.architecture, + emit, + signal + ) + candidate = archiveLease.candidate + method = 'goodbuddy-transfer' + } else { + try { + candidate = + await this.#agentPackageManager.getRemoteInstallCandidate( + probe.architecture, + { signal } + ) + } catch (error) { + if (requestedMethod === 'remote-download') { + throw error + } + archiveLease = + await this.#acquireGoodBuddyArchive( + probe.architecture, + emit, + signal + ) + candidate = archiveLease.candidate + method = 'goodbuddy-transfer' + } + } + signal.throwIfAborted() + assertCandidate(candidate, probe.architecture) + await assertTargetCurrent(this.#resolver, target, signal) + + operationId = randomUUID() + const remoteCandidate: SshRemotePackageCandidate = { + operationId, + urls: candidate.urls, + size: candidate.size, + sha256: candidate.sha256 + } + bootstrapLease = + await this.#sshPool.acquireRemotePackageBootstrap( + target, + signal + ) + assertLeaseMatchesTarget(bootstrapLease, target) + assertSameLeaseIdentity(probeLease, bootstrapLease) + + if (!method) { + method = await resolvePreparationMethod( + requestedMethod, + bootstrapLease, + remoteCandidate, + signal + ) + } + if ( + method === 'goodbuddy-transfer' && + !archiveLease + ) { + archiveLease = + await this.#agentPackageManager.acquireInstallArchive( + candidate.architecture, + candidate, + { + signal, + onProgress: (progress) => { + emit( + method!, + progress.phase === 'downloading' + ? 'downloading' + : progress.phase === 'catalog' + ? 'probing' + : 'verifying' + ) + } + } + ) + } + emit(method, 'probing') + + // Persist recovery identity before either preparation path may create + // remote staging. A Main crash must never leave an untracked operation. + await this.#operationStore.save({ + version: 1, + hostId, + targetIdentity: remoteHostRecoveryIdentityKey(target), + operationId, + size: remoteCandidate.size, + sha256: remoteCandidate.sha256, + urls: [...remoteCandidate.urls] + }) + pendingOperationSaved = true + + const prepared = + method === 'remote-download' + ? await bootstrapLease.prepare(remoteCandidate, { + signal, + onProgress: (progress) => { + emit(method, mapBootstrapPhase(progress)) + } + }) + : await this.#prepareUploadedArchive({ + probeLease, + bootstrapLease, + archive: archiveLease!, + remoteCandidate, + method, + emit, + signal + }) + assertPrepared(prepared, operationId) + assertPackageIdentity(prepared.identity, candidate) + + // Snapshot the complete environment before commit so an Agent success + // followed by a Runtime failure cannot expose a partially adopted pair. + metadataSftp = await probeLease.openStagedSftp( + probe.canonicalHomeDirectory, + { + maximumFileBytes: MAXIMUM_METADATA_BYTES, + maximumTotalBytes: MAXIMUM_METADATA_BYTES * 24, + maximumOperations: 128 + }, + signal + ) + metadataSnapshots = + await snapshotRemoteEnvironmentMetadata( + metadataSftp, + probe.uid, + signal + ) + + // Keep the full target re-resolution immediately adjacent to commit. + await assertTargetCurrent(this.#resolver, target, signal) + assertLeaseMatchesTarget(bootstrapLease, target) + signal.throwIfAborted() + + const committed = await bootstrapLease.commit( + remoteCandidate, + { + signal, + onProgress: (progress) => { + emit(method, mapBootstrapPhase(progress)) + } + } + ) + signal.throwIfAborted() + if (!committed.committed) { + if ( + committed.reason === + 'installer-rollback-incomplete' + ) { + throw new SshRemotePackageCommitIndeterminateError( + '远端环境发布失败且回滚结果不完整,请重新检查 Host 状态' + ) + } + throw new Error( + `远端安装提交失败:${committed.reason}` + ) + } + if ( + committed.operationId !== operationId || + !isDeepStrictEqual( + committed.identity, + prepared.identity + ) + ) { + throw new Error('远端安装提交身份与准备身份不匹配') + } + assertPackageIdentity(committed.identity, candidate) + committedIdentity = committed.identity + + await assertTargetCurrent(this.#resolver, target, signal) + const activeAgent = + await this.#agentInstallationManager.activatePublished( + hostId, + committed.identity.agent, + { + signal, + onProgress: (phase) => { + const mapped = mapAgentInstallationPhase(phase) + if (mapped) { + emit(method, mapped) + } + } + } + ) + signal.throwIfAborted() + await assertTargetCurrent(this.#resolver, target, signal) + + await this.#runtimeInstallationManager.activatePublished( + hostId, + committed.identity.runtime, + { + agentInstallationId: activeAgent.installationId, + signal, + onProgress: (phase) => { + const mapped = mapRuntimeInstallationPhase(phase) + if (mapped) { + emit(method, mapped) + } + } + } + ) + signal.throwIfAborted() + await assertTargetCurrent(this.#resolver, target, signal) + + const cleanup = await bootstrapLease.cleanup(operationId, { + signal + }) + if (!cleanup.cleaned) { + throw new Error( + `远程环境已安装,但暂存文件清理失败:${cleanup.reason}` + ) + } + operationCleaned = true + await this.#operationStore.remove(hostId) + } catch (error) { + let rollbackError: unknown + if ( + committedIdentity && + metadataSftp && + metadataSnapshots + ) { + await stopCandidateBestEffort( + probeLease, + committedIdentity.agent.installationId + ) + try { + await restoreRemoteEnvironmentMetadata( + metadataSftp, + metadataSnapshots + ) + } catch (reason) { + rollbackError = reason + } + } + if ( + operationId && + bootstrapLease && + !operationCleaned && + !(error instanceof SshRemotePackageCommitIndeterminateError) + ) { + operationCleaned = await cleanupBestEffort( + bootstrapLease, + operationId + ) + if (operationCleaned && pendingOperationSaved) { + await this.#operationStore.remove(hostId) + } + } + if (rollbackError) { + throw new AggregateError( + [error, rollbackError], + '远程运行环境准备失败,且原有环境元数据未能完整恢复', + { cause: error } + ) + } + throw error + } finally { + metadataSftp?.close() + archiveLease?.release() + bootstrapLease?.release() + probeLease?.release() + } + } + + async #prepareUploadedArchive(options: { + probeLease: SshConnectionLease + bootstrapLease: SshRemotePackageBootstrapLease + archive: AgentPackageArchiveLease + remoteCandidate: SshRemotePackageCandidate + method: 'goodbuddy-transfer' + emit: ( + method: RemoteEnvironmentPreparationMethod, + phase: RemoteEnvironmentUpdateProgress['phase'] + ) => void + signal: AbortSignal + }): Promise { + const { + probeLease, + bootstrapLease, + archive, + remoteCandidate, + method, + emit, + signal + } = options + signal.throwIfAborted() + + const staging = await bootstrapLease.createUploadStaging( + remoteCandidate, + { signal } + ) + if (!staging.created) { + throw new Error( + staging.unavailable + ? `GoodBuddy 传输暂存不可用:${staging.reason}` + : `GoodBuddy 传输暂存失败:${staging.reason}` + ) + } + if (staging.operationId !== remoteCandidate.operationId) { + throw new Error('GoodBuddy 传输暂存操作身份不匹配') + } + const operationRoot = dirname(staging.archivePath) + if ( + staging.archivePath !== + `${operationRoot}/package.gbagent` || + staging.bootstrapNodePath !== + `${operationRoot}/bootstrap/agent/node` + ) { + throw new Error('GoodBuddy 传输暂存路径无效') + } + + const sftp = await probeLease.openStagedSftp( + operationRoot, + { + maximumFileBytes: MAXIMUM_PACKAGE_BYTES, + maximumTotalBytes: + archive.size + archive.nodeSize, + maximumOperations: 16 + }, + signal + ) + try { + emit(method, 'downloading') + await sftp.uploadFile( + 'package.gbagent', + archive.path, + { + size: archive.size, + sha256: archive.sha256 + }, + signal + ) + await sftp.uploadFile( + 'bootstrap/agent/node', + archive.nodePath, + { + size: archive.nodeSize, + sha256: archive.nodeSha256 + }, + signal + ) + await sftp.setExecutable( + 'bootstrap/agent/node', + signal + ) + } finally { + sftp.close() + } + + return bootstrapLease.prepareUploaded(remoteCandidate, { + signal, + onProgress: (progress) => { + emit(method, mapBootstrapPhase(progress)) + } + }) + } + + #acquireGoodBuddyArchive( + architecture: 'x64' | 'arm64', + emit: ( + method: RemoteEnvironmentPreparationMethod, + phase: RemoteEnvironmentUpdateProgress['phase'] + ) => void, + signal: AbortSignal + ): Promise { + return this.#agentPackageManager.acquireGoodBuddyInstallArchive( + architecture, + { + signal, + onProgress: (progress) => { + emit( + 'goodbuddy-transfer', + progress.phase === 'downloading' + ? 'downloading' + : progress.phase === 'catalog' + ? 'probing' + : 'verifying' + ) + } + } + ) + } + + async #reconcilePendingOperation( + hostId: string, + target: SshConnectionTarget, + emit: ( + method: RemoteEnvironmentPreparationMethod, + phase: RemoteEnvironmentUpdateProgress['phase'] + ) => void, + signal: AbortSignal + ): Promise { + const pending = await this.#operationStore.load(hostId) + if (!pending) { + return + } + if ( + pending.targetIdentity !== + remoteHostRecoveryIdentityKey(target) + ) { + throw new SshRemotePackageCommitIndeterminateError( + 'SSH Host 连接目标已变化,无法确认上一次远程环境提交;请恢复原 Host 连接后重试,或删除此 Host 以明确放弃恢复' + ) + } + emit('auto', 'applying') + const candidate: SshRemotePackageCandidate = { + operationId: pending.operationId, + size: pending.size, + sha256: pending.sha256, + urls: pending.urls + } + const lease = + await this.#sshPool.acquireRemotePackageBootstrap( + target, + signal + ) + try { + assertLeaseMatchesTarget(lease, target) + const status = await lease.commitStatus(candidate, { + signal + }) + if ( + !status.committed && + status.reason === 'installer-rollback-incomplete' + ) { + throw new SshRemotePackageCommitIndeterminateError( + '上一次远程环境发布失败且回滚结果不完整,请检查 Host 状态' + ) + } + const cleanup = await lease.cleanup( + pending.operationId, + { signal } + ) + if ( + !cleanup.cleaned && + cleanup.reason !== 'operation-unavailable' + ) { + throw new Error( + `上一次远程环境暂存清理失败:${cleanup.reason}` + ) + } + await this.#operationStore.remove(hostId) + } catch (error) { + if ( + error instanceof + SshRemotePackageCommitIndeterminateError + ) { + throw error + } + throw new SshRemotePackageCommitIndeterminateError( + '上一次远程环境提交结果仍不确定,请稍后重试' + ) + } finally { + lease.release() + } + } +} + +async function resolvePreparationMethod( + requested: RemoteEnvironmentPreparationMethod, + lease: SshRemotePackageBootstrapLease, + candidate: SshRemotePackageCandidate, + signal: AbortSignal +): Promise<'remote-download' | 'goodbuddy-transfer'> { + if (requested !== 'auto') { + return requested + } + const availability = await lease.probe(candidate, { signal }) + return availability.available + ? 'remote-download' + : 'goodbuddy-transfer' +} + +function assertPrepared( + prepared: SshRemotePackageBootstrapPrepareResult, + operationId: string +): asserts prepared is Extract< + SshRemotePackageBootstrapPrepareResult, + { prepared: true } +> { + if (!prepared.prepared) { + throw new Error( + prepared.unavailable + ? `远端环境准备不可用:${prepared.reason}` + : `远端环境准备失败:${prepared.reason}` + ) + } + if (prepared.operationId !== operationId) { + throw new Error('远端环境准备操作身份不匹配') + } +} + +async function cleanupBestEffort( + lease: SshRemotePackageBootstrapLease, + operationId: string +): Promise { + try { + const result = await lease.cleanup(operationId) + return ( + result.cleaned || + result.reason === 'operation-unavailable' + ) + } catch { + // Preserve the original deterministic preparation failure. + return false + } +} + +async function stopCandidateBestEffort( + lease: SshConnectionLease | undefined, + installationId: string +): Promise { + if (!lease?.isUsable()) { + return + } + try { + await lease.runAgentLifecycleAction( + verifyAgentInstallationId(installationId), + 'stop' + ) + } catch { + // Metadata rollback remains the authoritative recovery action. + } +} + +async function assertTargetCurrent( + resolver: AgentInstallationTargetResolver, + expected: SshConnectionTarget, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const current = await resolver.resolve(expected.host.id) + signal.throwIfAborted() + assertValidSshConnectionTarget(expected.host.id, current) + if (!isDeepStrictEqual(current, expected)) { + throw new Error( + 'SSH 主机身份在远端环境安装期间发生变化' + ) + } +} + +function assertLeaseMatchesTarget( + lease: Pick, + target: SshConnectionTarget +): void { + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 远端安装连接身份不匹配' + ) + if ( + !/^[a-f0-9]{64}$/u.test( + lease.identity.authenticationIdentity + ) + ) { + throw new Error('SSH 远端安装连接身份不匹配') + } +} + +function assertSameLeaseIdentity( + probeLease: Pick, + bootstrapLease: Pick< + SshRemotePackageBootstrapLease, + 'identity' + > +): void { + if ( + !isDeepStrictEqual( + probeLease.identity, + bootstrapLease.identity + ) + ) { + throw new Error('SSH 远端安装认证身份发生变化') + } +} + +function assertCandidate( + candidate: VerifiedRemoteAgentInstallCandidate, + architecture: 'x64' | 'arm64' +): void { + if ( + candidate.platform !== 'linux' || + candidate.architecture !== architecture + ) { + throw new Error('签名 Agent 安装候选与 Host 架构不匹配') + } +} + +function assertPackageIdentity( + identity: SshRemotePackageIdentity, + candidate: VerifiedRemoteAgentInstallCandidate +): void { + if ( + identity.archiveSha256 !== candidate.sha256 || + identity.agent.agentVersion !== candidate.version || + identity.agent.platform !== candidate.platform || + identity.agent.architecture !== candidate.architecture || + !isDeepStrictEqual( + identity.agent.protocol, + candidate.agentProtocol + ) || + identity.runtime.runtimeId !== + candidate.remoteRuntime.runtimeId || + identity.runtime.runtimeVersion !== + candidate.remoteRuntime.version || + identity.runtime.bundleDigest !== + candidate.remoteRuntime.bundleDigest || + identity.runtime.platform !== candidate.platform || + identity.runtime.architecture !== + candidate.architecture || + !isDeepStrictEqual( + identity.runtime.protocol, + candidate.remoteRuntime.protocol + ) + ) { + throw new Error('远端准备的安装身份与签名候选不匹配') + } +} + +function mapBootstrapPhase( + progress: SshRemotePackageBootstrapProgress +): RemoteEnvironmentUpdateProgress['phase'] { + switch (progress.phase) { + case 'checking': + return 'probing' + case 'downloading': + return 'downloading' + case 'verifying': + case 'validating': + case 'hashing-archive': + case 'verifying-zip': + case 'verifying-payload': + case 'validating-prepared-state': + case 'revalidating-archive': + return 'verifying' + case 'extracting': + case 'preparing': + case 'prepared': + case 'committing': + case 'committed': + case 'persisting-prepared-state': + case 'extracting-payload': + case 'publishing-content': + case 'cleaning': + return 'applying' + } +} diff --git a/src/main/remote-agent/remote-environment-progress.ts b/src/main/remote-agent/remote-environment-progress.ts new file mode 100644 index 00000000..faa8aed6 --- /dev/null +++ b/src/main/remote-agent/remote-environment-progress.ts @@ -0,0 +1,35 @@ +import type { RemoteEnvironmentUpdateProgress } from '../../shared/ssh-host-contracts' +import type { AgentInstallationPhase } from './agent-installation-manager' +import type { RemoteRuntimeInstallationPhase } from './remote-runtime-installation-manager' + +export function mapAgentInstallationPhase( + phase: AgentInstallationPhase +): RemoteEnvironmentUpdateProgress['phase'] | undefined { + switch (phase) { + case 'inspecting-host': + return 'probing' + case 'verifying-bundle': + return 'verifying' + case 'starting-agent': + return 'installing-agent' + case 'checking-health': + return 'checking-health' + case 'complete': + return undefined + } +} + +export function mapRuntimeInstallationPhase( + phase: RemoteRuntimeInstallationPhase +): RemoteEnvironmentUpdateProgress['phase'] | undefined { + switch (phase) { + case 'inspecting-host': + return 'probing' + case 'verifying-bundle': + return 'verifying' + case 'activating-runtime': + return 'installing-runtime' + case 'complete': + return undefined + } +} diff --git a/src/main/remote-agent/remote-environment-update-service.test.ts b/src/main/remote-agent/remote-environment-update-service.test.ts index c11dd587..607c271d 100644 --- a/src/main/remote-agent/remote-environment-update-service.test.ts +++ b/src/main/remote-agent/remote-environment-update-service.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it, vi } from 'vitest' -import { RemoteEnvironmentUpdateService } from './remote-environment-update-service' +import type { RemoteEnvironmentUpdateProgress } from '../../shared/ssh-host-contracts' +import { + RemoteEnvironmentUpdateService, + type RemoteEnvironmentPreparer +} from './remote-environment-update-service' const HOST_ID = '00000000-0000-4000-8000-000000000101' -const OTHER_HOST_ID = '00000000-0000-4000-8000-000000000102' -function createOwner() { +function createOwner(id = 1) { let destroyed = false const listeners = new Set<() => void>() return { - id: 1, + id, isDestroyed: vi.fn(() => destroyed), on: vi.fn((_event: 'destroyed', listener: () => void) => { listeners.add(listener) @@ -20,15 +23,17 @@ function createOwner() { ), destroy() { destroyed = true - for (const listener of [...listeners]) { - listener() - } + for (const listener of [...listeners]) listener() } } } function abortablePending(signal: AbortSignal): Promise { return new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } signal.addEventListener('abort', () => reject(signal.reason), { once: true }) @@ -36,222 +41,243 @@ function abortablePending(signal: AbortSignal): Promise { } describe('RemoteEnvironmentUpdateService', () => { - it('force-updates Agent then locked Runtime with one signal before finalizing', async () => { - const sequence: string[] = [] - const signals: AbortSignal[] = [] - const agent = { - ensureInstalled: vi.fn( - async ( - _hostId: string, - options: { force?: boolean; signal?: AbortSignal } - ) => { - sequence.push('agent') - expect(options.force).toBe(true) - signals.push(options.signal!) - return {} - } - ) - } - const runtime = { - ensureInstalled: vi.fn( - async ( - _hostId: string, - options: { force?: boolean; signal?: AbortSignal } - ) => { - sequence.push('runtime') - expect(options.force).toBe(true) - signals.push(options.signal!) - return {} - } - ) - } - const afterUpdate = vi.fn(async () => { - sequence.push('finalizing') - }) - const service = new RemoteEnvironmentUpdateService( - agent as never, - runtime as never, - afterUpdate - ) - const progress: string[] = [] - - await expect( - service.update(createOwner(), HOST_ID, (event) => { - progress.push(event.phase) - }) - ).resolves.toBeUndefined() - - expect(agent.ensureInstalled).toHaveBeenCalledWith(HOST_ID, { - force: true, - signal: expect.any(AbortSignal) - }) - expect(runtime.ensureInstalled).toHaveBeenCalledWith(HOST_ID, { - force: true, - signal: expect.any(AbortSignal) - }) - expect(signals[0]).toBe(signals[1]) - expect(sequence).toEqual(['agent', 'runtime', 'finalizing']) - expect(progress).toEqual(['agent', 'runtime', 'finalizing']) - expect(afterUpdate).toHaveBeenCalledWith(HOST_ID) - }) - - it('does not run the Runtime or finalizer after an Agent error', async () => { - const failure = new Error('agent failed') - const agent = { - ensureInstalled: vi.fn(async () => { - throw failure - }) - } - const runtime = { ensureInstalled: vi.fn() } - const afterUpdate = vi.fn() - const service = new RemoteEnvironmentUpdateService( - agent as never, - runtime as never, - afterUpdate - ) - - await expect( - service.update(createOwner(), HOST_ID) - ).rejects.toBe(failure) - expect(runtime.ensureInstalled).not.toHaveBeenCalled() - expect(afterUpdate).not.toHaveBeenCalled() - }) - - it('joins duplicate updates and rejects a different Host for the same owner', async () => { + it('deduplicates the same Host and method across owners and fans out progress', async () => { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) - const agent = { - ensureInstalled: vi.fn(async () => { + let emit: + | ((progress: RemoteEnvironmentUpdateProgress) => void) + | undefined + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async (_hostId, method, observer) => { + expect(method).toBe('remote-download') + emit = observer await gate - return {} }) } - const runtime = { - ensureInstalled: vi.fn(async () => ({})) - } - const owner = createOwner() - const service = new RemoteEnvironmentUpdateService( - agent as never, - runtime as never - ) + const service = new RemoteEnvironmentUpdateService(preparer) + const firstProgress: string[] = [] + const secondProgress: string[] = [] - const first = service.update(owner, HOST_ID) - expect(service.update(owner, HOST_ID)).toBe(first) - await expect( - service.update(owner, OTHER_HOST_ID) - ).rejects.toThrow('another Host') + const first = service.update( + createOwner(1), + { hostId: HOST_ID, method: 'remote-download' }, + (progress) => firstProgress.push(progress.phase) + ) + const second = service.update( + createOwner(2), + { hostId: HOST_ID, method: 'remote-download' }, + (progress) => secondProgress.push(progress.phase) + ) + await vi.waitFor(() => expect(emit).toBeTypeOf('function')) + emit?.({ + hostId: HOST_ID, + method: 'remote-download', + phase: 'downloading' + }) release() - await first + + await Promise.all([first, second]) + expect(preparer.prepare).toHaveBeenCalledOnce() + expect(firstProgress).toEqual([ + 'downloading', + 'finalizing', + 'complete' + ]) + expect(secondProgress).toEqual(firstProgress) }) - it('aborts on explicit cancellation and owner destruction', async () => { - for (const cancel of ['explicit', 'destroyed'] as const) { - let signal: AbortSignal | undefined - const agent = { - ensureInstalled: vi.fn( - async ( - _hostId: string, - options: { signal?: AbortSignal } - ) => { - signal = options.signal - return abortablePending(options.signal!) - } - ) - } - const runtime = { ensureInstalled: vi.fn() } - const owner = createOwner() - const service = new RemoteEnvironmentUpdateService( - agent as never, - runtime as never - ) - const update = service.update(owner, HOST_ID) - await vi.waitFor(() => - expect(signal).toBeInstanceOf(AbortSignal) - ) - - if (cancel === 'explicit') { - service.cancel(owner, HOST_ID) - } else { - owner.destroy() - } - - await expect(update).rejects.toMatchObject({ - name: 'AbortError' - }) - expect(signal?.aborted).toBe(true) - expect(runtime.ensureInstalled).not.toHaveBeenCalled() - } - }) - - it('aborts active work and settles cleanly on disposal', async () => { + it('rejects a different method while the same Host has active work', async () => { let signal: AbortSignal | undefined - const agent = { - ensureInstalled: vi.fn( - async ( - _hostId: string, - options: { signal?: AbortSignal } - ) => { - signal = options.signal - return abortablePending(options.signal!) - } - ) + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async ( + _hostId, + _method, + _observer, + operationSignal + ) => { + signal = operationSignal + return abortablePending(operationSignal) + }) } - const service = new RemoteEnvironmentUpdateService( - agent as never, - { ensureInstalled: vi.fn() } as never + const service = new RemoteEnvironmentUpdateService(preparer) + const owner = createOwner() + const active = service.update( + owner, + { hostId: HOST_ID, method: 'auto' } + ) + + await expect(service.update( + createOwner(2), + { hostId: HOST_ID, method: 'goodbuddy-transfer' } + )).rejects.toThrow('conflicting') + owner.destroy() + await expect(active).rejects.toMatchObject({ name: 'AbortError' }) + expect(signal?.aborted).toBe(true) + }) + + it('cancels only one waiter and aborts underlying work when the last owner is destroyed', async () => { + let signal: AbortSignal | undefined + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async ( + _hostId, + _method, + _observer, + operationSignal + ) => { + signal = operationSignal + return abortablePending(operationSignal) + }) + } + const service = new RemoteEnvironmentUpdateService(preparer) + const firstOwner = createOwner(1) + const secondOwner = createOwner(2) + const first = service.update( + firstOwner, + { hostId: HOST_ID, method: 'remote-download' } + ) + const second = service.update( + secondOwner, + { hostId: HOST_ID, method: 'remote-download' } ) - const update = service.update(createOwner(), HOST_ID) await vi.waitFor(() => expect(signal).toBeInstanceOf(AbortSignal) ) - await expect(service.dispose()).resolves.toBeUndefined() - await expect(update).rejects.toMatchObject({ name: 'AbortError' }) + service.cancel(firstOwner, HOST_ID) + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(signal?.aborted).toBe(false) + + secondOwner.destroy() + await expect(second).rejects.toMatchObject({ name: 'AbortError' }) expect(signal?.aborted).toBe(true) - expect(() => - service.update(createOwner(), HOST_ID) - ).toThrow('disposed') + expect(firstOwner.removeListener).toHaveBeenCalledOnce() + expect(secondOwner.removeListener).toHaveBeenCalledOnce() }) - it('bounds disposal when an installation ignores cancellation', async () => { + it('aborts active work and bounds disposal when the preparer ignores cancellation', async () => { vi.useFakeTimers() try { - let started = false - const service = new RemoteEnvironmentUpdateService( - { - ensureInstalled: vi.fn(async () => { - started = true - await new Promise(() => undefined) - }) - } as never, - { ensureInstalled: vi.fn() } as never - ) - void service.update(createOwner(), HOST_ID) - await vi.waitFor(() => expect(started).toBe(true)) + let signal: AbortSignal | undefined + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async ( + _hostId, + _method, + _observer, + operationSignal + ) => { + signal = operationSignal + await new Promise(() => undefined) + }) + } + const service = new RemoteEnvironmentUpdateService(preparer) + void service.update( + createOwner(), + { hostId: HOST_ID, method: 'remote-download' } + ).catch(() => undefined) + await Promise.resolve() + await Promise.resolve() + expect(signal).toBeInstanceOf(AbortSignal) const disposal = service.dispose() await vi.advanceTimersByTimeAsync(5_000) await expect(disposal).resolves.toBeUndefined() + expect(signal?.aborted).toBe(true) + expect(() => service.update( + createOwner(2), + { hostId: HOST_ID, method: 'remote-download' } + )).toThrow('disposed') } finally { vi.useRealTimers() } }) - it('isolates progress observer errors from the update outcome', async () => { - const afterUpdate = vi.fn() + it('isolates observers and preserves an auto-resolved method through finalizing and complete', async () => { + const afterUpdate = vi.fn(async () => undefined) + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async (hostId, method, observer, signal) => { + expect(hostId).toBe(HOST_ID) + expect(method).toBe('auto') + expect(signal.aborted).toBe(false) + observer?.({ + hostId, + method: 'remote-download', + phase: 'downloading' + }) + }) + } const service = new RemoteEnvironmentUpdateService( - { ensureInstalled: vi.fn(async () => ({})) } as never, - { ensureInstalled: vi.fn(async () => ({})) } as never, + preparer, afterUpdate ) + const observed: RemoteEnvironmentUpdateProgress[] = [] + const throwingObserver = vi.fn(() => { + throw new Error('observer failed') + }) + const first = service.update( + createOwner(1), + { hostId: HOST_ID, method: 'auto' }, + throwingObserver + ) + const second = service.update( + createOwner(2), + { hostId: HOST_ID, method: 'auto' }, + (event) => observed.push(event) + ) - await expect( - service.update(createOwner(), HOST_ID, () => { - throw new Error('observer failed') + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined + ]) + expect(afterUpdate).toHaveBeenCalledWith(HOST_ID) + expect(throwingObserver).toHaveBeenCalledTimes(3) + expect(observed).toEqual([ + { + hostId: HOST_ID, + method: 'remote-download', + phase: 'downloading' + }, + { + hostId: HOST_ID, + method: 'remote-download', + phase: 'finalizing' + }, + { + hostId: HOST_ID, + method: 'remote-download', + phase: 'complete' + } + ]) + }) + + it('does not let preparer-emitted terminal phases bypass finalization', async () => { + const order: string[] = [] + const preparer: RemoteEnvironmentPreparer = { + prepare: vi.fn(async (hostId, method, observer) => { + observer?.({ hostId, method, phase: 'finalizing' }) + observer?.({ hostId, method, phase: 'complete' }) + order.push('prepared') }) - ).resolves.toBeUndefined() - expect(afterUpdate).toHaveBeenCalledOnce() + } + const service = new RemoteEnvironmentUpdateService( + preparer, + async () => { + order.push('after') + } + ) + + await service.update( + createOwner(), + { hostId: HOST_ID, method: 'goodbuddy-transfer' }, + (event) => order.push(event.phase) + ) + + expect(order).toEqual([ + 'prepared', + 'finalizing', + 'after', + 'complete' + ]) }) }) diff --git a/src/main/remote-agent/remote-environment-update-service.ts b/src/main/remote-agent/remote-environment-update-service.ts index 648847f2..3a9261ea 100644 --- a/src/main/remote-agent/remote-environment-update-service.ts +++ b/src/main/remote-agent/remote-environment-update-service.ts @@ -1,10 +1,8 @@ import type { - RemoteEnvironmentUpdateProgress + RemoteEnvironmentPreparationMethod, + RemoteEnvironmentUpdateProgress, + RemoteEnvironmentUpdateRequest } from '../../shared/ssh-host-contracts' -import type { AgentInstallationManager } from './agent-installation-manager' -import type { - RemoteRuntimeInstallationManager -} from './remote-runtime-installation-manager' import { settleBoundedly } from './bounded-settlement' const DISPOSE_WAIT_TIMEOUT_MS = 5_000 @@ -20,114 +18,96 @@ export type RemoteEnvironmentUpdateProgressObserver = ( progress: RemoteEnvironmentUpdateProgress ) => void +/** + * Narrow boundary for the SSH-side download, verification, installation, and + * adoption workflow. Its implementation remains Main-only. + */ +export interface RemoteEnvironmentPreparer { + prepare( + hostId: string, + method: RemoteEnvironmentPreparationMethod, + observer: RemoteEnvironmentUpdateProgressObserver | undefined, + signal: AbortSignal + ): Promise +} + type ActiveUpdate = { - hostId: string + request: RemoteEnvironmentUpdateRequest controller: AbortController + observers: Set + waiters: Set promise: Promise + settled: boolean + lastEmittedPhase?: RemoteEnvironmentUpdateProgress['phase'] + lastEmittedMethod?: RemoteEnvironmentPreparationMethod +} + +type UpdateWaiter = { + owner: RemoteEnvironmentUpdateOwner + active: ActiveUpdate + observer?: RemoteEnvironmentUpdateProgressObserver + promise: Promise + resolve: () => void + reject: (reason: unknown) => void + settled: boolean +} + +type OwnerState = { + listener: () => void + waitersByHost: Map> } export class RemoteEnvironmentUpdateService { - readonly #agentInstallationManager: Pick< - AgentInstallationManager, - 'ensureInstalled' - > - readonly #runtimeInstallationManager: Pick< - RemoteRuntimeInstallationManager, - 'ensureInstalled' - > + readonly #preparer: RemoteEnvironmentPreparer readonly #afterUpdate?: (hostId: string) => void | Promise - readonly #active = new Map< - RemoteEnvironmentUpdateOwner, - ActiveUpdate - >() + readonly #activeByHost = new Map() + readonly #owners = new Map() #disposed = false #disposePromise?: Promise constructor( - agentInstallationManager: Pick< - AgentInstallationManager, - 'ensureInstalled' - >, - runtimeInstallationManager: Pick< - RemoteRuntimeInstallationManager, - 'ensureInstalled' - >, + preparer: RemoteEnvironmentPreparer, afterUpdate?: (hostId: string) => void | Promise ) { - this.#agentInstallationManager = agentInstallationManager - this.#runtimeInstallationManager = runtimeInstallationManager + this.#preparer = preparer this.#afterUpdate = afterUpdate } update( owner: RemoteEnvironmentUpdateOwner, - hostId: string, - onProgress?: RemoteEnvironmentUpdateProgressObserver + request: RemoteEnvironmentUpdateRequest, + observer?: RemoteEnvironmentUpdateProgressObserver ): Promise { this.#assertAvailable(owner) - const existing = this.#active.get(owner) - if (existing) { - if (existing.hostId === hostId) { - return existing.promise - } + const existing = this.#activeByHost.get(request.hostId) + if (existing && existing.request.method !== request.method) { return Promise.reject( new Error( - 'A remote environment update is already running for another Host' + 'A conflicting remote environment preparation method is already running for this Host' ) ) } - const controller = new AbortController() - const destroyedListener = (): void => { - controller.abort( - new DOMException( - 'Remote environment update owner was destroyed', - 'AbortError' - ) - ) - } - const promise = this.#run( - hostId, - controller.signal, - onProgress - ).finally(() => { - owner.removeListener('destroyed', destroyedListener) - if (this.#active.get(owner)?.promise === promise) { - this.#active.delete(owner) - } - }) - const active: ActiveUpdate = { - hostId, - controller, - promise - } - this.#active.set(owner, active) - owner.on('destroyed', destroyedListener) - if (owner.isDestroyed()) { - destroyedListener() - } - return promise + const active = existing ?? this.#createActive(request) + return this.#attachWaiter(owner, active, observer) } cancel( owner: RemoteEnvironmentUpdateOwner, hostId: string ): void { - const active = this.#active.get(owner) - if (!active) { + const state = this.#owners.get(owner) + const waiters = state?.waitersByHost.get(hostId) + if (!waiters || waiters.size === 0) { return } - if (active.hostId !== hostId) { - throw new Error( - 'The active remote environment update targets another Host' - ) - } - active.controller.abort( - new DOMException( - 'Remote environment update was cancelled', - 'AbortError' - ) + const reason = new DOMException( + 'Remote environment update was cancelled', + 'AbortError' ) + for (const waiter of [...waiters]) { + this.#detachWaiter(waiter, reason) + } } dispose(): Promise { @@ -135,37 +115,222 @@ export class RemoteEnvironmentUpdateService { return this.#disposePromise } - async #run( - hostId: string, - signal: AbortSignal, - onProgress?: RemoteEnvironmentUpdateProgressObserver + #createActive( + request: RemoteEnvironmentUpdateRequest + ): ActiveUpdate { + const controller = new AbortController() + const active: ActiveUpdate = { + request, + controller, + observers: new Set(), + waiters: new Set(), + promise: Promise.resolve(), + settled: false, + lastEmittedPhase: undefined, + lastEmittedMethod: undefined + } + active.promise = Promise.resolve() + .then(() => this.#run(active)) + .finally(() => { + active.settled = true + if (this.#activeByHost.get(request.hostId) === active) { + this.#activeByHost.delete(request.hostId) + } + }) + // Every operation promise is observed by its waiters. This additional + // handler prevents a late rejection from becoming unhandled if all + // waiters cancel before the underlying operation notices cancellation. + void active.promise.catch(() => undefined) + this.#activeByHost.set(request.hostId, active) + return active + } + + #attachWaiter( + owner: RemoteEnvironmentUpdateOwner, + active: ActiveUpdate, + observer?: RemoteEnvironmentUpdateProgressObserver ): Promise { - this.#emit(onProgress, { hostId, phase: 'agent' }) - await this.#agentInstallationManager.ensureInstalled(hostId, { - force: true, - signal + let resolve!: () => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise }) - signal.throwIfAborted() + const waiter: UpdateWaiter = { + owner, + active, + observer, + promise, + resolve, + reject, + settled: false + } + active.waiters.add(waiter) + if (observer) { + active.observers.add(observer) + } - this.#emit(onProgress, { hostId, phase: 'runtime' }) - await this.#runtimeInstallationManager.ensureInstalled(hostId, { - force: true, - signal + let ownerState = this.#owners.get(owner) + if (!ownerState) { + const listener = (): void => { + this.#detachOwner( + owner, + new DOMException( + 'Remote environment update owner was destroyed', + 'AbortError' + ) + ) + } + ownerState = { + listener, + waitersByHost: new Map() + } + this.#owners.set(owner, ownerState) + owner.on('destroyed', listener) + } + let hostWaiters = ownerState.waitersByHost.get( + active.request.hostId + ) + if (!hostWaiters) { + hostWaiters = new Set() + ownerState.waitersByHost.set( + active.request.hostId, + hostWaiters + ) + } + hostWaiters.add(waiter) + + void active.promise.then( + () => this.#detachWaiter(waiter), + (error) => this.#detachWaiter(waiter, error) + ) + if (owner.isDestroyed()) { + ownerState.listener() + } + return promise + } + + #detachOwner( + owner: RemoteEnvironmentUpdateOwner, + reason: unknown + ): void { + const state = this.#owners.get(owner) + if (!state) { + return + } + const waiters = [...state.waitersByHost.values()] + .flatMap((entries) => [...entries]) + for (const waiter of waiters) { + this.#detachWaiter(waiter, reason) + } + } + + #detachWaiter( + waiter: UpdateWaiter, + rejection?: unknown + ): void { + if (waiter.settled) { + return + } + waiter.settled = true + const { active, owner, observer } = waiter + active.waiters.delete(waiter) + if ( + observer && + ![...active.waiters].some( + (candidate) => candidate.observer === observer + ) + ) { + active.observers.delete(observer) + } + + const ownerState = this.#owners.get(owner) + const hostWaiters = ownerState?.waitersByHost.get( + active.request.hostId + ) + hostWaiters?.delete(waiter) + if (hostWaiters?.size === 0) { + ownerState?.waitersByHost.delete(active.request.hostId) + } + if (ownerState?.waitersByHost.size === 0) { + owner.removeListener('destroyed', ownerState.listener) + this.#owners.delete(owner) + } + + if (rejection === undefined) { + waiter.resolve() + } else { + waiter.reject(rejection) + } + if ( + active.waiters.size === 0 && + !active.settled && + !active.controller.signal.aborted + ) { + active.controller.abort( + new DOMException( + 'Remote environment update has no remaining waiters', + 'AbortError' + ) + ) + } + } + + async #run(active: ActiveUpdate): Promise { + const { request, controller } = active + await this.#preparer.prepare( + request.hostId, + request.method, + (progress) => { + if ( + progress.phase === 'finalizing' || + progress.phase === 'complete' + ) { + return + } + this.#emit(active, { + ...progress, + hostId: request.hostId + }) + }, + controller.signal + ) + controller.signal.throwIfAborted() + + const resolvedMethod = + active.lastEmittedMethod ?? request.method + this.#emit(active, { + hostId: request.hostId, + method: resolvedMethod, + phase: 'finalizing' + }) + await this.#afterUpdate?.(request.hostId) + controller.signal.throwIfAborted() + this.#emit(active, { + hostId: request.hostId, + method: resolvedMethod, + phase: 'complete' }) - signal.throwIfAborted() - - this.#emit(onProgress, { hostId, phase: 'finalizing' }) - await this.#afterUpdate?.(hostId) } #emit( - observer: RemoteEnvironmentUpdateProgressObserver | undefined, + active: ActiveUpdate, progress: RemoteEnvironmentUpdateProgress ): void { - try { - observer?.(progress) - } catch { - // Progress observers cannot alter the update outcome. + if ( + active.lastEmittedPhase === progress.phase && + active.lastEmittedMethod === progress.method + ) { + return + } + active.lastEmittedPhase = progress.phase + active.lastEmittedMethod = progress.method + for (const observer of active.observers) { + try { + observer(progress) + } catch { + // Progress observers cannot alter the update outcome. + } } } @@ -180,7 +345,7 @@ export class RemoteEnvironmentUpdateService { async #dispose(): Promise { this.#disposed = true - const operations = [...this.#active.values()] + const operations = [...this.#activeByHost.values()] for (const active of operations) { active.controller.abort( new DOMException( diff --git a/src/main/remote-agent/remote-host-target-identity.ts b/src/main/remote-agent/remote-host-target-identity.ts new file mode 100644 index 00000000..00363597 --- /dev/null +++ b/src/main/remote-agent/remote-host-target-identity.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto' +import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' + +/** + * Stable Main-only key for one exact resolved SSH target. All Host mutation + * coordinators use the same material so credential and Host Key edits cannot + * be overlooked by one component. + */ +export function remoteHostTargetIdentityKey( + target: SshConnectionPoolTarget +): string { + const hostKey = target.host.hostKey + return createHash('sha256') + .update(JSON.stringify([ + 'goodbuddy-remote-host-target-v1', + target.host.id, + target.host.name, + target.host.hostname, + target.host.port, + target.host.username, + target.host.authentication, + target.host.password ?? null, + target.hostRevision, + target.hostKeyGeneration, + hostKey?.algorithm ?? null, + hostKey?.publicKeyBase64 ?? null, + hostKey?.fingerprintSha256 ?? null, + hostKey?.acceptedAt ?? null, + hostKey?.generation ?? null + ])) + .digest('hex') +} + +/** + * Stable identity for recovering a remote operation after an ordinary Host + * edit or revalidation. It binds the actual SSH account and Host Key, but not + * display names, credentials, acceptance timestamps, or local revisions. + */ +export function remoteHostRecoveryIdentityKey( + target: SshConnectionPoolTarget +): string { + const hostKey = target.host.hostKey + return createHash('sha256') + .update(JSON.stringify([ + 'goodbuddy-remote-host-recovery-v1', + target.host.id, + target.host.hostname, + target.host.port, + target.host.username, + hostKey?.algorithm ?? null, + hostKey?.publicKeyBase64 ?? null + ])) + .digest('hex') +} diff --git a/src/main/remote-agent/remote-project-save-service.test.ts b/src/main/remote-agent/remote-project-save-service.test.ts index 4348f492..9994b15f 100644 --- a/src/main/remote-agent/remote-project-save-service.test.ts +++ b/src/main/remote-agent/remote-project-save-service.test.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AssistantProject } from '../../shared/assistant-contracts' -import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' import type { SshConnectionTarget } from '../ssh/ssh-host-store' import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' @@ -26,12 +25,15 @@ afterEach(async () => { class Owner extends EventEmitter implements RemoteProjectSaveOwner { destroyed = false + constructor(readonly id: number) { super() } + isDestroyed(): boolean { return this.destroyed } + destroy(): void { this.destroyed = true this.emit('destroyed') @@ -47,6 +49,39 @@ const draft = { remoteRootPath: root } +const installation: AgentInstallationIdentity = { + installationId: verifyAgentInstallationId('agent-installation'), + binaryDigest: digest, + agentVersion: '1.2.3', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand' +} + +function project( + overrides: Partial = {} +): AssistantProject { + return { + id: projectId, + name: draft.name, + description: draft.description, + rootPath: root, + defaultWorkMode: draft.defaultWorkMode, + runtimeSelection: draft.runtimeSelection, + kind: 'user', + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: root + }, + status: 'active', + createdAt: '2030-01-01T00:00:00.000Z', + updatedAt: '2030-01-01T00:00:00.000Z', + ...overrides + } +} + function target(): SshConnectionTarget { return { host: { @@ -69,40 +104,19 @@ function target(): SshConnectionTarget { } } -const installation: AgentInstallationIdentity = { - installationId: verifyAgentInstallationId('agent-installation'), - binaryDigest: digest, - agentVersion: '1.2.3', - protocol: { major: 1, minor: 0 }, - platform: 'linux', - architecture: 'x64', - supervisor: 'detached-on-demand' -} - -function project(): AssistantProject { - return { - id: projectId, - ...draft, - rootPath: root, - kind: 'user', - executionSpace: { kind: 'ssh', hostId, remoteRootPath: root }, - status: 'active', - createdAt: '2030-01-01T00:00:00.000Z', - updatedAt: '2030-01-01T00:00:00.000Z' - } -} - -function harness(options: { - ensureInstalled?: (signal: AbortSignal) => Promise - validateWorkspace?: () => unknown - create?: () => AssistantProject +type HarnessOptions = { getProject?: () => AssistantProject + validateWorkspace?: (signal?: AbortSignal) => unknown | Promise + create?: () => AssistantProject + update?: () => AssistantProject resolveRuntimeSelection?: ( selection: AgentRuntimeSelection ) => Promise -} = {}) { +} + +function harness(options: HarnessOptions = {}) { const calls: string[] = [] - const currentTarget = target() + const progress: string[] = [] const workspace = { workspaceId: 'workspace-id', workspaceIdentity: 'workspace-identity', @@ -119,25 +133,31 @@ function harness(options: { ], generation: 1 } - const connectionRelease = vi.fn(() => calls.push('connection-release')) - const request = vi.fn(async (method: string) => { - calls.push(method) - if (method === 'workspace/validate') { - return ( - options.validateWorkspace?.() ?? { - handle: workspace, - validatedAt: '2030-01-01T00:00:01.000Z' - } - ) - } - if (method === 'workspace/close') { - return { - workspaceId: workspace.workspaceId, - generation: workspace.generation, - closed: true + const request = vi.fn( + async (method: string, _params: unknown, requestOptions?: { + signal?: AbortSignal + }) => { + calls.push(method) + if (method === 'workspace/validate') { + return options.validateWorkspace + ? await options.validateWorkspace(requestOptions?.signal) + : { + handle: workspace, + validatedAt: '2030-01-01T00:00:01.000Z' + } } + if (method === 'workspace/close') { + return { + workspaceId: workspace.workspaceId, + generation: workspace.generation, + closed: true + } + } + throw new Error(`Unexpected method: ${method}`) } - throw new Error(`Unexpected method: ${method}`) + ) + const connectionRelease = vi.fn(() => { + calls.push('connection-release') }) const connection = { identity: { @@ -156,7 +176,7 @@ function harness(options: { installationId: installation.installationId, binaryDigest: digest, daemonBootId: 'boot', - agentVersion: '1.2.3', + agentVersion: installation.agentVersion, protocol: { major: 1, minor: 0 }, platform: 'linux', architecture: 'x64', @@ -167,7 +187,13 @@ function harness(options: { capabilities: { generation: 1, capabilities: [ - { name: 'workspace/read', version: 1, critical: true } + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true }, + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } ], runtimes: [] }, @@ -175,68 +201,57 @@ function harness(options: { state: 'ready', release: connectionRelease } as unknown as RemoteAgentConnection - const runtimeRelease = vi.fn(() => calls.push('runtime-release')) + const runtimeRelease = vi.fn(() => { + calls.push('runtime-release') + }) const runtimeLease: RemoteProjectRuntimeValidationLease = { - evidence: { - runtimeSelectionKey: agentRuntimeSelectionKey( - draft.runtimeSelection - ), - runtimeBundleDigest: digest, - runtimeAdapterDigest: `sha256:${'b'.repeat(64)}`, - agentInstallationIdAtValidation: installation.installationId, - validatedAt: '2030-01-01T00:00:02.000Z', - workMode: 'ask' - }, assertCurrent: vi.fn(() => calls.push('runtime-current')), release: runtimeRelease } - const assertWrite = (write: Parameters< - RemoteProjectSaveServiceOptions['database']['createValidatedSshProject'] - >[0]) => { - write.assertSshHostCurrent({ - hostId, - hostRevision: 3, - hostKeyGeneration: 2, - remoteUsername: 'builder', - remoteRootPath: root, - workspaceIdentity: workspace.workspaceIdentity, - agentProtocolMajor: 1, - agentInstallationId: installation.installationId, - agentBinaryDigest: digest, - agentVersion: '1.2.3', - agentArchitecture: 'x64' - }) + const writes: Array< + Parameters< + RemoteProjectSaveServiceOptions['database']['createSshProject'] + >[0] + > = [] + const persist = ( + operation: 'database-create' | 'database-update', + write: (typeof writes)[number], + result: AssistantProject + ): AssistantProject => { + calls.push(operation) + writes.push(write) + write.assertCurrent() + return result } - const createProject = vi.fn((write) => { - calls.push('database-create') - assertWrite(write) - return options.create?.() ?? project() - }) - const updateProject = vi.fn((_id, _updatedAt, write) => { - calls.push('database-update') - assertWrite(write) - return project() - }) - const progress: string[] = [] + const createProject = vi.fn((write: (typeof writes)[number]) => + persist('database-create', write, options.create?.() ?? project()) + ) + const updateProject = vi.fn( + ( + _id: string, + _updatedAt: string, + write: (typeof writes)[number] + ) => persist('database-update', write, options.update?.() ?? project()) + ) const service = new RemoteProjectSaveService({ database: { getProject: vi.fn(() => options.getProject?.() ?? project()), - createValidatedSshProject: createProject, - updateValidatedSshProject: updateProject + createSshProject: createProject, + updateSshProject: updateProject }, sshHosts: { resolveConnectionTarget: vi.fn(async () => { calls.push('host') - return structuredClone(currentTarget) + return target() }), - assertConnectionTargetCurrent: vi.fn(() => calls.push('host-current')) + assertConnectionTargetCurrent: vi.fn(() => + calls.push('host-current') + ) }, installationManager: { - ensureInstalled: vi.fn(async (_hostId, requestOptions) => { - calls.push('install') - return options.ensureInstalled - ? options.ensureInstalled(requestOptions.signal!) - : installation + activateInstalled: vi.fn(async () => { + calls.push('activate') + return installation }) }, connectionManager: { @@ -249,15 +264,9 @@ function harness(options: { options.resolveRuntimeSelection ?? (async (selection) => selection), runtimeValidator: { - validate: vi.fn(async (input) => { + validate: vi.fn(async () => { calls.push('runtime') - return { - ...runtimeLease, - evidence: { - ...runtimeLease.evidence, - runtimeSelectionKey: input.runtimeSelectionKey - } - } + return runtimeLease }) }, notify: (_owner, value) => progress.push(value.phase) @@ -266,20 +275,24 @@ function harness(options: { return { service, calls, + progress, + writes, createProject, updateProject, - runtimeRelease, + request, connectionRelease, - progress + runtimeRelease } } -describe('RemoteProjectSaveService awaited save', () => { - it('validates and creates in one awaited request, then cleans up in reverse order', async () => { +describe('RemoteProjectSaveService', () => { + it('prepares the current Host and creates one stable project record', async () => { const value = harness() + await expect( value.service.save(new Owner(1), { intent: 'create', draft }) ).resolves.toMatchObject({ id: projectId }) + expect(value.progress).toEqual([ 'host', 'agent', @@ -287,50 +300,58 @@ describe('RemoteProjectSaveService awaited save', () => { 'runtime', 'saving' ]) - expect(value.createProject).toHaveBeenCalledOnce() - expect(value.calls.slice(-3)).toEqual([ + expect(value.writes[0]).toMatchObject({ + project: { + rootPath: root, + runtimeSelection: { provider: 'opencode' } + }, + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: root + } + }) + expect(value.calls).toEqual([ + 'host', + 'activate', + 'attach', + 'workspace/validate', + 'runtime', + 'runtime-current', + 'database-create', + 'host-current', + 'runtime-current', 'runtime-release', 'workspace/close', 'connection-release' ]) }) - it('accepts a readable non-Git remote workspace', async () => { + it('persists the resolved Runtime selection', async () => { + const selection = { + provider: 'opencode' as const, + profileId: '00000000-0000-4000-8000-000000000099' + } const value = harness({ - validateWorkspace: () => ({ - handle: { - workspaceId: 'workspace-id', - workspaceIdentity: 'workspace-identity', - canonicalDisplayPath: root, - access: 'read-only' as const, - git: 'not-a-repository' as const, - capabilities: [ - 'list', - 'stat', - 'read-text', - 'search' - ] as const, - generation: 1 - }, - validatedAt: '2030-01-01T00:00:01.000Z' - }) + resolveRuntimeSelection: vi.fn(async () => selection) }) - await expect( - value.service.save(new Owner(1), { - intent: 'create', - draft - }) - ).resolves.toMatchObject({ id: projectId }) - expect(value.createProject).toHaveBeenCalledOnce() + await value.service.save(new Owner(2), { + intent: 'create', + draft + }) + + expect(value.writes[0]?.project.runtimeSelection).toEqual(selection) }) - it('updates using the project revision captured before remote validation', async () => { + it('uses the preflight project revision for updates', async () => { const value = harness() - await value.service.save(new Owner(1), { + + await value.service.save(new Owner(3), { intent: 'update', - draft: { projectId, ...draft } + draft: { ...draft, projectId } }) + expect(value.updateProject).toHaveBeenCalledWith( projectId, '2030-01-01T00:00:00.000Z', @@ -338,173 +359,68 @@ describe('RemoteProjectSaveService awaited save', () => { ) }) - it('validates and persists the configured Runtime profile selection', async () => { - const configuredSelection = { - provider: 'opencode' as const, - profileId: '00000000-0000-4000-8000-000000000003' - } - const value = harness({ - resolveRuntimeSelection: vi.fn(async () => configuredSelection) - }) - - await value.service.save(new Owner(1), { - intent: 'create', - draft - }) - - expect(value.createProject).toHaveBeenCalledWith( - expect.objectContaining({ - project: expect.objectContaining({ - runtimeSelection: configuredSelection - }) - }) - ) - }) - - it('activates a persisted SSH project through the full update pipeline', async () => { - const value = harness() - - await expect( - value.service.activate(new Owner(1), projectId) - ).resolves.toMatchObject({ id: projectId }) - - expect(value.updateProject).toHaveBeenCalledWith( - projectId, - '2030-01-01T00:00:00.000Z', - expect.objectContaining({ - project: expect.objectContaining({ - name: draft.name, - description: draft.description, - defaultWorkMode: draft.defaultWorkMode, - runtimeSelection: draft.runtimeSelection - }), - executionSpace: expect.objectContaining({ - hostId, - remoteRootPath: root - }) - }) - ) - expect(value.calls).toEqual(expect.arrayContaining([ - 'host', - 'install', - 'attach', - 'workspace/validate', - 'runtime', - 'database-update' - ])) - }) - - it('coalesces repeated activation for the same owner and project', async () => { - let releaseInstallation!: () => void - const installationGate = new Promise((resolve) => { - releaseInstallation = resolve - }) - const value = harness({ - ensureInstalled: async () => { - await installationGate - return installation - } - }) - const owner = new Owner(1) - - const first = value.service.activate(owner, projectId) - await vi.waitFor(() => expect(value.calls).toContain('install')) - const second = value.service.activate(owner, projectId) - await expect( - value.service.save(owner, { intent: 'create', draft }) - ).rejects.toThrow('already in progress') - - expect( - value.calls.filter((call) => call === 'install') - ).toHaveLength(1) - releaseInstallation() - await expect(Promise.all([first, second])).resolves.toEqual([ - expect.objectContaining({ id: projectId }), - expect.objectContaining({ id: projectId }) - ]) - expect(value.updateProject).toHaveBeenCalledOnce() - }) - - it('rejects activation for a local project before remote work starts', async () => { - const value = harness({ - getProject: () => ({ - ...project(), - rootPath: 'C:\\workspace', - executionSpace: { - kind: 'local', - rootPath: 'C:\\workspace' - } - }) - }) - - await expect( - value.service.activate(new Owner(1), projectId) - ).rejects.toThrow('active managed SSH projects') - expect(value.calls).toEqual([]) - expect(value.updateProject).not.toHaveBeenCalled() - }) - it('does not write when workspace validation fails', async () => { const value = harness({ validateWorkspace: () => { - throw new Error('workspace unavailable') + throw new Error('workspace denied') } }) + await expect( - value.service.save(new Owner(1), { intent: 'create', draft }) - ).rejects.toThrow('workspace unavailable') + value.service.save(new Owner(7), { intent: 'create', draft }) + ).rejects.toThrow('workspace denied') expect(value.createProject).not.toHaveBeenCalled() expect(value.connectionRelease).toHaveBeenCalledOnce() }) - it('cancels the current owner save without an operation id', async () => { + it('cancels the active owner request', async () => { const value = harness({ - ensureInstalled: (signal) => - new Promise((_, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { - once: true - }) + validateWorkspace: async (signal) => + await new Promise((_, reject) => { + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) }) }) - const owner = new Owner(1) - const saving = value.service.save(owner, { intent: 'create', draft }) - await vi.waitFor(() => expect(value.calls).toContain('install')) + const owner = new Owner(8) + const pending = value.service.save(owner, { + intent: 'create', + draft + }) + await vi.waitFor(() => { + expect(value.calls).toContain('workspace/validate') + }) + value.service.cancelCurrent(owner) - await expect(saving).rejects.toMatchObject({ name: 'AbortError' }) + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) expect(value.createProject).not.toHaveBeenCalled() }) - it('cancels when the renderer owner is destroyed', async () => { + it('cancels when the owner is destroyed', async () => { const value = harness({ - ensureInstalled: (signal) => - new Promise((_, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { - once: true - }) + validateWorkspace: async (signal) => + await new Promise((_, reject) => { + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) }) }) - const owner = new Owner(1) - const saving = value.service.save(owner, { intent: 'create', draft }) - await vi.waitFor(() => expect(value.calls).toContain('install')) - owner.destroy() - await expect(saving).rejects.toMatchObject({ name: 'AbortError' }) - }) - - it('surfaces transactional conflicts and permits a later retry', async () => { - const value = harness({ - create: () => { - throw new Error('project conflict') - } + const owner = new Owner(9) + const pending = value.service.save(owner, { + intent: 'create', + draft }) - const owner = new Owner(1) - await expect( - value.service.save(owner, { intent: 'create', draft }) - ).rejects.toThrow('project conflict') - await expect( - value.service.save(owner, { - intent: 'update', - draft: { projectId, ...draft } - }) - ).resolves.toMatchObject({ id: projectId }) + await vi.waitFor(() => { + expect(value.calls).toContain('workspace/validate') + }) + + owner.destroy() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) }) }) diff --git a/src/main/remote-agent/remote-project-save-service.ts b/src/main/remote-agent/remote-project-save-service.ts index 91cbe26a..f1dd142e 100644 --- a/src/main/remote-agent/remote-project-save-service.ts +++ b/src/main/remote-agent/remote-project-save-service.ts @@ -1,10 +1,4 @@ -import { isDeepStrictEqual } from 'node:util' -import type { - AssistantProject, - ProjectRuntimeValidation, - SshExecutionValidation -} from '../../shared/assistant-contracts' -import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts' +import type { AssistantProject } from '../../shared/assistant-contracts' import { remoteProjectSaveRequestSchema, type RemoteProjectSavePhase, @@ -17,27 +11,26 @@ import { remoteWorkspaceValidateResultSchema, type RemoteWorkspaceHandle } from '../../shared/remote-agent-contracts' -import { - agentRuntimeSelectionKey, - type AgentRuntimeSelection -} from '../../shared/runtime-selection-contracts' +import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' import type { AssistantDatabase, - ValidatedSshHostPrecondition, - ValidatedSshProjectWrite + SshProjectWrite } from '../assistant/assistant-database' import type { - CurrentSshConnectionTarget, SshConnectionTarget, SshHostStore } from '../ssh/ssh-host-store' +import { + assertValidSshConnectionTarget, + toCurrentSshConnectionTarget +} from '../ssh/ssh-connection-target' import type { AgentInstallationIdentity, AgentInstallationManager } from './agent-installation-manager' -import type { - RemoteAgentConnection, - RemoteAgentConnectionManager +import { + type RemoteAgentConnection, + type RemoteAgentConnectionManager } from './remote-agent-connection-manager' const CLEANUP_TIMEOUT_MS = 5_000 @@ -56,8 +49,6 @@ export interface RemoteProjectSaveOwner { export type RemoteProjectRuntimeValidationInput = Readonly<{ selection: AgentRuntimeSelection - runtimeSelectionKey: string - workMode: 'ask' | 'execute' host: Readonly<{ hostId: string hostRevision: number @@ -65,29 +56,17 @@ export type RemoteProjectRuntimeValidationInput = Readonly<{ remoteUsername: string }> agent: Readonly<{ - installationId: string + installationId: AgentInstallationIdentity['installationId'] binaryDigest: string version: string architecture: 'x64' | 'arm64' protocolMajor: number }> - workspace: Readonly<{ - canonicalRemoteRoot: string - workspaceIdentity: string - workspaceId: string - generation: number - access: 'read-only' - capabilities: readonly string[] - }> connection: RemoteAgentConnection signal: AbortSignal }> -export type RemoteProjectRuntimeValidationEvidence = - ProjectRuntimeValidation - export interface RemoteProjectRuntimeValidationLease { - readonly evidence: RemoteProjectRuntimeValidationEvidence assertCurrent(): void release(): void } @@ -111,24 +90,38 @@ type ActiveSave = { done: Promise resolveDone: () => void destroyedListener: () => void - installation?: AgentInstallationIdentity connection?: RemoteAgentConnection workspace?: RemoteWorkspaceHandle runtimeLease?: RemoteProjectRuntimeValidationLease } +type RemoteProjectPreparationDraft = Readonly<{ + hostId: string + remoteRootPath: string + runtimeSelection: AgentRuntimeSelection +}> + +type PreparedRemoteProject = Readonly<{ + runtimeSelection: AgentRuntimeSelection + target: SshConnectionTarget + installation: AgentInstallationIdentity + connection: RemoteAgentConnection + workspace: RemoteWorkspaceHandle + runtimeLease: RemoteProjectRuntimeValidationLease +}> + export type RemoteProjectSaveServiceOptions = { database: Pick< AssistantDatabase, | 'getProject' - | 'createValidatedSshProject' - | 'updateValidatedSshProject' + | 'createSshProject' + | 'updateSshProject' > sshHosts: Pick< SshHostStore, 'resolveConnectionTarget' | 'assertConnectionTargetCurrent' > - installationManager: Pick + installationManager: Pick connectionManager: Pick resolveRuntimeSelection( selection: AgentRuntimeSelection @@ -151,10 +144,6 @@ export class RemoteProjectSaveService { readonly #notify?: RemoteProjectSaveServiceOptions['notify'] readonly #cleanupTimeoutMs: number readonly #active = new Map() - readonly #activations = new Map< - RemoteProjectSaveOwner, - { projectId: string; promise: Promise } - >() #disposed = false constructor(options: RemoteProjectSaveServiceOptions) { @@ -186,75 +175,12 @@ export class RemoteProjectSaveService { throw new Error('A remote project save is already in progress') } const request = remoteProjectSaveRequestSchema.parse(input) - let resolveDone!: () => void - const active: ActiveSave = { - controller: new AbortController(), - done: new Promise((resolve) => { - resolveDone = resolve - }), - resolveDone, - destroyedListener: () => { - active.controller.abort( - new DOMException('Remote project owner was destroyed', 'AbortError') - ) - } - } - this.#active.set(owner, active) - owner.on('destroyed', active.destroyedListener) + const active = this.#start(owner) try { return await this.#performSave(owner, active, request) } finally { - await this.#cleanup(active) - owner.removeListener('destroyed', active.destroyedListener) - if (this.#active.get(owner) === active) { - this.#active.delete(owner) - } - active.resolveDone() - } - } - - async activate( - owner: RemoteProjectSaveOwner, - projectId: string - ): Promise { - this.#assertOwnerAvailable(owner) - const existing = this.#activations.get(owner) - if (existing) { - if (existing.projectId === projectId) { - return await existing.promise - } - throw new Error('A remote project save is already in progress') - } - const project = this.#database.getProject(projectId) - if ( - project.kind !== 'user' || - project.channel !== undefined || - project.status !== 'active' || - project.executionSpace.kind !== 'ssh' || - project.runtimeSelection === undefined - ) { - throw new Error('Only active managed SSH projects can be activated') - } - const promise = this.save(owner, { - intent: 'update', - draft: { - projectId: project.id, - name: project.name, - description: project.description, - defaultWorkMode: project.defaultWorkMode, - runtimeSelection: project.runtimeSelection, - hostId: project.executionSpace.hostId, - remoteRootPath: project.executionSpace.remoteRootPath - } - }) - this.#activations.set(owner, { projectId, promise }) - try { - return await promise - } finally { - if (this.#activations.get(owner)?.promise === promise) { - this.#activations.delete(owner) - } + await this.#finish(owner, active) } } @@ -281,33 +207,60 @@ export class RemoteProjectSaveService { active: ActiveSave, request: RemoteProjectSaveRequest ): Promise { - const { signal } = active.controller const expectedUpdatedAt = request.intent === 'update' ? this.#readUpdateRevision(request) : undefined + const prepared = await this.#prepare( + owner, + active, + request.draft + ) + + this.#progress(owner, 'saving') + active.controller.signal.throwIfAborted() + const write = this.#projectWrite(request, prepared) + return request.intent === 'create' + ? this.#database.createSshProject(write) + : this.#database.updateSshProject( + request.draft.projectId, + expectedUpdatedAt!, + write + ) + } + + async #prepare( + owner: RemoteProjectSaveOwner, + active: ActiveSave, + draft: RemoteProjectPreparationDraft + ): Promise { + const { signal } = active.controller const runtimeSelection = await this.#resolveRuntimeSelection( - request.draft.runtimeSelection + draft.runtimeSelection ) signal.throwIfAborted() this.#progress(owner, 'host') const target = await this.#sshHosts.resolveConnectionTarget( - request.draft.hostId + draft.hostId ) signal.throwIfAborted() - assertTarget(request.draft.hostId, target) + assertValidSshConnectionTarget( + draft.hostId, + target, + 'SSH Host binding is unavailable' + ) this.#progress(owner, 'agent') - const installation = await this.#installationManager.ensureInstalled( - request.draft.hostId, - { signal } - ) + const installation = + await this.#installationManager.activateInstalled( + draft.hostId, + { signal } + ) signal.throwIfAborted() assertInstallation(installation) - active.installation = installation const connection = await this.#connectionManager.acquire( - request.draft.hostId, + draft.hostId, { ...installation, requiredCapabilities: [WORKSPACE_READ_CAPABILITY] @@ -323,9 +276,11 @@ export class RemoteProjectSaveService { await connection.client.request( 'workspace/validate', { - remoteRootPath: request.draft.remoteRootPath, + remoteRootPath: draft.remoteRootPath, requestedAccess: 'read-only', - requiredCapabilities: [...REMOTE_WORKSPACE_READ_CAPABILITIES] + requiredCapabilities: [ + ...REMOTE_WORKSPACE_READ_CAPABILITIES + ] }, { signal } ) @@ -334,21 +289,15 @@ export class RemoteProjectSaveService { signal.throwIfAborted() assertWorkspace(validated.handle) if ( - validated.handle.canonicalDisplayPath !== - request.draft.remoteRootPath + validated.handle.canonicalDisplayPath !== draft.remoteRootPath ) { throw new Error('Remote workspace path changed during validation') } assertCriticalWorkspaceRead(connection) this.#progress(owner, 'runtime') - const selectionKey = agentRuntimeSelectionKey( - runtimeSelection - ) const runtimeLease = await this.#runtimeValidator.validate({ selection: runtimeSelection, - runtimeSelectionKey: selectionKey, - workMode: request.draft.defaultWorkMode, host: { hostId: target.host.id, hostRevision: target.hostRevision, @@ -362,49 +311,20 @@ export class RemoteProjectSaveService { architecture: installation.architecture, protocolMajor: installation.protocol.major }, - workspace: { - canonicalRemoteRoot: validated.handle.canonicalDisplayPath, - workspaceIdentity: validated.handle.workspaceIdentity, - workspaceId: validated.handle.workspaceId, - generation: validated.handle.generation, - access: 'read-only', - capabilities: validated.handle.capabilities - }, connection, signal }) active.runtimeLease = runtimeLease signal.throwIfAborted() - const runtimeEvidence = projectRuntimeValidationSchema.parse( - runtimeLease.evidence - ) - assertRuntimeEvidence( - runtimeEvidence, - selectionKey, - installation.installationId, - request.draft.defaultWorkMode - ) - - this.#progress(owner, 'saving') - signal.throwIfAborted() - const write = this.#validatedWrite( - request, + runtimeLease.assertCurrent() + return { runtimeSelection, target, installation, connection, - validated.handle, - runtimeLease, - runtimeEvidence, - validated.validatedAt - ) - return request.intent === 'create' - ? this.#database.createValidatedSshProject(write) - : this.#database.updateValidatedSshProject( - request.draft.projectId, - expectedUpdatedAt!, - write - ) + workspace: validated.handle, + runtimeLease + } } #readUpdateRevision( @@ -426,29 +346,18 @@ export class RemoteProjectSaveService { return project.updatedAt } - #validatedWrite( + #projectWrite( request: RemoteProjectSaveRequest, - runtimeSelection: AgentRuntimeSelection, - target: SshConnectionTarget, - installation: AgentInstallationIdentity, - connection: RemoteAgentConnection, - workspace: RemoteWorkspaceHandle, - runtimeLease: RemoteProjectRuntimeValidationLease, - runtime: ProjectRuntimeValidation, - validatedAt: string - ): ValidatedSshProjectWrite { - const executionValidation: SshExecutionValidation = { - hostRevision: target.hostRevision, - hostKeyGeneration: target.hostKeyGeneration, - remoteUsername: target.host.username, - workspaceIdentity: workspace.workspaceIdentity, - agentProtocolMajor: installation.protocol.major, - agentInstallationIdAtValidation: installation.installationId, - agentBinaryDigestAtValidation: installation.binaryDigest, - agentVersionAtValidation: installation.agentVersion, - agentArchitectureAtValidation: installation.architecture, - validatedAt - } + prepared: PreparedRemoteProject + ): SshProjectWrite { + const { + runtimeSelection, + target, + installation, + connection, + workspace, + runtimeLease + } = prepared return { project: { name: request.draft.name, @@ -460,35 +369,57 @@ export class RemoteProjectSaveService { executionSpace: { kind: 'ssh', hostId: target.host.id, - remoteRootPath: workspace.canonicalDisplayPath, - validation: executionValidation + remoteRootPath: workspace.canonicalDisplayPath }, - runtimeValidation: structuredClone(runtime), - assertSshHostCurrent: (expected) => { - assertPrecondition( - expected, - target, - installation, - workspace - ) + assertCurrent: () => { this.#sshHosts.assertConnectionTargetCurrent( - currentTargetEvidence(target) + toCurrentSshConnectionTarget(target) ) assertConnectionIdentity(target, installation, connection) assertCriticalWorkspaceRead(connection) runtimeLease.assertCurrent() - if ( - !isDeepStrictEqual( - projectRuntimeValidationSchema.parse(runtimeLease.evidence), - runtime - ) - ) { - throw new Error('Remote Runtime validation changed before save') - } } } } + #start(owner: RemoteProjectSaveOwner): ActiveSave { + const controller = new AbortController() + let resolveDone!: () => void + const active: ActiveSave = { + controller, + done: new Promise((resolve) => { + resolveDone = resolve + }), + resolveDone, + destroyedListener: () => { + controller.abort( + new DOMException( + 'Remote project owner was destroyed', + 'AbortError' + ) + ) + } + } + this.#active.set(owner, active) + owner.on('destroyed', active.destroyedListener) + return active + } + + async #finish( + owner: RemoteProjectSaveOwner, + active: ActiveSave + ): Promise { + try { + await this.#cleanup(active) + } finally { + owner.removeListener('destroyed', active.destroyedListener) + if (this.#active.get(owner) === active) { + this.#active.delete(owner) + } + active.resolveDone() + } + } + async #cleanup(active: ActiveSave): Promise { try { active.runtimeLease?.release() @@ -545,18 +476,6 @@ export class RemoteProjectSaveService { } } -function assertTarget(hostId: string, target: SshConnectionTarget): void { - if ( - target.host.id !== hostId || - !target.host.hostKey || - target.host.hostKey.generation !== target.hostKeyGeneration || - target.hostRevision < 1 || - target.hostKeyGeneration < 1 - ) { - throw new Error('SSH Host binding is unavailable') - } -} - function assertInstallation( installation: AgentInstallationIdentity ): void { @@ -575,7 +494,10 @@ function assertInstallation( function assertConnectionIdentity( target: SshConnectionTarget, - installation: AgentInstallationIdentity, + installation: Pick< + AgentInstallationIdentity, + 'installationId' | 'binaryDigest' + >, connection: RemoteAgentConnection ): void { if ( @@ -622,52 +544,3 @@ function assertWorkspace(handle: RemoteWorkspaceHandle): void { throw new Error('Remote workspace validation is incompatible') } } - -function assertRuntimeEvidence( - evidence: ProjectRuntimeValidation, - selectionKey: string, - installationId: string, - workMode: 'ask' | 'execute' -): void { - if ( - evidence.runtimeSelectionKey !== selectionKey || - evidence.agentInstallationIdAtValidation !== installationId || - evidence.workMode !== workMode - ) { - throw new Error('Remote Runtime validation does not match the project') - } -} - -function assertPrecondition( - expected: ValidatedSshHostPrecondition, - target: SshConnectionTarget, - installation: AgentInstallationIdentity, - workspace: RemoteWorkspaceHandle -): void { - if ( - expected.hostId !== target.host.id || - expected.hostRevision !== target.hostRevision || - expected.hostKeyGeneration !== target.hostKeyGeneration || - expected.remoteUsername !== target.host.username || - expected.remoteRootPath !== workspace.canonicalDisplayPath || - expected.workspaceIdentity !== workspace.workspaceIdentity || - expected.agentProtocolMajor !== installation.protocol.major || - expected.agentInstallationId !== installation.installationId || - expected.agentBinaryDigest !== installation.binaryDigest || - expected.agentVersion !== installation.agentVersion || - expected.agentArchitecture !== installation.architecture - ) { - throw new Error('Validated SSH project precondition changed') - } -} - -function currentTargetEvidence( - target: SshConnectionTarget -): CurrentSshConnectionTarget { - return { - hostId: target.host.id, - hostRevision: target.hostRevision, - hostKeyGeneration: target.hostKeyGeneration, - username: target.host.username - } -} diff --git a/src/main/remote-agent/remote-runtime-installation-manager.test.ts b/src/main/remote-agent/remote-runtime-installation-manager.test.ts index af7091b2..2a85dbf9 100644 --- a/src/main/remote-agent/remote-runtime-installation-manager.test.ts +++ b/src/main/remote-agent/remote-runtime-installation-manager.test.ts @@ -32,12 +32,12 @@ import type { SshConnectionLease, SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' import { - RemoteRuntimeBundleResourcesUnavailableError, - RemoteRuntimeInstallationError, RemoteRuntimeInstallationManager, - type VerifiedRemoteRuntimeInstallationBundle + type RemoteRuntimeActivator } from './remote-runtime-installation-manager' +import type { VerifiedRemoteRuntimeResourceBundle } from './remote-runtime-resource-loader' type RemoteEntry = { type: 'file' | 'directory' | 'symbolic-link' @@ -82,6 +82,10 @@ class MemorySftp implements StagedSftp { } } + async uploadFile(): Promise { + throw new Error('uploadFile is unused in this fixture') + } + async mkdir(path: string): Promise { this.operations.push(`mkdir:${path}`) const absolute = this.absolute(path) @@ -310,7 +314,7 @@ function target( } async function bundleFixture(): Promise< - VerifiedRemoteRuntimeInstallationBundle + VerifiedRemoteRuntimeResourceBundle > { const directory = await mkdtemp( join(tmpdir(), 'runtime-manager-') @@ -409,79 +413,6 @@ async function bundleFixture(): Promise< } } -async function harness(options: { - sftp?: MemorySftp - resolverTargets?: SshConnectionPoolTarget[] - bootstrapGate?: Promise - maximumConcurrentHosts?: number -} = {}) { - const bundle = await bundleFixture() - const sftp = options.sftp ?? new MemorySftp() - const release = vi.fn() - const activate = vi.fn(async () => undefined) - const lease = { - identity: { - hostId: 'host-1', - hostRevision: 1, - hostKeyGeneration: 1, - authenticationIdentity: 'b'.repeat(64) - }, - isUsable: () => true, - runAgentBootstrapProbe: vi.fn( - async (signal?: AbortSignal) => { - if (options.bootstrapGate) { - await waitForGate(options.bootstrapGate, signal) - } - return { - ready: true, - platform: 'linux', - architecture: 'x64', - canonicalHomeDirectory: home, - uid, - shell: '/bin/bash', - procfs: 'ready' - } as const - } - ), - openStagedSftp: vi.fn(async () => sftp), - release - } as unknown as SshConnectionLease - let resolveCount = 0 - const targets = options.resolverTargets ?? [target()] - const resolver = { - resolve: vi.fn(async () => - targets[ - Math.min(resolveCount++, targets.length - 1) - ]! - ) - } - const sshPool = { - acquire: vi.fn(async () => lease) - } - const manager = new RemoteRuntimeInstallationManager({ - resolver, - sshPool, - loadVerifiedBundle: async () => bundle, - activate, - maximumConcurrentHosts: options.maximumConcurrentHosts - }) - const destination = - `.goodbuddy/runtimes/opencode/${ - bundle.manifest.bundleDigest.slice('sha256:'.length) - }` - return { - manager, - bundle, - sftp, - release, - activate, - lease, - resolver, - sshPool, - destination - } -} - async function waitForGate( gate: Promise, signal?: AbortSignal @@ -512,54 +443,18 @@ async function waitForGate( }) } -function populateBundle( - fixture: Awaited> -): void { - fixture.sftp.add(fixture.destination, { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add(`${fixture.destination}/bin`, { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add(`${fixture.destination}/licenses`, { - type: 'directory', - mode: 0o700, - uid - }) - fixture.sftp.add(`${fixture.destination}/bin/opencode`, { - type: 'file', - mode: 0o755, - uid, - contents: Buffer.from('opencode') - }) - fixture.sftp.add(`${fixture.destination}/licenses/LICENSE`, { - type: 'file', - mode: 0o644, - uid, - contents: Buffer.from('license') - }) - fixture.sftp.add(`${fixture.destination}/manifest.json`, { - type: 'file', - mode: 0o644, - uid, - contents: canonical(fixture.bundle.manifest) - }) - fixture.sftp.add(`${fixture.destination}/manifest.sig`, { - type: 'file', - mode: 0o644, - uid, - contents: Buffer.alloc(64, 7) - }) -} - async function metadataOnlyHarness(options: { corruptPath?: string remoteReleaseKeyBytes?: Buffer + remoteRuntimeLockBytes?: Buffer + remoteReleaseKeysAbsent?: boolean + remoteRuntimeLockAbsent?: boolean resolverTargets?: SshConnectionPoolTarget[] + runtimeRegistryBytes?: Buffer + runtimeRegistryAbsent?: boolean + activationFailure?: boolean + activationMutatesRegistry?: boolean + bootstrapGate?: Promise } = {}) { const baseBundle = await bundleFixture() const keyPair = generateKeyPairSync('ed25519') @@ -585,7 +480,7 @@ async function metadataOnlyHarness(options: { ) const releaseKeyRegistryBytes = canonical(signedRegistry) const remoteRuntimeLockBytes = canonical(runtimeLock) - const bundle: VerifiedRemoteRuntimeInstallationBundle = { + const bundle: VerifiedRemoteRuntimeResourceBundle = { bundleDirectory: baseBundle.bundleDirectory, manifest: baseBundle.manifest, manifestDigest: baseBundle.manifestDigest, @@ -625,26 +520,33 @@ async function metadataOnlyHarness(options: { bundle.manifest.acpCapabilitiesDigest }] }) - for (const [path, contents] of [ - [ - '.goodbuddy/runtimes/registry.json', - runtimeRegistryBytes - ], - [ - '.goodbuddy/runtimes/release-keys.json', - options.remoteReleaseKeyBytes ?? - releaseKeyRegistryBytes - ], - [ - '.goodbuddy/runtimes/remote-runtime-lock.json', - remoteRuntimeLockBytes - ] - ] as const) { - sftp.add(path, { + if (!options.remoteReleaseKeysAbsent) { + sftp.add('.goodbuddy/runtimes/release-keys.json', { type: 'file', mode: 0o600, uid, - contents + contents: + options.remoteReleaseKeyBytes ?? + releaseKeyRegistryBytes + }) + } + if (!options.remoteRuntimeLockAbsent) { + sftp.add('.goodbuddy/runtimes/remote-runtime-lock.json', { + type: 'file', + mode: 0o600, + uid, + contents: + options.remoteRuntimeLockBytes ?? + remoteRuntimeLockBytes + }) + } + if (!options.runtimeRegistryAbsent) { + sftp.add('.goodbuddy/runtimes/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: + options.runtimeRegistryBytes ?? runtimeRegistryBytes }) } for (const file of bundle.manifest.files) { @@ -678,8 +580,17 @@ async function metadataOnlyHarness(options: { }) const release = vi.fn() - const activate = vi.fn(async () => { + const activate = vi.fn(async () => { + if (options.activationMutatesRegistry) { + sftp.add('.goodbuddy/runtimes/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from('changed registry bytes\n') + }) + } if ( + options.activationFailure || options.corruptPath !== undefined && options.corruptPath !== 'manifest.sig' ) { @@ -694,7 +605,11 @@ async function metadataOnlyHarness(options: { authenticationIdentity: 'b'.repeat(64) }, isUsable: () => true, - runAgentBootstrapProbe: vi.fn(async () => ({ + runAgentBootstrapProbe: vi.fn(async (signal?: AbortSignal) => { + if (options.bootstrapGate) { + await waitForGate(options.bootstrapGate, signal) + } + return { ready: true, platform: 'linux', architecture: 'x64', @@ -702,7 +617,8 @@ async function metadataOnlyHarness(options: { uid, shell: '/bin/bash', procfs: 'ready' - }) as const), + } as const + }), openStagedSftp: vi.fn(async () => sftp), release } as unknown as SshConnectionLease @@ -716,9 +632,6 @@ async function metadataOnlyHarness(options: { const manager = new RemoteRuntimeInstallationManager({ resolver, sshPool: { acquire: vi.fn(async () => lease) }, - loadVerifiedBundle: async () => { - throw new RemoteRuntimeBundleResourcesUnavailableError() - }, loadVerificationMetadata: async () => ({ releaseKeyRegistry: signedRegistry, runtimeLock, @@ -732,6 +645,18 @@ async function metadataOnlyHarness(options: { return { manager, bundle, + publishedIdentity: { + runtimeId: 'opencode' as const, + runtimeVersion: bundle.manifest.runtimeVersion, + bundleDigest: bundle.manifest.bundleDigest, + manifestDigest: bundle.manifestDigest, + runtimeAdapterDigest: bundle.manifest.adapterDigest, + acpCapabilitiesDigest: + bundle.manifest.acpCapabilitiesDigest, + platform: 'linux' as const, + architecture: bundle.manifest.architecture, + protocol: { ...bundle.manifest.protocol } + }, activate, destination, release, @@ -743,9 +668,13 @@ async function metadataOnlyHarness(options: { describe('RemoteRuntimeInstallationManager', () => { it('cryptographically reuses an exact current Host Runtime when bundle resources are absent', async () => { const fixture = await metadataOnlyHarness() + const agentInstallationId = + verifyAgentInstallationId('agent-current') await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1', { + agentInstallationId + }) ).resolves.toMatchObject({ runtimeId: 'opencode', runtimeVersion: fixture.bundle.manifest.runtimeVersion, @@ -753,6 +682,9 @@ describe('RemoteRuntimeInstallationManager', () => { architecture: 'x64' }) expect(fixture.activate).toHaveBeenCalledOnce() + expect(fixture.activate.mock.calls[0]?.[4]).toBe( + agentInstallationId + ) expect( fixture.sftp.operations.some((operation) => operation.startsWith('write:') @@ -761,6 +693,265 @@ describe('RemoteRuntimeInstallationManager', () => { expect(fixture.release).toHaveBeenCalledOnce() }) + it('reuses the current Runtime identity until the Host is invalidated', async () => { + const fixture = await metadataOnlyHarness() + const agentInstallationId = + verifyAgentInstallationId('agent-current') + + const first = await fixture.manager.activateInstalled('host-1', { + agentInstallationId + }) + await expect( + fixture.manager.activateInstalled('host-1', { + agentInstallationId + }) + ).resolves.toEqual(first) + expect(fixture.activate).toHaveBeenCalledOnce() + expect(fixture.release).toHaveBeenCalledOnce() + + fixture.manager.invalidateHost('host-1') + await expect( + fixture.manager.activateInstalled('host-1', { + agentInstallationId + }) + ).resolves.toEqual(first) + expect(fixture.activate).toHaveBeenCalledTimes(2) + expect(fixture.release).toHaveBeenCalledTimes(2) + }) + + it('activates an exact installed Runtime without loading or publishing installable payloads', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activateInstalled('host-1') + ).resolves.toMatchObject({ + runtimeId: 'opencode', + bundleDigest: fixture.bundle.manifest.bundleDigest, + architecture: 'x64' + }) + + expect(fixture.activate).toHaveBeenCalledOnce() + expect( + fixture.sftp.operations.filter((operation) => + /^(?:mkdir|write|chmod|rename|replace|unlink|rmdir):/u + .test(operation) + ) + ).toEqual([]) + }) + + it('adopts the exact package-published Runtime without loading or mutating payloads', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toEqual({ + runtimeId: 'opencode', + runtimeVersion: fixture.bundle.manifest.runtimeVersion, + bundleDigest: fixture.bundle.manifest.bundleDigest, + manifestDigest: fixture.bundle.manifestDigest, + runtimeAdapterDigest: + fixture.bundle.manifest.adapterDigest, + acpCapabilitiesDigest: + fixture.bundle.manifest.acpCapabilitiesDigest, + platform: 'linux', + architecture: 'x64' + }) + + expect(fixture.activate).toHaveBeenCalledOnce() + expect( + fixture.sftp.operations.filter((operation) => + /^(?:mkdir|write|chmod|rename|replace|unlink|rmdir):/u + .test(operation) + ) + ).toEqual([]) + }) + + it('adopts a package-published Runtime when all global Runtime metadata is absent', async () => { + const fixture = await metadataOnlyHarness({ + remoteReleaseKeysAbsent: true, + remoteRuntimeLockAbsent: true, + runtimeRegistryAbsent: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + runtimeId: 'opencode', + bundleDigest: fixture.bundle.manifest.bundleDigest + }) + + expect(fixture.activate).toHaveBeenCalledOnce() + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/release-keys.json` + ) + ).toBe(true) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/remote-runtime-lock.json` + ) + ).toBe(true) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/registry.json` + ) + ).toBe(false) + }) + + it('verifies a published Runtime with packaged trust instead of stale Host metadata', async () => { + const fixture = await metadataOnlyHarness({ + remoteReleaseKeyBytes: canonical({ + formatVersion: 1, + keys: [], + revocations: [] + }), + remoteRuntimeLockBytes: Buffer.from( + '{ "legacy": "runtime lock" }\n' + ) + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + runtimeId: 'opencode' + }) + expect(fixture.activate).toHaveBeenCalledOnce() + }) + + it('rejects a package-published Runtime identity mismatch before activation', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activatePublished('host-1', { + ...fixture.publishedIdentity, + runtimeAdapterDigest: `sha256:${'f'.repeat(64)}` + }) + ).rejects.toMatchObject({ reason: 'corrupt' }) + + expect(fixture.activate).not.toHaveBeenCalled() + }) + + it('does not transfer installer-verified Runtime payloads back through SFTP', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).resolves.toMatchObject({ + bundleDigest: fixture.bundle.manifest.bundleDigest + }) + + expect(fixture.activate).toHaveBeenCalledOnce() + for (const file of fixture.bundle.manifest.files) { + expect(fixture.sftp.operations).not.toContain( + `read:${fixture.destination}/${file.path}` + ) + } + }) + + it('rejects package-published Runtime adoption after Host identity changes', async () => { + const fixture = await metadataOnlyHarness({ + resolverTargets: [target(), target(2)] + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + + expect(fixture.activate).not.toHaveBeenCalled() + }) + + it('restores exact Runtime registry bytes when published activation fails', async () => { + const oldRegistry = Buffer.from( + '{ "legacy": true, "spacing": "preserved" }\n' + ) + const oldReleaseKeys = Buffer.from( + '{ "legacy": "release keys" }\n' + ) + const oldRuntimeLock = Buffer.from( + '{ "legacy": "runtime lock" }\n' + ) + const fixture = await metadataOnlyHarness({ + runtimeRegistryBytes: oldRegistry, + remoteReleaseKeyBytes: oldReleaseKeys, + remoteRuntimeLockBytes: oldRuntimeLock, + activationFailure: true, + activationMutatesRegistry: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ reason: 'activation' }) + + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/runtimes/registry.json` + )?.contents + ).toEqual(oldRegistry) + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/runtimes/release-keys.json` + )?.contents + ).toEqual(oldReleaseKeys) + expect( + fixture.sftp.entries.get( + `${home}/.goodbuddy/runtimes/remote-runtime-lock.json` + )?.contents + ).toEqual(oldRuntimeLock) + }) + + it('restores absent Runtime metadata when published activation fails', async () => { + const fixture = await metadataOnlyHarness({ + runtimeRegistryAbsent: true, + remoteReleaseKeysAbsent: true, + remoteRuntimeLockAbsent: true, + activationFailure: true, + activationMutatesRegistry: true + }) + + await expect( + fixture.manager.activatePublished( + 'host-1', + fixture.publishedIdentity + ) + ).rejects.toMatchObject({ reason: 'activation' }) + + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/registry.json` + ) + ).toBe(false) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/release-keys.json` + ) + ).toBe(false) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/runtimes/remote-runtime-lock.json` + ) + ).toBe(false) + }) + it('rejects metadata-only Runtime reuse when trust metadata differs', async () => { const fixture = await metadataOnlyHarness({ remoteReleaseKeyBytes: canonical({ @@ -771,7 +962,7 @@ describe('RemoteRuntimeInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'corrupt' }) expect(fixture.activate).not.toHaveBeenCalled() }) @@ -782,7 +973,7 @@ describe('RemoteRuntimeInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'corrupt' }) expect(fixture.activate).not.toHaveBeenCalled() }) @@ -793,7 +984,7 @@ describe('RemoteRuntimeInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'activation' }) expect(fixture.activate).toHaveBeenCalledOnce() }) @@ -804,242 +995,68 @@ describe('RemoteRuntimeInstallationManager', () => { }) await expect( - fixture.manager.ensureInstalled('host-1') + fixture.manager.activateInstalled('host-1') ).rejects.toMatchObject({ reason: 'host-identity-changed' }) expect(fixture.activate).not.toHaveBeenCalled() }) - it('installs, atomically publishes, then activates OpenCode', async () => { - const fixture = await harness() - const identity = await fixture.manager.ensureInstalled('host-1') - - expect(identity).toEqual({ - runtimeId: 'opencode', - runtimeVersion: fixture.bundle.manifest.runtimeVersion, - bundleDigest: fixture.bundle.manifest.bundleDigest, - manifestDigest: fixture.bundle.manifestDigest, - runtimeAdapterDigest: - fixture.bundle.manifest.adapterDigest, - acpCapabilitiesDigest: - fixture.bundle.manifest.acpCapabilitiesDigest, - platform: 'linux', - architecture: 'x64' - }) - expect( - fixture.sftp.entries.has(`${home}/${fixture.destination}`) - ).toBe(true) - expect( - fixture.sftp.operations.filter((entry) => - entry.startsWith('replace:.goodbuddy/runtimes/') - ) - ).toHaveLength(2) - const publishIndex = fixture.sftp.operations.findIndex( - (entry) => - entry.startsWith( - 'rename:.goodbuddy/runtimes/staging/op-' - ) - ) - expect(publishIndex).toBeGreaterThan(-1) - expect(fixture.activate).toHaveBeenCalledOnce() - expect(fixture.activate).toHaveBeenCalledWith( - fixture.lease, - 'opencode', - fixture.bundle.manifest.bundleDigest, - 'x64', - expect.any(AbortSignal) - ) - expect(fixture.release).toHaveBeenCalledOnce() - }) - - it('fully verifies and reuses an existing digest destination', async () => { - const fixture = await harness() - populateBundle(fixture) - - const identity = - await fixture.manager.ensureInstalled('host-1') - - expect(identity).toEqual({ - runtimeId: 'opencode', - runtimeVersion: fixture.bundle.manifest.runtimeVersion, - bundleDigest: fixture.bundle.manifest.bundleDigest, - manifestDigest: fixture.bundle.manifestDigest, - runtimeAdapterDigest: - fixture.bundle.manifest.adapterDigest, - acpCapabilitiesDigest: - fixture.bundle.manifest.acpCapabilitiesDigest, - platform: 'linux', - architecture: 'x64' - }) - expect( - fixture.sftp.operations.some((entry) => - entry.startsWith( - 'rename:.goodbuddy/runtimes/staging/op-' - ) - ) - ).toBe(false) - expect(fixture.activate).toHaveBeenCalledOnce() - }) - - it('rejects mutated remote readback, cleans staging, and never activates', async () => { - const fixture = await harness() - fixture.sftp.mutateRead = (path, contents) => - path.endsWith('/bin/opencode') - ? Buffer.from('mutated') - : contents - - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ - reason: 'corrupt' - }) - expect(fixture.activate).not.toHaveBeenCalled() - expect( - [...fixture.sftp.entries.keys()].some((path) => - path.includes('/runtimes/staging/op-') - ) - ).toBe(false) - }) - - it('rejects an identity race before publication and activation', async () => { - const fixture = await harness({ - resolverTargets: [target(1), target(2)] - }) - - await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toMatchObject({ - reason: 'host-identity-changed' - }) - expect(fixture.activate).not.toHaveBeenCalled() - expect( - [...fixture.sftp.entries.keys()].some((path) => - path.includes('/runtimes/staging/op-') - ) - ).toBe(false) - }) - - it('cancels the shared operation when its final waiter leaves and cleans up', async () => { - const sftp = new MemorySftp() - let payloadStarted!: () => void - const started = new Promise((resolve) => { - payloadStarted = resolve - }) - const originalWrite = sftp.writeFile.bind(sftp) - sftp.writeFile = vi.fn(async ( - path: string, - contents: Buffer, - signal?: AbortSignal - ) => { - await originalWrite(path, contents) - if (path.endsWith('/bin/opencode')) { - payloadStarted() - await waitForGate( - new Promise(() => undefined), - signal - ) - } - }) - const fixture = await harness({ sftp }) - const controller = new AbortController() - const installation = fixture.manager.ensureInstalled('host-1', { - signal: controller.signal - }) - await started - controller.abort() - - await expect(installation).rejects.toMatchObject({ - name: 'AbortError' - }) - await vi.waitFor(() => - expect(fixture.release).toHaveBeenCalledOnce() - ) - expect( - [...sftp.entries.keys()].some((path) => - path.includes('/runtimes/staging/op-') - ) - ).toBe(false) - }) - - it('deduplicates one host and enforces concurrent host capacity', async () => { + it('deduplicates installed activation and lets one waiter cancel', async () => { let releaseGate!: () => void const gate = new Promise((resolve) => { releaseGate = resolve }) - const fixture = await harness({ - bootstrapGate: gate, - maximumConcurrentHosts: 1, - resolverTargets: [ - target(), - target(), - target(1, 'host-2'), - target() - ] + const fixture = await metadataOnlyHarness({ bootstrapGate: gate }) + const controller = new AbortController() + const canceled = fixture.manager.activateInstalled('host-1', { + signal: controller.signal }) - const first = fixture.manager.ensureInstalled('host-1') - const second = fixture.manager.ensureInstalled('host-1', { - force: true + const survivor = fixture.manager.activateInstalled('host-1') + await vi.waitFor(() => + expect(fixture.resolver.resolve).toHaveBeenCalledTimes(2) + ) + + controller.abort() + await expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) + releaseGate() + await expect(survivor).resolves.toMatchObject({ runtimeId: 'opencode' }) + expect(fixture.activate).toHaveBeenCalledOnce() + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('cancels installed activation when its final waiter leaves', async () => { + const fixture = await metadataOnlyHarness({ + bootstrapGate: new Promise(() => undefined) + }) + const controller = new AbortController() + const activation = fixture.manager.activateInstalled('host-1', { + signal: controller.signal }) await vi.waitFor(() => - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(fixture.resolver.resolve).toHaveBeenCalledOnce() ) - await expect( - fixture.manager.ensureInstalled('host-2') - ).rejects.toMatchObject({ - reason: 'capacity' + controller.abort() + await expect(activation).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(fixture.release).toHaveBeenCalledOnce()) + }) + + it('dispose aborts active activation and rejects future calls', async () => { + const fixture = await metadataOnlyHarness({ + bootstrapGate: new Promise(() => undefined) }) - releaseGate() - await expect(Promise.all([first, second])).resolves.toHaveLength(2) - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - }) - - it('reuses a successful Runtime installation for the same Host identity', async () => { - const fixture = await harness() - const first = await fixture.manager.ensureInstalled('host-1') - const phases: string[] = [] - const second = await fixture.manager.ensureInstalled('host-1', { - onProgress: (phase) => phases.push(phase) - }) - - expect(second).toEqual(first) - expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() - expect(fixture.activate).toHaveBeenCalledOnce() - expect(phases).toEqual(['inspecting-host', 'complete']) - }) - - it('force bypasses the settled Runtime cache and refreshes it after verification', async () => { - const fixture = await harness() - const first = await fixture.manager.ensureInstalled('host-1') - - const refreshed = await fixture.manager.ensureInstalled( - 'host-1', - { force: true } - ) - const cached = await fixture.manager.ensureInstalled('host-1') - - expect(refreshed).toEqual(first) - expect(cached).toEqual(refreshed) - expect(fixture.sshPool.acquire).toHaveBeenCalledTimes(2) - expect(fixture.activate).toHaveBeenCalledTimes(2) - }) - - it('reports activation errors distinctly after verified publication', async () => { - const fixture = await harness() - fixture.activate.mockRejectedValueOnce( - new Error('fixed activation command failed') + const activation = fixture.manager.activateInstalled('host-1') + await vi.waitFor(() => + expect(fixture.resolver.resolve).toHaveBeenCalledOnce() ) + const first = fixture.manager.dispose() + expect(fixture.manager.dispose()).toBe(first) + await expect(activation).rejects.toMatchObject({ name: 'AbortError' }) + await first await expect( - fixture.manager.ensureInstalled('host-1') - ).rejects.toBeInstanceOf(RemoteRuntimeInstallationError) - await expect( - fixture.manager.ensureInstalled('host-1') - ).resolves.toMatchObject({ runtimeId: 'opencode' }) - expect( - fixture.sftp.entries.has(`${home}/${fixture.destination}`) - ).toBe(true) - expect(fixture.activate).toHaveBeenCalledTimes(2) + fixture.manager.activateInstalled('host-1') + ).rejects.toThrow('Remote Runtime installation manager is disposed') }) }) diff --git a/src/main/remote-agent/remote-runtime-installation-manager.ts b/src/main/remote-agent/remote-runtime-installation-manager.ts index 713afdc5..3ea0a55f 100644 --- a/src/main/remote-agent/remote-runtime-installation-manager.ts +++ b/src/main/remote-agent/remote-runtime-installation-manager.ts @@ -1,6 +1,4 @@ import { createHash, randomUUID } from 'node:crypto' -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' import { agentReleaseKeyRegistrySchema, type AgentArchitecture, @@ -9,16 +7,13 @@ import { import type { AgentBootstrapIncompatibleReason } from '../../shared/ssh-host-contracts' import { assertRuntimeManifestMatchesLock, - canonicalRuntimeManifestBytes, verifyRuntimeManifestSignature } from '../../shared/node/runtime-bundle-verifier' import { parseRuntimeRegistryState } from '../../shared/remote-environment-registry-contracts' import { - digestRemoteRuntimeBundleIdentity, digestRemoteRuntimeBundleManifest, - remoteRuntimeBundleManifestSchema, remoteRuntimeLockSchema, type RemoteRuntimeBundleManifest, type RemoteRuntimeLock @@ -34,11 +29,16 @@ import type { SshConnectionPool, SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' +import type { + SshRemotePackageRuntimeIdentity +} from '../ssh/ssh-remote-package-bootstrap' +import type { VerifiedAgentInstallationId } from '../ssh/ssh-agent-command' import { settleBoundedly } from './bounded-settlement' import { parseAgentReleaseKeyRegistryBytes } from './agent-bundle-verifier' import { isMissingPathError } from './path-errors' +import { remoteHostTargetIdentityKey } from './remote-host-target-identity' const MAXIMUM_INSTALLATION_FILES = 200 const MAXIMUM_INSTALLATION_BYTES = 512 * 1024 * 1024 @@ -51,29 +51,14 @@ const PRIVATE_DIRECTORY_MODE = 0o700 const DISPOSE_WAIT_TIMEOUT_MS = 5_000 const RUNTIME_ROOT = '.goodbuddy/runtimes' -const RUNTIME_STAGING_ROOT = `${RUNTIME_ROOT}/staging` const OPENCODE_ROOT = `${RUNTIME_ROOT}/opencode` const RELEASE_KEYS_PATH = `${RUNTIME_ROOT}/release-keys.json` const RUNTIME_LOCK_PATH = `${RUNTIME_ROOT}/remote-runtime-lock.json` const RUNTIME_REGISTRY_PATH = `${RUNTIME_ROOT}/registry.json` -export class RemoteRuntimeBundleResourcesUnavailableError - extends Error { - constructor(cause?: unknown) { - super( - 'OpenCode Runtime installation resources are not included in this package', - { cause } - ) - this.name = 'RemoteRuntimeBundleResourcesUnavailableError' - } -} - export type RemoteRuntimeInstallationPhase = | 'inspecting-host' | 'verifying-bundle' - | 'preparing-installation' - | 'uploading-bundle' - | 'publishing-bundle' | 'activating-runtime' | 'complete' @@ -92,12 +77,15 @@ export interface RemoteRuntimeInstallationTargetResolver { resolve(hostId: string): Promise } -export type RemoteRuntimeInstallationRequestOptions = { +export type RemoteRuntimeActivationRequestOptions = { signal?: AbortSignal onProgress?: (phase: RemoteRuntimeInstallationPhase) => void - force?: boolean + agentInstallationId?: VerifiedAgentInstallationId } +export type PublishedRemoteRuntimeInstallationIdentity = + SshRemotePackageRuntimeIdentity + export class RemoteRuntimeInstallationError extends Error { constructor( message: string, @@ -120,17 +108,6 @@ type RuntimeBundleBase = { manifestDigest: string } -export type VerifiedRemoteRuntimeInstallationBundle = - RuntimeBundleBase & { - canonicalReleaseKeyRegistryBytes: Uint8Array - canonicalRemoteRuntimeLockBytes: Uint8Array - release?: () => void - } - -export type RemoteRuntimeInstallationBundleLoader = ( - architecture: AgentArchitecture -) => Promise - export type RemoteRuntimeInstallationVerificationMetadata = { releaseKeyRegistry: AgentReleaseKeyRegistry runtimeLock: RemoteRuntimeLock @@ -148,6 +125,7 @@ export type RemoteRuntimeActivator = ( runtimeId: string, bundleDigest: string, architecture: AgentArchitecture, + agentInstallationId: VerifiedAgentInstallationId | undefined, signal: AbortSignal ) => Promise @@ -166,33 +144,41 @@ type CleanupEntry = { type: 'file' | 'directory' } +type ManagedFileSnapshot = { + path: string + contents: Buffer | undefined + uid: number +} + type LoadedBundle = RuntimeBundleBase & { releaseKeyRegistryBytes: Buffer remoteRuntimeLockBytes: Buffer - release?: () => void } export class RemoteRuntimeInstallationManager { readonly #resolver: RemoteRuntimeInstallationTargetResolver readonly #sshPool: Pick - readonly #loadVerifiedBundle: RemoteRuntimeInstallationBundleLoader readonly #loadVerificationMetadata?: RemoteRuntimeInstallationVerificationMetadataLoader readonly #activate: RemoteRuntimeActivator readonly #sftpLimits?: BoundedSftpLimits readonly #maximumConcurrentHosts: number readonly #active = new Map() - readonly #installed = new Map< + readonly #current = new Map< string, - RemoteRuntimeInstallationIdentity + { + targetKey: string + agentKey: string + identity: RemoteRuntimeInstallationIdentity + } >() + readonly #cacheGenerations = new Map() #closed = false #disposePromise: Promise | undefined constructor(options: { resolver: RemoteRuntimeInstallationTargetResolver sshPool: Pick - loadVerifiedBundle: RemoteRuntimeInstallationBundleLoader loadVerificationMetadata?: RemoteRuntimeInstallationVerificationMetadataLoader activate: RemoteRuntimeActivator @@ -201,7 +187,6 @@ export class RemoteRuntimeInstallationManager { }) { this.#resolver = options.resolver this.#sshPool = options.sshPool - this.#loadVerifiedBundle = options.loadVerifiedBundle this.#loadVerificationMetadata = options.loadVerificationMetadata this.#activate = options.activate @@ -219,9 +204,49 @@ export class RemoteRuntimeInstallationManager { } } - async ensureInstalled( + /** + * Verifies and activates the installed Runtime selected by packaged trust + * metadata without loading or publishing installable Runtime payloads. + */ + async activateInstalled( hostId: string, - options: RemoteRuntimeInstallationRequestOptions = {} + options: RemoteRuntimeActivationRequestOptions = {} + ): Promise { + return this.#request(hostId, 'activate', options) + } + + /** + * Verifies and activates the exact Runtime directory already published by + * the authenticated package installer, installing canonical metadata but + * never loading or staging payloads. + */ + async activatePublished( + hostId: string, + expectedIdentity: PublishedRemoteRuntimeInstallationIdentity, + options: RemoteRuntimeActivationRequestOptions = {} + ): Promise { + assertPublishedRuntimeIdentity(expectedIdentity) + return this.#request( + hostId, + 'adopt', + options, + expectedIdentity + ) + } + + invalidateHost(hostId: string): void { + this.#current.delete(hostId) + this.#cacheGenerations.set( + hostId, + (this.#cacheGenerations.get(hostId) ?? 0) + 1 + ) + } + + async #request( + hostId: string, + mode: 'activate' | 'adopt', + options: RemoteRuntimeActivationRequestOptions, + expectedIdentity?: PublishedRemoteRuntimeInstallationIdentity ): Promise { this.#throwIfClosed() options.signal?.throwIfAborted() @@ -236,16 +261,29 @@ export class RemoteRuntimeInstallationManager { ) } - const operationKey = targetIdentityKey(target) + const targetKey = remoteHostTargetIdentityKey(target) + const agentKey = options.agentInstallationId ?? '' + const cacheGeneration = + this.#cacheGenerations.get(hostId) ?? 0 + if (mode === 'activate') { + const current = this.#current.get(hostId) + if ( + current?.targetKey === targetKey && + current.agentKey === agentKey + ) { + emitOne(options.onProgress, 'complete') + return current.identity + } + } + const operationKey = mode === 'adopt' + ? `${mode}:${targetKey}:${publishedRuntimeIdentityKey( + expectedIdentity! + )}:${agentKey}` + : `${mode}:${targetKey}:${agentKey}` const existing = this.#active.get(operationKey) if (existing) { return this.#waitForActive(existing, options) } - const installed = this.#installed.get(operationKey) - if (!options.force && installed !== undefined) { - emitOne(options.onProgress, 'complete') - return installed - } if (this.#active.size >= this.#maximumConcurrentHosts) { throw new RemoteRuntimeInstallationError( 'Too many remote Runtime installations are already running', @@ -264,11 +302,33 @@ export class RemoteRuntimeInstallationManager { emitOne(callback, phase) } } - const promise = this.#run(target, controller.signal, progress) - .then((identity) => { - this.#installed.set(operationKey, identity) - return identity - }) + const promise = (mode === 'activate' + ? this.#activateInstalled( + target, + options.agentInstallationId, + controller.signal, + progress + ) + : this.#activatePublished( + target, + expectedIdentity!, + options.agentInstallationId, + controller.signal, + progress + )).then((identity) => { + if ( + mode === 'activate' && + (this.#cacheGenerations.get(hostId) ?? 0) === + cacheGeneration + ) { + this.#current.set(hostId, { + targetKey, + agentKey, + identity + }) + } + return identity + }) const active: ActiveInstallation = { promise, controller, @@ -291,6 +351,8 @@ export class RemoteRuntimeInstallationManager { return this.#disposePromise } this.#closed = true + this.#current.clear() + this.#cacheGenerations.clear() const operations = [...this.#active.values()] const reason = new DOMException( 'Remote Runtime installation manager was disposed', @@ -303,7 +365,6 @@ export class RemoteRuntimeInstallationManager { operations.map((operation) => operation.promise), DISPOSE_WAIT_TIMEOUT_MS ) - this.#installed.clear() return this.#disposePromise } @@ -317,7 +378,7 @@ export class RemoteRuntimeInstallationManager { async #waitForActive( active: ActiveInstallation, - options: RemoteRuntimeInstallationRequestOptions + options: RemoteRuntimeActivationRequestOptions ): Promise { active.waiters += 1 if (options.onProgress) { @@ -345,24 +406,20 @@ export class RemoteRuntimeInstallationManager { } } - async #run( + async #activateInstalled( target: SshConnectionPoolTarget, + agentInstallationId: VerifiedAgentInstallationId | undefined, signal: AbortSignal, progress: (phase: RemoteRuntimeInstallationPhase) => void ): Promise { let lease: SshConnectionLease | undefined - let sftp: StagedSftp | undefined - let cleanupHomeDirectory: string | undefined - const cleanup: CleanupEntry[] = [] - let stagingPublished = false - let releaseBundle: (() => void) | undefined try { lease = await this.#sshPool.acquire(target, signal) assertLeaseMatchesTarget(lease, target) const probe = await lease.runAgentBootstrapProbe(signal) if (!probe.ready) { throw new RemoteRuntimeInstallationError( - `Remote host cannot install the OpenCode Runtime: ${probe.reason}`, + `Remote host cannot activate the OpenCode Runtime: ${probe.reason}`, 'incompatible', probe.reason ) @@ -377,166 +434,15 @@ export class RemoteRuntimeInstallationManager { 'incompatible' ) } - progress('verifying-bundle') - let bundle: LoadedBundle - try { - bundle = await this.#loadAndValidateBundle( - probe.architecture - ) - releaseBundle = bundle.release - } catch (error) { - if ( - error instanceof RemoteRuntimeBundleResourcesUnavailableError - ) { - if (this.#loadVerificationMetadata === undefined) { - throw new RemoteRuntimeInstallationError( - 'This package does not include Runtime installation resources', - 'incompatible', - safeErrorDetail(error) - ) - } - return await this.#reuseInstalledBundle( - target, - lease, - probe, - signal, - progress - ) - } - throw error - } - assertBundleCapacity(bundle) - const identity = publicIdentity(bundle) - const destination = - `${OPENCODE_ROOT}/${digestDirectoryName(bundle.manifest.bundleDigest)}` - - progress('preparing-installation') - sftp = await lease.openStagedSftp( - probe.canonicalHomeDirectory, - installationSftpLimits(bundle, this.#sftpLimits), - signal - ) - cleanupHomeDirectory = probe.canonicalHomeDirectory - await ensureManagedHierarchy(sftp, probe.uid, signal) - await installPrivateMetadata( - sftp, - RELEASE_KEYS_PATH, - '.release-keys', - bundle.releaseKeyRegistryBytes, - probe.uid, - cleanup, - signal - ) - await installPrivateMetadata( - sftp, - RUNTIME_LOCK_PATH, - '.remote-runtime-lock', - bundle.remoteRuntimeLockBytes, - probe.uid, - cleanup, - signal - ) - - const existingDestination = await pathMetadata( - sftp, - destination, - signal - ) - if (existingDestination !== undefined) { - await verifyRemoteBundle( - sftp, - destination, - bundle, - probe.uid, - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw new RemoteRuntimeInstallationError( - 'Existing OpenCode Runtime installation is corrupt', - 'corrupt', - safeErrorDetail(error) - ) - }) - await assertCurrentTarget( - this.#resolver, - lease, - target, - signal - ) - progress('activating-runtime') - await activateRuntime( - this.#activate, - lease, - bundle, - signal - ) - progress('complete') - return identity - } - - progress('uploading-bundle') - const staging = `${RUNTIME_STAGING_ROOT}/op-${randomUUID()}` - await sftp.mkdir(staging, signal) - trackCleanup(cleanup, { path: staging, type: 'directory' }) - await assertRemoteMetadata( - sftp, - staging, - 'directory', - probe.uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - await uploadAndVerifyBundle( - sftp, - staging, - bundle, - probe.uid, - cleanup, - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw new RemoteRuntimeInstallationError( - 'OpenCode Runtime bundle upload verification failed', - 'corrupt', - safeErrorDetail(error) - ) - }) - - await assertCurrentTarget( - this.#resolver, - lease, + return await this.#reuseInstalledBundle( target, - signal - ) - progress('publishing-bundle') - await sftp.rename(staging, destination, signal) - stagingPublished = true - await verifyRemoteBundle( - sftp, - destination, - bundle, - probe.uid, - signal - ).catch((error: unknown) => { - rethrowAbort(error) - throw new RemoteRuntimeInstallationError( - 'Published OpenCode Runtime bundle verification failed', - 'corrupt', - safeErrorDetail(error) - ) - }) - - progress('activating-runtime') - await activateRuntime( - this.#activate, lease, - bundle, - signal + probe, + agentInstallationId, + signal, + progress ) - progress('complete') - return identity } catch (error) { if ( error instanceof RemoteRuntimeInstallationError || @@ -545,102 +451,252 @@ export class RemoteRuntimeInstallationManager { throw error } throw new RemoteRuntimeInstallationError( - 'Remote Runtime installation data is corrupt or unsafe', + 'Remote Runtime activation data is corrupt or unsafe', 'corrupt', safeErrorDetail(error) ) } finally { - let cleanupIncomplete = false - if (sftp && !stagingPublished) { - cleanupIncomplete = !(await cleanupOwnedStaging(sftp, cleanup)) - } - sftp?.close() - if ( - cleanupIncomplete && - lease?.isUsable() && - cleanupHomeDirectory - ) { - let cleanupSftp: StagedSftp | undefined - try { - cleanupSftp = await lease.openStagedSftp( - cleanupHomeDirectory, - this.#sftpLimits - ) - await cleanupOwnedStaging(cleanupSftp, cleanup) - } catch { - // Cancellation can make the lease unavailable. Cleanup remains - // bounded to this operation's temporary files and staging tree. - } finally { - cleanupSftp?.close() - } - } lease?.release() - releaseBundle?.() } } - async #loadAndValidateBundle( - architecture: AgentArchitecture - ): Promise { - let loaded: VerifiedRemoteRuntimeInstallationBundle | undefined + async #activatePublished( + target: SshConnectionPoolTarget, + expected: PublishedRemoteRuntimeInstallationIdentity, + agentInstallationId: VerifiedAgentInstallationId | undefined, + signal: AbortSignal, + progress: (phase: RemoteRuntimeInstallationPhase) => void + ): Promise { + let lease: SshConnectionLease | undefined + let sftp: StagedSftp | undefined + let metadataSnapshots: readonly ManagedFileSnapshot[] | undefined + const cleanup: CleanupEntry[] = [] try { - loaded = await this.#loadVerifiedBundle(architecture) - const manifest = remoteRuntimeBundleManifestSchema.parse( - loaded.manifest - ) - if ( - manifest.runtimeId !== 'opencode' || - manifest.provider !== 'opencode' || - manifest.platform !== 'linux' || - manifest.architecture !== architecture - ) { - throw new Error( - 'Verified Runtime bundle does not match the OpenCode host target' + lease = await this.#sshPool.acquire(target, signal) + assertLeaseMatchesTarget(lease, target) + const probe = await lease.runAgentBootstrapProbe(signal) + if (!probe.ready) { + throw new RemoteRuntimeInstallationError( + `Remote host cannot activate the published OpenCode Runtime: ${probe.reason}`, + 'incompatible', + probe.reason ) } if ( - loaded.manifestDigest !== - await digestRemoteRuntimeBundleManifest(manifest) + probe.platform !== expected.platform || + probe.architecture !== expected.architecture ) { - throw new Error('Verified Runtime manifest digest is invalid') + throw new RemoteRuntimeInstallationError( + 'Published Runtime does not match the Host platform or architecture', + 'incompatible' + ) } - if ( - manifest.bundleDigest !== - await digestRemoteRuntimeBundleIdentity(manifest) - ) { - throw new Error('Verified Runtime bundle digest is invalid') - } - const releaseKeyRegistryBytes = canonicalJsonBytes( - loaded.canonicalReleaseKeyRegistryBytes, - agentReleaseKeyRegistrySchema, - 'Runtime release-key registry' + + progress('verifying-bundle') + const metadata = await this.#loadPublishedVerificationMetadata( + probe.architecture ) - const remoteRuntimeLockBytes = canonicalJsonBytes( - loaded.canonicalRemoteRuntimeLockBytes, - remoteRuntimeLockSchema, - 'Remote Runtime lock' + sftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + { + maximumFileBytes: Math.max( + MAXIMUM_RUNTIME_FILE_BYTES, + this.#sftpLimits?.maximumFileBytes ?? 0 + ), + maximumTotalBytes: Math.max( + MAXIMUM_INSTALLATION_BYTES + + MAXIMUM_METADATA_BYTES * 6, + this.#sftpLimits?.maximumTotalBytes ?? 0 + ), + maximumOperations: + this.#sftpLimits?.maximumOperations ?? 2_048, + operationTimeoutMs: + this.#sftpLimits?.operationTimeoutMs + }, + signal ) - return { - bundleDirectory: loaded.bundleDirectory, + const destination = + `${OPENCODE_ROOT}/${digestDirectoryName( + expected.bundleDigest + )}` + const manifestBytes = await sftp.readFile( + `${destination}/manifest.json`, + signal + ) + const signatureFile = await sftp.readFile( + `${destination}/manifest.sig`, + signal + ) + const signature = decodeDetachedSignature( + signatureFile, + 'Runtime detached signature' + ) + const manifest = await verifyRuntimeManifestSignature( + manifestBytes, + signature, + metadata.releaseKeyRegistry + ) + assertRuntimeManifestMatchesLock( manifest, - manifestDigest: loaded.manifestDigest, - releaseKeyRegistryBytes, - remoteRuntimeLockBytes, - ...(loaded.release ? { release: loaded.release } : {}) + metadata.runtimeLock, + probe.architecture + ) + const manifestDigest = + await digestRemoteRuntimeBundleManifest(manifest) + const bundle: LoadedBundle = { + bundleDirectory: destination, + manifest, + manifestDigest, + releaseKeyRegistryBytes: + metadata.releaseKeyRegistryBytes, + remoteRuntimeLockBytes: + metadata.remoteRuntimeLockBytes } + assertPublishedRuntimeMatches(expected, bundle) + assertBundleCapacity(bundle) + // The fixed SSH installer has already verified the complete published + // tree against this signed manifest. Adoption rechecks its immutable + // identity and metadata without transferring the payload back to Main. + await verifyRemoteBundleFromMetadata( + sftp, + destination, + bundle, + manifestBytes, + signatureFile, + probe.uid, + signal + ) + metadataSnapshots = [] + for (const path of [ + RELEASE_KEYS_PATH, + RUNTIME_LOCK_PATH, + RUNTIME_REGISTRY_PATH + ]) { + metadataSnapshots = [ + ...metadataSnapshots, + await snapshotManagedFile( + sftp, + path, + probe.uid, + signal + ) + ] + } + await installPrivateMetadata( + sftp, + RELEASE_KEYS_PATH, + '.release-keys', + metadata.releaseKeyRegistryBytes, + probe.uid, + cleanup, + signal + ) + await installPrivateMetadata( + sftp, + RUNTIME_LOCK_PATH, + '.remote-runtime-lock', + metadata.remoteRuntimeLockBytes, + probe.uid, + cleanup, + signal + ) + await assertCurrentTarget( + this.#resolver, + lease, + target, + signal + ) + progress('activating-runtime') + await activateRuntime( + this.#activate, + lease, + bundle, + agentInstallationId, + signal + ) + progress('complete') + return publicIdentity(bundle) } catch (error) { - loaded?.release?.() - rethrowAbort(error) + if (metadataSnapshots && sftp) { + await restoreManagedFiles(sftp, metadataSnapshots).catch( + (rollbackError: unknown) => { + throw new AggregateError( + [error, rollbackError], + 'Published Runtime activation failed and its previous metadata state could not be restored' + ) + } + ) + } if ( - error instanceof RemoteRuntimeBundleResourcesUnavailableError + error instanceof RemoteRuntimeInstallationError || + isAbortError(error) ) { throw error } throw new RemoteRuntimeInstallationError( - 'Verified OpenCode Runtime bundle is corrupt', + 'The published Host OpenCode Runtime could not be verified', 'corrupt', safeErrorDetail(error) ) + } finally { + if (sftp) { + await cleanupOwnedStaging(sftp, cleanup) + } + sftp?.close() + lease?.release() + } + } + + async #loadPublishedVerificationMetadata( + architecture: AgentArchitecture + ): Promise<{ + releaseKeyRegistry: AgentReleaseKeyRegistry + runtimeLock: RemoteRuntimeLock + releaseKeyRegistryBytes: Buffer + remoteRuntimeLockBytes: Buffer + }> { + const loadMetadata = this.#loadVerificationMetadata + if (loadMetadata === undefined) { + throw new RemoteRuntimeInstallationError( + 'This package cannot verify a Host Runtime', + 'incompatible' + ) + } + const metadata = await loadMetadata(architecture) + const releaseKeyRegistryBytes = canonicalJsonBytes( + metadata.canonicalReleaseKeyRegistryBytes, + agentReleaseKeyRegistrySchema, + 'Runtime release-key registry' + ) + const remoteRuntimeLockBytes = canonicalJsonBytes( + metadata.canonicalRemoteRuntimeLockBytes, + remoteRuntimeLockSchema, + 'Remote Runtime lock' + ) + const releaseKeyRegistry = + agentReleaseKeyRegistrySchema.parse( + metadata.releaseKeyRegistry + ) + const runtimeLock = remoteRuntimeLockSchema.parse( + metadata.runtimeLock + ) + if ( + !canonicalJsonValueBytes(releaseKeyRegistry).equals( + releaseKeyRegistryBytes + ) || + !canonicalJsonValueBytes(runtimeLock).equals( + remoteRuntimeLockBytes + ) + ) { + throw new Error( + 'Runtime verification metadata values do not match their canonical bytes' + ) + } + return { + releaseKeyRegistry, + runtimeLock, + releaseKeyRegistryBytes, + remoteRuntimeLockBytes } } @@ -652,48 +708,20 @@ export class RemoteRuntimeInstallationManager { canonicalHomeDirectory: string uid: number }, + agentInstallationId: VerifiedAgentInstallationId | undefined, signal: AbortSignal, progress: (phase: RemoteRuntimeInstallationPhase) => void ): Promise { - const loadMetadata = this.#loadVerificationMetadata - if (loadMetadata === undefined) { - throw new RemoteRuntimeInstallationError( - 'This package cannot verify an existing Host Runtime', - 'incompatible' - ) - } let metadataSftp: StagedSftp | undefined try { - const metadata = await loadMetadata(probe.architecture) - const releaseKeyRegistryBytes = canonicalJsonBytes( - metadata.canonicalReleaseKeyRegistryBytes, - agentReleaseKeyRegistrySchema, - 'Runtime release-key registry' + const { + releaseKeyRegistry, + runtimeLock, + releaseKeyRegistryBytes, + remoteRuntimeLockBytes + } = await this.#loadPublishedVerificationMetadata( + probe.architecture ) - const remoteRuntimeLockBytes = canonicalJsonBytes( - metadata.canonicalRemoteRuntimeLockBytes, - remoteRuntimeLockSchema, - 'Remote Runtime lock' - ) - const releaseKeyRegistry = - agentReleaseKeyRegistrySchema.parse( - metadata.releaseKeyRegistry - ) - const runtimeLock = remoteRuntimeLockSchema.parse( - metadata.runtimeLock - ) - if ( - !canonicalJsonValueBytes(releaseKeyRegistry).equals( - releaseKeyRegistryBytes - ) || - !canonicalJsonValueBytes(runtimeLock).equals( - remoteRuntimeLockBytes - ) - ) { - throw new Error( - 'Runtime verification metadata values do not match their canonical bytes' - ) - } metadataSftp = await lease.openStagedSftp( probe.canonicalHomeDirectory, @@ -823,7 +851,6 @@ export class RemoteRuntimeInstallationManager { PRIVATE_FILE_MODE, signal ) - progress('preparing-installation') await verifyRemoteBundleFromMetadata( metadataSftp, destination, @@ -831,7 +858,6 @@ export class RemoteRuntimeInstallationManager { manifestBytes, signatureFile, probe.uid, - 'metadata', signal ) metadataSftp.close() @@ -847,6 +873,7 @@ export class RemoteRuntimeInstallationManager { this.#activate, lease, bundle, + agentInstallationId, signal ) progress('complete') @@ -874,27 +901,6 @@ export class RemoteRuntimeInstallationManager { } } -function targetIdentityKey(target: SshConnectionPoolTarget): string { - const hostKey = target.host.hostKey - return createHash('sha256') - .update(JSON.stringify([ - 'goodbuddy-remote-runtime-installation-target-v1', - target.host.id, - target.host.name, - target.host.hostname, - target.host.port, - target.host.username, - target.host.authentication, - target.hostRevision, - target.hostKeyGeneration, - hostKey?.algorithm ?? null, - hostKey?.publicKeyBase64 ?? null, - hostKey?.fingerprintSha256 ?? null, - hostKey?.generation ?? null - ])) - .digest('hex') -} - function assertLeaseMatchesTarget( lease: SshConnectionLease, target: SshConnectionPoolTarget @@ -927,8 +933,8 @@ async function assertCurrentTarget( ) signal.throwIfAborted() if ( - targetIdentityKey(currentTarget) !== - targetIdentityKey(originalTarget) + remoteHostTargetIdentityKey(currentTarget) !== + remoteHostTargetIdentityKey(originalTarget) ) { throw new RemoteRuntimeInstallationError( 'SSH host identity changed before Runtime publication or activation', @@ -954,6 +960,67 @@ function publicIdentity( } } +function assertPublishedRuntimeIdentity( + identity: PublishedRemoteRuntimeInstallationIdentity +): void { + if ( + identity.runtimeId !== 'opencode' || + identity.runtimeVersion.length < 1 || + !sha256DigestSchema.safeParse(identity.bundleDigest).success || + !sha256DigestSchema.safeParse(identity.manifestDigest).success || + !sha256DigestSchema.safeParse( + identity.runtimeAdapterDigest + ).success || + !sha256DigestSchema.safeParse( + identity.acpCapabilitiesDigest + ).success || + identity.platform !== 'linux' || + (identity.architecture !== 'x64' && + identity.architecture !== 'arm64') || + !Number.isSafeInteger(identity.protocol.major) || + identity.protocol.major < 0 || + !Number.isSafeInteger(identity.protocol.minor) || + identity.protocol.minor < 0 + ) { + throw new RemoteRuntimeInstallationError( + 'Published Runtime identity is invalid', + 'corrupt' + ) + } +} + +function assertPublishedRuntimeMatches( + expected: PublishedRemoteRuntimeInstallationIdentity, + bundle: LoadedBundle +): void { + if ( + expected.runtimeId !== bundle.manifest.runtimeId || + expected.runtimeVersion !== bundle.manifest.runtimeVersion || + expected.bundleDigest !== bundle.manifest.bundleDigest || + expected.manifestDigest !== bundle.manifestDigest || + expected.runtimeAdapterDigest !== bundle.manifest.adapterDigest || + expected.acpCapabilitiesDigest !== + bundle.manifest.acpCapabilitiesDigest || + expected.platform !== bundle.manifest.platform || + expected.architecture !== bundle.manifest.architecture || + expected.protocol.major !== bundle.manifest.protocol.major || + expected.protocol.minor !== bundle.manifest.protocol.minor + ) { + throw new RemoteRuntimeInstallationError( + 'Published Runtime does not match the authenticated package result', + 'corrupt' + ) + } +} + +function publishedRuntimeIdentityKey( + identity: PublishedRemoteRuntimeInstallationIdentity +): string { + return createHash('sha256') + .update(JSON.stringify(identity)) + .digest('hex') +} + function digestDirectoryName(bundleDigest: string): string { if (!sha256DigestSchema.safeParse(bundleDigest).success) { throw new Error('Runtime bundle digest is invalid') @@ -989,78 +1056,6 @@ function assertBundleCapacity(bundle: LoadedBundle): void { } } -function installationSftpLimits( - bundle: LoadedBundle, - configured?: BoundedSftpLimits -): BoundedSftpLimits { - const largest = Math.max( - bundle.releaseKeyRegistryBytes.byteLength, - bundle.remoteRuntimeLockBytes.byteLength, - ...bundle.manifest.files.map((file) => file.size), - MAXIMUM_METADATA_BYTES - ) - const payload = - bundle.manifest.files.reduce( - (sum, file) => sum + file.size, - 0 - ) + - bundle.releaseKeyRegistryBytes.byteLength + - bundle.remoteRuntimeLockBytes.byteLength + - 2 * MAXIMUM_METADATA_BYTES - return { - maximumFileBytes: Math.max( - largest, - configured?.maximumFileBytes ?? 0 - ), - maximumTotalBytes: Math.max( - payload * 4, - configured?.maximumTotalBytes ?? 0 - ), - maximumOperations: - configured?.maximumOperations ?? 2_048, - operationTimeoutMs: configured?.operationTimeoutMs - } -} - -async function ensureManagedHierarchy( - sftp: StagedSftp, - uid: number, - signal: AbortSignal -): Promise { - for (const path of [ - '.goodbuddy', - RUNTIME_ROOT, - RUNTIME_STAGING_ROOT, - OPENCODE_ROOT - ]) { - const existing = await pathMetadata(sftp, path, signal) - if (existing === undefined) { - await sftp.mkdir(path, signal) - } else { - assertMetadata( - existing, - path, - 'directory', - uid, - undefined, - undefined - ) - if (existing.mode !== PRIVATE_DIRECTORY_MODE) { - await sftp.chmod(path, PRIVATE_DIRECTORY_MODE, signal) - } - } - await assertRemoteMetadata( - sftp, - path, - 'directory', - uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - } -} - async function installPrivateMetadata( sftp: StagedSftp, destination: string, @@ -1114,123 +1109,109 @@ async function installPrivateMetadata( ) } -async function uploadAndVerifyBundle( +async function snapshotManagedFile( sftp: StagedSftp, - staging: string, - bundle: LoadedBundle, + path: string, uid: number, - cleanup: CleanupEntry[], signal: AbortSignal -): Promise { - const createdDirectories = new Set() - for (const file of bundle.manifest.files) { - const parts = file.path.split('/') - let relativeDirectory = '' - for (const part of parts.slice(0, -1)) { - relativeDirectory = relativeDirectory - ? `${relativeDirectory}/${part}` - : part - if (!createdDirectories.has(relativeDirectory)) { - const directory = `${staging}/${relativeDirectory}` - await sftp.mkdir(directory, signal) - trackCleanup(cleanup, { - path: directory, - type: 'directory' - }) - createdDirectories.add(relativeDirectory) - await assertRemoteMetadata( - sftp, - directory, - 'directory', - uid, - PRIVATE_DIRECTORY_MODE, - undefined, - signal - ) - } - } - - const contents = await readFile( - join(bundle.bundleDirectory, ...file.path.split('/')) - ) - if ( - contents.byteLength !== file.size || - sha256(contents) !== file.sha256 - ) { - throw new Error( - `Runtime bundle changed after local verification: ${file.path}` - ) - } - const destination = `${staging}/${file.path}` - trackCleanup(cleanup, { path: destination, type: 'file' }) - await sftp.writeFile(destination, contents, signal) - const mode = file.mode === '0755' ? 0o755 : 0o644 - await sftp.chmod(destination, mode, signal) - await verifyRemoteFile( - sftp, - destination, - file.size, - file.sha256, - uid, - mode, - signal - ) +): Promise { + const metadata = await pathMetadata(sftp, path, signal) + if (metadata === undefined) { + return { path, contents: undefined, uid } } - - const expectedManifestBytes = - canonicalRuntimeManifestBytes(bundle.manifest) - for (const metadataName of ['manifest.json', 'manifest.sig']) { - const contents = await readFile( - join(bundle.bundleDirectory, metadataName) - ) - if ( - contents.byteLength > MAXIMUM_METADATA_BYTES || - ( - metadataName === 'manifest.json' && - !contents.equals(expectedManifestBytes) - ) - ) { - throw new Error( - `Runtime bundle metadata changed after verification: ${metadataName}` - ) - } - const destination = `${staging}/${metadataName}` - trackCleanup(cleanup, { path: destination, type: 'file' }) - await sftp.writeFile(destination, contents, signal) - await sftp.chmod(destination, METADATA_MODE, signal) - await verifyRemoteFile( - sftp, - destination, - contents.byteLength, - sha256(contents), - uid, - METADATA_MODE, - signal - ) + assertMetadata( + metadata, + path, + 'file', + uid, + PRIVATE_FILE_MODE, + undefined + ) + return { + path, + contents: await sftp.readFile(path, signal), + uid } } -async function verifyRemoteBundle( +async function restoreManagedFile( sftp: StagedSftp, - destination: string, - bundle: LoadedBundle, - uid: number, - signal: AbortSignal + snapshot: ManagedFileSnapshot ): Promise { - const [manifestBytes, signatureBytes] = await Promise.all([ - readFile(join(bundle.bundleDirectory, 'manifest.json')), - readFile(join(bundle.bundleDirectory, 'manifest.sig')) - ]) - await verifyRemoteBundleFromMetadata( - sftp, - destination, - bundle, - manifestBytes, - signatureBytes, - uid, - 'contents', - signal - ) + const existing = await pathMetadata(sftp, snapshot.path) + if (snapshot.contents === undefined) { + if (existing !== undefined) { + assertMetadata( + existing, + snapshot.path, + 'file', + snapshot.uid, + PRIVATE_FILE_MODE, + undefined + ) + await sftp.unlink(snapshot.path) + } + return + } + if ( + existing !== undefined && + existing.type === 'file' && + existing.uid === snapshot.uid && + existing.mode === PRIVATE_FILE_MODE && + existing.size === snapshot.contents.byteLength && + (await sftp.readFile(snapshot.path)).equals(snapshot.contents) + ) { + return + } + if (existing !== undefined) { + assertMetadata( + existing, + snapshot.path, + 'file', + snapshot.uid, + undefined, + undefined + ) + } + const temporary = + `${RUNTIME_ROOT}/.registry-rollback-${randomUUID()}.tmp` + try { + await sftp.writeFile(temporary, snapshot.contents) + await sftp.chmod(temporary, PRIVATE_FILE_MODE) + await sftp.replaceFile(temporary, snapshot.path) + await verifyRemoteFile( + sftp, + snapshot.path, + snapshot.contents.byteLength, + sha256(snapshot.contents), + snapshot.uid, + PRIVATE_FILE_MODE, + new AbortController().signal + ) + } catch (error) { + await sftp.unlink(temporary).catch(() => undefined) + throw error + } +} + +async function restoreManagedFiles( + sftp: StagedSftp, + snapshots: readonly ManagedFileSnapshot[] +): Promise { + const errors: unknown[] = [] + for (const snapshot of snapshots) { + try { + await restoreManagedFile(sftp, snapshot) + } catch (error) { + errors.push(error) + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + 'Remote Runtime metadata rollback was incomplete' + ) + } } async function verifyRemoteBundleFromMetadata( @@ -1240,7 +1221,6 @@ async function verifyRemoteBundleFromMetadata( manifestBytes: Buffer, signatureBytes: Buffer, uid: number, - verification: 'metadata' | 'contents', signal: AbortSignal ): Promise { await assertRemoteMetadata( @@ -1273,27 +1253,15 @@ async function verifyRemoteBundleFromMetadata( } const path = `${destination}/${file.path}` const mode = file.mode === '0755' ? 0o755 : 0o644 - if (verification === 'contents') { - await verifyRemoteFile( - sftp, - path, - file.size, - file.sha256, - uid, - mode, - signal - ) - } else { - await assertRemoteMetadata( - sftp, - path, - 'file', - uid, - mode, - file.size, - signal - ) - } + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + file.size, + signal + ) } for (const [metadataName, contents] of [ ['manifest.json', manifestBytes], @@ -1303,27 +1271,15 @@ async function verifyRemoteBundleFromMetadata( throw new Error('Runtime metadata exceeds its safety limit') } const path = `${destination}/${metadataName}` - if (verification === 'contents') { - await verifyRemoteFile( - sftp, - path, - contents.byteLength, - sha256(contents), - uid, - METADATA_MODE, - signal - ) - } else { - await assertRemoteMetadata( - sftp, - path, - 'file', - uid, - METADATA_MODE, - contents.byteLength, - signal - ) - } + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + METADATA_MODE, + contents.byteLength, + signal + ) } } @@ -1416,6 +1372,7 @@ async function activateRuntime( activate: RemoteRuntimeActivator, lease: SshConnectionLease, bundle: LoadedBundle, + agentInstallationId: VerifiedAgentInstallationId | undefined, signal: AbortSignal ): Promise { signal.throwIfAborted() @@ -1425,6 +1382,7 @@ async function activateRuntime( bundle.manifest.runtimeId, bundle.manifest.bundleDigest, bundle.manifest.architecture, + agentInstallationId, signal ) } catch (error) { diff --git a/src/main/remote-agent/remote-runtime-resource-loader.test.ts b/src/main/remote-agent/remote-runtime-resource-loader.test.ts index f763db67..ecc89b45 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.test.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.test.ts @@ -1,33 +1,11 @@ -import { createHash } from 'node:crypto' -import { - mkdir, - mkdtemp, - rm, - symlink, - writeFile -} from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - AgentArchitecture, - AgentReleaseKeyRegistry -} from '../../shared/agent-installation-contracts' -import { - digestRemoteRuntimeBundleIdentity, - digestRemoteRuntimeBundleManifest, - type RemoteRuntimeBundleManifest, - type RemoteRuntimeLock -} from '../../shared/remote-runtime-launch-contracts' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts' +import type { RemoteRuntimeLock } from '../../shared/remote-runtime-launch-contracts' import type { BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources' -import { - createRemoteRuntimeResourceLoader, - loadRemoteRuntimeVerificationMetadata, - loadVerifiedRemoteRuntimeResourceBundle -} from './remote-runtime-resource-loader' -import { - RemoteRuntimeBundleResourcesUnavailableError -} from './remote-runtime-installation-manager' +import { loadRemoteRuntimeVerificationMetadata } from './remote-runtime-resource-loader' const temporaryDirectories: string[] = [] const registry: AgentReleaseKeyRegistry = { @@ -45,124 +23,60 @@ const runtimeLock: RemoteRuntimeLock = { entrypointIdentity: 'opencode-acp', argvPrefix: ['acp'], allowedEnvironmentNames: [ - 'HOME', - 'LANG', - 'LC_ALL', - 'PATH', - 'TMPDIR', - 'XDG_CACHE_HOME', - 'XDG_CONFIG_HOME', - 'XDG_DATA_HOME', + 'HOME', 'LANG', 'LC_ALL', 'PATH', 'TMPDIR', + 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME' ], protocol: { major: 1, minor: 0 }, targets: { x64: { package: 'opencode-linux-x64-baseline', - integrity: - 'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ==' + integrity: `sha512-${'A'.repeat(86)}==` }, arm64: { package: 'opencode-linux-arm64', - integrity: - 'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A==' + integrity: `sha512-${'B'.repeat(86)}==` } } } } } +function canonical(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }) - ) - ) + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + )) }) -describe('remote Runtime resource loader', () => { - it('loads one verified digest directory and preserves metadata bytes', async () => { +async function createFixture(): Promise<{ + root: string + paths: BundledRemoteRuntimeResourcePaths +}> { + const root = await mkdtemp(join(tmpdir(), 'runtime-metadata-loader-')) + temporaryDirectories.push(root) + const paths: BundledRemoteRuntimeResourcePaths = { + keyRegistryPath: join(root, 'agent-release-keys.json'), + runtimeLockPath: join(root, 'remote-runtime-lock.json'), + runtimeRoots: { + x64: join(root, 'unused-x64'), + arm64: join(root, 'unused-arm64') + } + } + await Promise.all([ + writeFile(paths.keyRegistryPath, canonical(registry)), + writeFile(paths.runtimeLockPath, canonical(runtimeLock)) + ]) + return { root, paths } +} + +describe('remote Runtime verification metadata loader', () => { + it('loads canonical verification metadata without Runtime payloads', async () => { const fixture = await createFixture() - const verify = vi.fn(async (bundleDirectory) => ({ - bundleDirectory, - executablePath: join(bundleDirectory, 'bin', 'opencode'), - manifest: fixture.manifest, - manifestDigest: - await digestRemoteRuntimeBundleManifest(fixture.manifest) - })) - const loaded = await loadVerifiedRemoteRuntimeResourceBundle( - fixture.paths, - 'x64', - { - verificationEnvironment: 'test', - verifyRuntimeBundle: verify - } - ) - - expect(verify).toHaveBeenCalledWith(fixture.bundleDirectory, { - architecture: 'x64', - releaseKeyRegistry: registry, - runtimeLock, - verificationEnvironment: 'test' - }) - expect(loaded).toEqual({ - bundleDirectory: fixture.bundleDirectory, - manifest: fixture.manifest, - manifestDigest: - await digestRemoteRuntimeBundleManifest(fixture.manifest), - canonicalReleaseKeyRegistryBytes: canonical(registry), - canonicalRemoteRuntimeLockBytes: canonical(runtimeLock) - }) - expect(Object.keys(loaded)).toHaveLength(5) - }) - - it('rejects verification injection outside test verification', async () => { - const fixture = await createFixture() - const verify = vi.fn() - - expect(() => - createRemoteRuntimeResourceLoader(fixture.paths, { - verifyRuntimeBundle: verify - }) - ).toThrow('allowed only in test verification') - await expect( - loadVerifiedRemoteRuntimeResourceBundle( - fixture.paths, - 'x64', - { verifyRuntimeBundle: verify } - ) - ).rejects.toThrow('allowed only in test verification') - }) - - it('rejects missing and multiple digest directories', async () => { - const missing = await createFixture({ digestDirectories: 0 }) - const multiple = await createFixture({ digestDirectories: 2 }) - const verify = vi.fn() - - await expect(loadWithTestVerifier(missing.paths, verify)) - .rejects.toThrow('exactly one digest directory') - await expect(loadWithTestVerifier(multiple.paths, verify)) - .rejects.toThrow('exactly one digest directory') - expect(verify).not.toHaveBeenCalled() - }) - - it('loads verification metadata when installable Runtime resources are not packaged', async () => { - const fixture = await createFixture() - await rm(fixture.paths.runtimeRoots.x64, { - recursive: true, - force: true - }) - - await expect( - loadVerifiedRemoteRuntimeResourceBundle( - fixture.paths, - 'x64', - { verificationEnvironment: 'test' } - ) - ).rejects.toBeInstanceOf( - RemoteRuntimeBundleResourcesUnavailableError - ) await expect( loadRemoteRuntimeVerificationMetadata(fixture.paths) ).resolves.toEqual({ @@ -173,13 +87,11 @@ describe('remote Runtime resource loader', () => { }) }) - it('accepts equivalent key-registry formatting and returns canonical bytes', async () => { + it('canonicalizes equivalent release-key registry formatting', async () => { const fixture = await createFixture() await writeFile( fixture.paths.keyRegistryPath, - Buffer.from( - canonical(registry).toString('utf8').replace(/\n/gu, '\r\n') - ) + canonical(registry).toString('utf8').replace(/\n/gu, '\r\n') ) await expect( @@ -190,207 +102,28 @@ describe('remote Runtime resource loader', () => { }) }) - it('rejects a symlinked digest directory', async () => { - const fixture = await createFixture({ digestDirectories: 0 }) - const target = join(fixture.root, 'target') - await mkdir(target) - await symlink( - target, - join(fixture.paths.runtimeRoots.x64, 'a'.repeat(64)), - process.platform === 'win32' ? 'junction' : 'dir' - ) - - await expect(loadWithTestVerifier(fixture.paths, vi.fn())) - .rejects.toThrow('not a real directory') - }) - - it('rejects architecture and digest-directory mismatches', async () => { - const fixture = await createFixture() - const wrongArchitecture = { - ...fixture.manifest, - architecture: 'arm64' as const - } - const architectureVerifier = vi.fn(async (bundleDirectory) => ({ - bundleDirectory, - executablePath: join(bundleDirectory, 'bin', 'opencode'), - manifest: wrongArchitecture, - manifestDigest: - await digestRemoteRuntimeBundleManifest(wrongArchitecture) - })) - await expect( - loadWithTestVerifier(fixture.paths, architectureVerifier) - ).rejects.toThrow('does not match the requested OpenCode target') - - const wrongDigest = { - ...fixture.manifest, - bundleDigest: `sha256:${'f'.repeat(64)}` as const - } - const digestVerifier = vi.fn(async (bundleDirectory) => ({ - bundleDirectory, - executablePath: join(bundleDirectory, 'bin', 'opencode'), - manifest: wrongDigest, - manifestDigest: - await digestRemoteRuntimeBundleManifest(wrongDigest) - })) - await expect( - loadWithTestVerifier(fixture.paths, digestVerifier) - ).rejects.toThrow('does not match its resource directory') - }) - it.each([ + { bytes: Buffer.from('not json\n'), error: 'invalid JSON' }, { - name: 'malformed', - bytes: Buffer.from('not json\n'), - error: 'invalid JSON' - }, - { - name: 'oversize', bytes: Buffer.alloc(1024 * 1024 + 1, 0x20), error: 'exceeds its safety limit' } - ])('rejects $name metadata', async ({ bytes, error }) => { + ])('rejects invalid bounded metadata', async ({ bytes, error }) => { const fixture = await createFixture() - await writeFile(fixture.paths.keyRegistryPath, bytes) + await writeFile(fixture.paths.runtimeLockPath, bytes) - await expect(loadWithTestVerifier(fixture.paths, vi.fn())) - .rejects.toThrow(error) + await expect( + loadRemoteRuntimeVerificationMetadata(fixture.paths) + ).rejects.toThrow(error) + }) + + it('rejects metadata paths that are not regular files', async () => { + const fixture = await createFixture() + await rm(fixture.paths.keyRegistryPath) + await mkdir(fixture.paths.keyRegistryPath) + + await expect( + loadRemoteRuntimeVerificationMetadata(fixture.paths) + ).rejects.toThrow('not a regular file') }) }) - -async function createFixture(options: { - digestDirectories?: number -} = {}): Promise<{ - root: string - paths: BundledRemoteRuntimeResourcePaths - bundleDirectory: string - manifest: RemoteRuntimeBundleManifest -}> { - const root = await mkdtemp(join(tmpdir(), 'runtime-loader-')) - temporaryDirectories.push(root) - const paths: BundledRemoteRuntimeResourcePaths = { - keyRegistryPath: join(root, 'agent-release-keys.json'), - runtimeLockPath: join(root, 'remote-runtime-lock.json'), - runtimeRoots: { - x64: join(root, 'linux-x64', 'opencode'), - arm64: join(root, 'linux-arm64', 'opencode') - } - } - await mkdir(paths.runtimeRoots.x64, { recursive: true }) - await mkdir(paths.runtimeRoots.arm64, { recursive: true }) - await writeFile(paths.keyRegistryPath, canonical(registry)) - await writeFile(paths.runtimeLockPath, canonical(runtimeLock)) - const manifest = await createManifest('x64') - const digest = manifest.bundleDigest.slice('sha256:'.length) - const directoryCount = options.digestDirectories ?? 1 - for (let index = 0; index < directoryCount; index += 1) { - await mkdir( - join( - paths.runtimeRoots.x64, - index === 0 ? digest : 'e'.repeat(64) - ) - ) - } - return { - root, - paths, - bundleDirectory: join(paths.runtimeRoots.x64, digest), - manifest - } -} - -async function createManifest( - architecture: AgentArchitecture -): Promise { - const executable = Buffer.from('opencode') - const license = Buffer.from('license') - const initial: RemoteRuntimeBundleManifest = { - formatVersion: 2, - product: 'GoodBuddy', - runtimeId: 'opencode', - runtimeVersion: '1.18.9', - provider: 'opencode', - platform: 'linux', - architecture, - signingKeyId: 'test-key', - bundleDigest: `sha256:${'0'.repeat(64)}`, - adapterDigest: `sha256:${'1'.repeat(64)}`, - sourcePackage: { - name: - runtimeLock.runtimes.opencode.targets[architecture].package, - integrity: - runtimeLock.runtimes.opencode.targets[architecture].integrity - }, - entrypoint: { - identity: 'opencode-acp', - path: 'bin/opencode', - sha256: sha256(executable), - argvPrefix: ['acp'] - }, - files: [ - { - path: 'bin/opencode', - size: executable.byteLength, - sha256: sha256(executable), - mode: '0755' - }, - { - path: 'licenses/LICENSE', - size: license.byteLength, - sha256: sha256(license), - mode: '0644' - } - ], - licenses: [ - { - package: 'opencode', - version: '1.18.9', - spdx: 'MIT', - path: 'licenses/LICENSE' - } - ], - allowedEnvironmentNames: [ - 'HOME', - 'LANG', - 'LC_ALL', - 'PATH', - 'TMPDIR', - 'XDG_CACHE_HOME', - 'XDG_CONFIG_HOME', - 'XDG_DATA_HOME', - 'XDG_STATE_HOME' - ], - protocol: { major: 1, minor: 0 }, - acpCapabilitiesDigest: `sha256:${'3'.repeat(64)}`, - limits: { - maximumPromptRuntimeMilliseconds: 60_000, - maximumPromptInputBytes: 1024 * 1024, - maximumPromptOutputBytes: 1024 * 1024 - } - } - return { - ...initial, - bundleDigest: await digestRemoteRuntimeBundleIdentity(initial) - } -} - -function loadWithTestVerifier( - paths: BundledRemoteRuntimeResourcePaths, - verifyRuntimeBundle: NonNullable< - Parameters< - typeof loadVerifiedRemoteRuntimeResourceBundle - >[2] - >['verifyRuntimeBundle'] -) { - return loadVerifiedRemoteRuntimeResourceBundle(paths, 'x64', { - verificationEnvironment: 'test', - verifyRuntimeBundle - }) -} - -function canonical(value: unknown): Buffer { - return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') -} - -function sha256(contents: Uint8Array): string { - return createHash('sha256').update(contents).digest('hex') -} diff --git a/src/main/remote-agent/remote-runtime-resource-loader.ts b/src/main/remote-agent/remote-runtime-resource-loader.ts index 8d82c76d..0004605b 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.ts @@ -18,13 +18,9 @@ import { digestRemoteRuntimeBundleManifest, remoteRuntimeBundleManifestSchema, remoteRuntimeLockSchema, + type RemoteRuntimeBundleManifest, type RemoteRuntimeLock } from '../../shared/remote-runtime-launch-contracts' -import { - RemoteRuntimeBundleResourcesUnavailableError, - type RemoteRuntimeInstallationBundleLoader, - type VerifiedRemoteRuntimeInstallationBundle -} from './remote-runtime-installation-manager' import { getBundledRemoteRuntimeRoot, type BundledRemoteRuntimeResourcePaths @@ -59,24 +55,19 @@ export type RemoteRuntimeVerificationMetadata = { canonicalRemoteRuntimeLockBytes: Buffer } -export function createRemoteRuntimeResourceLoader( - paths: BundledRemoteRuntimeResourcePaths, - options: RemoteRuntimeResourceLoaderOptions = {} -): RemoteRuntimeInstallationBundleLoader { - assertSafeVerificationInjection(options) - return (architecture) => - loadVerifiedRemoteRuntimeResourceBundle( - paths, - architecture, - options - ) +export type VerifiedRemoteRuntimeResourceBundle = { + bundleDirectory: string + manifest: RemoteRuntimeBundleManifest + manifestDigest: string + canonicalReleaseKeyRegistryBytes: Buffer + canonicalRemoteRuntimeLockBytes: Buffer } export async function loadVerifiedRemoteRuntimeResourceBundle( paths: BundledRemoteRuntimeResourcePaths, architecture: AgentArchitecture, options: RemoteRuntimeResourceLoaderOptions = {} -): Promise { +): Promise { assertSafeVerificationInjection(options) const verificationEnvironment = options.verificationEnvironment ?? 'production' @@ -234,7 +225,10 @@ async function resolveSingleDigestDirectory( rootStat = await lstat(runtimeRoot) } catch (error) { if (isMissingPathError(error)) { - throw new RemoteRuntimeBundleResourcesUnavailableError(error) + throw new Error( + 'OpenCode Runtime resources are not included in this package', + { cause: error } + ) } throw error } @@ -273,7 +267,7 @@ async function validateVerifiedBundle( architecture: AgentArchitecture, keyRegistryBytes: Buffer, runtimeLockBytes: Buffer -): Promise { +): Promise { const manifest = remoteRuntimeBundleManifestSchema.parse( verified.manifest ) diff --git a/src/main/ssh/bounded-sftp.test.ts b/src/main/ssh/bounded-sftp.test.ts index b5031b3e..be902df3 100644 --- a/src/main/ssh/bounded-sftp.test.ts +++ b/src/main/ssh/bounded-sftp.test.ts @@ -1,4 +1,8 @@ import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { BoundedStagedSftp } from './bounded-sftp' @@ -153,11 +157,28 @@ function createSftp(root = '/safe/staging') { open: vi.fn( ( path: string, - _mode: string, - callback: (error?: Error, handle?: Buffer) => void + mode: string, + attributesOrCallback: + | { mode: number } + | ((error?: Error, handle?: Buffer) => void), + possibleCallback?: ( + error?: Error, + handle?: Buffer + ) => void ) => { - const entry = entries.get(path) - if (!entry) { + const callback = + typeof attributesOrCallback === 'function' + ? attributesOrCallback + : possibleCallback! + let entry = entries.get(path) + if (mode === 'wx') { + if (entry) { + callback(new Error('destination exists')) + return + } + entry = { type: 'file', contents: Buffer.alloc(0) } + entries.set(path, entry) + } else if (!entry) { callback(missingError()) return } @@ -166,6 +187,41 @@ function createSftp(root = '/safe/staging') { callback(undefined, handle) } ), + write: vi.fn( + ( + handle: Buffer, + buffer: Buffer, + offset: number, + length: number, + position: number, + callback: ( + error: Error | undefined, + bytesWritten: number + ) => void + ) => { + const entry = handles.get(handle.toString()) + if (!entry) { + callback(missingError(), 0) + return + } + const requiredSize = position + length + if ( + !entry.contents || + entry.contents.byteLength < requiredSize + ) { + const grown = Buffer.alloc(requiredSize) + entry.contents?.copy(grown) + entry.contents = grown + } + buffer.copy( + entry.contents, + position, + offset, + offset + length + ) + callback(undefined, length) + } + ), fstat: vi.fn( ( handle: Buffer, @@ -221,6 +277,188 @@ function createSftp(root = '/safe/staging') { } describe('bounded staged SFTP', () => { + it('streams local files in bounded chunks and verifies both identities', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-sftp-upload-') + ) + try { + const sourcePath = join(directory, 'package.gbagent') + const contents = Buffer.alloc(3 * 64 * 1024 + 17, 0x5a) + await writeFile(sourcePath, contents) + const { sftp, entries } = createSftp() + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + + await staged.uploadFile('package.gbagent', sourcePath, { + size: contents.byteLength, + sha256: createHash('sha256') + .update(contents) + .digest('hex') + }) + + expect( + entries.get('/safe/staging/package.gbagent')?.contents + ).toEqual(contents) + expect(sftp.write).toHaveBeenCalledTimes(4) + expect( + Math.max( + ...sftp.write.mock.calls.map( + ([, , , length]) => length + ) + ) + ).toBe(64 * 1024) + expect( + sftp.write.mock.calls.every( + ([, buffer]) => buffer.byteLength <= 64 * 1024 + ) + ).toBe(true) + expect(sftp.open).toHaveBeenCalledWith( + '/safe/staging/package.gbagent', + 'wx', + { mode: 0o600 }, + expect.any(Function) + ) + expect(sftp.fstat).toHaveBeenCalledTimes(2) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects local upload size and hash mismatches', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-sftp-upload-') + ) + try { + const sourcePath = join(directory, 'package.gbagent') + const contents = Buffer.alloc(64 * 1024 + 1, 0x41) + await writeFile(sourcePath, contents) + + const sizeHarness = createSftp() + const sizeSftp = new BoundedStagedSftp( + sizeHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + await expect( + sizeSftp.uploadFile('size', sourcePath, { + size: contents.byteLength - 1, + sha256: createHash('sha256') + .update(contents) + .digest('hex') + }) + ).rejects.toThrow('预期的普通文件') + expect(sizeHarness.sftp.open).not.toHaveBeenCalled() + + const hashHarness = createSftp() + const hashSftp = new BoundedStagedSftp( + hashHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + await expect( + hashSftp.uploadFile('hash', sourcePath, { + size: contents.byteLength, + sha256: '0'.repeat(64) + }) + ).rejects.toThrow('SHA-256 校验失败') + expect(hashHarness.sftp.close).toHaveBeenCalledOnce() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('cancels a streaming upload and rejects unsafe boundaries', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-sftp-upload-') + ) + try { + const sourcePath = join(directory, 'package.gbagent') + const contents = Buffer.alloc(2 * 64 * 1024, 0x42) + await writeFile(sourcePath, contents) + const hash = createHash('sha256').update(contents).digest('hex') + + const cancellationHarness = createSftp() + cancellationHarness.sftp.write.mockImplementation( + () => undefined + ) + const cancellable = new BoundedStagedSftp( + cancellationHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + const controller = new AbortController() + const upload = cancellable.uploadFile( + 'package.gbagent', + sourcePath, + { size: contents.byteLength, sha256: hash }, + controller.signal + ) + await vi.waitFor(() => + expect( + cancellationHarness.sftp.write + ).toHaveBeenCalledOnce() + ) + controller.abort() + await expect(upload).rejects.toMatchObject({ + name: 'AbortError' + }) + expect( + cancellationHarness.sftp.end + ).toHaveBeenCalledOnce() + + const boundaryHarness = createSftp() + const bounded = new BoundedStagedSftp( + boundaryHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength - 1, + maximumTotalBytes: contents.byteLength + } + ) + await expect( + bounded.uploadFile('too-large', sourcePath, { + size: contents.byteLength, + sha256: hash + }) + ).rejects.toThrow('大小超过安全限制') + boundaryHarness.entries.set('/safe/staging/link', { + type: 'symbolic-link' + }) + const ancestors = new BoundedStagedSftp( + boundaryHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + await expect( + ancestors.uploadFile('link/package', sourcePath, { + size: contents.byteLength, + sha256: hash + }) + ).rejects.toThrow('符号链接') + expect(boundaryHarness.sftp.open).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('keeps writes inside staging and supports bounded metadata and reads', async () => { const { sftp } = createSftp( '/home/builder/.goodbuddy/staging/random' diff --git a/src/main/ssh/bounded-sftp.ts b/src/main/ssh/bounded-sftp.ts index b68265d2..39645a88 100644 --- a/src/main/ssh/bounded-sftp.ts +++ b/src/main/ssh/bounded-sftp.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { lstat as localLstat, open as localOpen } from 'node:fs/promises' import type { SFTPWrapper, Stats } from 'ssh2' const DEFAULT_OPERATION_TIMEOUT_MS = 30_000 @@ -5,6 +8,7 @@ const MAX_OPERATION_TIMEOUT_MS = 60_000 const MINIMUM_TRANSFER_BYTES_PER_SECOND = 64 * 1024 const MAX_TRANSFER_TIMEOUT_MS = 60 * 60_000 const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024 +const MAX_MAXIMUM_FILE_BYTES = 512 * 1024 * 1024 const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024 const DEFAULT_MAX_OPERATIONS = 2_048 const MAX_REMOTE_PATH_BYTES = 4_096 @@ -37,6 +41,11 @@ export type SftpEntryMetadata = { export type AllowedSftpMode = (typeof ALLOWED_MODES)[number] +export type SftpUploadIdentity = { + size: number + sha256: string +} + export type StagedSftp = { readonly stagingDirectory: string mkdir(relativePath: string, signal?: AbortSignal): Promise @@ -45,6 +54,12 @@ export type StagedSftp = { contents: Buffer, signal?: AbortSignal ): Promise + uploadFile( + relativePath: string, + sourcePath: string, + identity: SftpUploadIdentity, + signal?: AbortSignal + ): Promise readFile( relativePath: string, signal?: AbortSignal @@ -100,6 +115,7 @@ type SftpLike = Pick< | 'stat' | 'open' | 'read' + | 'write' | 'close' | 'fstat' | 'end' @@ -246,7 +262,7 @@ export class BoundedStagedSftp implements StagedSftp { this.maximumFileBytes = validatePositiveLimit( limits.maximumFileBytes, DEFAULT_MAX_FILE_BYTES, - DEFAULT_MAX_FILE_BYTES + MAX_MAXIMUM_FILE_BYTES ) this.maximumTotalBytes = validatePositiveLimit( limits.maximumTotalBytes, @@ -316,6 +332,146 @@ export class BoundedStagedSftp implements StagedSftp { ) } + async uploadFile( + relativePath: string, + sourcePath: string, + identity: SftpUploadIdentity, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + if ( + !Number.isSafeInteger(identity.size) || + identity.size < 0 || + identity.size > this.maximumFileBytes + ) { + throw new Error('SFTP 文件大小超过安全限制') + } + if (!/^[a-f0-9]{64}$/u.test(identity.sha256)) { + throw new Error('SFTP 文件 SHA-256 无效') + } + return this.enqueue( + signal, + async () => { + if ( + this.totalBytes + identity.size > + this.maximumTotalBytes + ) { + throw new Error('SFTP 传输总量超过安全限制') + } + await this.assertSafeAncestors(validated) + const pathMetadata = await localLstat(sourcePath) + if ( + !pathMetadata.isFile() || + pathMetadata.isSymbolicLink() || + pathMetadata.size !== identity.size + ) { + throw new Error('SFTP 本地上传源不是预期的普通文件') + } + const source = await localOpen( + sourcePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + let remoteHandle: Buffer | undefined + const closeSourceOnAbort = (): void => { + void source.close().catch(() => undefined) + } + signal?.addEventListener('abort', closeSourceOnAbort, { + once: true + }) + try { + const openedMetadata = await source.stat() + if ( + !openedMetadata.isFile() || + openedMetadata.size !== identity.size || + openedMetadata.dev !== pathMetadata.dev || + openedMetadata.ino !== pathMetadata.ino + ) { + throw new Error( + 'SFTP 本地上传源在打开期间发生变化' + ) + } + remoteHandle = await this.call((done) => { + this.sftp.open( + remotePath, + 'wx', + { mode: 0o600 }, + done + ) + }) + const initialRemoteMetadata = metadataFromStats( + await this.call((done) => { + this.sftp.fstat(remoteHandle!, done) + }) + ) + this.assertType(initialRemoteMetadata, 'file') + if (initialRemoteMetadata.size !== 0) { + throw new Error('SFTP 上传目标初始大小无效') + } + + const hash = createHash('sha256') + const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES) + let position = 0 + while (true) { + signal?.throwIfAborted() + const { bytesRead } = await source.read( + buffer, + 0, + buffer.byteLength, + position + ) + if (bytesRead === 0) { + break + } + position += bytesRead + if ( + position > identity.size || + this.totalBytes + bytesRead > + this.maximumTotalBytes + ) { + throw new Error('SFTP 文件大小超过安全限制') + } + const chunk = buffer.subarray(0, bytesRead) + hash.update(chunk) + await this.writeHandle( + remoteHandle, + chunk, + position - bytesRead + ) + this.totalBytes += bytesRead + } + if (position !== identity.size) { + throw new Error('SFTP 本地上传源大小与预期不一致') + } + if (hash.digest('hex') !== identity.sha256) { + throw new Error('SFTP 本地上传源 SHA-256 校验失败') + } + const remoteMetadata = metadataFromStats( + await this.call((done) => { + this.sftp.fstat(remoteHandle!, done) + }) + ) + this.assertType(remoteMetadata, 'file') + if (remoteMetadata.size !== identity.size) { + throw new Error('SFTP 上传目标大小与预期不一致') + } + } finally { + signal?.removeEventListener( + 'abort', + closeSourceOnAbort + ) + await Promise.allSettled([ + source.close(), + remoteHandle + ? this.closeHandle(remoteHandle) + : Promise.resolve() + ]) + } + }, + this.transferTimeoutMs(identity.size) + ) + } + async readFile( relativePath: string, signal?: AbortSignal @@ -713,6 +869,23 @@ export class BoundedStagedSftp implements StagedSftp { : contents.subarray(0, actualSize) } + private async writeHandle( + handle: Buffer, + contents: Buffer, + position: number + ): Promise { + await this.call((done) => { + this.sftp.write( + handle, + contents, + 0, + contents.byteLength, + position, + done + ) + }) + } + private async closeHandle(handle: Buffer): Promise { try { await this.call((done) => { diff --git a/src/main/ssh/ssh-connection-pool.test.ts b/src/main/ssh/ssh-connection-pool.test.ts index 30e69dc2..386f9205 100644 --- a/src/main/ssh/ssh-connection-pool.test.ts +++ b/src/main/ssh/ssh-connection-pool.test.ts @@ -1,60 +1,58 @@ -import { EventEmitter } from 'node:events' -import type { ConnectConfig } from 'ssh2' -import { describe, expect, it, vi } from 'vitest' -import type { ResolvedSshHost } from './ssh-host-store' +import { EventEmitter } from "node:events"; +import type { ConnectConfig } from "ssh2"; +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedSshHost } from "./ssh-host-store"; import { AGENT_BOOTSTRAP_PROBE_COMMAND, - verifyAgentInstallationId -} from './ssh-agent-command' + verifyAgentInstallationId, +} from "./ssh-agent-command"; +import { SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND } from "./ssh-remote-package-bootstrap"; import { Ssh2AuthenticatedConnection, SshConnectionPool, type AuthenticatedSshConnection, type SshConnectionFactory, type SshConnectionPoolTarget, - type SshPoolConnectionIdentity -} from './ssh-connection-pool' + type SshPoolConnectionIdentity, +} from "./ssh-connection-pool"; -const sshEd25519Algorithm = Buffer.from('ssh-ed25519') +const sshEd25519Algorithm = Buffer.from("ssh-ed25519"); const pinnedHostKey = Buffer.concat([ Buffer.from([0, 0, 0, sshEd25519Algorithm.byteLength]), sshEd25519Algorithm, Buffer.from([0, 0, 0, 32]), - Buffer.alloc(32, 7) -]) + Buffer.alloc(32, 7), +]); const hostKey = { - algorithm: 'ssh-ed25519', - publicKeyBase64: pinnedHostKey.toString('base64'), - fingerprintSha256: `SHA256:${'A'.repeat(43)}`, - generation: 3 -} + algorithm: "ssh-ed25519", + publicKeyBase64: pinnedHostKey.toString("base64"), + fingerprintSha256: `SHA256:${"A".repeat(43)}`, + generation: 3, +}; function createTarget( overrides: Partial = {}, identityOverrides: Partial< - Pick< - SshConnectionPoolTarget, - 'hostRevision' | 'hostKeyGeneration' - > - > = {} + Pick + > = {}, ): SshConnectionPoolTarget { return { host: { - id: '00000000-0000-4000-8000-000000000201', - name: 'Build host', - hostname: 'build.example.com', + id: "00000000-0000-4000-8000-000000000201", + name: "Build host", + hostname: "build.example.com", port: 22, - username: 'builder', - authentication: 'password', - password: 'private password', + username: "builder", + authentication: "password", + password: "private password", hostKey, - ...overrides + ...overrides, }, hostRevision: 7, hostKeyGeneration: 3, - ...identityOverrides - } + ...identityOverrides, + }; } function createConnection(identity: SshPoolConnectionIdentity) { @@ -64,342 +62,509 @@ function createConnection(identity: SshPoolConnectionIdentity) { openAgentAttach: vi.fn(), runAgentDoctor: vi.fn(), runAgentBootstrapProbe: vi.fn(), + probeRemotePackageBootstrap: vi.fn(), + createRemotePackageUploadStaging: vi.fn(), + prepareRemotePackageBootstrap: vi.fn(), + prepareUploadedRemotePackageBootstrap: vi.fn(), + commitRemotePackageBootstrap: vi.fn(), + getRemotePackageBootstrapCommitStatus: vi.fn(), + cleanupRemotePackageBootstrap: vi.fn(), runAgentLifecycleAction: vi.fn(), runAgentRuntimeAction: vi.fn(), openStagedSftp: vi.fn(), listDirectories: vi.fn(), - dispose: vi.fn() - } satisfies AuthenticatedSshConnection + dispose: vi.fn(), + } satisfies AuthenticatedSshConnection; } -describe('SSH connection pool', () => { - it('reuses one authenticated connection for matching identities and refcounts leases', async () => { - vi.useFakeTimers() +describe("SSH connection pool", () => { + it("reuses one authenticated connection for matching identities and refcounts leases", async () => { + vi.useFakeTimers(); try { - const connections: ReturnType[] = [] - const factory: SshConnectionFactory = vi.fn( - async (_target, identity) => { - const connection = createConnection(identity) - connections.push(connection) - return connection - } - ) + const connections: ReturnType[] = []; + const factory: SshConnectionFactory = vi.fn(async (_target, identity) => { + const connection = createConnection(identity); + connections.push(connection); + return connection; + }); const pool = new SshConnectionPool(factory, { - idleTimeoutMs: 100 - }) + idleTimeoutMs: 100, + }); const [first, second] = await Promise.all([ pool.acquire(createTarget()), - pool.acquire(createTarget()) - ]) + pool.acquire(createTarget()), + ]); - expect(factory).toHaveBeenCalledOnce() - expect(first.identity).toEqual(second.identity) - first.release() - await vi.advanceTimersByTimeAsync(100) - expect(connections[0]?.dispose).not.toHaveBeenCalled() - second.release() - await vi.advanceTimersByTimeAsync(99) - expect(connections[0]?.dispose).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(connections[0]?.dispose).toHaveBeenCalledOnce() + expect(factory).toHaveBeenCalledOnce(); + expect(first.identity).toEqual(second.identity); + first.release(); + await vi.advanceTimersByTimeAsync(100); + expect(connections[0]?.dispose).not.toHaveBeenCalled(); + second.release(); + await vi.advanceTimersByTimeAsync(99); + expect(connections[0]?.dispose).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(connections[0]?.dispose).toHaveBeenCalledOnce(); } finally { - vi.useRealTimers() + vi.useRealTimers(); } - }) + }); - it('isolates revision, host-key generation, credential, and host identities', async () => { - const factory: SshConnectionFactory = vi.fn( - async (_target, identity) => createConnection(identity) - ) - const pool = new SshConnectionPool(factory) + it("isolates revision, host-key generation, credential, and host identities", async () => { + const factory: SshConnectionFactory = vi.fn(async (_target, identity) => + createConnection(identity), + ); + const pool = new SshConnectionPool(factory); const targets = [ createTarget(), createTarget({}, { hostRevision: 8 }), createTarget( { hostKey: { ...hostKey, generation: 4 } }, - { hostKeyGeneration: 4 } + { hostKeyGeneration: 4 }, ), - createTarget({ password: 'rotated password' }), + createTarget({ password: "rotated password" }), createTarget({ - id: '00000000-0000-4000-8000-000000000202' - }) - ] + id: "00000000-0000-4000-8000-000000000202", + }), + ]; const leases = await Promise.all( - targets.map((target) => pool.acquire(target)) - ) + targets.map((target) => pool.acquire(target)), + ); - expect(factory).toHaveBeenCalledTimes(5) + expect(factory).toHaveBeenCalledTimes(5); expect( new Set( leases.map( (lease) => `${lease.identity.hostId}:${lease.identity.hostRevision}:` + `${lease.identity.hostKeyGeneration}:` + - lease.identity.authenticationIdentity - ) - ).size - ).toBe(5) + lease.identity.authenticationIdentity, + ), + ).size, + ).toBe(5); for (const lease of leases) { - lease.release() + lease.release(); } - pool.dispose() - }) + pool.dispose(); + }); - it('lets one canceled waiter leave without canceling a shared connection', async () => { + it("evicts the oldest idle entry for sequential distinct hosts beyond the pool limit", async () => { + const connections: ReturnType[] = []; + const factory: SshConnectionFactory = vi.fn(async (_target, identity) => { + const connection = createConnection(identity); + connections.push(connection); + return connection; + }); + const pool = new SshConnectionPool(factory, { + idleTimeoutMs: 60_000, + }); + + for (let index = 0; index < 40; index += 1) { + const hostId = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; + const lease = await pool.acquire(createTarget({ id: hostId })); + lease.release(); + } + + expect(factory).toHaveBeenCalledTimes(40); + expect( + connections + .slice(0, 8) + .every((connection) => connection.dispose.mock.calls.length === 1), + ).toBe(true); + expect( + connections + .slice(8) + .every((connection) => connection.dispose.mock.calls.length === 0), + ).toBe(true); + pool.dispose(); + }); + + it("rejects a distinct host when all pool entries have active leases", async () => { + const connections: ReturnType[] = []; + const factory: SshConnectionFactory = vi.fn(async (_target, identity) => { + const connection = createConnection(identity); + connections.push(connection); + return connection; + }); + const pool = new SshConnectionPool(factory); + const leases = await Promise.all( + Array.from({ length: 32 }, (_, index) => + pool.acquire( + createTarget({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + }), + ), + ), + ); + + await expect( + pool.acquire( + createTarget({ + id: "00000000-0000-4000-8000-000000000032", + }), + ), + ).rejects.toThrow("SSH 连接池已达到安全上限"); + expect(factory).toHaveBeenCalledTimes(32); + expect( + connections.every( + (connection) => connection.dispose.mock.calls.length === 0, + ), + ).toBe(true); + + for (const lease of leases) { + lease.release(); + } + pool.dispose(); + }); + + it("lets one canceled waiter leave without canceling a shared connection", async () => { let resolveConnection: - | ((connection: AuthenticatedSshConnection) => void) - | undefined + ((connection: AuthenticatedSshConnection) => void) | undefined; const factory: SshConnectionFactory = vi.fn( (_target, identity) => new Promise((resolve) => { - resolveConnection = resolve - expect(identity.authenticationIdentity).toMatch( - /^[a-f0-9]{64}$/u - ) - }) - ) - const pool = new SshConnectionPool(factory) - const controller = new AbortController() - const canceled = pool.acquire(createTarget(), controller.signal) - const surviving = pool.acquire(createTarget()) - controller.abort() + resolveConnection = resolve; + expect(identity.authenticationIdentity).toMatch(/^[a-f0-9]{64}$/u); + }), + ); + const pool = new SshConnectionPool(factory); + const controller = new AbortController(); + const canceled = pool.acquire(createTarget(), controller.signal); + const surviving = pool.acquire(createTarget()); + controller.abort(); await expect(canceled).rejects.toMatchObject({ - name: 'AbortError' - }) - const identity = ( - factory as ReturnType - ).mock.calls[0]?.[1] as SshPoolConnectionIdentity - resolveConnection?.(createConnection(identity)) - const lease = await surviving - expect(lease.isUsable()).toBe(true) - lease.release() - pool.dispose() - }) + name: "AbortError", + }); + const identity = (factory as ReturnType).mock + .calls[0]?.[1] as SshPoolConnectionIdentity; + resolveConnection?.(createConnection(identity)); + const lease = await surviving; + expect(lease.isUsable()).toBe(true); + lease.release(); + pool.dispose(); + }); - it('disposes all host entries and prevents use after release', async () => { + it("provides a dedicated bounded remote-package bootstrap lease", async () => { const connection = createConnection({ hostId: createTarget().host.id, hostRevision: 7, hostKeyGeneration: 3, - authenticationIdentity: 'a'.repeat(64) - }) - const pool = new SshConnectionPool(async () => connection) - const lease = await pool.acquire(createTarget()) - const signal = new AbortController().signal - void lease.runAgentBootstrapProbe(signal) + authenticationIdentity: "a".repeat(64), + }); + connection.probeRemotePackageBootstrap.mockResolvedValue({ + available: true, + }); + connection.createRemotePackageUploadStaging.mockResolvedValue({ + created: true, + operationId: "00000000-0000-4000-8000-000000000301", + archivePath: + "/home/builder/.goodbuddy/agent/staging/op-00000000-0000-4000-8000-000000000301/package.gbagent", + bootstrapNodePath: + "/home/builder/.goodbuddy/agent/staging/op-00000000-0000-4000-8000-000000000301/bootstrap/agent/node", + }); + connection.prepareUploadedRemotePackageBootstrap.mockResolvedValue({ + prepared: false, + unavailable: false, + reason: "size-mismatch", + }); + connection.getRemotePackageBootstrapCommitStatus.mockResolvedValue({ + committed: false, + reason: "operation-unavailable", + }); + const pool = new SshConnectionPool(async () => connection); + const lease = await pool.acquireRemotePackageBootstrap(createTarget()); + const candidate = { + operationId: "00000000-0000-4000-8000-000000000301", + urls: [ + "https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v1.0.0/package.gbagent", + ], + size: 123, + sha256: "a".repeat(64), + }; + + await expect(lease.probe(candidate)).resolves.toEqual({ + available: true, + }); + expect(connection.probeRemotePackageBootstrap).toHaveBeenCalledWith( + candidate, + undefined, + ); + await expect(lease.createUploadStaging(candidate)).resolves.toMatchObject({ + created: true, + }); + expect(connection.createRemotePackageUploadStaging).toHaveBeenCalledWith( + candidate, + undefined, + ); + await expect(lease.prepareUploaded(candidate)).resolves.toMatchObject({ + reason: "size-mismatch", + }); expect( - connection.runAgentBootstrapProbe - ).toHaveBeenCalledWith(signal) - void lease.listDirectories('/srv', signal) - expect(connection.listDirectories).toHaveBeenCalledWith( - '/srv', - signal - ) + connection.prepareUploadedRemotePackageBootstrap, + ).toHaveBeenCalledWith(candidate, undefined); + await expect(lease.commitStatus(candidate)).resolves.toMatchObject({ + reason: "operation-unavailable", + }); + expect( + connection.getRemotePackageBootstrapCommitStatus, + ).toHaveBeenCalledWith(candidate, undefined); + expect(lease).not.toHaveProperty("exec"); + expect(lease).not.toHaveProperty("sftp"); + lease.release(); + expect(() => lease.probe(candidate)).toThrow("租约已释放"); + expect(() => lease.prepareUploaded(candidate)).toThrow("租约已释放"); + pool.dispose(); + }); + + it("disposes all host entries and prevents use after release", async () => { + const connection = createConnection({ + hostId: createTarget().host.id, + hostRevision: 7, + hostKeyGeneration: 3, + authenticationIdentity: "a".repeat(64), + }); + const pool = new SshConnectionPool(async () => connection); + const lease = await pool.acquire(createTarget()); + const signal = new AbortController().signal; + void lease.runAgentBootstrapProbe(signal); + expect(connection.runAgentBootstrapProbe).toHaveBeenCalledWith(signal); + void lease.listDirectories("/srv", signal); + expect(connection.listDirectories).toHaveBeenCalledWith("/srv", signal); void lease.runAgentRuntimeAction( - verifyAgentInstallationId('agent-v1'), + verifyAgentInstallationId("agent-v1"), { - kind: 'runtime-activate', - runtimeId: 'opencode', - bundleDigest: `sha256:${'a'.repeat(64)}`, - architecture: 'x64' + kind: "runtime-activate", + runtimeId: "opencode", + bundleDigest: `sha256:${"a".repeat(64)}`, + architecture: "x64", }, - signal - ) + signal, + ); expect(connection.runAgentRuntimeAction).toHaveBeenCalledWith( - 'agent-v1', - expect.objectContaining({ kind: 'runtime-activate' }), - signal - ) - lease.release() + "agent-v1", + expect.objectContaining({ kind: "runtime-activate" }), + signal, + ); + lease.release(); expect(() => - lease.openAgentAttach( - verifyAgentInstallationId('agent-v1') - ) - ).toThrow('租约已释放') - expect(() => - lease.runAgentBootstrapProbe() - ).toThrow('租约已释放') - expect(() => - lease.listDirectories() - ).toThrow('租约已释放') - expect(lease).not.toHaveProperty('exec') - expect(lease).not.toHaveProperty('sftp') - pool.disposeHost(createTarget().host.id) - expect(connection.dispose).toHaveBeenCalledOnce() - }) -}) + lease.openAgentAttach(verifyAgentInstallationId("agent-v1")), + ).toThrow("租约已释放"); + expect(() => lease.runAgentBootstrapProbe()).toThrow("租约已释放"); + expect(() => lease.listDirectories()).toThrow("租约已释放"); + expect(lease).not.toHaveProperty("exec"); + expect(lease).not.toHaveProperty("sftp"); + pool.disposeHost(createTarget().host.id); + expect(connection.dispose).toHaveBeenCalledOnce(); + }); +}); -describe('authenticated ssh2 connection', () => { - it('uses pinned algorithms, keepalive, no forwarding, and only fixed Agent exec', async () => { - let connectConfig: ConnectConfig | undefined +describe("authenticated ssh2 connection", () => { + it("uses pinned algorithms, keepalive, no forwarding, and only fixed Agent exec", async () => { + let connectConfig: ConnectConfig | undefined; const channel = Object.assign(new EventEmitter(), { stderr: new EventEmitter(), - destroy: vi.fn() - }) + destroy: vi.fn(), + end: vi.fn(), + }); const client = Object.assign(new EventEmitter(), { connect: vi.fn(function connect( this: EventEmitter, - config: ConnectConfig + config: ConnectConfig, ) { - connectConfig = config + connectConfig = config; expect( - ( - config.hostVerifier as - | ((key: Buffer) => boolean) - | undefined - )?.(pinnedHostKey) - ).toBe(true) - this.emit('ready') + (config.hostVerifier as ((key: Buffer) => boolean) | undefined)?.( + pinnedHostKey, + ), + ).toBe(true); + this.emit("ready"); }), end: vi.fn(), destroy: vi.fn(), exec: vi.fn( ( - _command: string, - _options: unknown, - callback: ( - error: Error | undefined, - stream: typeof channel - ) => void - ) => callback(undefined, channel) + command: string, + options: unknown, + callback: (error: Error | undefined, stream: typeof channel) => void, + ) => { + if (command === SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND) { + channel.end.mockImplementationOnce(function end( + this: EventEmitter, + ) { + queueMicrotask(() => { + if ( + (options as { allowHalfOpen?: boolean }).allowHalfOpen !== + true + ) { + this.emit("close", null); + return; + } + this.emit( + "data", + '{"type":"result","status":"available"}\n', + ); + this.emit("close", 0); + }); + }); + } + callback(undefined, channel); + }, ), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const identity: SshPoolConnectionIdentity = { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) - } + authenticationIdentity: "a".repeat(64), + }; const connection = await Ssh2AuthenticatedConnection.connect( target, identity, new AbortController().signal, { createClient: () => client as never, - supportedOpenSslCiphers: () => ['aes-256-gcm'] - } - ) + supportedOpenSslCiphers: () => ["aes-256-gcm"], + controlPlanePackageInstaller: + 'module.exports = { source: "GoodBuddy" }\n', + }, + ); - await connection.openAgentAttach( - verifyAgentInstallationId('agent-v1') - ) + await connection.openAgentAttach(verifyAgentInstallationId("agent-v1")); + await expect( + connection.probeRemotePackageBootstrap({ + operationId: "00000000-0000-4000-8000-000000000301", + urls: [ + "https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v1.0.0/package.gbagent", + ], + size: 123, + sha256: "a".repeat(64), + }), + ).resolves.toEqual({ available: true }); expect(connectConfig).toMatchObject({ - host: 'build.example.com', + host: "build.example.com", port: 22, - username: 'builder', - password: 'private password', + username: "builder", + password: "private password", agentForward: false, keepaliveInterval: 5_000, keepaliveCountMax: 2, algorithms: { - cipher: ['aes256-gcm@openssh.com'] - } - }) + cipher: ["aes256-gcm@openssh.com"], + }, + }); expect(client.exec).toHaveBeenCalledWith( 'exec "$HOME/.goodbuddy/agent/installations/' + 'agent-v1/goodbuddy-agent" attach-or-bootstrap ' + - '--installation-id agent-v1', + "--installation-id agent-v1", { env: {}, pty: false, x11: false, - allowHalfOpen: false + allowHalfOpen: false, }, - expect.any(Function) - ) - expect(connection).not.toHaveProperty('exec') - }) + expect.any(Function), + ); + expect(client.exec).toHaveBeenCalledWith( + SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND, + { + env: {}, + pty: false, + x11: false, + allowHalfOpen: true, + }, + expect.any(Function), + ); + expect(connection).not.toHaveProperty("exec"); + }); - it('hard-fails a host-key mismatch and aborts pending authentication', async () => { - let authenticationAttempted = false + it("hard-fails a host-key mismatch and aborts pending authentication", async () => { + let authenticationAttempted = false; const mismatchedClient = Object.assign(new EventEmitter(), { connect: vi.fn(function connect( this: EventEmitter, - config: ConnectConfig + config: ConnectConfig, ) { const accepted = ( - config.hostVerifier as - | ((key: Buffer) => boolean) - | undefined - )?.(Buffer.from('different-key')) + config.hostVerifier as ((key: Buffer) => boolean) | undefined + )?.(Buffer.from("different-key")); if (accepted) { - authenticationAttempted = true + authenticationAttempted = true; } this.emit( - 'error', - Object.assign(new Error('key rejected'), { - level: 'handshake' - }) - ) + "error", + Object.assign(new Error("key rejected"), { + level: "handshake", + }), + ); }), end: vi.fn(), destroy: vi.fn(), exec: vi.fn(), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const identity = { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) - } + authenticationIdentity: "a".repeat(64), + }; expect(() => Ssh2AuthenticatedConnection.connect( target, { ...identity, hostRevision: identity.hostRevision + 1 }, new AbortController().signal, - { createClient: () => mismatchedClient as never } - ) - ).toThrow('认证目标不匹配') + { createClient: () => mismatchedClient as never }, + ), + ).toThrow("认证目标不匹配"); await expect( Ssh2AuthenticatedConnection.connect( target, identity, new AbortController().signal, - { createClient: () => mismatchedClient as never } - ) - ).rejects.toThrow('主机密钥已变化') - expect(authenticationAttempted).toBe(false) - expect(mismatchedClient.destroy).toHaveBeenCalledOnce() + { createClient: () => mismatchedClient as never }, + ), + ).rejects.toThrow("主机密钥已变化"); + expect(authenticationAttempted).toBe(false); + expect(mismatchedClient.destroy).toHaveBeenCalledOnce(); const pendingClient = Object.assign(new EventEmitter(), { connect: vi.fn(), end: vi.fn(), destroy: vi.fn(), exec: vi.fn(), - sftp: vi.fn() - }) - const controller = new AbortController() + sftp: vi.fn(), + }); + const controller = new AbortController(); const pending = Ssh2AuthenticatedConnection.connect( target, identity, controller.signal, - { createClient: () => pendingClient as never } - ) - controller.abort() + { createClient: () => pendingClient as never }, + ); + controller.abort(); await expect(pending).rejects.toMatchObject({ - name: 'AbortError' - }) - expect(pendingClient.destroy).toHaveBeenCalledOnce() - }) + name: "AbortError", + }); + expect(pendingClient.destroy).toHaveBeenCalledOnce(); + }); - it('bounds fixed diagnostic output and destroys an overflowing channel', async () => { + it("bounds fixed diagnostic output and destroys an overflowing channel", async () => { const channel = Object.assign(new EventEmitter(), { stderr: new EventEmitter(), - destroy: vi.fn() - }) + destroy: vi.fn(), + }); const client = Object.assign(new EventEmitter(), { connect: vi.fn(function connect(this: EventEmitter) { - this.emit('ready') + this.emit("ready"); }), end: vi.fn(), destroy: vi.fn(), @@ -407,51 +572,46 @@ describe('authenticated ssh2 connection', () => { ( _command: string, _options: unknown, - callback: ( - error: Error | undefined, - stream: typeof channel - ) => void + callback: (error: Error | undefined, stream: typeof channel) => void, ) => { - callback(undefined, channel) + callback(undefined, channel); setTimeout(() => { - channel.emit('data', Buffer.alloc(64 * 1024 + 1)) - }, 0) - } + channel.emit("data", Buffer.alloc(64 * 1024 + 1)); + }, 0); + }, ), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const connection = await Ssh2AuthenticatedConnection.connect( target, { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) + authenticationIdentity: "a".repeat(64), }, new AbortController().signal, - { createClient: () => client as never } - ) + { createClient: () => client as never }, + ); await expect( - connection.runAgentDoctor( - verifyAgentInstallationId('agent-v1') - ) - ).rejects.toThrow('输出超过安全限制') + connection.runAgentDoctor(verifyAgentInstallationId("agent-v1")), + ).rejects.toThrow("输出超过安全限制"); expect(client.exec.mock.calls[0]?.[0]).toContain( - '/goodbuddy-agent" doctor' - ) - expect(channel.destroy).toHaveBeenCalledOnce() - }) + '/goodbuddy-agent" doctor', + ); + expect(channel.destroy).toHaveBeenCalledOnce(); + }); - it('bounds bootstrap probe output and destroys an overflowing channel', async () => { + it("bounds bootstrap probe output and destroys an overflowing channel", async () => { const channel = Object.assign(new EventEmitter(), { stderr: new EventEmitter(), - destroy: vi.fn() - }) + destroy: vi.fn(), + }); const client = Object.assign(new EventEmitter(), { connect: vi.fn(function connect(this: EventEmitter) { - this.emit('ready') + this.emit("ready"); }), end: vi.fn(), destroy: vi.fn(), @@ -459,59 +619,54 @@ describe('authenticated ssh2 connection', () => { ( _command: string, _options: unknown, - callback: ( - error: Error | undefined, - stream: typeof channel - ) => void + callback: (error: Error | undefined, stream: typeof channel) => void, ) => { - callback(undefined, channel) + callback(undefined, channel); setTimeout(() => { - channel.emit('data', Buffer.alloc(64 * 1024 + 1)) - }, 0) - } + channel.emit("data", Buffer.alloc(64 * 1024 + 1)); + }, 0); + }, ), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const connection = await Ssh2AuthenticatedConnection.connect( target, { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) + authenticationIdentity: "a".repeat(64), }, new AbortController().signal, - { createClient: () => client as never } - ) + { createClient: () => client as never }, + ); - await expect( - connection.runAgentBootstrapProbe() - ).rejects.toThrow('输出超过安全限制') - expect(client.exec.mock.calls[0]?.[0]).toBe( - AGENT_BOOTSTRAP_PROBE_COMMAND - ) - expect(channel.destroy).toHaveBeenCalledOnce() - }) + await expect(connection.runAgentBootstrapProbe()).rejects.toThrow( + "输出超过安全限制", + ); + expect(client.exec.mock.calls[0]?.[0]).toBe(AGENT_BOOTSTRAP_PROBE_COMMAND); + expect(channel.destroy).toHaveBeenCalledOnce(); + }); - it('runs the fixed bootstrap probe and collects split output', async () => { + it("runs the fixed bootstrap probe and collects split output", async () => { const channel = Object.assign(new EventEmitter(), { stderr: new EventEmitter(), - destroy: vi.fn() - }) + destroy: vi.fn(), + }); const output = [ - 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1', - 'home=/home/builder', - 'uid=1001', - 'os=Linux', - 'arch=aarch64', - 'shell=/bin/bash', - 'procfs=ready', - '' - ].join('\n') + "GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1", + "home=/home/builder", + "uid=1001", + "os=Linux", + "arch=aarch64", + "shell=/bin/bash", + "procfs=ready", + "", + ].join("\n"); const client = Object.assign(new EventEmitter(), { connect: vi.fn(function connect(this: EventEmitter) { - this.emit('ready') + this.emit("ready"); }), end: vi.fn(), destroy: vi.fn(), @@ -519,58 +674,55 @@ describe('authenticated ssh2 connection', () => { ( _command: string, _options: unknown, - callback: ( - error: Error | undefined, - stream: typeof channel - ) => void + callback: (error: Error | undefined, stream: typeof channel) => void, ) => { - callback(undefined, channel) + callback(undefined, channel); setTimeout(() => { - channel.emit('data', output.slice(0, 17)) - channel.emit('data', Buffer.from(output.slice(17))) - channel.emit('close', 0) - }, 0) - } + channel.emit("data", output.slice(0, 17)); + channel.emit("data", Buffer.from(output.slice(17))); + channel.emit("close", 0); + }, 0); + }, ), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const connection = await Ssh2AuthenticatedConnection.connect( target, { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) + authenticationIdentity: "a".repeat(64), }, new AbortController().signal, - { createClient: () => client as never } - ) + { createClient: () => client as never }, + ); await expect(connection.runAgentBootstrapProbe()).resolves.toMatchObject({ ready: true, - architecture: 'arm64' - }) + architecture: "arm64", + }); expect(client.exec).toHaveBeenCalledWith( AGENT_BOOTSTRAP_PROBE_COMMAND, { env: {}, pty: false, x11: false, - allowHalfOpen: false + allowHalfOpen: false, }, - expect.any(Function) - ) - }) + expect.any(Function), + ); + }); - it('cancels a running bootstrap probe and destroys its channel', async () => { + it("cancels a running bootstrap probe and destroys its channel", async () => { const channel = Object.assign(new EventEmitter(), { stderr: new EventEmitter(), - destroy: vi.fn() - }) + destroy: vi.fn(), + }); const client = Object.assign(new EventEmitter(), { connect: vi.fn(function connect(this: EventEmitter) { - this.emit('ready') + this.emit("ready"); }), end: vi.fn(), destroy: vi.fn(), @@ -578,33 +730,30 @@ describe('authenticated ssh2 connection', () => { ( _command: string, _options: unknown, - callback: ( - error: Error | undefined, - stream: typeof channel - ) => void - ) => callback(undefined, channel) + callback: (error: Error | undefined, stream: typeof channel) => void, + ) => callback(undefined, channel), ), - sftp: vi.fn() - }) - const target = createTarget() + sftp: vi.fn(), + }); + const target = createTarget(); const connection = await Ssh2AuthenticatedConnection.connect( target, { hostId: target.host.id, hostRevision: target.hostRevision, hostKeyGeneration: target.hostKeyGeneration, - authenticationIdentity: 'a'.repeat(64) + authenticationIdentity: "a".repeat(64), }, new AbortController().signal, - { createClient: () => client as never } - ) - const controller = new AbortController() - const result = connection.runAgentBootstrapProbe(controller.signal) - controller.abort() + { createClient: () => client as never }, + ); + const controller = new AbortController(); + const result = connection.runAgentBootstrapProbe(controller.signal); + controller.abort(); await expect(result).rejects.toMatchObject({ - name: 'AbortError' - }) - expect(channel.destroy).toHaveBeenCalledOnce() - }) -}) + name: "AbortError", + }); + expect(channel.destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/main/ssh/ssh-connection-pool.ts b/src/main/ssh/ssh-connection-pool.ts index 376a7f54..94e8181f 100644 --- a/src/main/ssh/ssh-connection-pool.ts +++ b/src/main/ssh/ssh-connection-pool.ts @@ -1,142 +1,192 @@ -import { createHmac, getCiphers, randomBytes } from 'node:crypto' -import { Client } from 'ssh2' -import type { - ClientChannel, - ConnectConfig, - SFTPWrapper -} from 'ssh2' +import { createHmac, getCiphers, randomBytes } from "node:crypto"; +import { Client } from "ssh2"; +import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2"; import { BoundedStagedSftp, type BoundedSftpLimits, - type StagedSftp -} from './bounded-sftp' -import { listBoundedSftpDirectories } from './bounded-directory-sftp' -import type { ResolvedSshHost } from './ssh-host-store' + type StagedSftp, +} from "./bounded-sftp"; +import { listBoundedSftpDirectories } from "./bounded-directory-sftp"; +import type { ResolvedSshHost } from "./ssh-host-store"; import { AGENT_BOOTSTRAP_PROBE_COMMAND, buildFixedAgentSshCommand, parseAgentBootstrapProbeOutput, type AgentLifecycleAction, type AgentRuntimeActivationAction, - type VerifiedAgentInstallationId -} from './ssh-agent-command' + type VerifiedAgentInstallationId, +} from "./ssh-agent-command"; +import { + createSshRemotePackageBootstrapExecutor, + SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND, + type SshRemotePackageBootstrapCommitResult, + type SshRemotePackageBootstrapExecutor, + type SshRemotePackageBootstrapOptions, + type SshRemotePackageBootstrapPrepareResult, + type SshRemotePackageBootstrapProbeResult, + type SshRemotePackageBootstrapCleanupResult, + type SshRemotePackageUploadStagingResult, + type SshRemotePackageCandidate, +} from "./ssh-remote-package-bootstrap"; import type { AgentBootstrapProbeResult, - SshHostDirectoryBrowseResult -} from '../../shared/ssh-host-contracts' + SshHostDirectoryBrowseResult, +} from "../../shared/ssh-host-contracts"; import { buildAuthenticatedSshConnectConfig, createStrongSshAlgorithms, defaultSystemAgent, safeSshError, - SSH_CONNECTION_TIMEOUT_MS -} from './ssh-transport' + SSH_CONNECTION_TIMEOUT_MS, +} from "./ssh-transport"; -const FIXED_COMMAND_TIMEOUT_MS = 15_000 -const CHANNEL_OPEN_TIMEOUT_MS = 10_000 -const MAX_DIAGNOSTIC_OUTPUT_BYTES = 64 * 1024 -const DEFAULT_IDLE_TIMEOUT_MS = 60_000 -const MAX_POOL_ENTRIES = 32 +const FIXED_COMMAND_TIMEOUT_MS = 15_000; +const CHANNEL_OPEN_TIMEOUT_MS = 10_000; +const MAX_DIAGNOSTIC_OUTPUT_BYTES = 64 * 1024; +const DEFAULT_IDLE_TIMEOUT_MS = 60_000; +const MAX_POOL_ENTRIES = 32; export type SshConnectionPoolTarget = { - host: ResolvedSshHost - hostRevision: number - hostKeyGeneration: number -} + host: ResolvedSshHost; + hostRevision: number; + hostKeyGeneration: number; +}; export type SshPoolConnectionIdentity = { - hostId: string - hostRevision: number - hostKeyGeneration: number - authenticationIdentity: string -} + hostId: string; + hostRevision: number; + hostKeyGeneration: number; + authenticationIdentity: string; +}; export type AgentDiagnosticResult = { - exitCode: number | null - stdout: string - stderr: string -} + exitCode: number | null; + stdout: string; + stderr: string; +}; export interface AuthenticatedSshConnection { - readonly identity: SshPoolConnectionIdentity - isUsable(): boolean + readonly identity: SshPoolConnectionIdentity; + isUsable(): boolean; openAgentAttach( installationId: VerifiedAgentInstallationId, - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; runAgentDoctor( installationId: VerifiedAgentInstallationId, - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; runAgentBootstrapProbe( - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; + probeRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + createRemotePackageUploadStaging( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + prepareRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + prepareUploadedRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + commitRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + getRemotePackageBootstrapCommitStatus( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + cleanupRemotePackageBootstrap( + operationId: string, + options?: Pick, + ): Promise; runAgentLifecycleAction( installationId: VerifiedAgentInstallationId, action: AgentLifecycleAction, - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; runAgentRuntimeAction( installationId: VerifiedAgentInstallationId, action: AgentRuntimeActivationAction, - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; openStagedSftp( stagingDirectory: string, limits?: BoundedSftpLimits, - signal?: AbortSignal - ): Promise + signal?: AbortSignal, + ): Promise; listDirectories( path?: string, - signal?: AbortSignal - ): Promise - dispose(): void + signal?: AbortSignal, + ): Promise; + dispose(): void; } -export interface SshConnectionLease - extends Omit { - release(): void +export interface SshConnectionLease extends Omit< + AuthenticatedSshConnection, + | "dispose" + | "createRemotePackageUploadStaging" + | "prepareUploadedRemotePackageBootstrap" + | "getRemotePackageBootstrapCommitStatus" +> { + release(): void; } +export interface SshRemotePackageBootstrapLease extends SshRemotePackageBootstrapExecutor { + readonly identity: SshPoolConnectionIdentity; + isUsable(): boolean; + release(): void; +} + +type InternalSshConnectionLease = SshConnectionLease & + Pick< + AuthenticatedSshConnection, + | "createRemotePackageUploadStaging" + | "prepareUploadedRemotePackageBootstrap" + | "getRemotePackageBootstrapCommitStatus" + >; + type ClientLike = Pick< Client, - | 'connect' - | 'end' - | 'destroy' - | 'exec' - | 'sftp' - | 'on' - | 'once' -> + "connect" | "end" | "destroy" | "exec" | "sftp" | "on" | "once" +>; type SshConnectionDependencies = { - createClient: () => ClientLike - supportedOpenSslCiphers: () => readonly string[] - systemAgent: () => string | undefined -} + createClient: () => ClientLike; + supportedOpenSslCiphers: () => readonly string[]; + systemAgent: () => string | undefined; + controlPlanePackageInstaller: Buffer | string; +}; export type SshConnectionFactory = ( target: SshConnectionPoolTarget, identity: SshPoolConnectionIdentity, - signal: AbortSignal -) => Promise + signal: AbortSignal, +) => Promise; type PoolEntry = { - key: string - identity: SshPoolConnectionIdentity - controller: AbortController - connectionPromise: Promise - connection?: AuthenticatedSshConnection - leases: number - waiters: number - idleTimer?: ReturnType -} + key: string; + identity: SshPoolConnectionIdentity; + controller: AbortController; + connectionPromise: Promise; + connection?: AuthenticatedSshConnection; + leases: number; + waiters: number; + idleTimer?: ReturnType; +}; function createAbortError(signal?: AbortSignal): unknown { return ( signal?.reason ?? - new DOMException('The operation was aborted', 'AbortError') - ) + new DOMException("The operation was aborted", "AbortError") + ); } function validatePoolTarget(target: SshConnectionPoolTarget): void { @@ -147,22 +197,19 @@ function validatePoolTarget(target: SshConnectionPoolTarget): void { !Number.isSafeInteger(target.hostKeyGeneration) || target.hostKeyGeneration <= 0 ) { - throw new Error('SSH 连接身份无效') + throw new Error("SSH 连接身份无效"); } if (!target.host.hostKey) { - throw new Error('请先验证并接受 SSH 主机密钥') + throw new Error("请先验证并接受 SSH 主机密钥"); } - if ( - target.host.hostKey.generation !== - target.hostKeyGeneration - ) { - throw new Error('SSH 主机密钥代际不匹配') + if (target.host.hostKey.generation !== target.hostKeyGeneration) { + throw new Error("SSH 主机密钥代际不匹配"); } } function validateConnectionIdentity( target: SshConnectionPoolTarget, - identity: SshPoolConnectionIdentity + identity: SshPoolConnectionIdentity, ): void { if ( identity.hostId !== target.host.id || @@ -170,39 +217,37 @@ function validateConnectionIdentity( identity.hostKeyGeneration !== target.hostKeyGeneration || !/^[a-f0-9]{64}$/u.test(identity.authenticationIdentity) ) { - throw new Error('SSH 连接身份与认证目标不匹配') + throw new Error("SSH 连接身份与认证目标不匹配"); } } function createSshAuthenticationIdentity( host: ResolvedSshHost, systemAgent: string | undefined, - identityKey: Buffer + identityKey: Buffer, ): string { const credentialIdentity = - host.authentication === 'password' - ? host.password - : systemAgent + host.authentication === "password" ? host.password : systemAgent; if (!credentialIdentity) { throw new Error( - host.authentication === 'password' - ? 'SSH 主机尚未配置密码' - : '当前系统未检测到可用的 SSH Agent' - ) + host.authentication === "password" + ? "SSH 主机尚未配置密码" + : "当前系统未检测到可用的 SSH Agent", + ); } - return createHmac('sha256', identityKey) + return createHmac("sha256", identityKey) .update( JSON.stringify([ - 'goodbuddy-ssh-auth-v1', + "goodbuddy-ssh-auth-v1", host.id, host.hostname, host.port, host.username, host.authentication, - credentialIdentity - ]) + credentialIdentity, + ]), ) - .digest('hex') + .digest("hex"); } function poolKey(identity: SshPoolConnectionIdentity): string { @@ -210,471 +255,517 @@ function poolKey(identity: SshPoolConnectionIdentity): string { identity.hostId, identity.hostRevision, identity.hostKeyGeneration, - identity.authenticationIdentity - ].join('\0') + identity.authenticationIdentity, + ].join("\0"); } function waitForSharedConnection( promise: Promise, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { if (!signal) { - return promise + return promise; } if (signal.aborted) { - return Promise.reject(createAbortError(signal)) + return Promise.reject(createAbortError(signal)); } return new Promise((resolve, reject) => { const abort = (): void => { - reject(createAbortError(signal)) - } - signal.addEventListener('abort', abort, { once: true }) + reject(createAbortError(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); promise.then( (connection) => { - signal.removeEventListener('abort', abort) - resolve(connection) + signal.removeEventListener("abort", abort); + resolve(connection); }, (error: unknown) => { - signal.removeEventListener('abort', abort) - reject(error) - } - ) - }) + signal.removeEventListener("abort", abort); + reject(error); + }, + ); + }); } -export class Ssh2AuthenticatedConnection - implements AuthenticatedSshConnection -{ - private usable = true - private disposed = false +export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection { + private usable = true; + private disposed = false; private constructor( readonly identity: SshPoolConnectionIdentity, - private readonly client: ClientLike + private readonly client: ClientLike, + private readonly controlPlanePackageInstaller: Buffer, ) { const disconnected = (): void => { - this.usable = false - } - this.client.on('close', disconnected) - this.client.on('end', disconnected) - this.client.on('error', disconnected) + this.usable = false; + }; + this.client.on("close", disconnected); + this.client.on("end", disconnected); + this.client.on("error", disconnected); } static connect( target: SshConnectionPoolTarget, identity: SshPoolConnectionIdentity, signal: AbortSignal, - dependencies: Partial = {} + dependencies: Partial = {}, ): Promise { - validatePoolTarget(target) - validateConnectionIdentity(target, identity) + validatePoolTarget(target); + validateConnectionIdentity(target, identity); const resolvedDependencies: SshConnectionDependencies = { createClient: () => new Client(), supportedOpenSslCiphers: getCiphers, systemAgent: defaultSystemAgent, - ...dependencies - } + controlPlanePackageInstaller: Buffer.alloc(0), + ...dependencies, + }; const algorithms = createStrongSshAlgorithms( - resolvedDependencies.supportedOpenSslCiphers() - ) + resolvedDependencies.supportedOpenSslCiphers(), + ); return new Promise((resolve, reject) => { if (signal.aborted) { - reject(createAbortError(signal)) - return + reject(createAbortError(signal)); + return; } - const client = resolvedDependencies.createClient() - let settled = false - let hostKeyMismatch = false + const client = resolvedDependencies.createClient(); + let settled = false; + let hostKeyMismatch = false; const settle = ( connection?: Ssh2AuthenticatedConnection, - error?: unknown + error?: unknown, ): void => { if (settled) { - connection?.dispose() - return + connection?.dispose(); + return; } - settled = true - clearTimeout(timeout) - signal.removeEventListener('abort', abort) + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", abort); if (error) { - client.destroy() - reject(error) + client.destroy(); + reject(error); } else if (connection) { - resolve(connection) + resolve(connection); } - } + }; const abort = (): void => { - settle(undefined, createAbortError(signal)) - } + settle(undefined, createAbortError(signal)); + }; const timeout = setTimeout(() => { - settle(undefined, new Error('SSH 连接超时')) - }, SSH_CONNECTION_TIMEOUT_MS) - signal.addEventListener('abort', abort, { once: true }) - client.once('error', (error) => { + settle(undefined, new Error("SSH 连接超时")); + }, SSH_CONNECTION_TIMEOUT_MS); + signal.addEventListener("abort", abort, { once: true }); + client.once("error", (error) => { settle( undefined, hostKeyMismatch - ? new Error( - 'SSH 主机密钥已变化,请重新验证主机身份' - ) - : safeSshError(error) - ) - }) - client.once('ready', () => { - settle(new Ssh2AuthenticatedConnection(identity, client)) - }) - let config: ConnectConfig + ? new Error("SSH 主机密钥已变化,请重新验证主机身份") + : safeSshError(error), + ); + }); + client.once("ready", () => { + settle( + new Ssh2AuthenticatedConnection( + identity, + client, + Buffer.from(resolvedDependencies.controlPlanePackageInstaller), + ), + ); + }); + let config: ConnectConfig; try { config = buildAuthenticatedSshConnectConfig( target.host, algorithms, resolvedDependencies.systemAgent(), () => { - hostKeyMismatch = true - } - ) + hostKeyMismatch = true; + }, + ); } catch (error) { - settle(undefined, error) - return + settle(undefined, error); + return; } - client.connect(config) - }) + client.connect(config); + }); } isUsable(): boolean { - return this.usable + return this.usable; } async openAgentAttach( installationId: VerifiedAgentInstallationId, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { const channel = await this.openFixedChannel( buildFixedAgentSshCommand(installationId, { - kind: 'attach' + kind: "attach", }), - signal - ) + signal, + ); if (signal?.aborted) { - channel.destroy() - throw createAbortError(signal) + channel.destroy(); + throw createAbortError(signal); } if (signal) { const abort = (): void => { - channel.destroy() - } - signal.addEventListener('abort', abort, { once: true }) - channel.once('close', () => { - signal.removeEventListener('abort', abort) - }) + channel.destroy(); + }; + signal.addEventListener("abort", abort, { once: true }); + channel.once("close", () => { + signal.removeEventListener("abort", abort); + }); } - return channel + return channel; } async runAgentBootstrapProbe( - signal?: AbortSignal + signal?: AbortSignal, ): Promise { const result = await this.runBoundedCommand( AGENT_BOOTSTRAP_PROBE_COMMAND, - signal - ) - if ( - result.exitCode !== 0 || - result.stderr.length !== 0 - ) { - throw new Error('Agent 启动探针执行失败') + signal, + ); + if (result.exitCode !== 0 || result.stderr.length !== 0) { + throw new Error("Agent 启动探针执行失败"); } - return parseAgentBootstrapProbeOutput(result.stdout) + return parseAgentBootstrapProbeOutput(result.stdout); + } + + probeRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().probe(candidate, options); + } + + createRemotePackageUploadStaging( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().createUploadStaging( + candidate, + options, + ); + } + + prepareRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().prepare(candidate, options); + } + + prepareUploadedRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().prepareUploaded(candidate, options); + } + + commitRemotePackageBootstrap( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().commit(candidate, options); + } + + getRemotePackageBootstrapCommitStatus( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise { + return this.remotePackageBootstrap().commitStatus(candidate, options); + } + + cleanupRemotePackageBootstrap( + operationId: string, + options?: Pick, + ): Promise { + return this.remotePackageBootstrap().cleanup(operationId, options); } runAgentDoctor( installationId: VerifiedAgentInstallationId, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { return this.runBoundedCommand( buildFixedAgentSshCommand(installationId, { - kind: 'doctor' + kind: "doctor", }), - signal - ) + signal, + ); } runAgentLifecycleAction( installationId: VerifiedAgentInstallationId, action: AgentLifecycleAction, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { return this.runBoundedCommand( buildFixedAgentSshCommand(installationId, { - kind: 'lifecycle', - action + kind: "lifecycle", + action, }), - signal - ) + signal, + ); } runAgentRuntimeAction( installationId: VerifiedAgentInstallationId, action: AgentRuntimeActivationAction, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { return this.runBoundedCommand( buildFixedAgentSshCommand(installationId, action), - signal - ) + signal, + ); } openStagedSftp( stagingDirectory: string, limits?: BoundedSftpLimits, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { - this.assertUsable() + this.assertUsable(); if (signal?.aborted) { - return Promise.reject(createAbortError(signal)) + return Promise.reject(createAbortError(signal)); } return new Promise((resolve, reject) => { - let settled = false - const finish = ( - error?: unknown, - sftp?: SFTPWrapper - ): void => { + let settled = false; + const finish = (error?: unknown, sftp?: SFTPWrapper): void => { if (settled) { - sftp?.end() - return + sftp?.end(); + return; } - settled = true - clearTimeout(timeout) - signal?.removeEventListener('abort', abort) + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); if (error || !sftp) { - reject(error ?? new Error('无法打开 SFTP 通道')) - return + reject(error ?? new Error("无法打开 SFTP 通道")); + return; } try { - resolve( - new BoundedStagedSftp( - sftp, - stagingDirectory, - limits - ) - ) + resolve(new BoundedStagedSftp(sftp, stagingDirectory, limits)); } catch (validationError) { - sftp.end() - reject(validationError) + sftp.end(); + reject(validationError); } - } + }; const abort = (): void => { - finish(createAbortError(signal)) - } + finish(createAbortError(signal)); + }; const timeout = setTimeout(() => { - finish(new Error('SFTP 通道打开超时')) - }, CHANNEL_OPEN_TIMEOUT_MS) - signal?.addEventListener('abort', abort, { once: true }) + finish(new Error("SFTP 通道打开超时")); + }, CHANNEL_OPEN_TIMEOUT_MS); + signal?.addEventListener("abort", abort, { once: true }); this.client.sftp((error, sftp) => { - finish(error, sftp) - }) - }) + finish(error, sftp); + }); + }); } listDirectories( path?: string, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { - this.assertUsable() + this.assertUsable(); return listBoundedSftpDirectories( (callback) => { this.client.sftp((error, sftp) => { - callback(error, sftp) - }) + callback(error, sftp); + }); }, path, - signal - ) + signal, + ); } dispose(): void { if (this.disposed) { - return + return; } - this.disposed = true - this.usable = false - this.client.end() - this.client.destroy() + this.disposed = true; + this.usable = false; + this.client.end(); + this.client.destroy(); } private assertUsable(): void { if (!this.usable) { - throw new Error('SSH 连接已关闭') + throw new Error("SSH 连接已关闭"); } } + private remotePackageBootstrapExecutor: + SshRemotePackageBootstrapExecutor | undefined; + + private remotePackageBootstrap(): SshRemotePackageBootstrapExecutor { + this.remotePackageBootstrapExecutor ??= + createSshRemotePackageBootstrapExecutor( + (signal) => + this.openFixedChannel( + SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND, + signal, + true, + ), + this.controlPlanePackageInstaller, + ); + return this.remotePackageBootstrapExecutor; + } + private openFixedChannel( command: string, - signal?: AbortSignal + signal?: AbortSignal, + allowHalfOpen = false, ): Promise { - this.assertUsable() + this.assertUsable(); if (signal?.aborted) { - return Promise.reject(createAbortError(signal)) + return Promise.reject(createAbortError(signal)); } return new Promise((resolve, reject) => { - let settled = false - const finish = ( - error?: unknown, - channel?: ClientChannel - ): void => { + let settled = false; + const finish = (error?: unknown, channel?: ClientChannel): void => { if (settled) { - channel?.destroy() - return + channel?.destroy(); + return; } - settled = true - clearTimeout(timeout) - signal?.removeEventListener('abort', abort) + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); if (error || !channel) { - reject(error ?? new Error('无法打开 Agent SSH 通道')) + reject(error ?? new Error("无法打开 Agent SSH 通道")); } else { - resolve(channel) + resolve(channel); } - } + }; const abort = (): void => { - finish(createAbortError(signal)) - } + finish(createAbortError(signal)); + }; const timeout = setTimeout(() => { - finish(new Error('Agent SSH 通道打开超时')) - }, CHANNEL_OPEN_TIMEOUT_MS) - signal?.addEventListener('abort', abort, { once: true }) + finish(new Error("Agent SSH 通道打开超时")); + }, CHANNEL_OPEN_TIMEOUT_MS); + signal?.addEventListener("abort", abort, { once: true }); this.client.exec( command, { env: {}, pty: false, x11: false, - allowHalfOpen: false + allowHalfOpen, }, - (error, channel) => finish(error, channel) - ) - }) + (error, channel) => finish(error, channel), + ); + }); } private async runBoundedCommand( command: string, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { - const channel = await this.openFixedChannel(command, signal) + const channel = await this.openFixedChannel(command, signal); if (signal?.aborted) { - channel.destroy() - throw createAbortError(signal) + channel.destroy(); + throw createAbortError(signal); } return new Promise((resolve, reject) => { - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - let outputBytes = 0 - let settled = false + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let outputBytes = 0; + let settled = false; const finish = ( result?: AgentDiagnosticResult, - error?: unknown + error?: unknown, ): void => { if (settled) { - return + return; } - settled = true - clearTimeout(timeout) - signal?.removeEventListener('abort', abort) + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); if (error) { - channel.destroy() - reject(error) + channel.destroy(); + reject(error); } else if (result) { - resolve(result) + resolve(result); } - } - const collect = ( - destination: Buffer[], - chunk: Buffer | string - ): void => { - const buffer = Buffer.from(chunk) - outputBytes += buffer.byteLength + }; + const collect = (destination: Buffer[], chunk: Buffer | string): void => { + const buffer = Buffer.from(chunk); + outputBytes += buffer.byteLength; if (outputBytes > MAX_DIAGNOSTIC_OUTPUT_BYTES) { - finish( - undefined, - new Error('Agent 诊断输出超过安全限制') - ) - return + finish(undefined, new Error("Agent 诊断输出超过安全限制")); + return; } - destination.push(buffer) - } + destination.push(buffer); + }; const abort = (): void => { - finish(undefined, createAbortError(signal)) - } + finish(undefined, createAbortError(signal)); + }; const timeout = setTimeout(() => { - finish(undefined, new Error('Agent 诊断超时')) - }, FIXED_COMMAND_TIMEOUT_MS) - signal?.addEventListener('abort', abort, { once: true }) - channel.on('data', (chunk: Buffer | string) => { - collect(stdout, chunk) - }) - channel.stderr.on('data', (chunk: Buffer | string) => { - collect(stderr, chunk) - }) - channel.once('error', (error: Error) => { - finish(undefined, error) - }) - channel.once( - 'close', - (code: number | null) => { - finish({ - exitCode: typeof code === 'number' ? code : null, - stdout: Buffer.concat(stdout).toString('utf8'), - stderr: Buffer.concat(stderr).toString('utf8') - }) - } - ) - }) + finish(undefined, new Error("Agent 诊断超时")); + }, FIXED_COMMAND_TIMEOUT_MS); + signal?.addEventListener("abort", abort, { once: true }); + channel.on("data", (chunk: Buffer | string) => { + collect(stdout, chunk); + }); + channel.stderr.on("data", (chunk: Buffer | string) => { + collect(stderr, chunk); + }); + channel.once("error", (error: Error) => { + finish(undefined, error); + }); + channel.once("close", (code: number | null) => { + finish({ + exitCode: typeof code === "number" ? code : null, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); + }); } } export class SshConnectionPool { - private readonly entries = new Map() - private readonly factory: SshConnectionFactory - private readonly systemAgent: () => string | undefined - private readonly idleTimeoutMs: number - private readonly authenticationIdentityKey = randomBytes(32) - private disposed = false + private readonly entries = new Map(); + private readonly factory: SshConnectionFactory; + private readonly systemAgent: () => string | undefined; + private readonly idleTimeoutMs: number; + private readonly authenticationIdentityKey = randomBytes(32); + private disposed = false; constructor( factory?: SshConnectionFactory, options: { - systemAgent?: () => string | undefined - idleTimeoutMs?: number - } = {} + systemAgent?: () => string | undefined; + idleTimeoutMs?: number; + controlPlanePackageInstaller?: Buffer | string; + } = {}, ) { - this.systemAgent = options.systemAgent ?? defaultSystemAgent + this.systemAgent = options.systemAgent ?? defaultSystemAgent; this.factory = factory ?? ((target, identity, signal) => - Ssh2AuthenticatedConnection.connect( - target, - identity, - signal, - { systemAgent: this.systemAgent } - )) - this.idleTimeoutMs = - options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS - if ( - !Number.isSafeInteger(this.idleTimeoutMs) || - this.idleTimeoutMs < 0 - ) { - throw new Error('SSH 连接池空闲超时无效') + Ssh2AuthenticatedConnection.connect(target, identity, signal, { + systemAgent: this.systemAgent, + controlPlanePackageInstaller: + options.controlPlanePackageInstaller ?? Buffer.alloc(0), + })); + this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + if (!Number.isSafeInteger(this.idleTimeoutMs) || this.idleTimeoutMs < 0) { + throw new Error("SSH 连接池空闲超时无效"); } } async acquire( target: SshConnectionPoolTarget, - signal?: AbortSignal + signal?: AbortSignal, ): Promise { if (this.disposed) { - throw new Error('SSH 连接池已关闭') + throw new Error("SSH 连接池已关闭"); } - signal?.throwIfAborted() - validatePoolTarget(target) + signal?.throwIfAborted(); + validatePoolTarget(target); const identity: SshPoolConnectionIdentity = { hostId: target.host.id, hostRevision: target.hostRevision, @@ -682,197 +773,257 @@ export class SshConnectionPool { authenticationIdentity: createSshAuthenticationIdentity( target.host, this.systemAgent(), - this.authenticationIdentityKey - ) - } - const key = poolKey(identity) - let entry = this.entries.get(key) + this.authenticationIdentityKey, + ), + }; + const key = poolKey(identity); + let entry = this.entries.get(key); if (entry?.connection && !entry.connection.isUsable()) { - this.disposeEntry(entry) - entry = undefined + this.disposeEntry(entry); + entry = undefined; } if (!entry) { if (this.entries.size >= MAX_POOL_ENTRIES) { - throw new Error('SSH 连接池已达到安全上限') + let oldestIdleEntry: PoolEntry | undefined; + for (const candidate of this.entries.values()) { + if (candidate.leases === 0 && candidate.waiters === 0) { + oldestIdleEntry = candidate; + break; + } + } + if (oldestIdleEntry) { + this.disposeEntry(oldestIdleEntry); + } else { + throw new Error("SSH 连接池已达到安全上限"); + } } - const controller = new AbortController() + const controller = new AbortController(); const newEntry: PoolEntry = { key, identity, controller, connectionPromise: Promise.resolve(undefined as never), leases: 0, - waiters: 0 - } + waiters: 0, + }; newEntry.connectionPromise = this.factory( target, identity, - controller.signal + controller.signal, ).then( (connection) => { - if ( - this.disposed || - this.entries.get(key) !== newEntry - ) { - connection.dispose() - throw new Error('SSH 连接请求已失效') + if (this.disposed || this.entries.get(key) !== newEntry) { + connection.dispose(); + throw new Error("SSH 连接请求已失效"); } - newEntry.connection = connection - return connection + newEntry.connection = connection; + return connection; }, (error: unknown) => { if (this.entries.get(key) === newEntry) { - this.entries.delete(key) + this.entries.delete(key); } - throw error - } - ) - this.entries.set(key, newEntry) - entry = newEntry + throw error; + }, + ); + this.entries.set(key, newEntry); + entry = newEntry; } if (entry.idleTimer) { - clearTimeout(entry.idleTimer) - entry.idleTimer = undefined + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; } - entry.waiters += 1 + entry.waiters += 1; try { const connection = await waitForSharedConnection( entry.connectionPromise, - signal - ) + signal, + ); if (!connection.isUsable()) { - this.disposeEntry(entry) - throw new Error('SSH 连接已关闭') + this.disposeEntry(entry); + throw new Error("SSH 连接已关闭"); } - entry.leases += 1 - return this.createLease(entry, connection) + entry.leases += 1; + return this.createLease(entry, connection); } finally { - entry.waiters -= 1 + entry.waiters -= 1; if (entry.waiters === 0 && entry.leases === 0) { - this.scheduleIdleDisposal(entry) + this.scheduleIdleDisposal(entry); } } } + async acquireRemotePackageBootstrap( + target: SshConnectionPoolTarget, + signal?: AbortSignal, + ): Promise { + const lease = await this.acquire(target, signal); + const bootstrapConnection = lease as InternalSshConnectionLease; + return { + identity: lease.identity, + isUsable: () => lease.isUsable(), + probe: (candidate, options) => + lease.probeRemotePackageBootstrap(candidate, options), + createUploadStaging: (candidate, options) => + bootstrapConnection.createRemotePackageUploadStaging( + candidate, + options, + ), + prepare: (candidate, options) => + lease.prepareRemotePackageBootstrap(candidate, options), + prepareUploaded: (candidate, options) => + bootstrapConnection.prepareUploadedRemotePackageBootstrap( + candidate, + options, + ), + commit: (candidate, options) => + lease.commitRemotePackageBootstrap(candidate, options), + commitStatus: (candidate, options) => + bootstrapConnection.getRemotePackageBootstrapCommitStatus( + candidate, + options, + ), + cleanup: (operationId, options) => + lease.cleanupRemotePackageBootstrap(operationId, options), + release: () => lease.release(), + }; + } + disposeHost(hostId: string): void { for (const entry of this.entries.values()) { if (entry.identity.hostId === hostId) { - this.disposeEntry(entry) + this.disposeEntry(entry); } } } dispose(): void { if (this.disposed) { - return + return; } - this.disposed = true + this.disposed = true; for (const entry of [...this.entries.values()]) { - this.disposeEntry(entry) + this.disposeEntry(entry); } } private createLease( entry: PoolEntry, - connection: AuthenticatedSshConnection + connection: AuthenticatedSshConnection, ): SshConnectionLease { - let released = false + let released = false; const assertActive = (): void => { if (released) { - throw new Error('SSH 连接租约已释放') + throw new Error("SSH 连接租约已释放"); } - } - return { + }; + const lease: InternalSshConnectionLease = { identity: connection.identity, isUsable: () => !released && connection.isUsable(), openAgentAttach: (installationId, signal) => { - assertActive() - return connection.openAgentAttach(installationId, signal) + assertActive(); + return connection.openAgentAttach(installationId, signal); }, runAgentDoctor: (installationId, signal) => { - assertActive() - return connection.runAgentDoctor(installationId, signal) + assertActive(); + return connection.runAgentDoctor(installationId, signal); }, runAgentBootstrapProbe: (signal) => { - assertActive() - return connection.runAgentBootstrapProbe(signal) + assertActive(); + return connection.runAgentBootstrapProbe(signal); }, - runAgentLifecycleAction: ( - installationId, - action, - signal - ) => { - assertActive() + probeRemotePackageBootstrap: (candidate, options) => { + assertActive(); + return connection.probeRemotePackageBootstrap(candidate, options); + }, + createRemotePackageUploadStaging: (candidate, options) => { + assertActive(); + return connection.createRemotePackageUploadStaging(candidate, options); + }, + prepareRemotePackageBootstrap: (candidate, options) => { + assertActive(); + return connection.prepareRemotePackageBootstrap(candidate, options); + }, + prepareUploadedRemotePackageBootstrap: (candidate, options) => { + assertActive(); + return connection.prepareUploadedRemotePackageBootstrap( + candidate, + options, + ); + }, + commitRemotePackageBootstrap: (candidate, options) => { + assertActive(); + return connection.commitRemotePackageBootstrap(candidate, options); + }, + getRemotePackageBootstrapCommitStatus: (candidate, options) => { + assertActive(); + return connection.getRemotePackageBootstrapCommitStatus( + candidate, + options, + ); + }, + cleanupRemotePackageBootstrap: (operationId, options) => { + assertActive(); + return connection.cleanupRemotePackageBootstrap(operationId, options); + }, + runAgentLifecycleAction: (installationId, action, signal) => { + assertActive(); return connection.runAgentLifecycleAction( installationId, action, - signal - ) + signal, + ); }, - runAgentRuntimeAction: ( - installationId, - action, - signal - ) => { - assertActive() - return connection.runAgentRuntimeAction( - installationId, - action, - signal - ) + runAgentRuntimeAction: (installationId, action, signal) => { + assertActive(); + return connection.runAgentRuntimeAction(installationId, action, signal); }, openStagedSftp: (stagingDirectory, limits, signal) => { - assertActive() - return connection.openStagedSftp( - stagingDirectory, - limits, - signal - ) + assertActive(); + return connection.openStagedSftp(stagingDirectory, limits, signal); }, listDirectories: (path, signal) => { - assertActive() - return connection.listDirectories(path, signal) + assertActive(); + return connection.listDirectories(path, signal); }, release: () => { if (released) { - return + return; } - released = true - entry.leases -= 1 + released = true; + entry.leases -= 1; if (entry.leases === 0 && entry.waiters === 0) { - this.scheduleIdleDisposal(entry) + this.scheduleIdleDisposal(entry); } - } - } + }, + }; + return lease; } private scheduleIdleDisposal(entry: PoolEntry): void { - if ( - this.entries.get(entry.key) !== entry || - entry.idleTimer - ) { - return + if (this.entries.get(entry.key) !== entry || entry.idleTimer) { + return; } if (!entry.connection || this.idleTimeoutMs === 0) { - this.disposeEntry(entry) - return + this.disposeEntry(entry); + return; } entry.idleTimer = setTimeout(() => { if (entry.leases === 0 && entry.waiters === 0) { - this.disposeEntry(entry) + this.disposeEntry(entry); } - }, this.idleTimeoutMs) + }, this.idleTimeoutMs); } private disposeEntry(entry: PoolEntry): void { if (this.entries.get(entry.key) === entry) { - this.entries.delete(entry.key) + this.entries.delete(entry.key); } if (entry.idleTimer) { - clearTimeout(entry.idleTimer) - entry.idleTimer = undefined + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; } - entry.controller.abort() - entry.connection?.dispose() + entry.controller.abort(); + entry.connection?.dispose(); } } diff --git a/src/main/ssh/ssh-connection-target.ts b/src/main/ssh/ssh-connection-target.ts new file mode 100644 index 00000000..89926e60 --- /dev/null +++ b/src/main/ssh/ssh-connection-target.ts @@ -0,0 +1,51 @@ +import type { + CurrentSshConnectionTarget, + SshConnectionTarget +} from './ssh-host-store' + +export function toCurrentSshConnectionTarget( + target: SshConnectionTarget +): CurrentSshConnectionTarget { + return { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + username: target.host.username + } +} + +export function assertValidSshConnectionTarget( + requestedHostId: string, + target: SshConnectionTarget, + message = 'SSH 主机连接目标无效' +): void { + if ( + target.host.id !== requestedHostId || + target.hostRevision < 1 || + target.hostKeyGeneration < 1 || + !target.host.hostKey || + target.host.hostKey.generation !== target.hostKeyGeneration + ) { + throw new Error(message) + } +} + +export function assertSshLeaseMatchesTarget( + lease: { + identity: { + hostId: string + hostRevision: number + hostKeyGeneration: number + } + }, + target: SshConnectionTarget, + message: string +): void { + if ( + lease.identity.hostId !== target.host.id || + lease.identity.hostRevision !== target.hostRevision || + lease.identity.hostKeyGeneration !== target.hostKeyGeneration + ) { + throw new Error(message) + } +} diff --git a/src/main/ssh/ssh-host-directory-browser.test.ts b/src/main/ssh/ssh-host-directory-browser.test.ts index 3f32e1c0..b2339568 100644 --- a/src/main/ssh/ssh-host-directory-browser.test.ts +++ b/src/main/ssh/ssh-host-directory-browser.test.ts @@ -55,6 +55,10 @@ function createLease( openAgentAttach: vi.fn(), runAgentDoctor: vi.fn(), runAgentBootstrapProbe: vi.fn(), + probeRemotePackageBootstrap: vi.fn(), + prepareRemotePackageBootstrap: vi.fn(), + commitRemotePackageBootstrap: vi.fn(), + cleanupRemotePackageBootstrap: vi.fn(), runAgentLifecycleAction: vi.fn(), runAgentRuntimeAction: vi.fn(), openStagedSftp: vi.fn(), diff --git a/src/main/ssh/ssh-host-directory-browser.ts b/src/main/ssh/ssh-host-directory-browser.ts index f2ac9e97..d343a3a1 100644 --- a/src/main/ssh/ssh-host-directory-browser.ts +++ b/src/main/ssh/ssh-host-directory-browser.ts @@ -2,14 +2,17 @@ import type { SshHostDirectoryBrowseResult } from '../../shared/ssh-host-contracts' import type { - CurrentSshConnectionTarget, - SshConnectionTarget, SshHostStore } from './ssh-host-store' import type { SshConnectionLease, SshConnectionPool } from './ssh-connection-pool' +import { + assertSshLeaseMatchesTarget, + assertValidSshConnectionTarget, + toCurrentSshConnectionTarget +} from './ssh-connection-target' export type SshHostDirectoryBrowserOptions = { sshHosts: Pick< @@ -19,47 +22,6 @@ export type SshHostDirectoryBrowserOptions = { sshPool: Pick } -function currentTarget( - target: SshConnectionTarget -): CurrentSshConnectionTarget { - return { - hostId: target.host.id, - hostRevision: target.hostRevision, - hostKeyGeneration: target.hostKeyGeneration, - username: target.host.username - } -} - -function assertTarget( - requestedHostId: string, - target: SshConnectionTarget -): void { - if ( - target.host.id !== requestedHostId || - target.hostRevision < 1 || - target.hostKeyGeneration < 1 || - !target.host.hostKey || - target.host.hostKey.generation !== - target.hostKeyGeneration - ) { - throw new Error('SSH 主机连接目标无效') - } -} - -function assertLeaseIdentity( - target: SshConnectionTarget, - lease: SshConnectionLease -): void { - if ( - lease.identity.hostId !== target.host.id || - lease.identity.hostRevision !== target.hostRevision || - lease.identity.hostKeyGeneration !== - target.hostKeyGeneration - ) { - throw new Error('SSH 目录浏览连接身份不匹配') - } -} - export class SshHostDirectoryBrowser { readonly #sshHosts: SshHostDirectoryBrowserOptions['sshHosts'] readonly #sshPool: SshHostDirectoryBrowserOptions['sshPool'] @@ -78,18 +40,26 @@ export class SshHostDirectoryBrowser { const target = await this.#sshHosts.resolveConnectionTarget(hostId) signal?.throwIfAborted() - assertTarget(hostId, target) - const expected = currentTarget(target) + assertValidSshConnectionTarget(hostId, target) + const expected = toCurrentSshConnectionTarget(target) this.#sshHosts.assertConnectionTargetCurrent(expected) let lease: SshConnectionLease | undefined try { lease = await this.#sshPool.acquire(target, signal) signal?.throwIfAborted() - assertLeaseIdentity(target, lease) + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 目录浏览连接身份不匹配' + ) const listing = await lease.listDirectories(path, signal) signal?.throwIfAborted() - assertLeaseIdentity(target, lease) + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 目录浏览连接身份不匹配' + ) this.#sshHosts.assertConnectionTargetCurrent(expected) return listing } finally { diff --git a/src/main/ssh/ssh-host-remote-environment.test.ts b/src/main/ssh/ssh-host-remote-environment.test.ts index 96507a35..0f72463b 100644 --- a/src/main/ssh/ssh-host-remote-environment.test.ts +++ b/src/main/ssh/ssh-host-remote-environment.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { StagedSftp } from './bounded-sftp' import type { SshConnectionLease, - SshConnectionPoolTarget + SshConnectionPoolTarget, + SshRemotePackageBootstrapLease } from './ssh-connection-pool' +import type { + SshRemotePackageBootstrapProbeResult +} from './ssh-remote-package-bootstrap' import { SshHostRemoteEnvironmentInspector } from './ssh-host-remote-environment' @@ -57,11 +61,37 @@ function missing(): Error & { code: number } { return Object.assign(new Error('missing'), { code: 2 }) } -function harness(registries: { - agent?: unknown - runtime?: unknown -} = {}) { +type HarnessOptions = { + registries?: { + agent?: unknown + runtime?: unknown + } + candidate?: { + source: 'github' | 'mirror' + size: number + sha256: string + urls: readonly string[] + } | null + candidateError?: Error + candidateFailure?: { + reason: 'package-unavailable' | 'probe-failed' + source: 'github' | 'mirror' | null + packageSize: number | null + } + bootstrapResult?: SshRemotePackageBootstrapProbeResult + bootstrapError?: Error + bootstrapIdentity?: { + hostId: string + hostRevision: number + hostKeyGeneration: number + authenticationIdentity: string + } +} + +function harness(options: HarnessOptions = {}) { + const registries = options.registries ?? {} const release = vi.fn() + const bootstrapRelease = vi.fn() const close = vi.fn() const readFile = vi.fn(async (path: string) => { const value = @@ -96,25 +126,66 @@ function harness(registries: { openStagedSftp: vi.fn(async () => sftp), release } as unknown as SshConnectionLease + const probe = vi.fn(async () => { + if (options.bootstrapError) { + throw options.bootstrapError + } + return options.bootstrapResult ?? { available: true as const } + }) + const prepare = vi.fn() + const commit = vi.fn() + const bootstrapLease = { + identity: options.bootstrapIdentity ?? lease.identity, + isUsable: vi.fn(() => true), + probe, + prepare, + commit, + cleanup: vi.fn(), + release: bootstrapRelease + } as unknown as SshRemotePackageBootstrapLease const assertConnectionTargetCurrent = vi.fn() + const acquireRemotePackageBootstrap = vi.fn( + async () => bootstrapLease + ) + const loadRemoteEnvironmentCatalog = vi.fn(async () => { + const candidate = + options.candidateError + ? null + : options.candidate === undefined + ? { + source: 'mirror' as const, + size: 12_345, + sha256: 'a'.repeat(64), + urls: [ + 'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent.zip' + ] + } + : options.candidate + return { + expected: { + agent: { version: '0.11.1' }, + runtimes: [{ + runtimeId: 'opencode' as const, + provider: 'opencode' as const, + version: '1.18.9' + }] + }, + candidate, + candidateFailure: options.candidateFailure + } + }) const inspector = new SshHostRemoteEnvironmentInspector({ sshHosts: { resolveConnectionTarget: vi.fn(async () => currentTarget), assertConnectionTargetCurrent }, sshPool: { - acquire: vi.fn(async () => lease) + acquire: vi.fn(async () => lease), + acquireRemotePackageBootstrap }, agentRuntimeLockPath: 'unused', remoteRuntimeLockPath: 'unused', - loadExpectedCatalog: async () => ({ - agent: { version: '0.11.1' }, - runtimes: [{ - runtimeId: 'opencode', - provider: 'opencode', - version: '1.18.9' - }] - }), + loadRemoteEnvironmentCatalog, now: () => new Date('2030-01-01T00:00:00.000Z') }) return { @@ -122,13 +193,19 @@ function harness(registries: { lease, close, release, + bootstrapRelease, + probe, + prepare, + commit, + acquireRemotePackageBootstrap, + loadRemoteEnvironmentCatalog, assertConnectionTargetCurrent } } describe('SshHostRemoteEnvironmentInspector', () => { it('reports installed Agent and Runtime versions against the desktop catalog', async () => { - const value = harness({ + const value = harness({ registries: { agent: { formatVersion: 1, minimumTrustedReleaseSequence: 1, @@ -153,7 +230,7 @@ describe('SshHostRemoteEnvironmentInspector', () => { signingKeyId: 'test-key' }] } - }) + } }) await expect(value.inspector.inspect(hostId)).resolves.toEqual({ hostId, @@ -182,20 +259,26 @@ describe('SshHostRemoteEnvironmentInspector', () => { version: '1.18.9', architecture: 'x64' } - }] + }], + remoteDownload: { + available: true, + source: 'mirror', + packageSize: 12_345 + } }) expect(value.close).toHaveBeenCalledOnce() expect(value.release).toHaveBeenCalledOnce() - expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(2) + expect(value.bootstrapRelease).toHaveBeenCalledOnce() + expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(5) }) it('reports an older Agent and a missing Runtime without installing either', async () => { - const value = harness({ + const value = harness({ registries: { agent: { formatVersion: 1, current: agentEntry('0.11.0', 'b') } - }) + } }) await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ agent: { @@ -210,15 +293,17 @@ describe('SshHostRemoteEnvironmentInspector', () => { }) expect(value.lease.runAgentBootstrapProbe).toHaveBeenCalledOnce() expect(value.lease.openStagedSftp).toHaveBeenCalledOnce() + expect(value.prepare).not.toHaveBeenCalled() + expect(value.commit).not.toHaveBeenCalled() }) it('rejects corrupt remote metadata and still closes every lease', async () => { - const value = harness({ + const value = harness({ registries: { agent: { formatVersion: 1, current: { command: 'untrusted' } } - }) + } }) await expect(value.inspector.inspect(hostId)).rejects.toThrow( '元数据已损坏' @@ -226,4 +311,170 @@ describe('SshHostRemoteEnvironmentInspector', () => { expect(value.close).toHaveBeenCalledOnce() expect(value.release).toHaveBeenCalledOnce() }) + + it.each([ + ['missing-curl', 'missing-tools'], + ['missing-sha256sum', 'missing-tools'], + ['missing-unzip', 'missing-tools'], + ['bootstrap-tools-unavailable', 'missing-tools'], + ['managed-path-unavailable', 'home-unwritable'], + ['insufficient-disk-space', 'insufficient-disk-space'], + ['download-unavailable', 'source-unreachable'] + ] as const)( + 'maps direct probe reason %s to %s', + async (probeReason, reason) => { + const value = harness({ + bootstrapResult: { + available: false, + reason: probeReason + } + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + agent: { state: 'not-installed' }, + runtimes: [{ state: 'not-installed' }], + remoteDownload: { + available: false, + source: 'mirror', + packageSize: 12_345, + reason + } + }) + expect(value.bootstrapRelease).toHaveBeenCalledOnce() + } + ) + + it('sanitizes catalog failures without hiding version status', async () => { + const value = harness({ + candidateError: new Error( + 'private catalog URL and credential details' + ) + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + agent: { state: 'not-installed' }, + runtimes: [{ state: 'not-installed' }], + remoteDownload: { + available: false, + source: null, + packageSize: null, + reason: 'package-unavailable' + } + }) + expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled() + }) + + it('probes and reports an available signed format-v1 candidate', async () => { + const value = harness({ + candidate: { + source: 'github', + size: 99, + sha256: 'b'.repeat(64), + urls: ['https://github.com/example/package.zip'] + } + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + remoteDownload: { + available: true, + source: 'github', + packageSize: 99 + } + }) + expect(value.acquireRemotePackageBootstrap).toHaveBeenCalledOnce() + expect(value.probe).toHaveBeenCalledWith( + { + operationId: expect.any(String), + urls: ['https://github.com/example/package.zip'], + size: 99, + sha256: 'b'.repeat(64) + }, + { signal: undefined } + ) + }) + + it('reports a missing compatible package without source metadata', async () => { + const value = harness({ candidate: null }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + remoteDownload: { + available: false, + source: null, + packageSize: null, + reason: 'package-unavailable' + } + }) + }) + + it('preserves a catalog candidate probe failure and its source metadata', async () => { + const value = harness({ + candidate: null, + candidateFailure: { + reason: 'probe-failed', + source: 'mirror', + packageSize: 12_345 + } + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + remoteDownload: { + available: false, + source: 'mirror', + packageSize: 12_345, + reason: 'probe-failed' + } + }) + expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled() + }) + + it('reports direct bootstrap probe failures without hiding version status', async () => { + const value = harness({ + bootstrapError: new Error('sensitive SSH diagnostic') + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + agent: { state: 'not-installed' }, + remoteDownload: { + available: false, + source: 'mirror', + packageSize: 12_345, + reason: 'probe-failed' + } + }) + expect(value.bootstrapRelease).toHaveBeenCalledOnce() + }) + + it('rejects a changed Host identity and releases both leases', async () => { + const value = harness({ + bootstrapIdentity: { + hostId, + hostRevision: 4, + hostKeyGeneration: 2, + authenticationIdentity: 'b'.repeat(64) + } + }) + + await expect(value.inspector.inspect(hostId)).rejects.toThrow( + '连接身份不匹配' + ) + expect(value.bootstrapRelease).toHaveBeenCalledOnce() + expect(value.release).toHaveBeenCalledOnce() + }) + + it('honors cancellation while loading the remote environment catalog', async () => { + const controller = new AbortController() + const value = harness() + value.loadRemoteEnvironmentCatalog.mockImplementationOnce( + async () => { + controller.abort() + throw controller.signal.reason + } + ) + + await expect( + value.inspector.inspect(hostId, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(value.acquireRemotePackageBootstrap).not.toHaveBeenCalled() + expect(value.release).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ssh/ssh-host-remote-environment.ts b/src/main/ssh/ssh-host-remote-environment.ts index 5d5b6d1b..242f4300 100644 --- a/src/main/ssh/ssh-host-remote-environment.ts +++ b/src/main/ssh/ssh-host-remote-environment.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' import { agentRuntimeLockSchema, @@ -23,10 +24,19 @@ import type { SshConnectionTarget, SshHostStore } from './ssh-host-store' +import { + assertSshLeaseMatchesTarget, + assertValidSshConnectionTarget, + toCurrentSshConnectionTarget +} from './ssh-connection-target' import type { SshConnectionLease, + SshRemotePackageBootstrapLease, SshConnectionPool } from './ssh-connection-pool' +import type { + SshRemotePackageBootstrapUnavailableReason +} from './ssh-remote-package-bootstrap' const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json' const RUNTIME_REGISTRY_PATH = '.goodbuddy/runtimes/registry.json' @@ -43,38 +53,63 @@ type ExpectedCatalog = { }> } +type RemoteInstallCandidate = { + source: 'github' | 'mirror' + size: number + sha256: string + urls: readonly string[] +} + +type RemoteEnvironmentCatalog = { + expected: ExpectedCatalog + candidate: RemoteInstallCandidate | null + candidateFailure?: { + reason: 'package-unavailable' | 'probe-failed' + source: 'github' | 'mirror' | null + packageSize: number | null + } +} + export type SshHostRemoteEnvironmentInspectorOptions = { sshHosts: Pick< SshHostStore, 'resolveConnectionTarget' | 'assertConnectionTargetCurrent' > - sshPool: Pick + sshPool: Pick< + SshConnectionPool, + 'acquire' | 'acquireRemotePackageBootstrap' + > agentRuntimeLockPath: string remoteRuntimeLockPath: string - loadExpectedCatalog?: ( - architecture: 'x64' | 'arm64' - ) => Promise + loadRemoteEnvironmentCatalog?: ( + architecture: 'x64' | 'arm64', + options: { signal?: AbortSignal } + ) => Promise now?: () => Date } export class SshHostRemoteEnvironmentInspector { readonly #sshHosts: SshHostRemoteEnvironmentInspectorOptions['sshHosts'] readonly #sshPool: SshHostRemoteEnvironmentInspectorOptions['sshPool'] - readonly #loadExpectedCatalog: ( - architecture: 'x64' | 'arm64' - ) => Promise + readonly #loadRemoteEnvironmentCatalog: NonNullable< + SshHostRemoteEnvironmentInspectorOptions[ + 'loadRemoteEnvironmentCatalog' + ] + > readonly #now: () => Date constructor(options: SshHostRemoteEnvironmentInspectorOptions) { this.#sshHosts = options.sshHosts this.#sshPool = options.sshPool - this.#loadExpectedCatalog = - options.loadExpectedCatalog ?? - (() => - loadExpectedCatalog( + this.#loadRemoteEnvironmentCatalog = + options.loadRemoteEnvironmentCatalog ?? + (async () => ({ + expected: await loadExpectedCatalog( options.agentRuntimeLockPath, options.remoteRuntimeLockPath - )) + ), + candidate: null + })) this.#now = options.now ?? (() => new Date()) } @@ -86,15 +121,19 @@ export class SshHostRemoteEnvironmentInspector { const target = await this.#sshHosts.resolveConnectionTarget(hostId) signal?.throwIfAborted() - assertTarget(hostId, target) - const expectedTarget = currentTarget(target) + assertValidSshConnectionTarget(hostId, target) + const expectedTarget = toCurrentSshConnectionTarget(target) this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) let lease: SshConnectionLease | undefined let sftp: StagedSftp | undefined try { lease = await this.#sshPool.acquire(target, signal) - assertLeaseIdentity(target, lease) + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) const probe = await lease.runAgentBootstrapProbe(signal) signal?.throwIfAborted() if (!probe.ready) { @@ -114,7 +153,10 @@ export class SshHostRemoteEnvironmentInspector { ) const [catalog, agentRegistry, runtimeRegistry] = await Promise.all([ - this.#expected(probe.architecture), + this.#loadRemoteEnvironmentCatalog( + probe.architecture, + { signal } + ), readOptionalRegistry( sftp, AGENT_REGISTRY_PATH, @@ -129,18 +171,36 @@ export class SshHostRemoteEnvironmentInspector { ) ]) signal?.throwIfAborted() - assertLeaseIdentity(target, lease) + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) + this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) + const remoteDownload = await this.#remoteDownload( + target, + expectedTarget, + catalog.candidate, + catalog.candidateFailure, + signal + ) + signal?.throwIfAborted() + assertSshLeaseMatchesTarget( + lease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) return sshHostRemoteEnvironmentSchema.parse({ hostId, checkedAt: this.#now().toISOString(), architecture: probe.architecture, agent: agentStatus( - catalog.agent, + catalog.expected.agent, agentRegistry?.current, probe.architecture ), - runtimes: catalog.runtimes.map((runtime) => + runtimes: catalog.expected.runtimes.map((runtime) => runtimeStatus( runtime, runtimeRegistry?.current.find( @@ -150,7 +210,8 @@ export class SshHostRemoteEnvironmentInspector { ), probe.architecture ) - ) + ), + remoteDownload }) } finally { sftp?.close() @@ -158,49 +219,134 @@ export class SshHostRemoteEnvironmentInspector { } } - #expected( - architecture: 'x64' | 'arm64' - ): Promise { - return this.#loadExpectedCatalog(architecture) + async #remoteDownload( + target: SshConnectionTarget, + expectedTarget: CurrentSshConnectionTarget, + candidate: RemoteInstallCandidate | null, + candidateFailure: + | RemoteEnvironmentCatalog['candidateFailure'] + | undefined, + signal?: AbortSignal + ): Promise { + if (!candidate) { + return unavailableRemoteDownload( + candidateFailure?.reason ?? 'package-unavailable', + candidateFailure + ? { + source: candidateFailure.source, + packageSize: candidateFailure.packageSize + } + : null + ) + } + const exposure = { + source: candidate.source, + packageSize: candidate.size + } + let bootstrapLease: + | SshRemotePackageBootstrapLease + | undefined + try { + this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) + bootstrapLease = + await this.#sshPool.acquireRemotePackageBootstrap( + target, + signal + ) + assertSshLeaseMatchesTarget( + bootstrapLease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) + const result = await bootstrapLease.probe( + { + operationId: randomUUID(), + urls: candidate.urls, + size: candidate.size, + sha256: candidate.sha256 + }, + { signal } + ) + signal?.throwIfAborted() + assertSshLeaseMatchesTarget( + bootstrapLease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) + this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) + return result.available + ? { + available: true, + ...exposure + } + : unavailableRemoteDownload( + mapRemoteUnavailableReason(result.reason), + exposure + ) + } catch { + signal?.throwIfAborted() + if (bootstrapLease) { + assertSshLeaseMatchesTarget( + bootstrapLease, + target, + 'SSH 远端运行环境连接身份不匹配' + ) + this.#sshHosts.assertConnectionTargetCurrent( + expectedTarget + ) + } + return unavailableRemoteDownload( + 'probe-failed', + exposure + ) + } finally { + bootstrapLease?.release() + } } } -function currentTarget( - target: SshConnectionTarget -): CurrentSshConnectionTarget { +type RemoteDownloadUnavailable = Extract< + SshHostRemoteEnvironment['remoteDownload'], + { available: false } +> + +function unavailableRemoteDownload( + reason: RemoteDownloadUnavailable['reason'], + exposure: { + source: 'github' | 'mirror' | null + packageSize: number | null + } | null +): SshHostRemoteEnvironment['remoteDownload'] { return { - hostId: target.host.id, - hostRevision: target.hostRevision, - hostKeyGeneration: target.hostKeyGeneration, - username: target.host.username + available: false, + source: exposure?.source ?? null, + packageSize: exposure?.packageSize ?? null, + reason } } -function assertTarget( - requestedHostId: string, - target: SshConnectionTarget -): void { - if ( - target.host.id !== requestedHostId || - target.hostRevision < 1 || - target.hostKeyGeneration < 1 || - !target.host.hostKey || - target.host.hostKey.generation !== target.hostKeyGeneration - ) { - throw new Error('SSH 主机连接目标无效') - } -} - -function assertLeaseIdentity( - target: SshConnectionTarget, - lease: SshConnectionLease -): void { - if ( - lease.identity.hostId !== target.host.id || - lease.identity.hostRevision !== target.hostRevision || - lease.identity.hostKeyGeneration !== target.hostKeyGeneration - ) { - throw new Error('SSH 远端运行环境连接身份不匹配') +function mapRemoteUnavailableReason( + reason: SshRemotePackageBootstrapUnavailableReason +): Extract< + SshHostRemoteEnvironment['remoteDownload'], + { available: false } +>['reason'] { + switch (reason) { + case 'missing-curl': + case 'missing-sha256sum': + case 'missing-unzip': + case 'bootstrap-tools-unavailable': + return 'missing-tools' + case 'managed-path-unavailable': + return 'home-unwritable' + case 'insufficient-disk-space': + return 'insufficient-disk-space' + case 'download-unavailable': + return 'source-unreachable' + default: + throw new Error( + `未知的远端下载安装探测结果:${String(reason)}` + ) } } diff --git a/src/main/ssh/ssh-host-service.test.ts b/src/main/ssh/ssh-host-service.test.ts index 28488816..d7b01425 100644 --- a/src/main/ssh/ssh-host-service.test.ts +++ b/src/main/ssh/ssh-host-service.test.ts @@ -650,6 +650,16 @@ describe('SshHostService', () => { expect(onHostEdited.mock.calls[0]).toEqual([host.id]) expect(onHostRemoved).not.toHaveBeenCalled() + await expect( + harness.service.remove(host.id, async () => { + throw new Error('project cleanup failed') + }) + ).rejects.toThrow('project cleanup failed') + await expect(harness.service.getSnapshot()).resolves.toMatchObject({ + hosts: [expect.objectContaining({ id: host.id })] + }) + expect(onHostRemoved).not.toHaveBeenCalled() + await harness.service.remove(host.id) expect(onHostRemoved).toHaveBeenCalledWith(host.id) diff --git a/src/main/ssh/ssh-host-service.ts b/src/main/ssh/ssh-host-service.ts index 9e6c4b2f..4c9524b6 100644 --- a/src/main/ssh/ssh-host-service.ts +++ b/src/main/ssh/ssh-host-service.ts @@ -73,14 +73,25 @@ export class SshHostService { return this.store.getSnapshot() } - async remove(hostId: string): Promise { - await this.store.remove(hostId) + remove(hostId: string): Promise + remove( + hostId: string, + commitLocalDeletion: () => T + ): Promise + async remove( + hostId: string, + commitLocalDeletion?: () => T + ): Promise { + const result = commitLocalDeletion + ? await this.store.remove(hostId, commitLocalDeletion) + : await this.store.remove(hostId) for (const [candidateId, pending] of this.pendingHostKeys) { if (pending.hostId === hostId) { this.pendingHostKeys.delete(candidateId) } } this.lifecycleHooks.onHostRemoved?.(hostId) + return result } async inspectDraftHostKey( diff --git a/src/main/ssh/ssh-host-store.test.ts b/src/main/ssh/ssh-host-store.test.ts index dc8aa151..f32658a0 100644 --- a/src/main/ssh/ssh-host-store.test.ts +++ b/src/main/ssh/ssh-host-store.test.ts @@ -133,6 +133,32 @@ describe('SshHostStore', () => { }) }) + it('restores a Host when its related local deletion fails', async () => { + const { filePath, store } = await createHarness() + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + + await expect( + store.remove(created.id, async () => { + throw new Error('project cleanup failed') + }) + ).rejects.toThrow('project cleanup failed') + + await expect(store.resolveHost(created.id)).resolves.toMatchObject({ + id: created.id, + password: 'private password' + }) + expect( + ( + JSON.parse(await readFile(filePath, 'utf8')) as { + hosts: Array<{ id: string }> + } + ).hosts + ).toEqual([expect.objectContaining({ id: created.id })]) + }) + it('resolves one atomic connection target snapshot with credentials and generations', async () => { const { store } = await createHarness() const created = await store.commitValidated({ diff --git a/src/main/ssh/ssh-host-store.ts b/src/main/ssh/ssh-host-store.ts index 001daa81..5f856574 100644 --- a/src/main/ssh/ssh-host-store.ts +++ b/src/main/ssh/ssh-host-store.ts @@ -366,19 +366,43 @@ export class SshHostStore { }) } - remove(hostId: string): Promise { + remove(hostId: string): Promise + remove(hostId: string, commitLocalDeletion: () => T): Promise + remove( + hostId: string, + commitLocalDeletion?: () => T + ): Promise { return this.queue(async () => { - const settings = cloneSettings(await this.load()) - const index = settings.hosts.findIndex( + const previousSettings = cloneSettings(await this.load()) + const nextSettings = cloneSettings(previousSettings) + const index = nextSettings.hosts.findIndex( (host) => host.id === hostId ) if (index === -1) { throw new Error('SSH 主机不存在') } - settings.hosts.splice(index, 1) - await this.persist(settings) - this.settings = settings - this.credentialSourceCache.delete(hostId) + nextSettings.hosts.splice(index, 1) + await this.persist(nextSettings) + try { + const result = await commitLocalDeletion?.() + this.settings = nextSettings + this.credentialSourceCache.delete(hostId) + return result + } catch (error) { + try { + await this.persist(previousSettings) + } catch (rollbackError) { + this.settings = undefined + this.settingsLoad = undefined + this.credentialSourceCache.clear() + throw new AggregateError( + [error, rollbackError], + 'SSH 主机删除失败,且主机设置恢复失败', + { cause: rollbackError } + ) + } + throw error + } }) } diff --git a/src/main/ssh/ssh-remote-package-bootstrap.test.ts b/src/main/ssh/ssh-remote-package-bootstrap.test.ts new file mode 100644 index 00000000..c5c1c98c --- /dev/null +++ b/src/main/ssh/ssh-remote-package-bootstrap.test.ts @@ -0,0 +1,1194 @@ +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import type { ClientChannel } from "ssh2"; +import { describe, expect, it, vi } from "vitest"; +import { + createSshRemotePackageBootstrapExecutor, + SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND, + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT, + SshRemotePackageCommitIndeterminateError, + type SshRemotePackageCandidate, + type SshRemotePackageIdentity, +} from "./ssh-remote-package-bootstrap"; + +const operationId = "00000000-0000-4000-8000-000000000301"; +const controlPlaneInstaller = Buffer.from( + '"use strict";console.log("control-plane installer");\n', + "utf8", +); +const controlPlaneInstallerSha256 = createHash("sha256") + .update(controlPlaneInstaller) + .digest("hex"); +const candidate: SshRemotePackageCandidate = { + operationId, + urls: [ + "https://github.com/mesalogo/goodbuddy/releases/download/agent-v1.0.0/goodbuddy-agent-1.0.0-linux-x64.gbagent", + "https://release-assets.githubusercontent.com/github-production-release-asset/123/package.gbagent", + ], + size: 12_345, + sha256: "a".repeat(64), +}; +const identity: SshRemotePackageIdentity = { + archiveSha256: candidate.sha256, + agent: { + installationId: `agent-${"b".repeat(64)}`, + agentVersion: "1.2.3-alpha.1+build.5", + manifestSha256: "b".repeat(64), + binaryDigest: `sha256:${"b".repeat(64)}`, + platform: "linux", + architecture: "x64", + protocol: { major: 1, minor: 2 }, + supervisor: "detached-on-demand", + }, + runtime: { + runtimeId: "opencode", + runtimeVersion: "2.3.4", + bundleDigest: `sha256:${"c".repeat(64)}`, + manifestDigest: `sha256:${"d".repeat(64)}`, + runtimeAdapterDigest: `sha256:${"e".repeat(64)}`, + acpCapabilitiesDigest: `sha256:${"f".repeat(64)}`, + platform: "linux", + architecture: "x64", + protocol: { major: 3, minor: 4 }, + }, +}; + +function identityResult(command: "prepare" | "commit") { + return { + type: "result", + command, + status: command === "prepare" ? "prepared" : "committed", + ...identity, + }; +} + +type FakeChannel = EventEmitter & { + stderr: EventEmitter; + destroy: ReturnType; + end: ReturnType; +}; + +function createChannel( + onEnd?: (payload: Buffer, channel: FakeChannel) => void, +): FakeChannel { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn(), + end: vi.fn((program: Buffer) => { + onEnd?.(protocolPayload(Buffer.from(program)), channel); + }), + }); + return channel; +} + +function protocolPayload(program: Buffer): Buffer { + const opening = Buffer.from( + "exec 3<<'GOODBUDDY_REMOTE_PACKAGE_INPUT_V1'\n", + "ascii", + ); + const closing = Buffer.from("GOODBUDDY_REMOTE_PACKAGE_INPUT_V1\n", "ascii"); + expect(program.subarray(0, opening.length)).toEqual(opening); + const end = program.indexOf(closing, opening.length); + expect(end).toBeGreaterThan(opening.length); + expect(program.subarray(end + closing.length).toString("utf8")).toBe( + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT, + ); + return program.subarray(opening.length, end); +} + +function executorFor( + channel: FakeChannel, + installer: Buffer | string = controlPlaneInstaller, +) { + const openChannel = vi.fn(async () => channel as unknown as ClientChannel); + return { + executor: createSshRemotePackageBootstrapExecutor(openChannel, installer), + openChannel, + }; +} + +function respond( + channel: FakeChannel, + messages: readonly Record[], + code = 0, +): void { + queueMicrotask(() => { + const output = `${messages + .map((message) => JSON.stringify(message)) + .join("\n")}\n`; + channel.emit("data", output.slice(0, 11)); + channel.emit("data", Buffer.from(output.slice(11))); + channel.emit("close", code); + }); +} + +describe("fixed SSH remote package bootstrap", () => { + it("keeps candidate data in bounded structured stdin and uses one fixed command", async () => { + let request = ""; + const channel = createChannel((payload, active) => { + request = payload.toString("utf8"); + respond(active, [ + { type: "progress", phase: "checking" }, + { type: "result", status: "available" }, + ]); + }); + const { executor, openChannel } = executorFor(channel); + const progress = vi.fn(); + + await expect( + executor.probe(candidate, { onProgress: progress }), + ).resolves.toEqual({ available: true }); + expect(openChannel).toHaveBeenCalledOnce(); + expect(request.split("\n")).toEqual([ + "GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1", + "probe", + operationId, + "12345", + "a".repeat(64), + "0", + "0".repeat(64), + "", + "2", + ...candidate.urls, + "", + ]); + expect(progress).toHaveBeenCalledWith({ phase: "checking" }); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND).toBe("exec sh -s"); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "IFS= read -r action", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "curl --silent --show-error", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + '--max-redirs 0 --proto "=https"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'prepare --operation-root "$operation_root" --archive "$archive"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'commit --operation-root "$operation_root"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'cat -- "$installer_output" || stop failed installer-failed', + ); + const commitResultOffset = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + 'cat -- "$installer_output" || stop failed installer-failed', + ); + const commitCleanupOffset = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + 'operation_created=1\n rm -rf -- "$operation_root"', + commitResultOffset, + ); + expect(commitResultOffset).toBeGreaterThan(-1); + expect(commitCleanupOffset).toBe(-1); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'if [ "$action" = commit-status ]; then', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toContain( + candidate.urls[0], + ); + }); + + it("transfers the bounded control-plane installer only for prepare actions", async () => { + const requests: string[] = []; + const openChannel = vi.fn(async () => { + const channel = createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + const action = requests.at(-1)?.split("\n")[1]; + respond( + active, + action === "prepare" || action === "prepare-uploaded" + ? [identityResult("prepare")] + : action === "commit" + ? [identityResult("commit")] + : action === "commit-status" + ? [identityResult("commit")] + : action === "cleanup" + ? [{ type: "result", status: "cleaned" }] + : action === "create-upload-staging" + ? [ + { + type: "result", + status: "upload-staging-created", + operationId, + archivePath: `/home/builder/.goodbuddy/agent/staging/op-${operationId}/package.gbagent`, + bootstrapNodePath: `/home/builder/.goodbuddy/agent/staging/op-${operationId}/bootstrap/agent/node`, + }, + ] + : [{ type: "result", status: "available" }], + ); + }); + return channel as unknown as ClientChannel; + }); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await executor.probe(candidate); + await executor.createUploadStaging(candidate); + await executor.prepare(candidate); + await executor.prepareUploaded(candidate); + await executor.commit(candidate); + await executor.commitStatus(candidate); + await executor.cleanup(operationId); + + const fields = requests.map((request) => request.split("\n")); + expect(fields[0]?.slice(5, 8)).toEqual(["0", "0".repeat(64), ""]); + expect(fields[1]?.slice(5, 8)).toEqual(["0", "0".repeat(64), ""]); + expect(fields[2]?.slice(5, 8)).toEqual([ + String(controlPlaneInstaller.byteLength), + controlPlaneInstallerSha256, + controlPlaneInstaller.toString("base64"), + ]); + expect(fields[3]?.slice(5, 8)).toEqual([ + String(controlPlaneInstaller.byteLength), + controlPlaneInstallerSha256, + controlPlaneInstaller.toString("base64"), + ]); + for (const index of [4, 5, 6]) { + expect(fields[index]?.slice(5, 8)).toEqual(["0", "0".repeat(64), ""]); + } + expect(requests.map((request) => request.split("\n")[1])).toEqual([ + "probe", + "create-upload-staging", + "prepare", + "prepare-uploaded", + "commit", + "commit-status", + "cleanup", + ]); + expect(fields[3]?.slice(8)).toEqual(["0", ""]); + }); + + it("copies installer bytes and rejects empty, malformed, or oversized installers before SSH", async () => { + const channel = createChannel((payload, active) => { + const fields = payload.toString("utf8").split("\n"); + expect(fields[5]).toBe(String(controlPlaneInstaller.byteLength)); + expect(fields[6]).toBe(controlPlaneInstallerSha256); + expect(fields[7]).toBe(controlPlaneInstaller.toString("base64")); + respond(active, [identityResult("prepare")]); + }); + const openChannel = vi.fn(async () => channel as unknown as ClientChannel); + const mutable = Buffer.from(controlPlaneInstaller); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + mutable, + ); + mutable.fill(0); + + expect(() => + createSshRemotePackageBootstrapExecutor(openChannel, ""), + ).toThrow("安装器大小无效"); + expect(() => + createSshRemotePackageBootstrapExecutor( + openChannel, + Buffer.alloc(256 * 1024 + 1), + ), + ).toThrow("安装器大小无效"); + expect(() => + createSshRemotePackageBootstrapExecutor(openChannel, Buffer.from([0xff])), + ).toThrow("安装器编码无效"); + expect(() => + createSshRemotePackageBootstrapExecutor( + openChannel, + 42 as unknown as Buffer, + ), + ).toThrow("安装器无效"); + expect(openChannel).not.toHaveBeenCalled(); + await expect(executor.prepare(candidate)).resolves.toMatchObject({ + prepared: true, + }); + }); + + it("requires only packaged Node and verifies the transferred installer before retaining it", () => { + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'node_count=$(unzip -Z1 "$archive"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toContain( + "installer_count=$(unzip -Z1", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toContain( + "agent/lib/package-installer.cjs", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'bytes.toString(\\"base64\\")!==input', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'createHash(\\"sha256\\").update(bytes).digest(\\"hex\\")', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'fs.writeFileSync(process.argv[1],bytes,{flag:\\"wx\\",mode:0o600})', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'chmod 0600 "$bootstrap/package-installer.mjs"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + '"$operation_root/bootstrap/package-installer.mjs"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "preserve_operation=1", + ); + }); + + it("emits executable POSIX parameter expansion without optional awk or printenv dependencies", () => { + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain("raw_home=${HOME:-}"); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain("actual_size=${1:-}"); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toContain( + String.raw`\${HOME:-}`, + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toMatch( + /\b(?:awk|printenv)\b/u, + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'if [ "$index" -lt "$url_count" ] || [ "$mode" = probe ]; then', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "--range 0-0 --max-filesize 1024", + ); + }); + + it("rejects injection, schemes, ports, hosts, redirects, sizes, and hashes before SSH", async () => { + const channel = createChannel(); + const { executor, openChannel } = executorFor(channel); + const invalid: SshRemotePackageCandidate[] = [ + { ...candidate, operationId: `${operationId};id` }, + { + ...candidate, + urls: ["https://github.com/package\n--output=/etc/passwd"], + }, + { + ...candidate, + urls: ["http://github.com/package.gbagent"], + }, + { + ...candidate, + urls: ["https://github.com:444/package.gbagent"], + }, + { + ...candidate, + urls: ["https://evil.example/package.gbagent"], + }, + { + ...candidate, + urls: [ + ...candidate.urls, + "https://objects.githubusercontent.com/a", + "https://objects.githubusercontent.com/b", + "https://objects.githubusercontent.com/c", + ], + }, + { ...candidate, size: 512 * 1024 * 1024 + 1 }, + { ...candidate, sha256: `${"a".repeat(63)};` }, + ]; + + for (const value of invalid) { + await expect(executor.prepare(value)).rejects.toThrow(); + } + expect(openChannel).not.toHaveBeenCalled(); + }); + + it("returns sanitized missing-tool unavailability without throwing", async () => { + const channel = createChannel((_payload, active) => { + respond(active, [ + { + type: "result", + status: "unavailable", + reason: "missing-curl", + }, + ]); + }); + const { executor } = executorFor(channel); + + await expect(executor.probe(candidate)).resolves.toEqual({ + available: false, + reason: "missing-curl", + }); + }); + + it("creates only fixed upload staging paths without download tools", async () => { + let request = ""; + const operationRoot = `/home/builder/.goodbuddy/agent/staging/op-${operationId}`; + const channel = createChannel((payload, active) => { + request = payload.toString("utf8"); + respond(active, [ + { + type: "result", + status: "upload-staging-created", + operationId, + archivePath: `${operationRoot}/package.gbagent`, + bootstrapNodePath: `${operationRoot}/bootstrap/agent/node`, + }, + ]); + }); + const { executor } = executorFor(channel); + + await expect(executor.createUploadStaging(candidate)).resolves.toEqual({ + created: true, + operationId, + archivePath: `${operationRoot}/package.gbagent`, + bootstrapNodePath: `${operationRoot}/bootstrap/agent/node`, + }); + expect(request.split("\n").slice(0, 10)).toEqual([ + "GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1", + "create-upload-staging", + operationId, + String(candidate.size), + candidate.sha256, + "0", + "0".repeat(64), + "", + "0", + "", + ]); + const stagingOffset = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + 'if [ "$action" = create-upload-staging ]; then', + ); + expect(stagingOffset).toBeGreaterThan(-1); + expect( + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + "command -v curl", + stagingOffset, + ), + ).toBeGreaterThan(stagingOffset); + expect( + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.slice( + stagingOffset, + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + "run_prepare()", + stagingOffset, + ), + ), + ).not.toMatch(/\b(?:curl|sha256sum|unzip)\b/u); + }); + + it("rejects non-fixed upload paths returned by the Host", async () => { + const channel = createChannel((_payload, active) => { + respond(active, [ + { + type: "result", + status: "upload-staging-created", + operationId, + archivePath: "/tmp/package.gbagent", + bootstrapNodePath: "/tmp/node", + }, + ]); + }); + + await expect( + executorFor(channel).executor.createUploadStaging(candidate), + ).rejects.toThrow("无效结果"); + }); + + it("validates uploaded archive size and packaged Node before prepare", () => { + const uploadedOffset = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + 'if [ "$action" = prepare-uploaded ]; then', + ); + const downloadOffset = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.indexOf( + "command -v curl", + uploadedOffset, + ); + const uploadedBlock = SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.slice( + uploadedOffset, + downloadOffset, + ); + + expect(uploadedBlock).toContain( + '[ "$actual_size" = "$expected_size" ] || stop failed size-mismatch', + ); + expect(uploadedBlock).toContain('[ ! -f "$bootstrap/agent/node" ]'); + expect(uploadedBlock).toContain('[ -L "$bootstrap/agent/node" ]'); + expect(uploadedBlock).toContain('[ ! -x "$bootstrap/agent/node" ]'); + expect(uploadedBlock).toContain("run_prepare"); + expect(uploadedBlock).not.toMatch(/\b(?:curl|sha256sum|unzip)\b/u); + }); + + it("rejects empty installer terminal files for prepare and status", () => { + expect( + SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT.match( + /\[ "\$installer_output_size" -gt 0 \]/gu, + ), + ).toHaveLength(4); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + '[ "$installer_output_size" -le 49152 ]', + ); + }); + + it("parses bounded split NDJSON progress and a prepared result", async () => { + const channel = createChannel((_payload, active) => { + respond(active, [ + { type: "progress", phase: "downloading" }, + { + type: "progress", + command: "prepare", + phase: "verifying-payload", + }, + identityResult("prepare"), + ]); + }); + const { executor } = executorFor(channel); + const phases: string[] = []; + + await expect( + executor.prepare(candidate, { + onProgress: ({ phase }) => phases.push(phase), + }), + ).resolves.toEqual({ + prepared: true, + operationId, + identity, + }); + expect(phases).toEqual(["downloading", "verifying-payload"]); + }); + + it("returns a strictly validated committed package identity", async () => { + const channel = createChannel((_payload, active) => { + respond(active, [ + { + type: "progress", + command: "commit", + phase: "publishing-content", + }, + identityResult("commit"), + ]); + }); + const { executor } = executorFor(channel); + + await expect(executor.commit(candidate)).resolves.toEqual({ + committed: true, + operationId, + identity, + }); + }); + + it("rejects malformed and mismatched package identity records", async () => { + const valid = identityResult("prepare"); + const invalidRecords: Record[] = [ + { ...valid, extra: true }, + { ...valid, command: "commit" }, + { ...valid, status: "committed" }, + { ...valid, archiveSha256: "0".repeat(64) }, + { + ...valid, + agent: { ...identity.agent, installationId: "agent-other" }, + }, + { + ...valid, + agent: { + ...identity.agent, + binaryDigest: `sha256:${"0".repeat(64)}`, + }, + }, + { + ...valid, + agent: { ...identity.agent, agentVersion: "01.2.3" }, + }, + { + ...valid, + agent: { ...identity.agent, platform: "darwin" }, + }, + { + ...valid, + agent: { + ...identity.agent, + protocol: { major: 1.5, minor: 2 }, + }, + }, + { + ...valid, + agent: { + ...identity.agent, + protocol: { major: 1, minor: 2, patch: 3 }, + }, + }, + { + ...valid, + agent: { ...identity.agent, supervisor: "systemd" }, + }, + { + ...valid, + runtime: { ...identity.runtime, runtimeId: "other" }, + }, + { + ...valid, + runtime: { + ...identity.runtime, + bundleDigest: "c".repeat(64), + }, + }, + { + ...valid, + runtime: { + ...identity.runtime, + architecture: "arm64", + }, + }, + { + ...valid, + runtime: { + ...identity.runtime, + unexpected: true, + }, + }, + ]; + + for (const record of invalidRecords) { + const channel = createChannel((_payload, active) => { + respond(active, [record]); + }); + await expect( + executorFor(channel).executor.prepare(candidate), + ).rejects.toThrow("无效结果"); + expect(channel.destroy).toHaveBeenCalledOnce(); + } + }); + + it("reports redirect mismatch as a bounded failure reason", async () => { + const channel = createChannel((_payload, active) => { + respond(active, [ + { + type: "result", + status: "failed", + reason: "redirect-mismatch", + }, + ]); + }); + const { executor } = executorFor(channel); + + await expect(executor.prepare(candidate)).resolves.toEqual({ + prepared: false, + unavailable: false, + reason: "redirect-mismatch", + }); + }); + + it("preserves bounded installer failure classifications", async () => { + const prepareFailure = createChannel((_payload, active) => { + respond(active, [{ + type: "error", + command: "prepare", + status: "failed", + message: "archive verification failed", + }]); + }); + await expect( + executorFor(prepareFailure).executor.prepare(candidate), + ).resolves.toEqual({ + prepared: false, + unavailable: false, + reason: "installer-failed", + }); + + const rollbackFailure = createChannel((_payload, active) => { + respond(active, [{ + type: "error", + command: "commit", + status: "rollback-incomplete", + message: "managed destination restore failed", + }]); + }); + await expect( + executorFor(rollbackFailure).executor.commit(candidate), + ).resolves.toEqual({ + committed: false, + reason: "installer-rollback-incomplete", + }); + }); + + it("accepts an URL-free candidate only for GoodBuddy upload actions", async () => { + const localCandidate = { ...candidate, urls: [] }; + const staging = createChannel((_payload, active) => { + respond(active, [{ + type: "result", + status: "upload-staging-created", + operationId, + archivePath: + `/home/goodbuddy/.goodbuddy/agent/staging/op-${operationId}/package.gbagent`, + bootstrapNodePath: + `/home/goodbuddy/.goodbuddy/agent/staging/op-${operationId}/bootstrap/agent/node`, + }]); + }); + + await expect( + executorFor(staging).executor.createUploadStaging(localCandidate), + ).resolves.toMatchObject({ created: true }); + await expect( + executorFor(createChannel()).executor.prepare(localCandidate), + ).rejects.toThrow("下载链无效"); + }); + + it("destroys channels on malformed or excessive output", async () => { + const malformed = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("data", '{"secret":"leak"}\n')); + }); + await expect( + executorFor(malformed).executor.probe(candidate), + ).rejects.toThrow("无效结果"); + expect(malformed.destroy).toHaveBeenCalledOnce(); + + const excessive = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("data", Buffer.alloc(64 * 1024 + 1))); + }); + await expect( + executorFor(excessive).executor.probe(candidate), + ).rejects.toThrow("超过安全限制"); + expect(excessive.destroy).toHaveBeenCalledOnce(); + + const oversizedRecord = createChannel((_payload, active) => { + queueMicrotask(() => + active.emit("data", Buffer.alloc(8 * 1024 + 1, 0x61)), + ); + }); + await expect( + executorFor(oversizedRecord).executor.prepare(candidate), + ).rejects.toThrow("超过安全限制"); + expect(oversizedRecord.destroy).toHaveBeenCalledOnce(); + + const channelError = createChannel((_payload, active) => { + queueMicrotask(() => + active.emit("error", new Error("private remote diagnostic")), + ); + }); + const channelErrorResult = + executorFor(channelError).executor.probe(candidate); + await expect(channelErrorResult).rejects.toThrow( + "Agent 远程安装能力检查 SSH 通道异常中断", + ); + await expect(channelErrorResult).rejects.not.toThrow( + "private remote diagnostic", + ); + expect(channelError.destroy).toHaveBeenCalledOnce(); + }); + + it("distinguishes shell exit and incomplete protocol failures", async () => { + const shellExit = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("close", 64)); + }); + await expect( + executorFor(shellExit).executor.probe(candidate), + ).rejects.toThrow("能力检查shell 退出状态 64"); + + const missingResult = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("close", 0)); + }); + await expect( + executorFor(missingResult).executor.probe(candidate), + ).rejects.toThrow("能力检查通道在返回终态前关闭"); + + const incompleteResult = createChannel((_payload, active) => { + queueMicrotask(() => { + active.emit("data", '{"type":"result"'); + active.emit("close", 0); + }); + }); + await expect( + executorFor(incompleteResult).executor.probe(candidate), + ).rejects.toThrow("能力检查返回了不完整结果"); + }); + + it("recovers a lost prepare terminal from retained operation status", async () => { + const requests: string[] = []; + const channels = [ + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + queueMicrotask(() => active.emit("close", 0)); + }), + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + respond(active, [ + { + type: "progress", + command: "prepare", + phase: "persisting-prepared-state", + }, + identityResult("prepare"), + ]); + }), + ]; + const openChannel = vi.fn(async () => { + const channel = channels.shift(); + if (!channel) { + throw new Error("unexpected channel"); + } + return channel as unknown as ClientChannel; + }); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + const progress = vi.fn(); + + await expect( + executor.prepare(candidate, { onProgress: progress }), + ).resolves.toMatchObject({ + prepared: true, + operationId, + }); + expect(openChannel).toHaveBeenCalledTimes(2); + expect(requests.map((request) => request.split("\n")[1])).toEqual([ + "prepare", + "prepare-status", + ]); + expect(requests[1]?.split("\n")).toEqual([ + "GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1", + "prepare-status", + operationId, + String(candidate.size), + candidate.sha256, + "0", + "0".repeat(64), + "", + "0", + "", + ]); + expect(progress).toHaveBeenCalledWith({ + phase: "persisting-prepared-state", + }); + }); + + it("preserves the original prepare error when status recovery is unavailable", async () => { + const first = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("close", 0)); + }); + const status = createChannel((_payload, active) => { + respond(active, [ + { + type: "result", + status: "failed", + reason: "operation-unavailable", + }, + ]); + }); + const channels = [first, status]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.prepare(candidate)).rejects.toThrow( + "准备通道在返回终态前关闭;尚未进入提交阶段,远端版本未切换,请重试", + ); + expect(openChannel).toHaveBeenCalledTimes(2); + }); + + it("recovers prepare status after a channel closes without an exit code", async () => { + const channels = [ + createChannel((_payload, active) => { + queueMicrotask(() => active.emit("close", null)); + }), + createChannel((_payload, active) => { + respond(active, [identityResult("prepare")]); + }), + ]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.prepare(candidate)).resolves.toMatchObject({ + prepared: true, + operationId, + }); + expect(openChannel).toHaveBeenCalledTimes(2); + }); + + it("revalidates a received prepare terminal when the exit code is missing", async () => { + const first = createChannel((_payload, active) => { + queueMicrotask(() => { + active.emit( + "data", + `${JSON.stringify(identityResult("prepare"))}\n`, + ); + active.emit("close", null); + }); + }); + const status = createChannel((_payload, active) => { + respond(active, [identityResult("prepare")]); + }); + const channels = [first, status]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.prepare(candidate)).resolves.toMatchObject({ + prepared: true, + operationId, + }); + expect(openChannel).toHaveBeenCalledTimes(2); + }); + + it("propagates cancellation while recovering prepare status", async () => { + const first = createChannel((_payload, active) => { + queueMicrotask(() => active.emit("close", 0)); + }); + const status = createChannel(); + const channels = [first, status]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + const controller = new AbortController(); + const preparing = executor.prepare(candidate, { + signal: controller.signal, + }); + await vi.waitFor(() => { + expect(openChannel).toHaveBeenCalledTimes(2); + }); + + controller.abort(); + + await expect(preparing).rejects.toMatchObject({ + name: "AbortError", + }); + expect(status.destroy).toHaveBeenCalledOnce(); + }); + + it("recovers prepared status after the command timeout boundary", async () => { + vi.useFakeTimers(); + try { + const prepare = createChannel(); + const status = createChannel((_payload, active) => { + respond(active, [identityResult("prepare")]); + }); + const channels = [prepare, status]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + const preparing = executor.prepare(candidate); + await vi.advanceTimersByTimeAsync(12 * 60_000); + + await expect(preparing).resolves.toMatchObject({ + prepared: true, + operationId, + }); + expect(openChannel).toHaveBeenCalledTimes(2); + expect(prepare.destroy).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("recovers a lost commit result from commit status without replaying commit", async () => { + const requests: string[] = []; + const channels = [ + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + queueMicrotask(() => active.emit("close", 0)); + }), + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + respond(active, [identityResult("commit")]); + }), + ]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.commit(candidate)).resolves.toEqual({ + committed: true, + operationId, + identity, + }); + expect(openChannel).toHaveBeenCalledTimes(2); + expect(requests.map((request) => request.split("\n")[1])).toEqual([ + "commit", + "commit-status", + ]); + }); + + it("keeps a lost commit indeterminate when status is unknown", async () => { + const requests: string[] = []; + const channels = [ + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + queueMicrotask(() => active.emit("close", 0)); + }), + createChannel((payload, active) => { + requests.push(payload.toString("utf8")); + respond(active, [ + { + type: "result", + status: "failed", + reason: "operation-unavailable", + }, + ]); + }), + ]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.commit(candidate)).rejects.toBeInstanceOf( + SshRemotePackageCommitIndeterminateError, + ); + expect(requests.map((request) => request.split("\n")[1])).toEqual([ + "commit", + "commit-status", + ]); + }); + + it("times out a stalled operation and destroys its channel", async () => { + vi.useFakeTimers(); + try { + const channel = createChannel(); + const promise = executorFor(channel).executor.probe(candidate); + const expectation = expect(promise).rejects.toThrow("操作超时"); + await vi.advanceTimersByTimeAsync(45_000); + + await expectation; + expect(channel.destroy).toHaveBeenCalledOnce(); + + const commitChannel = createChannel(); + const statusChannel = createChannel(); + const channels = [commitChannel, statusChannel]; + const openChannel = vi.fn( + async () => channels.shift() as unknown as ClientChannel, + ); + const committing = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ).commit(candidate); + const commitExpectation = expect(committing).rejects.toBeInstanceOf( + SshRemotePackageCommitIndeterminateError, + ); + await vi.advanceTimersByTimeAsync(12 * 60_000); + await vi.advanceTimersByTimeAsync(15_000); + + await commitExpectation; + expect(commitChannel.destroy).toHaveBeenCalledOnce(); + expect(statusChannel.destroy).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("aborts preparation but marks an interrupted commit indeterminate", async () => { + const prepareChannel = createChannel(); + const prepareController = new AbortController(); + const preparing = executorFor(prepareChannel).executor.prepare(candidate, { + signal: prepareController.signal, + }); + prepareController.abort(); + await expect(preparing).rejects.toMatchObject({ + name: "AbortError", + }); + expect(prepareChannel.destroy).toHaveBeenCalledOnce(); + + const commitChannel = createChannel(); + const commitController = new AbortController(); + const committing = executorFor(commitChannel).executor.commit(candidate, { + signal: commitController.signal, + }); + await Promise.resolve(); + commitController.abort(); + await expect(committing).rejects.toBeInstanceOf( + SshRemotePackageCommitIndeterminateError, + ); + expect(commitChannel.destroy).toHaveBeenCalledOnce(); + const commitRequest = protocolPayload( + Buffer.from(commitChannel.end.mock.calls[0]?.[0] as Buffer), + ).toString("utf8"); + expect(commitRequest.split("\n")).toEqual([ + "GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1", + "commit", + operationId, + String(candidate.size), + candidate.sha256, + "0", + "0".repeat(64), + "", + "0", + "", + ]); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "trap cleanup_operation EXIT", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "trap 'exit 129' HUP", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "trap 'exit 130' INT", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "trap 'exit 143' TERM", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + '--archive "$operation_root/package.gbagent"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + '"$staging_root/op-"*', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'if [ "$action" = prepare-status ]; then', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + 'preserve_operation=1\n cat -- "$installer_output"', + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain( + "grep -Eq '^{\"type\":\"(result|error)\",'", + ); + }); + + it("requires explicit cleanup after a successful commit", async () => { + const requests: string[] = []; + const openChannel = vi.fn(async () => { + const channel = createChannel((payload, active) => { + const request = payload.toString("utf8"); + requests.push(request); + respond( + active, + request.split("\n")[1] === "commit" + ? [identityResult("commit")] + : [{ type: "result", status: "cleaned" }], + ); + }); + return channel as unknown as ClientChannel; + }); + const executor = createSshRemotePackageBootstrapExecutor( + openChannel, + controlPlaneInstaller, + ); + + await expect(executor.commit(candidate)).resolves.toMatchObject({ + committed: true, + }); + expect(requests).toHaveLength(1); + await expect(executor.cleanup(operationId)).resolves.toEqual({ + cleaned: true, + operationId, + }); + expect(requests).toHaveLength(2); + expect(requests[1]).toContain(`cleanup\n${operationId}\n0\n`); + await expect(executor.cleanup("../../unrelated")).rejects.toThrow( + "operation ID 无效", + ); + expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).not.toMatch( + /rm -rf -- "\$managed_root"|rm -rf -- "\$home_value"/u, + ); + }); +}); diff --git a/src/main/ssh/ssh-remote-package-bootstrap.ts b/src/main/ssh/ssh-remote-package-bootstrap.ts new file mode 100644 index 00000000..90b9c318 --- /dev/null +++ b/src/main/ssh/ssh-remote-package-bootstrap.ts @@ -0,0 +1,1614 @@ +import type { ClientChannel } from "ssh2"; +import { createHash } from "node:crypto"; + +const MAX_PACKAGE_BYTES = 512 * 1024 * 1024; +const MAX_CONTROL_PLANE_INSTALLER_BYTES = 256 * 1024; +const MAX_REDIRECTS = 3; +const MAX_URL_BYTES = 4_096; +const MAX_INPUT_BYTES = 384 * 1024; +const MAX_OUTPUT_BYTES = 64 * 1024; +const MAX_LINE_BYTES = 8 * 1024; +const MAX_MESSAGES = 128; +const PROBE_TIMEOUT_MS = 45_000; +const PREPARE_TIMEOUT_MS = 12 * 60_000; +const COMMIT_TIMEOUT_MS = 12 * 60_000; +const STATUS_TIMEOUT_MS = 15_000; +const CLEANUP_TIMEOUT_MS = 15_000; +const MAX_REMOTE_PATH_BYTES = 4_096; +const OPERATION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const VERSION_PATTERN = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u; +const MAX_VERSION_LENGTH = 128; +const MAX_PROTOCOL_COMPONENT = 65_535; +const ALLOWED_DOWNLOAD_HOSTS = new Set([ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", + "goodbuddy.oss-cn-beijing.aliyuncs.com", +]); + +const progressPhases = [ + "checking", + "downloading", + "verifying", + "extracting", + "preparing", + "prepared", + "committing", + "committed", + "cleaning", + "validating", + "hashing-archive", + "verifying-zip", + "verifying-payload", + "persisting-prepared-state", + "validating-prepared-state", + "revalidating-archive", + "extracting-payload", + "publishing-content", +] as const; + +const unavailableReasons = [ + "missing-curl", + "missing-sha256sum", + "missing-unzip", + "bootstrap-tools-unavailable", + "managed-path-unavailable", + "insufficient-disk-space", + "download-unavailable", +] as const; + +const failureReasons = [ + "operation-conflict", + "download-failed", + "redirect-mismatch", + "size-mismatch", + "sha256-mismatch", + "archive-invalid", + "installer-failed", + "installer-rollback-incomplete", + "operation-unavailable", + "cleanup-failed", +] as const; + +type BootstrapAction = + | "probe" + | "create-upload-staging" + | "prepare" + | "prepare-uploaded" + | "prepare-status" + | "commit" + | "commit-status" + | "cleanup"; + +export type SshRemotePackageCandidate = { + operationId: string; + /** + * The exact Main-approved request sequence. Each non-final response must + * redirect to the next URL; curl is never allowed to follow redirects. + */ + urls: readonly string[]; + size: number; + sha256: string; +}; + +export type SshRemotePackageBootstrapProgress = { + phase: (typeof progressPhases)[number]; +}; + +export type SshRemotePackageBootstrapOptions = { + signal?: AbortSignal; + onProgress?: (progress: SshRemotePackageBootstrapProgress) => void; +}; + +export type SshRemotePackageBootstrapUnavailableReason = + (typeof unavailableReasons)[number]; + +export type SshRemotePackageBootstrapFailureReason = + (typeof failureReasons)[number]; + +export type SshRemotePackageBootstrapProbeResult = + | { available: true } + | { + available: false; + reason: SshRemotePackageBootstrapUnavailableReason; + }; + +export type SshRemotePackageProtocol = { + major: number; + minor: number; +}; + +export type SshRemotePackageAgentIdentity = { + installationId: string; + agentVersion: string; + manifestSha256: string; + binaryDigest: string; + platform: "linux"; + architecture: "x64" | "arm64"; + protocol: SshRemotePackageProtocol; + supervisor: "detached-on-demand"; +}; + +export type SshRemotePackageRuntimeIdentity = { + runtimeId: "opencode"; + runtimeVersion: string; + bundleDigest: string; + manifestDigest: string; + runtimeAdapterDigest: string; + acpCapabilitiesDigest: string; + platform: "linux"; + architecture: "x64" | "arm64"; + protocol: SshRemotePackageProtocol; +}; + +export type SshRemotePackageIdentity = { + archiveSha256: string; + agent: SshRemotePackageAgentIdentity; + runtime: SshRemotePackageRuntimeIdentity; +}; + +export type SshRemotePackageBootstrapPrepareResult = + | { + prepared: true; + operationId: string; + identity: SshRemotePackageIdentity; + } + | { + prepared: false; + unavailable: true; + reason: SshRemotePackageBootstrapUnavailableReason; + } + | { + prepared: false; + unavailable: false; + reason: SshRemotePackageBootstrapFailureReason; + }; + +export type SshRemotePackageUploadStagingResult = + | { + created: true; + operationId: string; + archivePath: string; + bootstrapNodePath: string; + } + | { + created: false; + unavailable: true; + reason: SshRemotePackageBootstrapUnavailableReason; + } + | { + created: false; + unavailable: false; + reason: SshRemotePackageBootstrapFailureReason; + }; + +export type SshRemotePackageBootstrapCommitResult = + | { + committed: true; + operationId: string; + identity: SshRemotePackageIdentity; + } + | { + committed: false; + reason: SshRemotePackageBootstrapFailureReason; + }; + +export type SshRemotePackageBootstrapCleanupResult = + | { cleaned: true; operationId: string } + | { + cleaned: false; + reason: "operation-unavailable" | "cleanup-failed"; + }; + +export interface SshRemotePackageBootstrapExecutor { + probe( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + createUploadStaging( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + prepare( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + prepareUploaded( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + /** + * Main must recheck the pinned Host identity immediately before this call. + * If this call is canceled or times out after opening its SSH channel, the + * commit outcome is deliberately reported as indeterminate and operation + * staging is retained for a subsequent identity/status inspection. + */ + commit( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + commitStatus( + candidate: SshRemotePackageCandidate, + options?: SshRemotePackageBootstrapOptions, + ): Promise; + cleanup( + operationId: string, + options?: Pick, + ): Promise; +} + +export class SshRemotePackageCommitIndeterminateError extends Error { + readonly commitMayHaveSucceeded = true; + + constructor(message = "Agent 安装提交结果不确定,请重新检查 Host 状态") { + super(message); + this.name = "SshRemotePackageCommitIndeterminateError"; + } +} + +class SshRemotePackagePrepareResultLostError extends Error { + constructor(readonly originalError: unknown) { + super("Agent 远程安装准备结果丢失"); + this.name = "SshRemotePackagePrepareResultLostError"; + } +} + +type TerminalMessage = + | { type: "result"; status: "available" } + | { + type: "result"; + status: "unavailable"; + reason: SshRemotePackageBootstrapUnavailableReason; + } + | { + type: "result"; + status: "failed"; + reason: SshRemotePackageBootstrapFailureReason; + } + | { + type: "result"; + command: "prepare" | "commit"; + status: "prepared" | "committed"; + archiveSha256: string; + agent: SshRemotePackageAgentIdentity; + runtime: SshRemotePackageRuntimeIdentity; + } + | { + type: "result"; + status: "upload-staging-created"; + operationId: string; + archivePath: string; + bootstrapNodePath: string; + } + | { type: "result"; status: "cleaned" }; + +type OpenBootstrapChannel = (signal?: AbortSignal) => Promise; + +const SHELL_HOME_OR_EMPTY = "${HOME:-}"; +const SHELL_FIRST_POSITIONAL_OR_EMPTY = "${1:-}"; +const SHELL_FOURTH_POSITIONAL_OR_EMPTY = "${4:-}"; +const BOOTSTRAP_INPUT_DELIMITER = "GOODBUDDY_REMOTE_PACKAGE_INPUT_V1"; + +/* + * The fixed command has no dynamic shell syntax and stays far below SSH + * command-length limits. Main sends the fixed script plus a bounded here-doc + * containing candidate data; the script reads that data only from fd 3. + */ +export const SSH_REMOTE_PACKAGE_BOOTSTRAP_COMMAND = "exec sh -s"; + +export const SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT = String.raw` +set -u +umask 077 +operation_created=0 +preserve_operation=0 +operation_root= +home_value= +staging_root= +emit_progress() { printf "{\"type\":\"progress\",\"phase\":\"%s\"}\n" "$1"; } +emit_result() { + if [ "$2" = "" ]; then + printf "{\"type\":\"result\",\"status\":\"%s\"}\n" "$1" + else + printf "{\"type\":\"result\",\"status\":\"%s\",\"reason\":\"%s\"}\n" "$1" "$2" + fi +} +cleanup_operation() { + if [ "$operation_created" = 1 ] && [ "$preserve_operation" = 0 ] && + [ -n "$operation_root" ]; then + case "$operation_root" in + "$staging_root/op-"*) rm -rf -- "$operation_root" ;; + esac + fi +} +stop() { + emit_result "$1" "$2" + cleanup_operation + exit 0 +} +trap cleanup_operation EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +IFS= read -r magic <&3 || exit 64 +IFS= read -r action <&3 || exit 64 +IFS= read -r operation_id <&3 || exit 64 +IFS= read -r expected_size <&3 || exit 64 +IFS= read -r expected_sha256 <&3 || exit 64 +IFS= read -r installer_size <&3 || exit 64 +IFS= read -r installer_sha256 <&3 || exit 64 +IFS= read -r installer_base64 <&3 || exit 64 +IFS= read -r url_count <&3 || exit 64 +[ "$magic" = GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1 ] || exit 64 +case "$action" in probe|create-upload-staging|prepare|prepare-uploaded|prepare-status|commit|commit-status|cleanup) ;; *) exit 64 ;; esac +case "$operation_id" in + ????????-????-????-????-????????????) ;; + *) exit 64 ;; +esac +case "$operation_id" in *[!0-9a-f-]*) exit 64 ;; esac +case "$expected_size" in ""|*[!0-9]*) exit 64 ;; esac +case "$expected_sha256" in + ????????????????????????????????????????????????????????????????) ;; + *) exit 64 ;; +esac +case "$expected_sha256" in *[!0-9a-f]*) exit 64 ;; esac +case "$installer_size" in ""|*[!0-9]*) exit 64 ;; esac +case "$installer_sha256" in + ????????????????????????????????????????????????????????????????) ;; + *) exit 64 ;; +esac +case "$installer_sha256" in *[!0-9a-f]*) exit 64 ;; esac +case "$installer_base64" in *[!A-Za-z0-9+/=]*) exit 64 ;; esac +case "$url_count" in 0|1|2|3|4) ;; *) exit 64 ;; esac +url1= url2= url3= url4= +i=1 +while [ "$i" -le "$url_count" ]; do + IFS= read -r url <&3 || exit 64 + case "$url" in + https://github.com/*|https://objects.githubusercontent.com/*|https://release-assets.githubusercontent.com/*|https://goodbuddy.oss-cn-beijing.aliyuncs.com/*) ;; + *) exit 64 ;; + esac + case "$i" in + 1) url1=$url ;; + 2) url2=$url ;; + 3) url3=$url ;; + 4) url4=$url ;; + esac + i=$((i + 1)) +done +if IFS= read -r unexpected <&3; then exit 64; fi +exec 3<&- +if [ "$action" = probe ] || [ "$action" = prepare ]; then + [ "$url_count" -ge 1 ] || exit 64 + [ "$expected_size" -ge 1 ] || exit 64 +else + [ "$url_count" = 0 ] || exit 64 +fi +if [ "$action" = prepare ] || [ "$action" = prepare-uploaded ]; then + [ "$installer_size" -ge 1 ] && [ "$installer_size" -le 262144 ] || + exit 64 + [ -n "$installer_base64" ] || exit 64 +else + [ "$installer_size" = 0 ] || exit 64 + [ "$installer_sha256" = "0000000000000000000000000000000000000000000000000000000000000000" ] || + exit 64 + [ -z "$installer_base64" ] || exit 64 +fi +raw_home=${SHELL_HOME_OR_EMPTY} +home_value=$(cd "$raw_home" 2>/dev/null && pwd -P) +case "$home_value" in /*) ;; *) stop unavailable managed-path-unavailable ;; esac +if [ "$home_value" = / ]; then + goodbuddy_root=/.goodbuddy +else + goodbuddy_root="$home_value/.goodbuddy" +fi +managed_root="$goodbuddy_root/agent" +staging_root="$managed_root/staging" +operation_root="$staging_root/op-$operation_id" +if [ -L "$goodbuddy_root" ] || [ -L "$managed_root" ] || + [ -L "$staging_root" ]; then + if [ "$action" = probe ] || [ "$action" = prepare ] || + [ "$action" = create-upload-staging ] || + [ "$action" = prepare-uploaded ]; then + stop unavailable managed-path-unavailable + else + stop failed operation-unavailable + fi +fi +if [ "$action" = cleanup ]; then + emit_progress cleaning + if [ ! -e "$operation_root" ]; then stop failed operation-unavailable; fi + operation_created=1 + if rm -rf -- "$operation_root" && [ ! -e "$operation_root" ]; then + operation_created=0 + emit_result cleaned "" + else + stop failed cleanup-failed + fi + exit 0 +fi +emit_progress checking +for tool in cat chmod cut df grep mkdir mv rm sed tail wc; do + command -v "$tool" >/dev/null 2>&1 || + stop unavailable bootstrap-tools-unavailable +done +home_bytes=$(printf %s "$home_value" | wc -c 2>/dev/null) || + stop unavailable managed-path-unavailable +set -- $home_bytes +home_bytes=${SHELL_FIRST_POSITIONAL_OR_EMPTY} +case "$home_bytes" in ""|*[!0-9]*) stop unavailable managed-path-unavailable ;; esac +[ "$home_bytes" -le 3800 ] || stop unavailable managed-path-unavailable +if printf %s "$home_value" | + LC_ALL=C grep '[[:cntrl:]]' >/dev/null 2>&1; then + stop unavailable managed-path-unavailable +fi +if [ "$action" = prepare-status ]; then + installer_output="$operation_root/installer-prepare.ndjson" + if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] || + [ ! -f "$installer_output" ] || [ -L "$installer_output" ]; then + stop failed operation-unavailable + fi + installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) || + stop failed operation-unavailable + set -- $installer_output_size + installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + case "$installer_output_size" in + ""|*[!0-9]*) stop failed operation-unavailable ;; + esac + [ "$installer_output_size" -gt 0 ] || + stop failed operation-unavailable + [ "$installer_output_size" -le 49152 ] || + stop failed operation-unavailable + cat -- "$installer_output" || stop failed operation-unavailable + exit 0 +fi +if [ "$action" = commit-status ]; then + installer_output="$operation_root/installer-commit.ndjson" + if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] || + [ ! -f "$installer_output" ] || [ -L "$installer_output" ]; then + stop failed operation-unavailable + fi + installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) || + stop failed operation-unavailable + set -- $installer_output_size + installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + case "$installer_output_size" in + ""|*[!0-9]*) stop failed operation-unavailable ;; + esac + [ "$installer_output_size" -gt 0 ] || + stop failed operation-unavailable + [ "$installer_output_size" -le 49152 ] || + stop failed operation-unavailable + cat -- "$installer_output" || stop failed operation-unavailable + exit 0 +fi +if [ "$action" = commit ]; then + if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] || + [ ! -x "$operation_root/bootstrap/agent/node" ] || + [ ! -f "$operation_root/bootstrap/package-installer.mjs" ] || + [ -L "$operation_root/bootstrap/agent/node" ] || + [ -L "$operation_root/bootstrap/package-installer.mjs" ]; then + stop failed operation-unavailable + fi + emit_progress committing + installer_output="$operation_root/installer-commit.ndjson" + rm -f -- "$installer_output" + "$operation_root/bootstrap/agent/node" \ + "$operation_root/bootstrap/package-installer.mjs" \ + commit --operation-root "$operation_root" \ + --archive "$operation_root/package.gbagent" \ + --expected-sha256 "$expected_sha256" \ + >"$installer_output" 2>/dev/null || true + installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) || + stop failed installer-failed + set -- $installer_output_size + installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + case "$installer_output_size" in + ""|*[!0-9]*) stop failed installer-failed ;; + esac + [ "$installer_output_size" -gt 0 ] || stop failed installer-failed + [ "$installer_output_size" -le 49152 ] || stop failed installer-failed + tail -n 1 "$installer_output" | + grep -Eq '^{"type":"(result|error)",' || + stop failed installer-failed + cat -- "$installer_output" || stop failed installer-failed + exit 0 +fi +if [ "$action" = create-upload-staging ]; then + if ! mkdir -p -- "$staging_root"; then + stop unavailable managed-path-unavailable + fi + if [ -L "$goodbuddy_root" ] || [ -L "$managed_root" ] || + [ -L "$staging_root" ]; then + stop unavailable managed-path-unavailable + fi + if ! mkdir -- "$operation_root"; then stop failed operation-conflict; fi + operation_created=1 + if ! mkdir -p -- "$operation_root/bootstrap/agent"; then + stop unavailable managed-path-unavailable + fi + available_line=$(df -Pk "$managed_root" 2>/dev/null | tail -n 1) + set -- $available_line + available_kb=${SHELL_FOURTH_POSITIONAL_OR_EMPTY} + case "$available_kb" in + ""|*[!0-9]*) stop unavailable managed-path-unavailable ;; + esac + required_bytes=$((expected_size * 3 + 67108864)) + available_bytes=$((available_kb * 1024)) + [ "$available_bytes" -ge "$required_bytes" ] || + stop unavailable insufficient-disk-space + escaped_operation_root=$(printf %s "$operation_root" | + sed 's/\\/\\\\/g;s/"/\\"/g') || + stop unavailable managed-path-unavailable + preserve_operation=1 + printf '{"type":"result","status":"upload-staging-created","operationId":"%s","archivePath":"%s/package.gbagent","bootstrapNodePath":"%s/bootstrap/agent/node"}\n' \ + "$operation_id" "$escaped_operation_root" "$escaped_operation_root" + exit 0 +fi +run_prepare() { + printf %s "$installer_base64" | + "$bootstrap/agent/node" -e \ + "const fs=require(\"fs\"),crypto=require(\"crypto\");const input=fs.readFileSync(0,\"ascii\"),size=Number(process.argv[2]);if(!Number.isSafeInteger(size)||size<1||size>262144||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input))process.exit(1);const bytes=Buffer.from(input,\"base64\");if(bytes.length!==size||bytes.toString(\"base64\")!==input||crypto.createHash(\"sha256\").update(bytes).digest(\"hex\")!==process.argv[3])process.exit(1);fs.writeFileSync(process.argv[1],bytes,{flag:\"wx\",mode:0o600});" \ + "$bootstrap/package-installer.mjs" "$installer_size" \ + "$installer_sha256" || + stop failed installer-failed + chmod 0600 "$bootstrap/package-installer.mjs" || + stop failed archive-invalid + emit_progress preparing + installer_output="$operation_root/installer-prepare.ndjson" + rm -f -- "$installer_output" + "$bootstrap/agent/node" "$bootstrap/package-installer.mjs" \ + prepare --operation-root "$operation_root" --archive "$archive" \ + --expected-sha256 "$expected_sha256" \ + >"$installer_output" 2>/dev/null || true + installer_output_size=$(wc -c < "$installer_output" 2>/dev/null) || + stop failed installer-failed + set -- $installer_output_size + installer_output_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + case "$installer_output_size" in + ""|*[!0-9]*) stop failed installer-failed ;; + esac + [ "$installer_output_size" -gt 0 ] || stop failed installer-failed + [ "$installer_output_size" -le 49152 ] || stop failed installer-failed + tail -n 1 "$installer_output" | + grep -Eq '^{"type":"(result|error)",' || + stop failed installer-failed + preserve_operation=1 + cat -- "$installer_output" || stop failed installer-failed + exit 0 +} +if [ "$action" = prepare-uploaded ]; then + archive="$operation_root/package.gbagent" + bootstrap="$operation_root/bootstrap" + if [ ! -d "$operation_root" ] || [ -L "$operation_root" ] || + [ ! -d "$bootstrap" ] || [ -L "$bootstrap" ] || + [ ! -d "$bootstrap/agent" ] || [ -L "$bootstrap/agent" ] || + [ ! -f "$archive" ] || [ -L "$archive" ]; then + stop failed operation-unavailable + fi + actual_size=$(wc -c < "$archive" 2>/dev/null) || + stop failed size-mismatch + set -- $actual_size + actual_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + [ "$actual_size" = "$expected_size" ] || stop failed size-mismatch + if [ ! -f "$bootstrap/agent/node" ] || + [ -L "$bootstrap/agent/node" ] || + [ ! -x "$bootstrap/agent/node" ]; then + stop failed archive-invalid + fi + node_size=$(wc -c < "$bootstrap/agent/node" 2>/dev/null) || + stop failed archive-invalid + set -- $node_size + node_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} + case "$node_size" in ""|*[!0-9]*) stop failed archive-invalid ;; esac + [ "$node_size" -gt 0 ] || stop failed archive-invalid + run_prepare +fi +command -v curl >/dev/null 2>&1 || stop unavailable missing-curl +command -v sha256sum >/dev/null 2>&1 || stop unavailable missing-sha256sum +command -v unzip >/dev/null 2>&1 || stop unavailable missing-unzip +printf test | sha256sum >/dev/null 2>&1 || stop unavailable missing-sha256sum +unzip -v >/dev/null 2>&1 || stop unavailable missing-unzip +if ! mkdir -p -- "$staging_root"; then stop unavailable managed-path-unavailable; fi +if [ -L "$goodbuddy_root" ] || [ -L "$managed_root" ] || + [ -L "$staging_root" ]; then + stop unavailable managed-path-unavailable +fi +if ! mkdir -- "$operation_root"; then stop failed operation-conflict; fi +operation_created=1 +available_line=$(df -Pk "$managed_root" 2>/dev/null | tail -n 1) +set -- $available_line +available_kb=${SHELL_FOURTH_POSITIONAL_OR_EMPTY} +case "$available_kb" in ""|*[!0-9]*) stop unavailable managed-path-unavailable ;; esac +required_bytes=$((expected_size * 3 + 67108864)) +available_bytes=$((available_kb * 1024)) +[ "$available_bytes" -ge "$required_bytes" ] || + stop unavailable insufficient-disk-space +header_file="$operation_root/headers" +response_file="$operation_root/response" +get_url() { + case "$1" in 1) printf %s "$url1" ;; 2) printf %s "$url2" ;; + 3) printf %s "$url3" ;; 4) printf %s "$url4" ;; esac +} +request_chain() { + mode=$1 + index=1 + while [ "$index" -le "$url_count" ]; do + current_url=$(get_url "$index") + rm -f -- "$header_file" "$response_file" + if [ "$index" -lt "$url_count" ]; then + request_timeout=20 + elif [ "$mode" = probe ]; then + request_timeout=30 + else + request_timeout=480 + fi + if [ "$index" -lt "$url_count" ] || [ "$mode" = probe ]; then + curl_status=$(curl --silent --show-error \ + --max-redirs 0 --proto "=https" --proto-redir "=https" \ + --connect-timeout 10 --max-time "$request_timeout" \ + --range 0-0 --max-filesize 1024 \ + --output "$response_file" --dump-header "$header_file" \ + --write-out "%{http_code}" --url "$current_url" 2>/dev/null) || + return 10 + else + curl_status=$(curl --silent --show-error \ + --max-redirs 0 --proto "=https" --proto-redir "=https" \ + --connect-timeout 15 --max-time "$request_timeout" \ + --max-filesize "$expected_size" \ + --output "$response_file" --dump-header "$header_file" \ + --write-out "%{http_code}" --url "$current_url" 2>/dev/null) || + return 10 + fi + if [ "$index" -lt "$url_count" ]; then + case "$curl_status" in 301|302|303|307|308) ;; *) return 11 ;; esac + location_count=$(grep -ic "^location:" "$header_file" 2>/dev/null || true) + [ "$location_count" = 1 ] || return 11 + location=$(grep -i "^location:" "$header_file" | tail -n 1 | + cut -d: -f2- | sed "s/^ *//;s/\r$//") + next_index=$((index + 1)) + [ "$location" = "$(get_url "$next_index")" ] || return 11 + else + case "$curl_status" in 200|206) ;; *) return 10 ;; esac + fi + index=$((index + 1)) + done + return 0 +} +if [ "$action" = probe ]; then + request_chain probe + request_result=$? + case "$request_result" in + 0) stop available "" ;; + 11) stop unavailable download-unavailable ;; + *) stop unavailable download-unavailable ;; + esac +fi +emit_progress downloading +request_chain download +request_result=$? +case "$request_result" in + 0) ;; + 11) stop failed redirect-mismatch ;; + *) stop failed download-failed ;; +esac +archive="$operation_root/package.gbagent" +mv -- "$response_file" "$archive" || stop failed download-failed +actual_size=$(wc -c < "$archive" 2>/dev/null) || stop failed size-mismatch +set -- $actual_size +actual_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY} +[ "$actual_size" = "$expected_size" ] || stop failed size-mismatch +emit_progress verifying +actual_sha256=$(sha256sum "$archive" 2>/dev/null) || stop failed sha256-mismatch +set -- $actual_sha256 +actual_sha256=${SHELL_FIRST_POSITIONAL_OR_EMPTY} +[ "$actual_sha256" = "$expected_sha256" ] || stop failed sha256-mismatch +node_count=$(unzip -Z1 "$archive" 2>/dev/null | grep -cx "agent/node" || true) +[ "$node_count" = 1 ] || stop failed archive-invalid +emit_progress extracting +bootstrap="$operation_root/bootstrap" +mkdir -p -- "$bootstrap/agent" || stop failed archive-invalid +unzip -p "$archive" agent/node > "$bootstrap/agent/node" || + stop failed archive-invalid +chmod 0755 "$bootstrap/agent/node" || stop failed archive-invalid +run_prepare +`; + +function createAbortError(signal?: AbortSignal): unknown { + return ( + signal?.reason ?? + new DOMException("The operation was aborted", "AbortError") + ); +} + +function validateOperationId(operationId: string): string { + if (!OPERATION_ID_PATTERN.test(operationId)) { + throw new Error("Agent 远程安装 operation ID 无效"); + } + return operationId; +} + +function validateCandidate( + candidate: SshRemotePackageCandidate, + requireUrls = false, +): SshRemotePackageCandidate { + validateOperationId(candidate.operationId); + if ( + !Number.isSafeInteger(candidate.size) || + candidate.size <= 0 || + candidate.size > MAX_PACKAGE_BYTES + ) { + throw new Error("Agent 远程安装包大小无效"); + } + if (!SHA256_PATTERN.test(candidate.sha256)) { + throw new Error("Agent 远程安装包 SHA-256 无效"); + } + if ( + (requireUrls && candidate.urls.length === 0) || + candidate.urls.length > MAX_REDIRECTS + 1 + ) { + throw new Error("Agent 远程安装下载链无效"); + } + const seen = new Set(); + for (const value of candidate.urls) { + if ( + Buffer.byteLength(value, "utf8") > MAX_URL_BYTES || + /[\p{Cc}\p{Cs}\s\u2028\u2029\ufffd]/u.test(value) + ) { + throw new Error("Agent 远程安装下载地址无效"); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Agent 远程安装下载地址无效"); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.port || + url.hash || + url.href !== value || + !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname) || + seen.has(value) + ) { + throw new Error("Agent 远程安装下载地址不受信任"); + } + seen.add(value); + } + return candidate; +} + +function serializeRequest( + action: BootstrapAction, + operationId: string, + candidate?: SshRemotePackageCandidate, + includeUrls = true, + installer?: ControlPlaneInstaller, +): Buffer { + validateOperationId(operationId); + if (candidate) { + validateCandidate(candidate, includeUrls); + } + const urls = candidate && includeUrls ? candidate.urls : []; + const lines = [ + "GOODBUDDY_REMOTE_PACKAGE_BOOTSTRAP_V1", + action, + operationId, + String(candidate?.size ?? 0), + candidate?.sha256 ?? "0".repeat(64), + String(installer?.size ?? 0), + installer?.sha256 ?? "0".repeat(64), + installer?.base64 ?? "", + String(urls.length), + ...urls, + ]; + const payload = Buffer.from(`${lines.join("\n")}\n`, "utf8"); + if (payload.byteLength > MAX_INPUT_BYTES) { + throw new Error("Agent 远程安装输入超过安全限制"); + } + return payload; +} + +function buildBootstrapProgram(payload: Buffer): Buffer { + const delimiter = Buffer.from(BOOTSTRAP_INPUT_DELIMITER, "ascii"); + if (payload.includes(delimiter)) { + throw new Error("Agent 远程安装输入包含保留分隔符"); + } + return Buffer.concat([ + Buffer.from(`exec 3<<'${BOOTSTRAP_INPUT_DELIMITER}'\n`, "ascii"), + payload, + Buffer.from(`${BOOTSTRAP_INPUT_DELIMITER}\n`, "ascii"), + Buffer.from(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT, "utf8"), + ]); +} + +type ControlPlaneInstaller = { + size: number; + sha256: string; + base64: string; +}; + +function validateControlPlaneInstaller( + value: Buffer | string, +): ControlPlaneInstaller { + if (typeof value !== "string" && !Buffer.isBuffer(value)) { + throw new Error("Agent 远程控制面安装器无效"); + } + const bytes = + typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value); + if ( + bytes.byteLength === 0 || + bytes.byteLength > MAX_CONTROL_PLANE_INSTALLER_BYTES + ) { + throw new Error("Agent 远程控制面安装器大小无效"); + } + let decoded: string; + try { + decoded = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + } catch { + throw new Error("Agent 远程控制面安装器编码无效"); + } + if (typeof value === "string" && decoded !== value) { + throw new Error("Agent 远程控制面安装器编码无效"); + } + return { + size: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + base64: bytes.toString("base64"), + }; +} + +function hasExactKeys( + value: Record, + keys: readonly string[], +): boolean { + return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function parseProtocol(value: unknown): SshRemotePackageProtocol | undefined { + const protocol = asRecord(value); + if ( + !protocol || + !hasExactKeys(protocol, ["major", "minor"]) || + !Number.isSafeInteger(protocol.major) || + !Number.isSafeInteger(protocol.minor) || + Number(protocol.major) < 0 || + Number(protocol.major) > MAX_PROTOCOL_COMPONENT || + Number(protocol.minor) < 0 || + Number(protocol.minor) > MAX_PROTOCOL_COMPONENT + ) { + return undefined; + } + return { + major: Number(protocol.major), + minor: Number(protocol.minor), + }; +} + +function isVersion(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= MAX_VERSION_LENGTH && + VERSION_PATTERN.test(value) + ); +} + +function parsePackageIdentity( + message: Record, + action: "prepare" | "commit", + candidate: SshRemotePackageCandidate, +): TerminalMessage | undefined { + const expectedStatus = action === "prepare" ? "prepared" : "committed"; + if ( + !hasExactKeys(message, [ + "type", + "command", + "status", + "archiveSha256", + "agent", + "runtime", + ]) || + message.type !== "result" || + message.command !== action || + message.status !== expectedStatus || + message.archiveSha256 !== candidate.sha256 + ) { + return undefined; + } + const agent = asRecord(message.agent); + const runtime = asRecord(message.runtime); + if ( + !agent || + !runtime || + !hasExactKeys(agent, [ + "installationId", + "agentVersion", + "manifestSha256", + "binaryDigest", + "platform", + "architecture", + "protocol", + "supervisor", + ]) || + !hasExactKeys(runtime, [ + "runtimeId", + "runtimeVersion", + "bundleDigest", + "manifestDigest", + "runtimeAdapterDigest", + "acpCapabilitiesDigest", + "platform", + "architecture", + "protocol", + ]) + ) { + return undefined; + } + const agentProtocol = parseProtocol(agent.protocol); + const runtimeProtocol = parseProtocol(runtime.protocol); + const architecture = agent.architecture; + if ( + typeof agent.manifestSha256 !== "string" || + !SHA256_PATTERN.test(agent.manifestSha256) || + agent.installationId !== `agent-${agent.manifestSha256}` || + agent.binaryDigest !== `sha256:${agent.manifestSha256}` || + !isVersion(agent.agentVersion) || + agent.platform !== "linux" || + (architecture !== "x64" && architecture !== "arm64") || + !agentProtocol || + agent.supervisor !== "detached-on-demand" || + runtime.runtimeId !== "opencode" || + !isVersion(runtime.runtimeVersion) || + typeof runtime.bundleDigest !== "string" || + !DIGEST_PATTERN.test(runtime.bundleDigest) || + typeof runtime.manifestDigest !== "string" || + !DIGEST_PATTERN.test(runtime.manifestDigest) || + typeof runtime.runtimeAdapterDigest !== "string" || + !DIGEST_PATTERN.test(runtime.runtimeAdapterDigest) || + typeof runtime.acpCapabilitiesDigest !== "string" || + !DIGEST_PATTERN.test(runtime.acpCapabilitiesDigest) || + runtime.platform !== "linux" || + runtime.architecture !== architecture || + !runtimeProtocol + ) { + return undefined; + } + return { + type: "result", + command: action, + status: expectedStatus, + archiveSha256: candidate.sha256, + agent: { + installationId: agent.installationId, + agentVersion: agent.agentVersion, + manifestSha256: agent.manifestSha256, + binaryDigest: agent.binaryDigest, + platform: "linux", + architecture, + protocol: agentProtocol, + supervisor: "detached-on-demand", + }, + runtime: { + runtimeId: "opencode", + runtimeVersion: runtime.runtimeVersion, + bundleDigest: runtime.bundleDigest, + manifestDigest: runtime.manifestDigest, + runtimeAdapterDigest: runtime.runtimeAdapterDigest, + acpCapabilitiesDigest: runtime.acpCapabilitiesDigest, + platform: "linux", + architecture, + protocol: runtimeProtocol, + }, + }; +} + +function parseUploadStagingResult( + message: Record, + candidate: SshRemotePackageCandidate, +): TerminalMessage | undefined { + if ( + !hasExactKeys(message, [ + "type", + "status", + "operationId", + "archivePath", + "bootstrapNodePath", + ]) || + message.type !== "result" || + message.status !== "upload-staging-created" || + message.operationId !== candidate.operationId || + typeof message.archivePath !== "string" || + typeof message.bootstrapNodePath !== "string" || + Buffer.byteLength(message.archivePath, "utf8") > MAX_REMOTE_PATH_BYTES || + Buffer.byteLength(message.bootstrapNodePath, "utf8") > MAX_REMOTE_PATH_BYTES + ) { + return undefined; + } + const suffix = `/.goodbuddy/agent/staging/op-${candidate.operationId}`; + const operationRoot = message.archivePath.endsWith( + `${suffix}/package.gbagent`, + ) + ? message.archivePath.slice(0, -"/package.gbagent".length) + : undefined; + if ( + !operationRoot || + !operationRoot.startsWith("/") || + message.bootstrapNodePath !== `${operationRoot}/bootstrap/agent/node` + ) { + return undefined; + } + return { + type: "result", + status: "upload-staging-created", + operationId: candidate.operationId, + archivePath: message.archivePath, + bootstrapNodePath: message.bootstrapNodePath, + }; +} + +function parseMessage( + line: string, + action: BootstrapAction, + candidate?: SshRemotePackageCandidate, +): { + progress?: SshRemotePackageBootstrapProgress; + terminal?: TerminalMessage; +} { + const packageAction = + action === "prepare-status" || action === "prepare-uploaded" + ? "prepare" + : action === "commit-status" + ? "commit" + : action; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("Agent 远程安装返回了无效结果"); + } + if (typeof value !== "object" || value === null) { + throw new Error("Agent 远程安装返回了无效结果"); + } + const message = value as Record; + if ( + (packageAction === "prepare" || packageAction === "commit") && + message.type === "error" && + message.command === packageAction && + (message.status === "failed" || + message.status === "rollback-incomplete") && + typeof message.message === "string" && + message.message.length > 0 && + message.message.length <= 2_000 && + !/[\r\n\0]/u.test(message.message) && + hasExactKeys(message, [ + "type", + "command", + "status", + "message", + ]) + ) { + return { + terminal: { + type: "result", + status: "failed", + reason: + message.status === "rollback-incomplete" + ? "installer-rollback-incomplete" + : "installer-failed", + }, + }; + } + if (action === "create-upload-staging" && candidate) { + const uploadStaging = parseUploadStagingResult(message, candidate); + if (uploadStaging) { + return { terminal: uploadStaging }; + } + } + if ( + message.type === "progress" && + typeof message.phase === "string" && + (progressPhases as readonly string[]).includes(message.phase) && + (hasExactKeys(message, ["type", "phase"]) || + ((packageAction === "prepare" || packageAction === "commit") && + message.command === packageAction && + hasExactKeys(message, ["type", "command", "phase"]))) + ) { + return { + progress: { + phase: message.phase as SshRemotePackageBootstrapProgress["phase"], + }, + }; + } + if ( + (packageAction === "prepare" || packageAction === "commit") && + candidate + ) { + const packageIdentity = parsePackageIdentity( + message, + packageAction, + candidate, + ); + if (packageIdentity) { + return { terminal: packageIdentity }; + } + } + if (message.type !== "result" || typeof message.status !== "string") { + throw new Error("Agent 远程安装返回了无效结果"); + } + if ( + (message.status === "available" || message.status === "cleaned") && + hasExactKeys(message, ["type", "status"]) + ) { + return { terminal: message as TerminalMessage }; + } + if ( + message.status === "unavailable" && + typeof message.reason === "string" && + (unavailableReasons as readonly string[]).includes(message.reason) && + hasExactKeys(message, ["type", "status", "reason"]) + ) { + return { terminal: message as TerminalMessage }; + } + if ( + message.status === "failed" && + typeof message.reason === "string" && + (failureReasons as readonly string[]).includes(message.reason) && + hasExactKeys(message, ["type", "status", "reason"]) + ) { + return { terminal: message as TerminalMessage }; + } + throw new Error("Agent 远程安装返回了无效结果"); +} + +async function runBootstrapCommand( + openChannel: OpenBootstrapChannel, + action: BootstrapAction, + payload: Buffer, + timeoutMs: number, + options: SshRemotePackageBootstrapOptions = {}, + candidate?: SshRemotePackageCandidate, +): Promise { + const { signal, onProgress } = options; + const channel = await openChannel(signal); + if (signal?.aborted) { + channel.destroy(); + throw createAbortError(signal); + } + return new Promise((resolve, reject) => { + let pending = Buffer.alloc(0); + let outputBytes = 0; + let messages = 0; + let terminal: TerminalMessage | undefined; + let settled = false; + let commandStarted = false; + const actionLabel = + action === "probe" + ? "能力检查" + : action === "create-upload-staging" + ? "创建上传暂存区" + : action === "prepare" || + action === "prepare-uploaded" || + action === "prepare-status" + ? "准备" + : action === "commit" || action === "commit-status" + ? "提交" + : "清理"; + + const fail = (error: unknown): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + channel.destroy(); + reject(error); + }; + const indeterminate = (): SshRemotePackageCommitIndeterminateError => + new SshRemotePackageCommitIndeterminateError(); + const failCommand = ( + error: unknown, + prepareResultMayExist = false, + ): void => { + fail( + action === "commit" && commandStarted + ? indeterminate() + : (action === "prepare" || action === "prepare-uploaded") && + commandStarted && + prepareResultMayExist + ? new SshRemotePackagePrepareResultLostError(error) + : error, + ); + }; + const abort = (): void => { + fail( + action === "commit" && commandStarted + ? indeterminate() + : createAbortError(signal), + ); + }; + const timeout = setTimeout(() => { + failCommand( + new Error("Agent 远程安装操作超时"), + (action === "prepare" || action === "prepare-uploaded") && + commandStarted, + ); + }, timeoutMs); + timeout.unref?.(); + + const acceptLine = (lineBytes: Buffer): void => { + if ( + lineBytes.byteLength === 0 || + lineBytes.byteLength > MAX_LINE_BYTES || + lineBytes.includes(0x0d) + ) { + throw new Error("Agent 远程安装返回了无效结果"); + } + messages += 1; + if (messages > MAX_MESSAGES || terminal) { + throw new Error("Agent 远程安装返回了过多结果"); + } + const parsed = parseMessage( + new TextDecoder("utf-8", { fatal: true }).decode(lineBytes), + action, + candidate, + ); + if (parsed.progress) { + onProgress?.(parsed.progress); + } else { + terminal = parsed.terminal; + } + }; + const collect = (chunk: Buffer | string): void => { + try { + const bytes = Buffer.from(chunk); + outputBytes += bytes.byteLength; + if (outputBytes > MAX_OUTPUT_BYTES) { + throw new Error("Agent 远程安装输出超过安全限制"); + } + pending = Buffer.concat([pending, bytes]); + while (true) { + const newline = pending.indexOf(0x0a); + if (newline < 0) { + if (pending.byteLength > MAX_LINE_BYTES) { + throw new Error("Agent 远程安装输出超过安全限制"); + } + return; + } + const line = pending.subarray(0, newline); + pending = pending.subarray(newline + 1); + acceptLine(line); + } + } catch (error) { + failCommand(error); + } + }; + + signal?.addEventListener("abort", abort, { once: true }); + channel.on("data", collect); + channel.stderr.on("data", (chunk: Buffer | string) => { + outputBytes += Buffer.byteLength(chunk); + if (outputBytes > MAX_OUTPUT_BYTES) { + failCommand(new Error("Agent 远程安装输出超过安全限制")); + } + }); + channel.once("error", (error: Error) => { + void error; + failCommand( + new Error(`Agent 远程安装${actionLabel} SSH 通道异常中断`), + true, + ); + }); + channel.once("close", (code: number | null) => { + if (settled) { + return; + } + if ( + pending.byteLength !== 0 || + typeof code !== "number" || + code !== 0 || + !terminal + ) { + const detail = + typeof code === "number" && code !== 0 + ? `shell 退出状态 ${code}` + : pending.byteLength !== 0 + ? "返回了不完整结果" + : !terminal + ? "通道在返回终态前关闭" + : "命令执行失败"; + failCommand( + new Error( + `Agent 远程安装${actionLabel}${detail}${ + !terminal && + (action === "prepare" || action === "prepare-uploaded") + ? ";尚未进入提交阶段,远端版本未切换,请重试" + : "" + }`, + ), + typeof code !== "number" || + (code === 0 && + (pending.byteLength !== 0 || !terminal)), + ); + return; + } + settled = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + resolve(terminal); + }); + try { + commandStarted = true; + channel.end(buildBootstrapProgram(payload)); + } catch (error) { + failCommand(error); + } + }); +} + +export function createSshRemotePackageBootstrapExecutor( + openChannel: OpenBootstrapChannel, + controlPlaneInstaller: Buffer | string, +): SshRemotePackageBootstrapExecutor { + const installer = validateControlPlaneInstaller(controlPlaneInstaller); + const prepareCandidate = async ( + action: "prepare" | "prepare-uploaded", + candidate: SshRemotePackageCandidate, + options: SshRemotePackageBootstrapOptions, + ): Promise => { + const verified = validateCandidate( + candidate, + action === "prepare", + ); + let result: TerminalMessage; + try { + result = await runBootstrapCommand( + openChannel, + action, + serializeRequest( + action, + verified.operationId, + verified, + action === "prepare", + installer, + ), + PREPARE_TIMEOUT_MS, + options, + verified, + ); + } catch (error) { + if (options.signal?.aborted) { + throw createAbortError(options.signal); + } + if (!(error instanceof SshRemotePackagePrepareResultLostError)) { + throw error; + } + try { + result = await runBootstrapCommand( + openChannel, + "prepare-status", + serializeRequest( + "prepare-status", + verified.operationId, + verified, + false, + ), + STATUS_TIMEOUT_MS, + options, + verified, + ); + } catch { + if (options.signal?.aborted) { + throw createAbortError(options.signal); + } + throw error.originalError; + } + if (result.status !== "prepared") { + throw error.originalError; + } + } + if (result.status === "prepared") { + return { + prepared: true, + operationId: verified.operationId, + identity: { + archiveSha256: result.archiveSha256, + agent: result.agent, + runtime: result.runtime, + }, + }; + } + if (result.status === "unavailable") { + return { + prepared: false, + unavailable: true, + reason: result.reason, + }; + } + if (result.status === "failed") { + return { + prepared: false, + unavailable: false, + reason: result.reason, + }; + } + throw new Error("Agent 远程安装准备返回了无效结果"); + }; + const readCommitStatus = async ( + candidate: SshRemotePackageCandidate, + options: SshRemotePackageBootstrapOptions, + ): Promise => { + const verified = validateCandidate(candidate); + const result = await runBootstrapCommand( + openChannel, + "commit-status", + serializeRequest("commit-status", verified.operationId, verified, false), + STATUS_TIMEOUT_MS, + options, + verified, + ); + if (result.status === "committed") { + return { + committed: true, + operationId: verified.operationId, + identity: { + archiveSha256: result.archiveSha256, + agent: result.agent, + runtime: result.runtime, + }, + }; + } + if (result.status === "failed") { + return { committed: false, reason: result.reason }; + } + throw new Error("Agent 远程安装提交状态返回了无效结果"); + }; + return { + async probe(candidate, options = {}) { + const verified = validateCandidate(candidate, true); + const result = await runBootstrapCommand( + openChannel, + "probe", + serializeRequest("probe", verified.operationId, verified), + PROBE_TIMEOUT_MS, + options, + ); + if (result.status === "available") { + return { available: true }; + } + if (result.status === "unavailable") { + return { available: false, reason: result.reason }; + } + throw new Error("Agent 远程安装探针返回了无效结果"); + }, + async createUploadStaging(candidate, options = {}) { + const verified = validateCandidate(candidate); + const result = await runBootstrapCommand( + openChannel, + "create-upload-staging", + serializeRequest( + "create-upload-staging", + verified.operationId, + verified, + false, + ), + PROBE_TIMEOUT_MS, + options, + verified, + ); + if (result.status === "upload-staging-created") { + return { + created: true, + operationId: verified.operationId, + archivePath: result.archivePath, + bootstrapNodePath: result.bootstrapNodePath, + }; + } + if (result.status === "unavailable") { + return { + created: false, + unavailable: true, + reason: result.reason, + }; + } + if (result.status === "failed") { + return { + created: false, + unavailable: false, + reason: result.reason, + }; + } + throw new Error("Agent 远程安装上传暂存返回了无效结果"); + }, + prepare(candidate, options = {}) { + return prepareCandidate("prepare", candidate, options); + }, + prepareUploaded(candidate, options = {}) { + return prepareCandidate("prepare-uploaded", candidate, options); + }, + async commit(candidate, options = {}) { + const verified = validateCandidate(candidate); + let result: TerminalMessage; + try { + result = await runBootstrapCommand( + openChannel, + "commit", + serializeRequest("commit", verified.operationId, verified, false), + COMMIT_TIMEOUT_MS, + options, + verified, + ); + } catch (error) { + if ( + !(error instanceof SshRemotePackageCommitIndeterminateError) || + options.signal?.aborted + ) { + throw error; + } + try { + const status = await readCommitStatus(verified, options); + if (status.committed) { + return status; + } + } catch { + // The commit remains indeterminate when its persisted result + // cannot be read and validated exactly. + } + throw error; + } + if (result.status === "committed") { + return { + committed: true, + operationId: verified.operationId, + identity: { + archiveSha256: result.archiveSha256, + agent: result.agent, + runtime: result.runtime, + }, + }; + } + if (result.status === "failed") { + return { committed: false, reason: result.reason }; + } + throw new Error("Agent 远程安装提交返回了无效结果"); + }, + commitStatus(candidate, options = {}) { + return readCommitStatus(candidate, options); + }, + async cleanup(operationId, options = {}) { + const verifiedOperationId = validateOperationId(operationId); + const result = await runBootstrapCommand( + openChannel, + "cleanup", + serializeRequest("cleanup", verifiedOperationId), + CLEANUP_TIMEOUT_MS, + options, + ); + if (result.status === "cleaned") { + return { cleaned: true, operationId: verifiedOperationId }; + } + if ( + result.status === "failed" && + (result.reason === "operation-unavailable" || + result.reason === "cleanup-failed") + ) { + return { cleaned: false, reason: result.reason }; + } + throw new Error("Agent 远程安装清理返回了无效结果"); + }, + }; +} diff --git a/src/main/workspace/remote-workspace-access.test.ts b/src/main/workspace/remote-workspace-access.test.ts index 62a90bb5..d677c443 100644 --- a/src/main/workspace/remote-workspace-access.test.ts +++ b/src/main/workspace/remote-workspace-access.test.ts @@ -13,20 +13,11 @@ import { const digest = `sha256:${'a'.repeat(64)}` const binding: RemoteWorkspaceProjectBinding = { hostId: 'host-1', - hostRevision: 2, - hostKeyGeneration: 3, - remoteUsername: 'builder', - remoteRootPath: '/srv/project', - workspaceIdentity: 'identity-1', - agentInstallationId: 'installation-1', - agentBinaryDigest: digest, - agentVersion: '0.11.0', - agentArchitecture: 'x64', - agentProtocolMajor: 1 + remoteRootPath: '/srv/project' } const handle: RemoteWorkspaceHandle = { workspaceId: 'workspace-1', - workspaceIdentity: binding.workspaceIdentity, + workspaceIdentity: 'identity-1', canonicalDisplayPath: binding.remoteRootPath, access: 'read-only', git: 'available', @@ -47,25 +38,20 @@ function createLease( return { binding: { hostId: binding.hostId, - hostRevision: binding.hostRevision, - hostKeyGeneration: binding.hostKeyGeneration, - remoteUsername: binding.remoteUsername, - agentInstallationId: binding.agentInstallationId, - agentBinaryDigest: binding.agentBinaryDigest, - agentVersion: binding.agentVersion, - agentArchitecture: binding.agentArchitecture, - agentProtocolMajor: binding.agentProtocolMajor, + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + agentInstallationId: 'installation-1', + agentBinaryDigest: digest, + agentVersion: '0.11.0', + agentArchitecture: 'x64', + agentProtocolMajor: 1, capabilityGeneration: 6 }, validateWorkspace: vi.fn(async () => ({ handle, validatedAt: '2026-08-21T00:00:00.000Z' })), - openWorkspace: vi.fn(async () => handle), - resumeWorkspace: vi.fn(async () => ({ - resumed: true, - handle - })), closeWorkspace: vi.fn(async (request) => ({ ...request, closed: true as const @@ -278,64 +264,59 @@ describe('RemoteWorkspaceAccess', () => { expect(getGitDiff).not.toHaveBeenCalled() }) - it('resumes a previous handle and opens a replacement when resume fails', async () => { - const lease = createLease({ - resumableHandle: handle, - resumeWorkspace: vi.fn(async () => ({ - resumed: false, - handle - })) - }) + it('validates the configured path instead of resuming persisted state', async () => { + const lease = createLease() const { access } = createAccess(lease) await access.getIdentity() - expect(lease.resumeWorkspace).toHaveBeenCalledWith( + expect(lease.validateWorkspace).toHaveBeenCalledWith( { - workspaceId: 'workspace-1', - generation: 5, - workspaceIdentity: 'identity-1' + remoteRootPath: binding.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [ + 'list', + 'stat', + 'read-text', + 'search' + ] }, undefined ) - expect(lease.openWorkspace).toHaveBeenCalledWith( - { - workspaceIdentity: 'identity-1', - requestedAccess: 'read-only' - }, - undefined - ) - expect(lease.validateWorkspace).not.toHaveBeenCalled() }) - it('rejects immutable transport and workspace binding mismatches', async () => { - for (const changedBinding of [ - { hostKeyGeneration: 99 }, - { agentBinaryDigest: `sha256:${'b'.repeat(64)}` }, - { agentVersion: '0.11.1' }, - { agentArchitecture: 'arm64' as const } - ]) { - const release = vi.fn() - const lease = createLease({ - binding: { - ...createLease().binding, - ...changedBinding - }, - release - }) - const { access } = createAccess(lease) + it('uses live Agent identity but rejects a different Host or path', async () => { + const currentAgent = createLease({ + binding: { + ...createLease().binding, + hostRevision: 99, + agentBinaryDigest: `sha256:${'b'.repeat(64)}`, + agentVersion: '0.11.1', + agentArchitecture: 'arm64' + } + }) + await expect( + createAccess(currentAgent).access.getIdentity() + ).resolves.toMatchObject({ + kind: 'remote', + canonicalDisplayPath: binding.remoteRootPath + }) - await expect(access.getIdentity()).rejects.toThrow( - '绑定已失效' - ) - expect(release).toHaveBeenCalledOnce() - } + const otherHost = createLease({ + binding: { + ...createLease().binding, + hostId: 'host-2' + } + }) + await expect( + createAccess(otherHost).access.getIdentity() + ).rejects.toThrow('绑定已失效') const identityMismatch = createLease({ validateWorkspace: vi.fn(async () => ({ handle: { ...handle, - workspaceIdentity: 'identity-2' + canonicalDisplayPath: '/srv/other' }, validatedAt: '2026-08-21T00:00:00.000Z' })) diff --git a/src/main/workspace/remote-workspace-access.ts b/src/main/workspace/remote-workspace-access.ts index a3684b98..f7e358a5 100644 --- a/src/main/workspace/remote-workspace-access.ts +++ b/src/main/workspace/remote-workspace-access.ts @@ -10,12 +10,8 @@ import { remoteWorkspaceHandleSchema, remoteWorkspaceListRequestSchema, remoteWorkspaceListResultSchema, - remoteWorkspaceOpenRequestSchema, - remoteWorkspaceOpenResultSchema, remoteWorkspaceReadTextRequestSchema, remoteWorkspaceReadTextResultSchema, - remoteWorkspaceResumeRequestSchema, - remoteWorkspaceResumeResultSchema, remoteWorkspaceSearchRequestSchema, remoteWorkspaceSearchResultSchema, remoteWorkspaceStatRequestSchema, @@ -31,12 +27,8 @@ import { type RemoteWorkspaceHandle, type RemoteWorkspaceListRequest, type RemoteWorkspaceListResult, - type RemoteWorkspaceOpenRequest, - type RemoteWorkspaceOpenResult, type RemoteWorkspaceReadTextRequest, type RemoteWorkspaceReadTextResult, - type RemoteWorkspaceResumeRequest, - type RemoteWorkspaceResumeResult, type RemoteWorkspaceSearchRequest, type RemoteWorkspaceSearchResult, type RemoteWorkspaceStatRequest, @@ -67,16 +59,7 @@ const DEFAULT_READ_BYTES = 256 * 1024 const DEFAULT_SEARCH_RESULTS = 100 export type RemoteWorkspaceProjectBinding = { hostId: string - hostRevision: number - hostKeyGeneration: number - remoteUsername: string remoteRootPath: string - workspaceIdentity: string - agentInstallationId: string - agentBinaryDigest: string - agentVersion: string - agentArchitecture: 'x64' | 'arm64' - agentProtocolMajor: number } export type RemoteWorkspaceTransportBinding = { @@ -98,19 +81,10 @@ export type RemoteWorkspaceTransportBinding = { */ export interface RemoteWorkspaceTransportLease { readonly binding: RemoteWorkspaceTransportBinding - readonly resumableHandle?: RemoteWorkspaceHandle validateWorkspace( request: RemoteWorkspaceValidateRequest, signal?: AbortSignal ): Promise - openWorkspace( - request: RemoteWorkspaceOpenRequest, - signal?: AbortSignal - ): Promise - resumeWorkspace( - request: RemoteWorkspaceResumeRequest, - signal?: AbortSignal - ): Promise closeWorkspace( request: RemoteWorkspaceCloseRequest ): Promise @@ -182,15 +156,7 @@ function assertTransportBinding( if ( !Number.isSafeInteger(actual.capabilityGeneration) || actual.capabilityGeneration < 1 || - actual.hostId !== expected.hostId || - actual.hostRevision !== expected.hostRevision || - actual.hostKeyGeneration !== expected.hostKeyGeneration || - actual.remoteUsername !== expected.remoteUsername || - actual.agentInstallationId !== expected.agentInstallationId || - actual.agentBinaryDigest !== expected.agentBinaryDigest || - actual.agentVersion !== expected.agentVersion || - actual.agentArchitecture !== expected.agentArchitecture || - actual.agentProtocolMajor !== expected.agentProtocolMajor + actual.hostId !== expected.hostId ) { throw new Error('远程工作区连接绑定已失效') } @@ -202,7 +168,7 @@ function assertHandleBinding( ): RemoteWorkspaceHandle { const handle = remoteWorkspaceHandleSchema.parse(handleInput) if ( - handle.workspaceIdentity !== expected.workspaceIdentity || + handle.canonicalDisplayPath !== expected.remoteRootPath || handle.access !== 'read-only' ) { throw new Error('远程工作区身份绑定不匹配') @@ -328,51 +294,22 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { try { assertTransportBinding(this.binding, lease.binding) signal?.throwIfAborted() - let handle: RemoteWorkspaceHandle - if (lease.resumableHandle) { - const previous = assertHandleBinding( - this.binding, - lease.resumableHandle + const validation = remoteWorkspaceValidateResultSchema.parse( + await lease.validateWorkspace( + remoteWorkspaceValidateRequestSchema.parse({ + remoteRootPath: this.binding.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [ + ...REMOTE_WORKSPACE_READ_CAPABILITIES + ] + }), + signal ) - const resumed = remoteWorkspaceResumeResultSchema.parse( - await lease.resumeWorkspace( - remoteWorkspaceResumeRequestSchema.parse({ - workspaceId: previous.workspaceId, - generation: previous.generation, - workspaceIdentity: this.binding.workspaceIdentity - }), - signal - ) - ) - handle = resumed.resumed - ? assertHandleBinding(this.binding, resumed.handle) - : assertHandleBinding( - this.binding, - remoteWorkspaceOpenResultSchema.parse( - await lease.openWorkspace( - remoteWorkspaceOpenRequestSchema.parse({ - workspaceIdentity: this.binding.workspaceIdentity, - requestedAccess: 'read-only' - }), - signal - ) - ) - ) - } else { - const validation = remoteWorkspaceValidateResultSchema.parse( - await lease.validateWorkspace( - remoteWorkspaceValidateRequestSchema.parse({ - remoteRootPath: this.binding.remoteRootPath, - requestedAccess: 'read-only', - requiredCapabilities: [ - ...REMOTE_WORKSPACE_READ_CAPABILITIES - ] - }), - signal - ) - ) - handle = assertHandleBinding(this.binding, validation.handle) - } + ) + const handle = assertHandleBinding( + this.binding, + validation.handle + ) assertTransportBinding(this.binding, lease.binding) retained = true return { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7d9979a6..975d9962 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -117,7 +117,9 @@ import type { SshDirectoryBrowseResult, SshHostKeyInspection, RemoteEnvironmentUpdateProgress, + RemoteEnvironmentUpdateRequest, SshHostRemoteEnvironment, + SshHostRemovalResult, SshHostsSnapshot, SshHostValidationRequest, SshHostValidationResult @@ -375,11 +377,10 @@ const desktopApi: DesktopApi = { handler ) }, - remove: async (hostId: string) => { - await ipcRenderer.invoke(ipcChannels.sshHostsRemove, { + remove: (hostId: string) => + ipcRenderer.invoke(ipcChannels.sshHostsRemove, { hostId - }) - }, + }) as Promise, inspectDraftHostKey: (input: SshHostDraftInspectionRequest) => ipcRenderer.invoke( ipcChannels.sshHostsInspectDraftKey, @@ -401,10 +402,12 @@ const desktopApi: DesktopApi = { ipcChannels.sshHostsRemoteEnvironment, { hostId } ) as Promise, - updateRemoteEnvironment: async (hostId: string) => { + updateRemoteEnvironment: async ( + input: RemoteEnvironmentUpdateRequest + ) => { await ipcRenderer.invoke( ipcChannels.sshHostsUpdateRemoteEnvironment, - { hostId } + input ) }, cancelRemoteEnvironmentUpdate: async (hostId: string) => { @@ -784,11 +787,6 @@ const desktopApi: DesktopApi = { }) }, remote: { - activate: (projectId: string) => - ipcRenderer.invoke( - ipcChannels.remoteProjectActivate, - projectId - ) as Promise, save: (input: RemoteProjectSaveRequest) => ipcRenderer.invoke( ipcChannels.remoteProjectSave, diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 20540617..05170476 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -168,7 +168,7 @@ describe('sandboxed preload', () => { expect(source).toContain('cancelDirectoryBrowse: async ()') expect(source).toContain('getRemoteEnvironment: (hostId: string)') expect(source).toContain( - 'updateRemoteEnvironment: async (hostId: string)' + 'input: RemoteEnvironmentUpdateRequest' ) expect(source).toContain( 'cancelRemoteEnvironmentUpdate: async (hostId: string)' @@ -230,7 +230,6 @@ describe('sandboxed preload', () => { 'utf8' ) expect(source).toContain('remote: {') - expect(source).toContain('remoteProjectActivate') expect(source).toContain('remoteProjectSave') expect(source).toContain('remoteProjectCancelCurrent') expect(source).toContain('remoteProjectSaveProgress') diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f408eb3c..f9e076b9 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -395,8 +395,6 @@ const api: DesktopApi = { setArchived: vi.fn(async () => {}), delete: vi.fn(async () => {}), remote: { - activate: - vi.fn(), save: vi.fn(), cancelCurrent: vi.fn(), @@ -983,7 +981,6 @@ describe('App', () => { vi.mocked(api.conversations.onChanged) .mockReset() .mockReturnValue(() => undefined) - vi.mocked(api.projects.remote.activate).mockReset() vi.mocked(api.projects.remote.cancelCurrent) .mockReset() .mockResolvedValue() @@ -5809,7 +5806,6 @@ describe('App', () => { expect( await screen.findByRole('button', { name: '当前项目' }) ).toHaveTextContent(project.name) - expect(api.projects.remote.activate).not.toHaveBeenCalled() await waitFor(() => expect( localStorage.getItem('goodbuddy.active-project.v1') @@ -5817,7 +5813,7 @@ describe('App', () => { ) }) - it('shows only supported OpenCode choices in a managed SSH conversation', async () => { + it('switches immediately to a managed SSH project and shows only supported OpenCode choices', async () => { installRemoteProjectsSetting(true) const remoteProject = { ...project, @@ -5836,9 +5832,6 @@ describe('App', () => { project, remoteProject ]) - vi.mocked(api.projects.remote.activate).mockResolvedValueOnce( - remoteProject - ) vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: '00000000-0000-4000-8000-000000000319', @@ -5872,6 +5865,9 @@ describe('App', () => { screen.getByRole('button', { name: '当前项目' }) ).toHaveTextContent(remoteProject.name) ) + expect( + screen.queryByRole('progressbar') + ).not.toBeInTheDocument() const runtimeButton = await screen.findByRole('button', { name: /OpenCode · 默认模型/u @@ -5914,6 +5910,57 @@ describe('App', () => { ).toBeInTheDocument() }) + it('switches between projects on one Host without a connection phase', async () => { + installRemoteProjectsSetting(true) + const first = { + ...project, + id: '00000000-0000-4000-8000-000000000126', + name: 'Host 项目一', + rootPath: '/srv/one', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000226', + remoteRootPath: '/srv/one' + }, + builtInDefault: false + } + const second = { + ...first, + id: '00000000-0000-4000-8000-000000000127', + name: 'Host 项目二', + rootPath: '/srv/two', + executionSpace: { + ...first.executionSpace, + remoteRootPath: '/srv/two' + } + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + first, + second + ]) + + render() + await screen.findByRole('button', { name: '当前项目' }) + vi.mocked( + api.runtimeCustomization.getNativeSnapshot + ).mockClear() + + for (const remoteProject of [first, second, first]) { + selectProjectOption(remoteProject.name) + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(remoteProject.name) + expect( + screen.queryByRole('progressbar') + ).not.toBeInTheDocument() + } + expect( + api.runtimeCustomization.getNativeSnapshot + ).not.toHaveBeenCalled() + }) + it('keeps saved remote projects hidden while the preview is disabled', async () => { installRemoteProjectsSetting(false) const remoteProject = { @@ -5944,7 +5991,6 @@ describe('App', () => { expect( within(menu).queryByText(remoteProject.name) ).not.toBeInTheDocument() - expect(api.projects.remote.activate).not.toHaveBeenCalled() }) it('falls back to a local project when the remote preview is disabled', async () => { @@ -5966,9 +6012,6 @@ describe('App', () => { project, remoteProject ]) - vi.mocked(api.projects.remote.activate).mockResolvedValueOnce( - remoteProject - ) render() await screen.findByRole('button', { name: '当前项目' }) @@ -6014,79 +6057,18 @@ describe('App', () => { expect(api.projects.delete).not.toHaveBeenCalled() }) - it('cancels pending remote activation when the preview is disabled', async () => { - const { updateSettings } = installRemoteProjectsSetting(true) - const remoteProject = { - ...project, - id: '00000000-0000-4000-8000-000000000118', - name: '连接中的远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, - executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000218', - remoteRootPath: '/srv/project' - }, - builtInDefault: false - } - const activation = deferred() - vi.mocked(api.projects.list).mockResolvedValueOnce([ - project, - remoteProject - ]) - vi.mocked(api.projects.remote.activate).mockReturnValueOnce( - activation.promise - ) - - render() - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) - await screen.findByRole('progressbar', { - name: '远程项目连接进度' - }) - - fireEvent.click(screen.getByText('本地工作区')) - fireEvent.click( - await screen.findByRole('tab', { name: '平台功能' }) - ) - fireEvent.click( - await screen.findByRole('tab', { - name: '远程项目(技术预览)' - }) - ) - fireEvent.click( - await screen.findByRole('switch', { - name: '远程项目(技术预览)' - }) - ) - - await waitFor(() => - expect(updateSettings).toHaveBeenCalledWith({ - remoteProjectsEnabled: false - }) - ) - expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce() - await act(async () => { - activation.resolve(remoteProject) - await activation.promise - }) - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - }) - - it('keeps the current project active when remote activation fails', async () => { + it('deletes an unreachable non-active remote project without connecting', async () => { installRemoteProjectsSetting(true) const remoteProject = { ...project, - id: '00000000-0000-4000-8000-000000000104', - name: '失效远程项目', - rootPath: '/srv/project', + id: '00000000-0000-4000-8000-000000000125', + name: '目录已丢失的远程项目', + rootPath: '/srv/missing', runtimeSelection: { provider: 'opencode' as const }, executionSpace: { kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000204', - remoteRootPath: '/srv/project' + hostId: '00000000-0000-4000-8000-000000000225', + remoteRootPath: '/srv/missing' }, builtInDefault: false } @@ -6094,489 +6076,40 @@ describe('App', () => { project, remoteProject ]) - vi.mocked(api.projects.remote.activate).mockRejectedValueOnce( - new Error( - "Error invoking remote method 'projects:remote:activate': AgentInstallationError: Agent upload failed" - ) - ) - render() - await waitFor(() => - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - ) - - selectProjectOption(remoteProject.name) - - expect( - await screen.findByText( - '远程项目更新失败:Agent upload failed。请检查 Host 后重试' - ) - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - }) - - it('revalidates the current SSH project when it is selected again', async () => { - installRemoteProjectsSetting(true) - const remoteProject = { - ...project, - id: '00000000-0000-4000-8000-000000000114', - name: '可重新连接的远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, - executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000214', - remoteRootPath: '/srv/project' - }, - builtInDefault: false - } - vi.mocked(api.projects.list).mockResolvedValueOnce([ - project, - remoteProject - ]) - vi.mocked(api.projects.remote.activate).mockResolvedValue( - remoteProject - ) render() await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) - await waitFor(() => - expect(api.projects.remote.activate).toHaveBeenCalledTimes(1) - ) - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(remoteProject.name) - - selectProjectOption(remoteProject.name) - - await waitFor(() => - expect(api.projects.remote.activate).toHaveBeenCalledTimes(2) - ) - expect(api.projects.remote.activate).toHaveBeenLastCalledWith( - remoteProject.id - ) - }) - - it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => { - installRemoteProjectsSetting(true) - const hostId = '00000000-0000-4000-8000-000000000215' - const candidateId = - '00000000-0000-4000-8000-000000000315' - const remoteConversationId = - '00000000-0000-4000-8000-000000000415' - const fingerprint = `SHA256:${'A'.repeat(43)}` - const host = { - id: hostId, - name: 'Build host', - hostname: '192.168.0.23', - port: 22, - username: 'root', - authentication: 'system-agent' as const, - credentialConfigured: true, - credentialSource: 'system-agent' as const, - hostKey: { - state: 'verified' as const, - algorithm: 'ecdsa-sha2-nistp256', - fingerprintSha256: fingerprint, - generation: 1 - }, - lastValidatedAt: '2026-08-24T00:00:00.000Z', - createdAt: '2026-08-24T00:00:00.000Z', - updatedAt: '2026-08-24T00:00:00.000Z' - } - const updatedHost = { - ...host, - hostname: '10.7.0.23', - hostKey: { ...host.hostKey, generation: 2 }, - updatedAt: '2026-08-24T00:01:00.000Z' - } - const remoteProject = { - ...project, - id: '00000000-0000-4000-8000-000000000115', - name: 'Host 一致性项目', - rootPath: '/root', - runtimeSelection: { provider: 'opencode' as const }, - executionSpace: { - kind: 'ssh' as const, - hostId, - remoteRootPath: '/root' - }, - builtInDefault: false - } - const sshHosts: NonNullable = { - getSnapshot: vi.fn(async () => ({ - hosts: [host], - secureStorageAvailable: true - })), - getAgentPackageInventory: vi.fn(async () => ({ - checkedAt: '2026-08-24T00:00:00.000Z', - catalog: { - state: 'available' as const, - checkedAt: '2026-08-24T00:00:00.000Z', - error: null - }, - entries: [ - { - platform: 'linux' as const, - architecture: 'x64' as const, - state: 'verified' as const, - version: '0.11.2', - latestVersion: '0.11.4', - updateAvailable: true, - agentProtocol: { major: 2, minor: 0 }, - remoteRuntimeVersion: '1.18.9' - }, - { - platform: 'linux' as const, - architecture: 'arm64' as const, - state: 'not-downloaded' as const, - version: null, - latestVersion: '0.11.4', - updateAvailable: false, - agentProtocol: null, - remoteRuntimeVersion: null - } - ] - })), - downloadAgentPackage: vi.fn(), - importAgentPackage: vi.fn(), - exportAgentPackage: vi.fn(async () => {}), - onAgentPackageProgress: vi.fn(() => () => {}), - remove: vi.fn(async () => undefined), - inspectDraftHostKey: vi.fn(async () => ({ - candidateId, - hostId, - state: 'verified' as const, - algorithm: 'ecdsa-sha2-nistp256', - fingerprintSha256: fingerprint - })), - discardCandidate: vi.fn(async () => undefined), - validateAndSave: vi.fn(async () => ({ - host: updatedHost, - connection: { - hostId, - connected: true as const, - latencyMs: 12, - platform: 'linux' as const, - architecture: 'x64' as const, - shell: '/bin/bash', - homeDirectory: '/root', - detail: 'SSH 已连接,远端系统为 linux/x64' - } - })), - getRemoteEnvironment: vi.fn(async () => ({ - hostId, - checkedAt: '2026-08-24T00:00:00.000Z', - architecture: 'x64' as const, - agent: { - state: 'current' as const, - expected: { - version: '0.11.2-e2e.8', - architecture: 'x64' as const - }, - installed: { - version: '0.11.2-e2e.8', - architecture: 'x64' as const - } - }, - runtimes: [{ - runtimeId: 'opencode' as const, - provider: 'opencode' as const, - state: 'current' as const, - expected: { - version: '1.18.9', - architecture: 'x64' as const - }, - installed: { - version: '1.18.9', - architecture: 'x64' as const - } - }] - })), - updateRemoteEnvironment: vi.fn(async () => undefined), - cancelRemoteEnvironmentUpdate: vi.fn(async () => undefined), - onRemoteEnvironmentUpdateProgress: vi.fn(() => () => undefined), - browseDirectories: vi.fn(async () => ({ - path: '/root', - homeDirectory: '/root', - parentPath: '/', - entries: [], - truncated: false - })), - cancelDirectoryBrowse: vi.fn(async () => undefined) - } - vi.mocked(api.projects.list).mockResolvedValueOnce([ - project, - remoteProject - ]) - vi.mocked(api.conversations.list).mockResolvedValueOnce([ - { - id: remoteConversationId, - projectId: remoteProject.id, - title: 'Host 一致性对话', - updatedAt: Date.now(), - messages: [] - } - ]) - const hostReactivation = deferred() - vi.mocked(api.projects.remote.activate) - .mockResolvedValueOnce(remoteProject) - .mockReturnValueOnce(hostReactivation.promise) - .mockResolvedValueOnce(remoteProject) - Object.defineProperty(window, 'goodbuddy', { - configurable: true, - value: { ...api, sshHosts } - }) - render() - await screen.findByRole('button', { name: '当前项目' }) - selectProjectOption(remoteProject.name) - await waitFor(() => - expect(api.projects.remote.activate).toHaveBeenCalledTimes(1) - ) - - fireEvent.click(await screen.findByText('本地工作区')) - await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click(screen.getByLabelText('当前项目')) fireEvent.click( - screen.getByRole('tab', { name: '主机与远程执行' }) - ) - fireEvent.click( - await screen.findByRole('button', { - name: '编辑 Build host' + screen.getByRole('menuitem', { + name: `管理项目 ${remoteProject.name}` }) ) - fireEvent.change(screen.getByLabelText('主机地址'), { - target: { value: '10.7.0.23' } - }) + const dialog = screen.getByRole('dialog', { name: '项目设置' }) fireEvent.click( - screen.getByRole('button', { name: '检查 Host Key' }) + within(dialog).getByRole('button', { name: '删除项目' }) + ) + fireEvent.change( + within(dialog).getByLabelText( + `输入“${remoteProject.name}”确认删除` + ), + { target: { value: remoteProject.name } } ) fireEvent.click( - await screen.findByRole('button', { - name: '确认身份并继续' + within(dialog).getByRole('button', { + name: '永久删除项目' }) ) - fireEvent.click( - screen.getByRole('button', { name: '验证并保存' }) - ) await waitFor(() => - expect(sshHosts.validateAndSave).toHaveBeenCalledOnce() - ) - await waitFor(() => - expect(api.projects.remote.activate).toHaveBeenCalledTimes(2) - ) - expect(api.projects.remote.activate).toHaveBeenLastCalledWith( - remoteProject.id - ) - - const queuedDispatch: ConversationQueueDispatch = { - item: { - id: '00000000-0000-4000-8000-000000000515', - conversationId: remoteConversationId, - source: 'user', - label: '等待 Host 恢复的消息', - createdAt: '2026-08-24T00:02:00.000Z' - }, - input: { - conversationId: remoteConversationId, - projectId: remoteProject.id, - runtimeSelection: { provider: 'opencode' }, - workMode: 'ask', - includeMemoryContext: true, - prompt: '等待 Host 恢复的消息', - attachments: [], - knowledgeLibraryIds: [], - knowledgeRetrievalMode: 'auto' - } - } - act(() => { - conversationQueueDispatchListener?.(queuedDispatch) - }) - await waitFor(() => - expect(api.conversationQueue.releaseUser).toHaveBeenCalledWith( - queuedDispatch.item.id + expect(api.projects.delete).toHaveBeenCalledWith( + remoteProject.id, + remoteProject.name ) ) - expect(run).not.toHaveBeenCalled() - - await act(async () => { - hostReactivation.reject(new Error('Host reconnect failed')) - await hostReactivation.promise.catch(() => undefined) - }) - expect( - await screen.findByText( - '远程项目更新失败:Host reconnect failed。请检查 Host 后重试' - ) - ).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: '对话' })) - const composer = await screen.findByLabelText('向 GoodBuddy 提问') - fireEvent.change(composer, { - target: { value: '前台消息也必须等待重新连接' } - }) - const send = screen.getByLabelText('发送') - expect(send).toBeDisabled() - expect(send).toHaveAttribute( - 'title', - '远程项目需要重新连接,请重新选择当前项目后再发送' - ) - - selectProjectOption(remoteProject.name) - await waitFor(() => - expect(api.projects.remote.activate).toHaveBeenCalledTimes(3) - ) - await waitFor(() => expect(send).toBeEnabled()) - await waitFor(() => - expect(api.conversationQueue.ready).toHaveBeenCalledWith( - remoteConversationId - ) - ) - - act(() => { - conversationQueueDispatchListener?.(queuedDispatch) - }) - await waitFor(() => expect(run).toHaveBeenCalledOnce()) - expect(run.mock.calls[0]![0]).toMatchObject({ - queueItemId: queuedDispatch.item.id, - projectId: remoteProject.id, - prompt: queuedDispatch.input.prompt - }) - expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce() - }) - - it('shows every direct remote activation phase and blocks conflicting project actions', async () => { - installRemoteProjectsSetting(true) - const remoteProject = { - ...project, - id: '00000000-0000-4000-8000-000000000105', - name: '阶段远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, - executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000205', - remoteRootPath: '/srv/project' - }, - builtInDefault: false - } - const activation = deferred() - vi.mocked(api.projects.list).mockResolvedValueOnce([ - project, - remoteProject - ]) - vi.mocked(api.projects.remote.activate).mockReturnValueOnce( - activation.promise - ) - render() - expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - - selectProjectOption(remoteProject.name) - - const progress = await screen.findByRole('progressbar', { - name: '远程项目连接进度' - }) - expect(progress).toHaveAttribute('max', '5') - expect(progress).toHaveAttribute('value', '1') - expect( - screen.getByText('正在连接远程项目“阶段远程项目”') - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toBeDisabled() - expect(screen.getByLabelText('新建项目')).toBeDisabled() - expect(screen.getByLabelText('项目设置')).toBeDisabled() - - act(() => { - remoteProjectSaveProgressListener?.({ phase: 'workspace' }) - }) - expect(progress).toHaveAttribute('value', '3') - expect( - screen.getByText('当前阶段:远端工作区') - ).toBeInTheDocument() - const stages = screen.getByRole('list', { - name: '远程项目连接阶段' - }) - expect(within(stages).getAllByRole('listitem')).toHaveLength(5) - expect( - within(stages).getByText('远端工作区').closest('li') - ).toHaveAttribute('aria-current', 'step') - - await act(async () => { - activation.resolve(remoteProject) - await activation.promise - }) - await waitFor(() => - expect( - screen.queryByRole('progressbar', { - name: '远程项目连接进度' - }) - ).not.toBeInTheDocument() - ) - expect( - screen.getByRole('button', { name: '当前项目' }) - ).toHaveTextContent(remoteProject.name) - }) - - it('cancels direct remote activation without switching projects', async () => { - installRemoteProjectsSetting(true) - const remoteProject = { - ...project, - id: '00000000-0000-4000-8000-000000000106', - name: '可取消远程项目', - rootPath: '/srv/project', - runtimeSelection: { provider: 'opencode' as const }, - executionSpace: { - kind: 'ssh' as const, - hostId: '00000000-0000-4000-8000-000000000206', - remoteRootPath: '/srv/project' - }, - builtInDefault: false - } - const activation = deferred() - vi.mocked(api.projects.list).mockResolvedValueOnce([ - project, - remoteProject - ]) - vi.mocked(api.projects.remote.activate).mockReturnValueOnce( - activation.promise - ) - render() - expect( - await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(project.name) - selectProjectOption(remoteProject.name) - - fireEvent.click( - await screen.findByRole('button', { name: '取消连接' }) - ) - expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce() - expect( - screen.getByRole('button', { name: '取消中…' }) - ).toBeDisabled() - await act(async () => { - activation.reject( - new DOMException('Remote project save cancelled', 'AbortError') - ) - await activation.promise.catch(() => undefined) - }) - - expect( - await screen.findByText('已取消远程项目连接') - ).toBeInTheDocument() expect( screen.getByRole('button', { name: '当前项目' }) ).toHaveTextContent(project.name) - expect( - screen.queryByRole('progressbar', { - name: '远程项目连接进度' - }) - ).not.toBeInTheDocument() }) it('shows grouped project details and keeps the rich menu keyboard accessible', async () => { @@ -6610,15 +6143,16 @@ describe('App', () => { const selectedProject = within(menu) .getByText(project.name, { selector: 'b' }) .closest('[role="menuitemradio"]') - const remoteProject = within(menu) - .getByText(channelProject.name, { selector: 'b' }) - .closest('[role="menuitemradio"]') + const remoteProjectSettings = within(menu).getByRole( + 'menuitem', + { name: `管理项目 ${channelProject.name}` } + ) expect(selectedProject).toHaveAttribute('aria-checked', 'true') await waitFor(() => expect(selectedProject).toHaveFocus()) fireEvent.keyDown(selectedProject!, { key: 'End' }) - expect(remoteProject).toHaveFocus() - fireEvent.keyDown(remoteProject!, { key: 'Escape' }) + expect(remoteProjectSettings).toHaveFocus() + fireEvent.keyDown(remoteProjectSettings, { key: 'Escape' }) expect(trigger).toHaveFocus() expect( screen.queryByRole('menu', { name: '当前项目' }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ca14ced5..4aa349cf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -89,9 +89,6 @@ import { agentRuntimeSelectionSchema, type AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' -import type { - RemoteProjectSavePhase -} from '../../shared/remote-project-candidate-contracts' import { getDefaultRuntimeSelection, getRuntimeSelectionForProvider @@ -1994,25 +1991,6 @@ function App(): React.JSX.Element { const [selectedExpertId, setSelectedExpertId] = useState('') const [activeProjectId, setActiveProjectId] = useState('') const activeProjectIdRef = useRef(activeProjectId) - const remoteProjectActivationRequestRef = useRef(0) - const remoteProjectActivationControlRef = useRef<{ - requestId: number - projectId: string - cancelRequested: boolean - } | undefined>(undefined) - const [remoteProjectActivation, setRemoteProjectActivation] = - useState<{ - projectId: string - phase: RemoteProjectSavePhase - cancelling: boolean - }>() - const remoteProjectsRequiringActivationRef = useRef( - new Set() - ) - const [ - remoteProjectsRequiringActivation, - setRemoteProjectsRequiringActivation - ] = useState>(() => new Set()) const workspaceChangesRequestRef = useRef(0) const runtimeStatusRequestRef = useRef(0) const runtimeSetupPromptedRef = useRef(false) @@ -3020,6 +2998,7 @@ function App(): React.JSX.Element { if ( !activeRuntimeSelection || activeConversation?.remote || + activeProjectUsesManagedSsh || (activeRuntimeSelection.provider !== 'opencode' && activeRuntimeSelection.provider !== 'continue') ) { @@ -3053,6 +3032,7 @@ function App(): React.JSX.Element { }) }, [ activeConversation?.remote, + activeProjectUsesManagedSsh, activeProjectId, activeRuntimeSelection, activeRuntimeSelectionKey @@ -3396,8 +3376,6 @@ function App(): React.JSX.Element { }, [notify, projects, runtimeSettings, setActiveId, setView] ) - const activeRemoteProjectRequiresActivation = - remoteProjectsRequiringActivation.has(activeProjectId) const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name : undefined @@ -4880,191 +4858,6 @@ function App(): React.JSX.Element { [] ) - const activateRemoteProject = useCallback( - async ( - project: AssistantProject - ): Promise<{ - project: AssistantProject - current: boolean - }> => { - if ( - !remoteProjectsEnabled || - project.executionSpace.kind !== 'ssh' - ) { - return { project, current: false } - } - const requestId = remoteProjectActivationRequestRef.current + 1 - remoteProjectActivationRequestRef.current = requestId - const control = { - requestId, - projectId: project.id, - cancelRequested: false - } - remoteProjectActivationControlRef.current = control - setRemoteProjectActivation({ - projectId: project.id, - phase: 'host', - cancelling: false - }) - const removeProgress = - window.goodbuddy.projects.remote.onSaveProgress( - (progress) => { - if ( - remoteProjectActivationControlRef.current !== - control - ) { - return - } - setRemoteProjectActivation({ - projectId: project.id, - phase: progress.phase, - cancelling: control.cancelRequested - }) - } - ) - try { - const activated = - await window.goodbuddy.projects.remote.activate(project.id) - setProjects((current) => - current.map((candidate) => - candidate.id === activated.id ? activated : candidate - ) - ) - const current = - remoteProjectActivationRequestRef.current === requestId && - !control.cancelRequested - if (current) { - notify({ - tone: 'success', - message: tRef.current('notices.remoteProjectActivated'), - dedupeKey: `remote-project-activated:${project.id}` - }) - } else if ( - remoteProjectActivationRequestRef.current === requestId && - control.cancelRequested - ) { - notify({ - tone: 'info', - message: tRef.current( - 'notices.remoteProjectActivationCancelled' - ), - dedupeKey: `remote-project-cancelled:${project.id}` - }) - } - return { project: activated, current } - } catch (reason) { - if ( - remoteProjectActivationRequestRef.current !== requestId - ) { - return { project, current: false } - } - if (control.cancelRequested) { - notify({ - tone: 'info', - message: tRef.current( - 'notices.remoteProjectActivationCancelled' - ), - dedupeKey: `remote-project-cancelled:${project.id}` - }) - return { project, current: false } - } - const message = displayErrorMessage( - reason, - tRef.current('notices.remoteProjectActivationUnknownError') - ) - throw new Error( - tRef.current('notices.remoteProjectActivationFailed', { - message - }), - { cause: reason } - ) - } finally { - removeProgress() - if ( - remoteProjectActivationControlRef.current === control - ) { - remoteProjectActivationControlRef.current = undefined - setRemoteProjectActivation(undefined) - } - } - }, - [notify, remoteProjectsEnabled] - ) - - const cancelRemoteProjectActivation = useCallback((): void => { - const control = remoteProjectActivationControlRef.current - if (!control || control.cancelRequested) { - return - } - control.cancelRequested = true - setRemoteProjectActivation((current) => - current && current.projectId === control.projectId - ? { ...current, cancelling: true } - : current - ) - void window.goodbuddy.projects.remote.cancelCurrent().catch( - (reason: unknown) => { - if ( - remoteProjectActivationControlRef.current !== control - ) { - return - } - control.cancelRequested = false - setRemoteProjectActivation((current) => - current && current.projectId === control.projectId - ? { ...current, cancelling: false } - : current - ) - notify({ - tone: 'error', - message: tRef.current( - 'notices.remoteProjectActivationCancelFailed', - { - message: displayErrorMessage( - reason, - tRef.current( - 'notices.remoteProjectActivationCancelUnknownError' - ) - ) - } - ) - }) - } - ) - }, [notify]) - - const markRemoteProjectsRequiringActivation = useCallback( - (projectIds: string[]): void => { - const next = new Set(remoteProjectsRequiringActivationRef.current) - let changed = false - for (const projectId of projectIds) { - if (!next.has(projectId)) { - next.add(projectId) - changed = true - } - } - if (!changed) { - return - } - remoteProjectsRequiringActivationRef.current = next - setRemoteProjectsRequiringActivation(next) - }, - [] - ) - - const clearRemoteProjectActivationRequirement = useCallback( - (projectId: string): void => { - if (!remoteProjectsRequiringActivationRef.current.has(projectId)) { - return - } - const next = new Set(remoteProjectsRequiringActivationRef.current) - next.delete(projectId) - remoteProjectsRequiringActivationRef.current = next - setRemoteProjectsRequiringActivation(next) - }, - [] - ) - const resumeProjectConversationQueues = useCallback( (projectId: string): void => { const conversationIds = conversationsRef.current @@ -5094,57 +4887,6 @@ function App(): React.JSX.Element { [notify] ) - const handleSshHostUpdated = useCallback( - (hostId: string): void => { - if (!remoteProjectsEnabled) { - return - } - const affectedProjects = projects.filter( - (candidate) => - candidate.executionSpace.kind === 'ssh' && - candidate.executionSpace.hostId === hostId - ) - markRemoteProjectsRequiringActivation( - affectedProjects.map((project) => project.id) - ) - const project = affectedProjects.find( - (candidate) => candidate.id === activeProjectIdRef.current - ) - if (!project) { - return - } - void activateRemoteProject(project).then( - (activated) => { - if (!activated.current) { - return - } - clearRemoteProjectActivationRequirement(activated.project.id) - resumeProjectConversationQueues(activated.project.id) - }, - (reason: unknown) => { - notify({ - tone: 'error', - message: - reason instanceof Error - ? reason.message - : tRef.current( - 'notices.remoteProjectActivationUnknownError' - ) - }) - } - ) - }, - [ - activateRemoteProject, - clearRemoteProjectActivationRequirement, - markRemoteProjectsRequiringActivation, - notify, - projects, - remoteProjectsEnabled, - resumeProjectConversationQueues - ] - ) - useEffect(() => { let active = true const initialization = Promise.all([ @@ -5160,7 +4902,6 @@ function App(): React.JSX.Element { if (!project) { throw new Error('没有可用的本地项目') } - remoteProjectActivationRequestRef.current += 1 setActiveProjectId(project.id) setWorkMode( normalizeInteractiveWorkMode(project.defaultWorkMode) @@ -5814,12 +5555,15 @@ function App(): React.JSX.Element { ) }, [startNewConversation]) - const commitProjectSelection = (selected: AssistantProject): void => { + const commitProjectSelection = ( + selected: AssistantProject, + candidateConversations = conversationsRef.current + ): void => { setActiveProjectId(selected.id) setWorkMode( normalizeInteractiveWorkMode(selected.defaultWorkMode) ) - const conversation = conversationsRef.current.find( + const conversation = candidateConversations.find( (candidate) => candidate.projectId === selected.id && ( @@ -5873,39 +5617,20 @@ function App(): React.JSX.Element { if (!project) { return } - if (project.executionSpace.kind !== 'ssh') { - remoteProjectActivationRequestRef.current += 1 - commitProjectSelection(project) + if ( + project.executionSpace.kind === 'ssh' && + !remoteProjectsEnabled + ) { return } - if (!remoteProjectsEnabled) { - return - } - void activateRemoteProject(project).then( - (activated) => { - if (activated.current) { - clearRemoteProjectActivationRequirement(activated.project.id) - commitProjectSelection(activated.project) - resumeProjectConversationQueues(activated.project.id) - } - }, - (reason: unknown) => { - notify({ - tone: 'error', - message: - reason instanceof Error - ? reason.message - : t('notices.remoteProjectActivationUnknownError') - }) - } - ) + commitProjectSelection(project) + resumeProjectConversationQueues(project.id) } const createProject = async ( input: ProjectCreateInput ): Promise => { const project = await window.goodbuddy.projects.create(input) - remoteProjectActivationRequestRef.current += 1 setProjects((current) => [project, ...current]) setActiveProjectId(project.id) setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)) @@ -5959,9 +5684,7 @@ function App(): React.JSX.Element { ) : [project, ...current] ) - remoteProjectActivationRequestRef.current += 1 commitProjectSelection(project) - clearRemoteProjectActivationRequirement(project.id) resumeProjectConversationQueues(project.id) } @@ -5972,10 +5695,6 @@ function App(): React.JSX.Element { if (enabled) { return } - remoteProjectActivationRequestRef.current += 1 - if (remoteProjectActivationControlRef.current) { - cancelRemoteProjectActivation() - } if (activeProject?.executionSpace.kind !== 'ssh') { return } @@ -5987,48 +5706,85 @@ function App(): React.JSX.Element { const archiveProject = async (projectId: string): Promise => { await window.goodbuddy.projects.setArchived(projectId, true) - clearRemoteProjectActivationRequirement(projectId) const remaining = projects.filter((project) => project.id !== projectId) setProjects(remaining) - const next = remoteProjectsEnabled - ? remaining[0] - : remaining.find(isOrdinaryLocalProject) - if (next) { - selectProject(next.id) + if (projectId === activeProjectIdRef.current) { + const next = + remaining.find(isOrdinaryLocalProject) ?? + (remoteProjectsEnabled ? remaining[0] : undefined) + if (next) { + selectProject(next.id) + } } } - const deleteProject = async ( - projectId: string, - confirmation: string - ): Promise => { - await window.goodbuddy.projects.delete(projectId, confirmation) - clearRemoteProjectActivationRequirement(projectId) - const remainingProjects = projects.filter( - (project) => project.id !== projectId + const removeProjectsFromUi = ( + projectIds: readonly string[] + ): void => { + if (projectIds.length === 0) { + return + } + const deletedProjectIds = new Set(projectIds) + const referencesDeletedProject = ( + projectId: string | undefined + ): boolean => + projectId !== undefined && deletedProjectIds.has(projectId) + const remainingProjects = projectsRef.current.filter( + (project) => !deletedProjectIds.has(project.id) ) - const remainingConversations = conversations.filter( - (conversation) => conversation.projectId !== projectId + const currentConversations = conversationsRef.current + const deletedConversationIds = new Set( + currentConversations + .filter((conversation) => + referencesDeletedProject(conversation.projectId) + ) + .map((conversation) => conversation.id) + ) + const remainingConversations = currentConversations.filter( + (conversation) => + !referencesDeletedProject(conversation.projectId) + ) + setProjects((current) => + current.filter( + (project) => !deletedProjectIds.has(project.id) + ) + ) + setConversations((current) => + current.filter( + (conversation) => + !referencesDeletedProject(conversation.projectId) + ) ) - setProjects(remainingProjects) - setConversations(remainingConversations) setAssistantTasks((current) => - current.filter((task) => task.projectId !== projectId) + current.filter( + (task) => !referencesDeletedProject(task.projectId) + ) ) setAssistantArtifacts((current) => - current.filter((artifact) => artifact.projectId !== projectId) + current.filter( + (artifact) => + !referencesDeletedProject(artifact.projectId) + ) ) setAssistantMemories((current) => current.filter( (memory) => !( memory.scope === 'project' && - memory.scopeId === projectId + referencesDeletedProject(memory.scopeId) + ) && + !( + memory.scope === 'conversation' && + memory.scopeId !== undefined && + deletedConversationIds.has(memory.scopeId) ) ) ) setAssistantSchedules((current) => - current.filter((schedule) => schedule.projectId !== projectId) + current.filter( + (schedule) => + !referencesDeletedProject(schedule.projectId) + ) ) setAssistantHeartbeats((current) => current.flatMap((heartbeat) => { @@ -6036,46 +5792,34 @@ function App(): React.JSX.Element { return heartbeat } const projectIds = heartbeat.scope.projectIds.filter( - (id) => id !== projectId + (id) => !deletedProjectIds.has(id) ) return projectIds.length > 0 ? [{ ...heartbeat, scope: { kind: 'projects', projectIds } }] : [] }) ) - const next = remoteProjectsEnabled - ? remainingProjects[0] - : remainingProjects.find(isOrdinaryLocalProject) - if (next) { - setActiveProjectId(next.id) - setWorkMode( - normalizeInteractiveWorkMode(next.defaultWorkMode) - ) - const nextConversation = remainingConversations.find( - (conversation) => - conversation.projectId === next.id && - (next.kind !== 'channel' || - conversation.remote !== undefined) - ) - if (nextConversation) { - setActiveId(nextConversation.id) - } else if (next.kind === 'channel') { - setActiveId('') - } else { - const created = createConversation( - next.id, - runtimeSettings - ? getProjectDefaultRuntimeSelection(next, runtimeSettings) - : undefined, - t('conversation.greeting') - ) - setConversations((current) => [created, ...current]) - setActiveId(created.id) - } + if (!deletedProjectIds.has(activeProjectIdRef.current)) { + return } + const next = remainingProjects.find(isOrdinaryLocalProject) + if (next) { + commitProjectSelection(next, remainingConversations) + return + } + setActiveProjectId('') + setActiveId('') setView('chat') } + const deleteProject = async ( + projectId: string, + confirmation: string + ): Promise => { + await window.goodbuddy.projects.delete(projectId, confirmation) + removeProjectsFromUi([projectId]) + } + const newConversation = (): boolean => { return startNewConversation(activeProjectId || undefined) } @@ -6590,39 +6334,6 @@ function App(): React.JSX.Element { await releaseQueuedItem() return } - const dispatchProjectId = - queuedInput?.projectId ?? conversationSnapshot.projectId - const dispatchProjectReactivating = - Boolean( - dispatchProjectId && - remoteProjectActivationControlRef.current?.projectId === - dispatchProjectId - ) - const dispatchProjectRequiresActivation = - Boolean( - dispatchProjectId && - remoteProjectsRequiringActivationRef.current.has( - dispatchProjectId - ) - ) - if ( - dispatchProjectReactivating || - dispatchProjectRequiresActivation - ) { - if (queuedInput) { - await releaseQueuedItem() - } else { - notify({ - tone: 'info', - message: t( - dispatchProjectRequiresActivation - ? 'notices.remoteProjectReconnectRequired' - : 'notices.remoteProjectReactivating' - ) - }) - } - return - } if (!queuedInput && selectingContextFilesRef.current) { notify({ tone: 'info', @@ -7930,7 +7641,6 @@ function App(): React.JSX.Element { @@ -9482,20 +9191,15 @@ function App(): React.JSX.Element { ) )) || selectingContextFiles || - remoteProjectActivation?.projectId === - activeProjectId || - activeRemoteProjectRequiresActivation || !runtime?.available || runtimeSwitching || runtimeStatusKey !== activeRuntimeSelectionKey } onClick={() => void submit()} title={ - activeRemoteProjectRequiresActivation - ? t('notices.remoteProjectReconnectRequired') - : conversationExecutionRunning - ? t('composer.queueMessageTitle') - : t('composer.sendTitle') + conversationExecutionRunning + ? t('composer.queueMessageTitle') + : t('composer.sendTitle') } >