Compare commits

...
22 Commits
Author SHA1 Message Date
mesalogo edba31e873 chore: prepare GoodBuddy 0.11.11 release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Bump the desktop package metadata to 0.11.11 and package the approved Simplified Chinese and English release notes for complete remote conversation saves and discoverable Magic Notes list shortcuts.

Release note: GoodBuddy 0.11.11 修复远程项目长工具结果的会话持久化,并补充魔法笔记列表快捷输入提示。
2026-08-30 09:33:58 +08:00
mesalogo 0804f5ccd2 fix: preserve complete remote tool details
Managed SSH Runtime events could complete successfully and then fail local conversation persistence when tool details exceeded a second, smaller renderer-to-Main schema limit. Conversation persistence now stores the complete tool details already accepted from the Runtime, so long remote results no longer leave the final assistant message stuck in a streaming state.

Release note: 修复远程项目工具结果较长时会话持久化失败的问题;Runtime 已返回的工具详情现在会完整保存到本地会话。
2026-08-30 09:29:28 +08:00
mesalogo 29639ff516 fix: surface Magic Notes list shortcuts
Magic Notes already accepted Quill's native bullet, numbered, and checklist
autofill, but the editor did not make those shortcuts discoverable or protect
them with behavior coverage. The composer now explains the supported markers
in both languages, and tests verify they continue producing Delta list formats.

Release note: 魔法笔记编辑区现在提示项目符号、编号和待办清单快捷输入;输入标记后按空格即可创建对应列表。
2026-08-30 09:20:27 +08:00
mesalogo 22c80b2d94 fix: align Linux Agent verifier regression
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The Linux source validator still expected the full verifier's old payload mode
message after entrypoint metadata checks moved ahead of the payload loop. The
test now asserts the actual bounded entrypoint error returned by the optimized
verifier.

The unpublished desktop 0.11.9 notes move to 0.11.10. The Agent lock remains
on the successfully published Agent 0.11.9, and the recovery release still has
no LoongArch preview.

Release note: GoodBuddy 0.11.10 延续已批准的 Host 更新修复;未发布的桌面 0.11.9 由新版本替代。
2026-08-29 23:01:46 +08:00
mesalogo 85e14ca626 fix: make Host updates simple and fast
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Host repair could fail while adopting the published OpenCode Runtime, then
repeat package and payload verification and carry durable metadata snapshots
for recovery. Host preparation now verifies each acquired compound archive
once, adopts signed Agent and Runtime metadata without rereading payload trees,
and retries failed work by cleaning staging and preparing fresh.

The desktop and Agent versions are advanced to 0.11.9 with the approved
bilingual release notes. Remote Projects remain a disabled-by-default technical
preview, Agent packages remain separate Linux x64/arm64 releases, and this
release has no LoongArch preview.

Release note: 修复 Host 更新采用失败,移除重复远端包体校验,并将中断恢复简化为清理暂存后重新准备。
2026-08-29 22:31:39 +08:00
mesalogo a99b90cc02 fix: make Host environment updates fast and recoverable
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
Published Host updates could fail while the desktop reverified Runtime against
its bundled lock and repeatedly traversed remote payloads. Host preparation now
verifies and extracts once, adopts Agent and Runtime on one pinned connection,
and uses registered metadata for normal health, capability, and prompt paths.
Interrupted commits and adoption retain bounded recovery and rollback state.

Desktop and Agent versions are 0.11.8. Remote Projects remain a technical
preview; the Agent targets Linux x64 and arm64, with no LoongArch preview.

Release note: 修复正式环境 Host 更新失败并移除重复远端包体扫描;中断恢复与清理失败不再破坏已健康环境。
2026-08-29 17:20:25 +08:00
mesalogo ff4fde282f chore: prepare GoodBuddy 0.11.7 release notes
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Add the approved matching Chinese and English notes for the desktop 0.11.7
release and its separately published Agent 0.11.7 requirement. Remote Projects
remain a technical preview, and no LoongArch preview is included.
2026-08-29 14:03:52 +08:00
mesalogo f327341b00 fix: prevent duplicate queue replay and use cached Agents
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
An interrupted dispatch could leave the same user input both in conversation
history and in the pending queue, causing it to be sent again after restart.
Persisted user messages now retain their queue identity, startup recovery drops
only matching materialized inputs, and conversation branches discard that
ownership marker.

Explicit GoodBuddy transfer now leases a verified local Agent package when the
catalog is unavailable or the local version is at least as new, while a known
newer online package still wins. Prepare both the desktop and Agent locks for
0.11.7; the experimental LoongArch preview remains excluded.

Release note: 修复对话中断后同一条排队输入可能重复发送的问题;GoodBuddy 传输现在可复用已验证的本地 Agent 包,同时不会阻止已知的线上新版。
2026-08-29 13:31:56 +08:00
mesalogo 459cc21e3d fix: make managed SSH sessions fast and resilient
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
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,并优化远程环境安装、更新与状态布局。
2026-08-29 10:58:19 +08:00
lofyerandGitHub eb947213c8 Merge pull request #1 from mesalogo/jojocoding-patch-1
Update README.md
2026-08-27 21:43:15 +08:00
jojokeeandGitHub c2095037ee Update README.md
更新社群二维码
2026-08-27 21:33:33 +08:00
mesalogo 93bf3b908a fix: keep Chinese downloads on OSS loading state
Cross-platform packages / Validate source (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The Chinese download cards briefly exposed the GitHub fallback before OSS index validation completed. They now stay disabled while loading and only show GitHub if the OSS request or validation fails; English downloads remain unchanged.

Release note: 中文官网下载入口加载 OSS 索引期间不再短暂跳转 GitHub,仅在索引失败时回退。
2026-08-27 21:30:38 +08:00
mesalogo 38f6a23306 chore: prepare GoodBuddy 0.11.6 release notes
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Add the approved Simplified Chinese and English release notes for sidebar brand customization, independent Anthropic output limits, and restored composer and brand guidance.
2026-08-27 12:56:51 +08:00
mesalogo 12463ad593 feat: add sidebar brand customization
The primary sidebar brand was fixed to the built-in name, subtitle, and logo. Appearance settings now let users preview and save a custom brand name, localized Chinese and English subtitles, and a validated local PNG, JPEG, or WebP logo, with default-logo and full-reset actions.

Customization remains device-local and limited to the top-left brand area; installer, update, Agent, and protocol identities remain GoodBuddy. The desktop package version is now 0.11.6.

Release note: 新增“外观 → 品牌定制”,可修改左上角品牌名称、中英文副标题和本地 Logo,并支持实时预览、恢复默认及本机持久化。
2026-08-27 12:42:26 +08:00
mesalogo 618e505b8b fix: restore the workspace brand subtitle
The primary sidebar showed only the GoodBuddy name after its supporting brand line was removed. It now displays the localized “智能工作台” / “Intelligent Workspace” subtitle beneath the product name again.

Release note: 恢复左上角 GoodBuddy 标题下方的“智能工作台”副标题,使品牌区域的信息层级更加完整。
2026-08-27 10:30:15 +08:00
mesalogo 0a3bf6094a fix: restore composer guidance in input hint
The composer placeholder had been reduced to only the message prompt, hiding send, newline, and paste guidance unless users hovered the field. The full localized keyboard guidance now appears directly in the empty text area again for both chat and image generation.

Release note: 恢复聊天输入框中的发送、换行和粘贴快捷键提示,空输入框会再次直接显示完整操作说明。
2026-08-27 10:14:39 +08:00
mesalogo 957e2ecde7 fix: align homepage feature checklist
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Wrapped feature rows were centered independently, which shifted the lower-right item away from the column above it. The Chinese homepage now uses a shared two-column grid and collapses to one aligned column on narrow screens.

Release note: 官网首页特性列表现在按列对齐,桌面与移动端布局更整齐。
2026-08-27 08:04:36 +08:00
mesalogo 9316fed3e3 fix: streamline homepage introduction
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The homepage hero paragraph and page description now focus on GoodBuddy's desktop assistant and Agent Runtime capabilities. Download options and architecture support remain unchanged.

Release note: 官网首页产品介绍现更聚焦桌面助手与 Agent Runtime 能力。
2026-08-27 07:54:04 +08:00
mesalogo d6fea0c6c2 fix: simplify LoongArch download copy
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
The Linux download card now presents only the preview version, DEB format, size, and checksum. Preview index validation and fail-closed download behavior are unchanged.

Release note: 官网龙芯下载信息现仅展示版本、包格式、大小与校验值。
2026-08-27 07:45:03 +08:00
mesalogo d9a554b4b1 feat: add LoongArch preview download to the website
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
The Chinese Linux download card now includes a LoongArch processor option and fixes its package type to DEB. It reads a separate strictly validated OSS preview index, while the formal six-target release index and English GitHub downloads remain unchanged.

The page keeps three symmetric platform cards, disables only the LoongArch selection when its index is unavailable, and labels the package as unverified on real LoongArch hardware.

Release note: 官网 Linux 下载新增龙芯 LoongArch 处理器选项,选择后提供独立 DEB 预览包;正式下载与更新索引保持不变。
2026-08-26 23:23:27 +08:00
mesalogo b20c10f820 fix: separate Anthropic output and context limits
Direct Anthropic connections reused the configured context window as max_tokens, which could impose an unintended output value or send a provider-invalid request. Anthropic connections now expose an independent per-response maximum output setting, defaulting to 32K for the protocol-required field, while OpenAI direct requests remain without an added output token cap and direct tool rounds remain unmetered.

Release note: Anthropic 直连模型现在可单独配置单次最大输出,不再复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。
2026-08-26 22:53:49 +08:00
mesalogo 49700bc7cf fix: make LoongArch preview checksum portable
Generate SHA256SUMS from inside the timestamped artifact directory so it records only the DEB filename and remains verifiable after copying the preview output elsewhere.
2026-08-26 17:12:20 +08:00
163 changed files with 22835 additions and 13533 deletions
+80 -281
View File
@@ -4,26 +4,60 @@
These instructions apply to the entire repository.
GoodBuddy is a secure, cross-platform Electron desktop assistant. It uses
Electron, React, TypeScript, Vite, Vitest, and SQLite. User-facing copy is
primarily Simplified Chinese.
GoodBuddy is a cross-platform Electron desktop assistant for internal
organizational networks. It uses Electron, React, TypeScript, Vite, Vitest, and
SQLite. User-facing copy is primarily Simplified Chinese.
## Product Priorities
GoodBuddy is primarily an internal-network product. Unless the user states
otherwise, assume a trusted organizational environment, not a hostile public,
multi-tenant, or zero-trust deployment.
Use this order when requirements or implementation choices compete:
1. Working product functionality.
2. Performance and responsiveness.
1. Complete, working product functionality.
2. Measured performance and responsiveness.
3. Clear, low-friction user experience.
4. Simple, conventional, maintainable implementation.
5. Security controls proportional to a concrete, realistic risk.
5. Security controls only for a concrete risk in the actual deployment model.
Follow KISS and YAGNI. Do not add trust tiers, consent ceremonies, durable
state machines, protocol phases, cryptographic identities, quotas, recovery
machinery, or compatibility layers unless they are needed for an actual user
workflow or a demonstrated failure mode. Prefer the normal platform mechanism
and the smallest design that works. Test Hosts are test environments; do not
turn their workflows into production compliance exercises.
Functionality and performance are the primary acceptance criteria. Do not
disable, delay, restrict, or complicate a working product path for speculative
security hardening. Preserve the existing Electron, credential, user-data, and
release-signing boundaries, but do not expand them without a concrete current
risk or an explicit user requirement.
## Design Simplicity
Follow KISS and YAGNI. Use the smallest conventional design that completes the
real user workflow.
- A demonstrated failure authorizes only the smallest proportional fix, not a
general recovery, compatibility, or security framework.
- Prefer deleting bounded staging and retrying, idempotent operations, and
reconciliation from one authoritative source of truth. Do not persist copies
of state that can be derived or safely retried.
- Do not add durable snapshots, journals, versioned internal receipts, rollback
engines, compatibility readers, multi-phase state machines, protocol phases,
cryptographic identities, trust tiers, consent ceremonies, or quotas when
cleanup, retry, or reconciliation is safe.
- Before introducing any of that machinery, stop before coding. Explain the
reproducible current failure, why the simple alternative is insufficient,
and obtain the user's explicit approval for the added complexity. A reviewer
suggestion, test-only scenario, hypothetical crash, or possible future need
is not sufficient evidence.
- Performance outranks simplicity only for a measured bottleneck. Security
complexity requires a concrete threat at an actual trust boundary.
- Ignore hypothetical threats that require a hostile public network,
untrusted tenants, or zero-trust deployment unless that environment is an
explicit product requirement. Do not turn such findings into implementation
work.
- A complete workflow means the normal production path plus demonstrated
failure cases. It does not require exhaustive recovery from every
hypothetical interruption point.
- Test Hosts are test environments. Do not turn their workflows into production
compliance exercises.
When the user asks to implement a feature, deliver the complete usable user
workflow unless the user explicitly requests a prototype, scaffold, staged
@@ -48,7 +82,8 @@ work as the implemented feature.
- `build`: packaging scripts and icons.
- `out` and `dist`: generated output. Change source files instead.
Keep Electron security boundaries intact:
Keep these existing Electron security boundaries intact. They do not authorize
additional hardening beyond the actual internal-network threat model:
- Preserve context isolation and sandboxing.
- Never enable renderer Node integration.
@@ -77,7 +112,11 @@ Keep Electron security boundaries intact:
## Data and Compatibility
- Preserve existing user data and migrations.
- Preserve released user-owned data, migrations required by released data, and
supported external contracts.
- Compatibility does not apply to unshipped, branch-local, or safely disposable
internal operation records. Replace or discard those records instead of
adding migrations, legacy readers, versioned receipts, or recovery machinery.
- Do not weaken SQLite transaction, lifecycle, deduplication, or cleanup logic.
- Treat user workspaces and untracked files as user-owned.
- Keep Windows, macOS, Linux x64, and Linux arm64 behavior in mind.
@@ -88,6 +127,10 @@ Keep Electron security boundaries intact:
- Follow surrounding TypeScript and React patterns.
- Reuse installed libraries and shared contracts before adding dependencies.
- Keep changes focused. Do not add unrelated refactors or documentation.
- Reviewer findings do not expand task scope. Implement a finding only when it
identifies a reachable current failure, a released compatibility contract,
or reproducible user-visible harm. Record speculative concerns without
building machinery around them.
- Validate the real end-to-end path, not only schemas, mocks, injected test
doubles, or isolated infrastructure. A feature is not complete until its
normal UI action reaches the production implementation and produces the
@@ -180,276 +223,28 @@ mode.
Release note: 修复左上角项目设置与消息通道项目设置不一致的问题;现在从任一入口修改后,另一处会同步显示相同配置。
```
## Release Packaging
## Releases
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
workflow. It validates and builds `out` once, then packages on six native
runners: Windows, macOS, and Linux, each for x64 and arm64.
- The experimental Linux LoongArch (`loong64`) preview is built separately
through `build/loongarch-cross` by following
`docs/development/loongarch-preview-build.md`. It is not part of
`.github/workflows/packages.yml`, the six-runner GitHub Actions matrix,
standard release manifests, GitHub Release asset expectations, or the
production update index. Do not add it to those paths unless the user
explicitly requests a future production-support migration.
- Run the unified packager with
`npm run release:package -- --platform <platform> --arch <arch>`. It only
packages for the native host and writes to
`dist/release/<platform>-<arch>`.
- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for
macOS, and AppImage, DEB, and RPM for Linux. Every target includes
`release-manifest.json` with SHA-256 hashes.
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
bundled Continue and OpenCode runtimes, executable architecture, and package
signatures before atomically replacing a release directory.
- All desktop build paths, including local `portable`, package the Agent and
remote Runtime version locks plus the public signing-key registry without
embedding installable Linux Agent or remote Runtime payloads. Managed SSH is
optional: users explicitly download a compatible compound `.gbagent` package
from Settings or import one offline before GoodBuddy can install or update a
Host.
- Each independently published Linux x64 or arm64 `.gbagent` contains the
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
Runtime. Build both architectures on native GitHub Actions runners from one
immutable `agent-v<agentVersion>` tag.
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
bundle tooling, and tests in this repository so a desktop commit identifies
the exact Agent source it expects. Do not create a separate Agent repository
or long-lived Agent release branch unless the Agent becomes an independently
released product serving multiple clients.
- `.github/workflows/agents.yml` is the branch and pull-request build
verification workflow for GoodBuddy Agent. It must build Linux x64 and
arm64 on native runners from the checked-out commit, acquire the locked
official Node archive and locked OpenCode npm archive, verify every locked
digest, use only an ephemeral in-memory test signing identity, and verify the
resulting compound package and deterministic archive. It must not read
production signing secrets, publish installable release artifacts, or modify
the checked-in public key registry.
- `.github/workflows/agent-release.yml` is the only production compound Agent
publication path. It requires an annotated immutable Agent tag, the protected
`agent-signing` Environment, native x64/arm64 builds, one production
GoodBuddy signing identity for every layer of the compound package and
catalog, a non-Latest GitHub Agent Release, and synchronized immutable
Beijing OSS objects plus the final signed latest-catalog pointer.
- `.github/workflows/packages.yml` and `release:package` build only desktop
products. Missing Agent packages, remote Runtime inputs, or GoodBuddy signing
credentials must never block an ordinary desktop package or release.
- Keep electron-builder invocations on `--publish never`. Main-branch builds
run validation and build the production bundle without running the native
package matrix. Manual builds upload 30-day GitHub Actions artifacts.
Version-tag builds verify and aggregate packages before publishing GitHub
Release assets. The macOS jobs sign, notarize, and verify packages when all
five Apple credentials are configured. With no Apple credentials they must
use the explicit `--unsigned` path, warn that Gatekeeper may block the
packages, and still complete; a partial credential set must fail rather than
silently downgrade.
- Keep `ELECTRON_CACHE` and `ELECTRON_BUILDER_CACHE` under
`${{ runner.temp }}` in step-level workflow contexts. A cache beneath the
repository inherits the root `"type": "module"` and breaks electron-builder's
CommonJS macOS icon tool.
- Tag builds must use `v${package.version}`. The workflow also supports manual
dispatch and main-branch changes to release tooling.
Detailed release operations are governed by
[`docs/development/release-runbook.md`](./docs/development/release-runbook.md).
Read that runbook only when preparing, publishing, recovering, or verifying a
desktop or GoodBuddy Agent release. It is an operator and CI procedure, not an
authorization to add product persistence or recovery machinery.
### Agent Release Process
Repository-wide release invariants:
- Agent releases use annotated `agent-v${agent-runtime-lock.agentVersion}` tags
and are separate from desktop `v${package.version}` releases. Confirm the
exact Agent release commit and tag with the user before creating or pushing
either.
- The tagged commit must be reachable from protected `main`. The two native
jobs build the compound x64/arm64 packages from the same source and locks;
the catalog job rejects missing architectures, changed bytes for an existing
version/architecture identity, invalid prior signatures, and incompatible
matrix metadata.
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
must use `--latest=false`; they must never replace the desktop release marked
Latest. Publish immutable packages and the versioned catalog to
`agent-releases/v<agentVersion>/` in Beijing OSS, publish the GitHub Agent
Release, then atomically update the single `agent-releases/latest.json`
pointer to that version's immutable catalog and signature.
- Do not report an Agent release complete until both public package
architectures match the signed catalog by size and SHA-256, the GitHub and
OSS catalog bytes/signatures are identical, the application can read the
selected source, and GitHub's repository Latest tag remains the desktop tag.
### Tagged Release Process
Every version-tag release must follow this sequence. A branch-only push does
not require release notes.
1. Confirm that the user wants a release tag and identify the exact release
commit and the new `package.json` version. Separately ask whether this
release should also receive an experimental LoongArch preview build; never
infer that choice from an earlier release.
2. Find the latest stable version tag reachable before the release commit and
inspect the complete commit and file diff from that tag to the release
commit. For the first tagged release, inspect the relevant repository
history instead.
3. Draft concise, user-facing release notes in both Simplified Chinese and
English based only on verified changes in that range. Use the titles
`GoodBuddy <version> 更新内容` and
`What's New in GoodBuddy <version>`, with corresponding `功能更新` /
`Features` and `问题修复` / `Bug Fixes` sections when applicable. The two
language versions must describe the same changes. Do not expose
internal-only details, credentials, private content, or unverified claims.
4. Show the exact bilingual release-note draft to the user and wait for
explicit approval. If the release commit or either language version changes
after approval, inspect the updated tag range and request approval again.
5. Write the approved notes to the single entry for the release version in
`resources/release-notes.json`. A failed unpublished candidate whose content
is carried forward must not retain a duplicate packaged entry.
6. Verify that `package.json`, the root `package-lock.json` version, and
`package-lock.json.packages[""].version` all equal the release version. Run
`npm run release:notes:verify`, the required source validators, the
production build, and any native candidate launch probe available on the
current host. The six native CI jobs remain the cross-platform authority.
If the user requested a LoongArch preview in step 1, build it separately
from this exact candidate by following the LoongArch build document, record
its size and SHA-256, and keep it outside the standard GitHub/OSS release
asset set unless the user separately authorizes publishing it.
7. Fetch both remotes immediately before tagging. Inspect any remote branch
movement instead of overwriting or silently merging it. Confirm the working
tree is clean, the candidate tag is unused locally and remotely, and the
exact approved commit has not changed.
8. Only after all previous steps pass, create an annotated
`v${package.version}` tag at the exact approved commit. Push `main` to
`origin` and `github`, verify both branch SHAs, then push the tag to both
remotes and verify each peeled tag SHA (`refs/tags/<tag>^{}`) equals the
release commit.
9. Keep both approved language versions as the single source for the GitHub
Release body and the packaged first-open release-notes modal. The modal
displays the release notes matching the current interface language and
contains no button linking to a full release page.
10. Observe the tag workflow through publication and complete the public
verification checklist below. A successful push is not a completed
release.
### OSS Publication Contract
The tagged release job publishes through the GitHub Environment selected by
`.github/workflows/packages.yml` (`aliyun-oss-release`) and reads the following
effective GitHub Actions variables. Before a release or same-tag rerun can
publish, verify that repository-, organization-, or environment-level
resolution exposes:
- `ALIYUN_OSS_BUCKET=goodbuddy`
- `ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com`
- a non-empty `ALIYUN_OIDC_PROVIDER_ARN` matching
`acs:ram::*:oidc-provider/*`
- a non-empty `ALIYUN_ROLE_ARN` matching `acs:ram::*:role/*`
For environment-scoped values, use the exact Environment name from the
workflow; do not assume a similarly named UI environment such as `Production`
contains the active variables.
The Bucket and Endpoint are a deployment contract, not interchangeable
examples. They must stay aligned with the trusted URL checks in
`src/main/version-checker.ts`, `sites/app.js`, website validation, and related
tests. A host, Bucket, Region, or CDN migration must update and validate every
surface together before a new release.
- Use GitHub OIDC and the RAM Role to obtain short-lived STS credentials.
Never add long-lived AccessKeys to repository or environment secrets.
- Keep `ossutil` pinned. Its V4 signing requires the Region; derive it from the
canonical Endpoint, verify that the production value resolves to
`cn-beijing`, and pass `--region` to every `ossutil cp`, including the final
`latest.json` update.
- Grant the RAM Role only the actions and prefixes required by the workflow.
It must be able to write immutable version objects and the final latest
pointer without granting unrelated administration privileges.
- Upload release assets and `site-release.json` under the immutable
`releases/<tag>/` prefix first. Verify all 14 installer URLs publicly before
creating or publishing the GitHub Release. Update
`releases/latest.json` only after the GitHub Release is public and all prior
checks succeeded.
- The expected GitHub Release contains 22 assets: 14 installers (two formats
for each Windows/macOS target and three formats for each Linux target), six
renamed target manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
`site-release.json` is an OSS publication artifact, not a GitHub Release
asset.
### Failed Tag Recovery
Classify a failed tag by the external side effects that completed before
choosing a recovery:
- If no source or release metadata must change, correct only the external
configuration and use **Re-run failed jobs** for the same immutable tag. Do
not change, move, delete, or recreate the tag.
- If immutable OSS objects were partially uploaded but their source bytes are
unchanged, a same-tag rerun may idempotently re-upload or verify them. Never
point `latest.json` at a partially verified prefix.
- If the GitHub Release is already public but the final latest-pointer step
failed, it is a published version. Keep its packaged notes and rerun the
failed release job for the same tag; do not classify it as an unpublished
candidate.
- If code or release metadata must change, keep the failed tag immutable,
increment the patch version, obtain approval for the revised exact release
commit and notes, and create a new tag. Do not reuse the failed version.
When recovering from a version tag whose workflow never published a public
GitHub Release:
- If a code or metadata change requires a higher version and a new tag, carry
the failed candidate's approved user-facing notes forward into the recovery
version, then remove the superseded failed version's entry from
`resources/release-notes.json`.
- The packaged first-open modal must show that carried-forward content only
once under the recovery version. Never retain both the failed version and
its cumulative recovery copy, because users upgrading across them would see
duplicate content.
- Never remove the packaged history for a version that successfully published
a GitHub Release. Verify the failed release state before treating an entry as
superseded.
### Post-Publication Verification
Do not report a release complete until all of the following are verified:
1. The tag workflow and all six native package jobs succeeded. Verify the
recorded macOS signing mode: signed builds must pass `codesign`, `spctl`,
and `stapler`; unsigned builds must record the Gatekeeper caveat in the
Actions log and job summary. In the final release job, explicitly verify
the OSS configuration, OIDC authentication, release-index generation,
immutable upload, public asset check, GitHub Release publication, and
latest-pointer steps.
2. The public GitHub Release is non-draft, non-prerelease, marked Latest, and
uses the expected tag and title. Its body must exactly match the Markdown
generated from the approved packaged bilingual notes.
3. The GitHub asset set has exactly the expected 22 names and every asset is
uploaded. Compare installer sizes and SHA-256 digests with the aggregate
manifest and `SHA256SUMS`.
4. The Beijing `releases/latest.json` returns HTTP 200, has the expected stable
version, exact six targets and 14 installer entries, the trusted Beijing
URLs, and the GitHub fallback URL. It must match the immutable
`releases/<tag>/site-release.json`.
5. All 14 public installer URLs accept `HEAD` without redirects and report the
declared size. For small JSON/checksum metadata, prefer a `GET` byte and
digest comparison; OSS may gzip JSON responses and omit an uncompressed
`Content-Length` on `HEAD`.
6. The live website successfully fetches the index and produces the 14 correct
platform/architecture/format links. Exercise the application's actual
mirror checker against the public index for all six targets.
7. Both remote `main` refs and both peeled tag refs still equal the approved
release commit, and the local working tree is clean.
Never create or push a release tag, and never push a previously created
release tag, before the release-note draft has received explicit approval.
- Before a push that updates the `github` remote, ask whether the user wants a
release tag unless they already specified that choice. A branch-only push
does not require a version bump or tag. When the user requests a release,
verify that `package.json` and `package-lock.json` contain the same release
version, create `v${package.version}` at the exact commit being pushed, and
push that tag so the native package matrix and GitHub Release run.
- Never move or reuse an existing release tag. If `v${package.version}` already
exists locally or on a remote at another commit, increment the package
version and create a new matching tag before the release push.
- Verified release baseline on 2026-08-18: commit
`60119a4317118fa3f077db0382664f15266a6682`, annotated tag `v0.10.4`, and
GitHub Actions run `32038633609` attempt 2 succeeded through all six native
packages, GitHub Release publication, Beijing OSS publication, and the final
`latest.json` switch.
- Never create or push a release tag before the user approves the exact release
commit and bilingual release notes.
- Never move, delete, recreate, or reuse a release tag.
- Keep desktop and Agent release paths, tags, artifacts, and Latest status
separate.
- Native GitHub Actions jobs are the cross-platform packaging authority. Never
run local Windows packaging or packaged-app probes unless the user explicitly
requests them.
- LoongArch is a separate experimental preview. Build or publish it only when
the user explicitly requests it for that release.
- Never commit or publish credentials. Production publication uses the existing
protected environments and short-lived credentials described in the runbook.
## Validation
@@ -474,6 +269,10 @@ Before committing or pushing, inspect `git status`, `git diff`, and
`git diff --cached`. Do not commit secrets, local databases, logs, generated
credentials, or private user artifacts.
Before a push that updates the `github` remote, ask whether the user wants a
release tag unless they already specified that choice. A branch-only push does
not require a version bump or tag.
This repository has two synchronized remotes, `origin` and `github`. Unless the
user explicitly names a remote, every requested push must update the current
branch on both remotes. When the user requests a release tag, push the new tag
+63 -13
View File
@@ -151,11 +151,54 @@ 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`。Main 只持久化清理 operation staging 所需的 operation ID,不保存
Agent/Runtime metadata 副本。操作中断或 adoption 失败后,下一次更新尽力清理旧暂存并
重新 prepare,不恢复或续写旧操作。确认 adoption 后显式 cleanup。Host 在 prepare
解包时完成一次完整 payload 校验;commit 只复核签名 metadata
并原子发布,Agent/Runtime 注册后的 health、capabilities 和 prompt 启动不再遍历或哈希
完整 payload。cleanup 失败不回滚已健康环境,也不阻塞下一次 fresh prepare。
进入“主机与远程执行”设置页只读取本地 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 +216,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;本地 pending record 只保存清理暂存所需的
operation ID。失败或中断后的下一次更新尽力清理旧暂存并重新 prepare,不保存或恢复
Agent/Runtime metadata 副本。生产构建命令为:
```bash
node build/agent-package.cjs build \
@@ -271,15 +319,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
+4 -3
View File
@@ -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.9。
- [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 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 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 进程,高风险控制面操作单独确认。
## 规划原则
+4 -3
View File
@@ -20,11 +20,11 @@ GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
- **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出;原生能力清单将 Tools 与 Commands、LSP、Formatters 分开,并显示来源及 Ask/Execute 可用性。内置 OpenCode 提供 Agent、Tool、Command 与原生 Compact,Continue 提供 Rules、Prompt 预设、结构化提问与 GoodBuddy 手动摘要压缩,并明确标记当前版本无法静态发现原生 Tools。
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。Anthropic 连接可独立配置协议必填的单次最大输出,不再复用上下文窗口;OpenAI 直连请求不额外设置输出 Token 上限。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
- **工作管理**:集中管理 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 图表。
@@ -96,7 +96,8 @@ npm run lint
目前采用微信群的方式供大家高效交流,大家可以微信扫码进入社区群:
<img width="993" height="1335" alt="image" src="https://github.com/user-attachments/assets/4893715a-edaf-4db6-8a6d-d529b972db8e" />
<img width="1250" height="1743" alt="6ae8664c83b2f182aa81ae94afe691fe" src="https://github.com/user-attachments/assets/10bb7ac2-0bc1-41a9-978d-c2862923fcd6" />
## 开源许可
+8
View File
@@ -298,6 +298,13 @@
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
颜色或动效表达状态。
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 助手工作栏
@@ -618,6 +625,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.5",
"agentVersion": "0.11.9",
"protocol": {
"major": 2,
"minor": 0
@@ -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
}
+4 -1
View File
@@ -300,7 +300,10 @@ assert_loongarch_elf \
assert_loongarch_elf \
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
sha256sum "${artifact}" > "${artifact_root}/SHA256SUMS"
(
cd "${artifact_root}"
sha256sum "${artifact_name}" > SHA256SUMS
)
cat > "${artifact_root}/preview-manifest.json" <<EOF
{
"formatVersion": 1,
+1
View File
@@ -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)
@@ -2,14 +2,27 @@
## 状态
本文记录当前代码实现,不定义额外的信任框架。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-29 的当前代码实现,不定义额外的信任框架。“新增 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 后,Agent `0.11.9` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
签名累计目录;当前桌面源码准备 `0.11.10`,修复 Host 更新并消除重复 payload 验证。失败的
`agent-v0.11.3` 保持不可变且未发布。
## 产品语义
远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立
“远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
项目或凭据;进行中的激活允许取消,当前远程项目切回第一个普通本地项目。应用启动仍固定
项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
远程项目只有两种工作模式:
@@ -28,7 +41,7 @@ Main
-> SSH Host 与 Host Key 管理
-> SSH connection pool
-> 签名 Agent / Runtime 安装
-> 远程项目验证与 SQLite 事务
-> 远程项目当前环境准备与稳定配置 SQLite 事务
-> Main-owned model bridge
SSH attach
-> 远端私有 Unix socket
@@ -46,33 +59,95 @@ 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 安装与生命周期
- Agent bundle 通过 manifest、Ed25519 签名、payload digest、平台和架构校验。
- 安装使用当前 SSH 用户目录中的 GoodBuddy-owned 路径和 side-by-side digest 目录。
- Agent 代码与固定 Node Runtime 是独立 payload。升级时如果当前 Host 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 绕过持久化绑定校验。
- Agent 代码与固定 Node Runtime 一起位于签名 compound payload。显式安装或重装从本次
已完整校验的准备目录原子发布整个 Agent,不扫描旧安装、不创建跨版本 Node 硬链接;
Agent 不匹配不会阻止升级,也不会覆盖 GoodBuddy-owned 路径以外的内容。
- 桌面包只携带版本 lock 与 production 公钥,不携带远端 payload。Host 卡片只有一个按
版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮;次级
SegmentedControl 选择“自动”“Host 下载”或“GoodBuddy 传输”,默认“自动”且不持久化。
每个复合包绑定 Agent、固定 Node 与桌面维护的 OpenCode Runtime 精确工件。添加或重新
验证 Host 只保存并探测,不自动传输完整包。
- Host 管理独占 Agent/Runtime 的包准备、更新和完整 payload 验证。已有项目的打开和切换
只更新本地项目选择。Workspace 和执行路径按需读取 Host current registry,
执行固定 `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`。
Main 只保存清理 operation staging 所需的 operation ID。中断或 adoption 失败后,下次
更新尽力清理旧暂存并重新 prepare,不保存或恢复 Agent/Runtime metadata 副本。确认
adoption 后才显式 cleanup;cleanup 失败不回滚健康环境,也不阻塞下一次 fresh prepare。
Host 在 prepare 解包时
完成一次完整 payload 校验,commit 和后续注册检查不再遍历包体。首次 bootstrap 不依赖
既有 Agent daemon,也不要求 format v1 归档携带 `agent/lib/package-installer.cjs`。
- 即使版本号均为当前版本,Host 卡片仍提供同版本“重新安装”,用于修复 registry、签名
或安装 identity 异常。GoodBuddy-owned 同 digest 目录损坏时先隔离后替换,并在发布或
原子 commit 失败时恢复;commit 成功后的激活失败保留新发布内容,下次点击重新安装时
清理旧暂存并重新 prepare/adopt。不要求删除 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 状态和有界能力。
@@ -112,8 +187,8 @@ Execute 不经过 Ask 的 bubblewrap profile:
- 直接启动已签名 Runtime entrypoint;
- 使用 Main-only 模型桥时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
Node Runtime;完全相同且已验证的 Node 可在 Agent 版本间复用。进程 owner 校验
最终 Node executable,而不是已经被替换的 shell launcher 路径;
Node Runtime;进程 owner 校验最终 Node executable,而不是已经被替换的 shell
launcher 路径;
- `cwd` 为项目 Workspace;
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
@@ -132,7 +207,8 @@ 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 会被确认但不会再次交给上层。
- Runtime 事件进入桌面会话后,Renderer 和 Main 将完整工具详情保存到本地 SQLite,不再对已接受的工具输入、输出、错误或摘要施加额外的会话字段长度限制。
- 同一 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 +246,8 @@ 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`,需要由新的复合包发布流程另行验证。
的双架构验收。Agent `0.11.9` 后续已由原生 workflow 发布并公开验证双架构工件;
当前源码 lock 固定为 `0.11.9`。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
@@ -182,31 +259,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 验证表。
## 资源与发布
@@ -0,0 +1,326 @@
# 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;不再先完整扫描 payload、再重复解包和哈希。
3. `commit` 只复核有界的签名 manifest、准备回执和入口 metadata,再以目录 rename 发布
已准备内容及包内认证的公钥 registry/Runtime lock;不会重读归档或遍历 payload。
4. commit 后在同一条已固定身份的 SSH lease 上执行 Agent bootstrap/readiness 和 Runtime
activate。新 Agent 最多完整验证一次,新 Runtime 最多完整验证一次;注册成功后的
health、capabilities 和 prompt 启动只读取 registry、签名 manifest 与入口 metadata。
5. Main 确认 adoption 后显式执行 cleanup。cleanup 失败不回滚已经健康的 Agent/Runtime,
也不阻塞下一次更新。
这不是两个并行安装管理器,也不存在把 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 与 Runtime
digest 目录,不为节省本机磁盘写入而重新遍历旧目录,也不使用跨安装硬链接。
- 操作中断后不续写旧 operation。下一次更新尽力清理其 staging,并从签名候选重新
prepare;正常 cleanup 随后删除本次准备目录。
只有真实包体和发布频率证明网络成本仍不可接受时,才另行设计 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 只为该 Host 持久化一个不含凭据的 version-1 pending operation ID,用于
下一次操作开始时尽力清理旧 staging。无论旧 cleanup 成功、目标已变化还是旧记录损坏,
都删除这条可丢弃记录并开始 fresh prepare;不保存远端 metadata 副本,不续写旧 commit,
也不跨重启恢复 adoption。当前操作中可确定的失败会立即尽力 cleanup。环境已健康后的
cleanup 失败不回滚环境,也不阻塞下一次更新。
## 项目创建与切换
- 新建托管 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 环境准备编排器直接完成一次 package prepare/commit 及 Agent/Runtime adoption。
Workspace 和执行路径只读取 current registry 并调用 Host 侧轻量 identity/health 检查,
在当前进程内复用已确认 identity 和 Agent 连接;Host 编辑或环境更新定向清空这些缓存。
这些路径不会取得安装包、发布组件或通过 SFTP 扫描 payload。
- Host 卡片即使显示相同版本也保留显式重装操作,用于修复 registry、签名或 payload
identity 异常;若同一 digest 的 GoodBuddy-owned 目录校验失败,重装会先隔离旧目录,
并在原子 commit 失败时恢复旧目录。commit 成功后的 adoption 失败保留新发布内容,
下次重装清理旧暂存并重新 prepare/adopt。重装不要求删除 Host、凭据或项目,也不会
处理所有者异常、符号链接或非 GoodBuddy 管理路径。
- 由桌面控制面打包有界 one-shot installer,并通过固定短命令 `exec sh -s` 和 stdin
发送脚本及结构化输入,避免 SSH command 长度限制;既有 package format v1 归档内固定
Node 负责解码和运行它。
- Preload 只暴露明确的 Host 探测、准备、取消和进度方法,不暴露 URL 或任意 SSH 命令。
- 现有 Agent/Runtime side-by-side、固定 SSH lease、Host 端一次完整验证、注册后轻量检查、
连接失效和项目稳定配置事务继续作为唯一实现。
## 迁移
- 已保存 Host 打开设置时只显示本地记录,不自动连接或检查版本;用户点击对应 Host 的
“刷新版本”后才执行只读状态探测。项目切换不依赖该按钮,也不建立连接。
- 数据库升级保留远程项目、Host ID、远端路径、Runtime 选择及关联会话/任务,并删除旧的
Agent/Runtime/Workspace/Host 验证列和 Runtime 验证表。下一次实际远程操作从 Host
current 环境重新取得所有 live identity。
- 如果 Host 未就绪或版本不兼容,项目仍可在本地选择,但实际远程操作失败;不再走旧的
隐式安装。
- 已下载或导入的本地 `.gbagent` 保持可用,不能因加入远程直连而迁移或删除。
- pending operation 保持 version 1,只记录 Host、目标 identity 与 operation ID,用于
尽力清理旧 staging;它不是远端环境状态或恢复日志。
- 远程直连继续使用现有 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. Host 对本次候选只完成一次完整 payload 校验;commit、adoption、health、capabilities
和 prompt 启动不得再次遍历或哈希完整 Agent/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. 中断或 adoption 失败后的下一次更新只尽力清理旧 staging,然后 fresh prepare;
pending record 不包含远端 metadata 副本。已健康环境的 cleanup 失败不触发回滚,
也不阻塞下一次更新。
+292
View File
@@ -0,0 +1,292 @@
# GoodBuddy Release Runbook
Read this runbook only when preparing, publishing, recovering, or verifying a
desktop or GoodBuddy Agent release.
This is an operator and CI procedure. It does not authorize adding application
persistence, snapshots, journals, rollback engines, recovery protocols, or
compatibility layers to automate release operations. Determine release recovery
from the authoritative GitHub, OSS, workflow, and Git state, then use the
smallest existing idempotent rerun or a new immutable version as specified
below.
## Release Packaging
- Unless the user explicitly requests a local Windows package, never run local
Windows packaging or launch probes as part of release preparation. In
particular, do not run `npm run portable`, Windows
`npm run release:package`, direct `electron-builder` packaging, or launch a
locally packaged Windows app before publishing a release. Source validation
and the native GitHub Actions jobs are the release authorities.
- After a release is published, confirm only that the expected Windows assets
exist in the public release metadata. Do not locally rebuild or launch the
Windows package, download or hash published installers, attach browser
automation to a packaged app, or perform exhaustive per-asset network probes
unless the user explicitly asks for that additional validation.
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
workflow. It validates and builds `out` once, then packages on six native
runners: Windows, macOS, and Linux, each for x64 and arm64.
- The experimental Linux LoongArch (`loong64`) preview is built separately
through `build/loongarch-cross` by following
`docs/development/loongarch-preview-build.md`. It is not part of
`.github/workflows/packages.yml`, the six-runner GitHub Actions matrix,
standard release manifests, GitHub Release asset expectations, or the
production update index. Do not add it to those paths unless the user
explicitly requests a future production-support migration.
- Run the unified packager with
`npm run release:package -- --platform <platform> --arch <arch>`. It only
packages for the native host and writes to
`dist/release/<platform>-<arch>`.
- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for
macOS, and AppImage, DEB, and RPM for Linux. Every target includes
`release-manifest.json` with SHA-256 hashes.
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
bundled Continue and OpenCode runtimes, executable architecture, and package
signatures before atomically replacing a release directory.
- All desktop build paths, including local `portable`, package the Agent and
remote Runtime version locks plus the public signing-key registry without
embedding installable Linux Agent or remote Runtime payloads. Managed SSH is
optional: users explicitly download a compatible compound `.gbagent` package
from Settings or import one offline before GoodBuddy can install or update a
Host.
- Each independently published Linux x64 or arm64 `.gbagent` contains the
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
Runtime. Build both architectures on native GitHub Actions runners from one
immutable `agent-v<agentVersion>` tag.
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
bundle tooling, and tests in this repository so a desktop commit identifies
the exact Agent source it expects. Do not create a separate Agent repository
or long-lived Agent release branch unless the Agent becomes an independently
released product serving multiple clients.
- `.github/workflows/agents.yml` is the branch and pull-request build
verification workflow for GoodBuddy Agent. It must build Linux x64 and
arm64 on native runners from the checked-out commit, acquire the locked
official Node archive and locked OpenCode npm archive, verify every locked
digest, use only an ephemeral in-memory test signing identity, and verify the
resulting compound package and deterministic archive. It must not read
production signing secrets, publish installable release artifacts, or modify
the checked-in public key registry.
- `.github/workflows/agent-release.yml` is the only production compound Agent
publication path. It requires an annotated immutable Agent tag, the protected
`agent-signing` Environment, native x64/arm64 builds, one production
GoodBuddy signing identity for every layer of the compound package and
catalog, a non-Latest GitHub Agent Release, and synchronized immutable
Beijing OSS objects plus the final signed latest-catalog pointer.
- `.github/workflows/packages.yml` and `release:package` build only desktop
products. Missing Agent packages, remote Runtime inputs, or GoodBuddy signing
credentials must never block an ordinary desktop package or release.
- Keep electron-builder invocations on `--publish never`. Main-branch builds
run validation and build the production bundle without running the native
package matrix. Manual builds upload 30-day GitHub Actions artifacts.
Version-tag builds verify and aggregate packages before publishing GitHub
Release assets. The macOS jobs sign, notarize, and verify packages when all
five Apple credentials are configured. With no Apple credentials they must
use the explicit `--unsigned` path, warn that Gatekeeper may block the
packages, and still complete; a partial credential set must fail rather than
silently downgrade.
- Keep `ELECTRON_CACHE` and `ELECTRON_BUILDER_CACHE` under
`${{ runner.temp }}` in step-level workflow contexts. A cache beneath the
repository inherits the root `"type": "module"` and breaks electron-builder's
CommonJS macOS icon tool.
- Tag builds must use `v${package.version}`. The workflow also supports manual
dispatch and main-branch changes to release tooling.
### Agent Release Process
- Agent releases use annotated `agent-v${agent-runtime-lock.agentVersion}` tags
and are separate from desktop `v${package.version}` releases. Confirm the
exact Agent release commit and tag with the user before creating or pushing
either.
- The tagged commit must be reachable from protected `main`. The two native
jobs build the compound x64/arm64 packages from the same source and locks;
the catalog job rejects missing architectures, changed bytes for an existing
version/architecture identity, invalid prior signatures, and incompatible
matrix metadata.
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
must use `--latest=false`; they must never replace the desktop release marked
Latest. Publish immutable packages and the versioned catalog to
`agent-releases/v<agentVersion>/` in Beijing OSS, publish the GitHub Agent
Release, then atomically update the single `agent-releases/latest.json`
pointer to that version's immutable catalog and signature.
- Do not report an Agent release complete until both public package
architectures match the signed catalog by size and SHA-256, the GitHub and
OSS catalog bytes/signatures are identical, the application can read the
selected source, and GitHub's repository Latest tag remains the desktop tag.
### Tagged Release Process
Every version-tag release must follow this sequence. A branch-only push does
not require release notes.
1. Confirm that the user wants a release tag and identify the exact release
commit and the new `package.json` version. Separately ask whether this
release should also receive an experimental LoongArch preview build; never
infer that choice from an earlier release.
2. Find the latest stable version tag reachable before the release commit and
inspect the complete commit and file diff from that tag to the release
commit. For the first tagged release, inspect the relevant repository
history instead.
3. Draft concise, user-facing release notes in both Simplified Chinese and
English based only on verified changes in that range. Use the titles
`GoodBuddy <version> 更新内容` and
`What's New in GoodBuddy <version>`, with corresponding `功能更新` /
`Features` and `问题修复` / `Bug Fixes` sections when applicable. The two
language versions must describe the same changes. Do not expose
internal-only details, credentials, private content, or unverified claims.
4. Show the exact bilingual release-note draft to the user and wait for
explicit approval. If the release commit or either language version changes
after approval, inspect the updated tag range and request approval again.
5. Write the approved notes to the single entry for the release version in
`resources/release-notes.json`. A failed unpublished candidate whose content
is carried forward must not retain a duplicate packaged entry.
6. Verify that `package.json`, the root `package-lock.json` version, and
`package-lock.json.packages[""].version` all equal the release version. Run
`npm run release:notes:verify`, the required source validators, the
production build. The six native CI jobs remain the cross-platform
authority; do not add local packaging or packaged-app launch probes unless
the user explicitly requests them.
If the user requested a LoongArch preview in step 1, build it separately
from this exact candidate by following the LoongArch build document, record
its size and SHA-256, and keep it outside the standard GitHub/OSS release
asset set unless the user separately authorizes publishing it.
7. Fetch both remotes immediately before tagging. Inspect any remote branch
movement instead of overwriting or silently merging it. Confirm the working
tree is clean, the candidate tag is unused locally and remotely, and the
exact approved commit has not changed.
8. Only after all previous steps pass, create an annotated
`v${package.version}` tag at the exact approved commit. Push `main` to
`origin` and `github`, verify both branch SHAs, then push the tag to both
remotes and verify each peeled tag SHA (`refs/tags/<tag>^{}`) equals the
release commit.
9. Keep both approved language versions as the single source for the GitHub
Release body and the packaged first-open release-notes modal. The modal
displays the release notes matching the current interface language and
contains no button linking to a full release page.
10. Observe the tag workflow through publication and complete the public
verification checklist below. A successful push is not a completed
release.
### OSS Publication Contract
The tagged release job publishes through the GitHub Environment selected by
`.github/workflows/packages.yml` (`aliyun-oss-release`) and reads the following
effective GitHub Actions variables. Before a release or same-tag rerun can
publish, verify that repository-, organization-, or environment-level
resolution exposes:
- `ALIYUN_OSS_BUCKET=goodbuddy`
- `ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com`
- a non-empty `ALIYUN_OIDC_PROVIDER_ARN` matching
`acs:ram::*:oidc-provider/*`
- a non-empty `ALIYUN_ROLE_ARN` matching `acs:ram::*:role/*`
For environment-scoped values, use the exact Environment name from the
workflow; do not assume a similarly named UI environment such as `Production`
contains the active variables.
The Bucket and Endpoint are a deployment contract, not interchangeable
examples. They must stay aligned with the trusted URL checks in
`src/main/version-checker.ts`, `sites/app.js`, website validation, and related
tests. A host, Bucket, Region, or CDN migration must update and validate every
surface together before a new release.
- Use GitHub OIDC and the RAM Role to obtain short-lived STS credentials.
Never add long-lived AccessKeys to repository or environment secrets.
- Keep `ossutil` pinned. Its V4 signing requires the Region; derive it from the
canonical Endpoint, verify that the production value resolves to
`cn-beijing`, and pass `--region` to every `ossutil cp`, including the final
`latest.json` update.
- Grant the RAM Role only the actions and prefixes required by the workflow.
It must be able to write immutable version objects and the final latest
pointer without granting unrelated administration privileges.
- Upload release assets and `site-release.json` under the immutable
`releases/<tag>/` prefix first. Verify all 14 installer URLs publicly before
creating or publishing the GitHub Release. Update
`releases/latest.json` only after the GitHub Release is public and all prior
checks succeeded.
- The expected GitHub Release contains 22 assets: 14 installers (two formats
for each Windows/macOS target and three formats for each Linux target), six
renamed target manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
`site-release.json` is an OSS publication artifact, not a GitHub Release
asset.
### Failed Tag Recovery
Classify a failed tag by the external side effects that completed before
choosing a recovery:
- If no source or release metadata must change, correct only the external
configuration and use **Re-run failed jobs** for the same immutable tag. Do
not change, move, delete, or recreate the tag.
- If immutable OSS objects were partially uploaded but their source bytes are
unchanged, a same-tag rerun may idempotently re-upload or verify them. Never
point `latest.json` at a partially verified prefix.
- If the GitHub Release is already public but the final latest-pointer step
failed, it is a published version. Keep its packaged notes and rerun the
failed release job for the same tag; do not classify it as an unpublished
candidate.
- If code or release metadata must change, keep the failed tag immutable,
increment the patch version, obtain approval for the revised exact release
commit and notes, and create a new tag. Do not reuse the failed version.
When recovering from a version tag whose workflow never published a public
GitHub Release:
- If a code or metadata change requires a higher version and a new tag, carry
the failed candidate's approved user-facing notes forward into the recovery
version, then remove the superseded failed version's entry from
`resources/release-notes.json`.
- The packaged first-open modal must show that carried-forward content only
once under the recovery version. Never retain both the failed version and
its cumulative recovery copy, because users upgrading across them would see
duplicate content.
- Never remove the packaged history for a version that successfully published
a GitHub Release. Verify the failed release state before treating an entry as
superseded.
### Post-Publication Verification
Do not report a release complete until all of the following are verified:
1. The tag workflow and all six native package jobs succeeded. Verify the
recorded macOS signing mode: signed builds must pass `codesign`, `spctl`,
and `stapler`; unsigned builds must record the Gatekeeper caveat in the
Actions log and job summary. In the final release job, explicitly verify
the OSS configuration, OIDC authentication, release-index generation,
immutable upload, public asset check, GitHub Release publication, and
latest-pointer steps.
2. The public GitHub Release is non-draft, non-prerelease, marked Latest, and
uses the expected tag and title. Its body must exactly match the Markdown
generated from the approved packaged bilingual notes.
3. GitHub release metadata contains exactly the expected 22 uploaded asset
names, including the expected Windows x64 and arm64 assets. Do not download,
hash, launch, or send individual network probes to published installers
unless the user explicitly requests that additional validation.
4. The Beijing `releases/latest.json` returns HTTP 200, has the expected stable
version, exact six targets and 14 installer entries, the trusted Beijing
URLs, and the GitHub fallback URL. It must match the immutable
`releases/<tag>/site-release.json`. Fetching and comparing these small JSON
metadata files is sufficient; rely on the successful publication job for
its per-asset public checks.
5. The live website successfully fetches the index and produces the 14 correct
platform/architecture/format links from that metadata.
6. Both remote `main` refs and both peeled tag refs still equal the approved
release commit, and the local working tree is clean.
Never create or push a release tag, and never push a previously created
release tag, before the release-note draft has received explicit approval.
- Before a push that updates the `github` remote, ask whether the user wants a
release tag unless they already specified that choice. A branch-only push
does not require a version bump or tag. When the user requests a release,
verify that `package.json` and `package-lock.json` contain the same release
version, create `v${package.version}` at the exact commit being pushed, and
push that tag so the native package matrix and GitHub Release run.
- Never move or reuse an existing release tag. If `v${package.version}` already
exists locally or on a remote at another commit, increment the package
version and create a new matching tag before the release push.
- Verified release baseline on 2026-08-18: commit
`60119a4317118fa3f077db0382664f15266a6682`, annotated tag `v0.10.4`, and
GitHub Actions run `32038633609` attempt 2 succeeded through all six native
packages, GitHub Release publication, Beijing OSS publication, and the final
`latest.json` switch.
@@ -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,25 @@ 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 各自维护版本,签名摘要标识精确工件。Host 在解包到准备目录的同一遍
处理中完成完整 payload 校验,commit 只复核签名 metadata 并原子替换 side-by-side
目录,不重新读取归档、扫描旧目录或使用跨安装硬链接。
- Main 只保存清理 operation staging 所需的 operation ID。控制通道中断或 adoption 失败
后,下次更新尽力清理旧暂存并重新 prepare,不保存或恢复 Agent/Runtime metadata 副本。
新 Agent/Runtime 各自最多完整验证一次;注册后的
health、capabilities 和 prompt 启动只检查 registry、签名 manifest 与入口 metadata。
确认 adoption 后显式 cleanup,cleanup 失败不回滚健康环境,也不阻塞下一次 fresh prepare。
- 握手报告 installation、Daemon boot identity、协议、系统、架构、用户监督器和能力。
- 在远程执行路径规范化、Git、文件、PTY、进程组和 Runtime 管理。
- 持久化有界 Agent 到 Main 输出;SSH 断开后 Agent 继续运行,同一 Agent 重连时只恢复
@@ -791,8 +835,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 +897,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 +926,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 自动裁剪的动态入口,
+2 -1
View File
@@ -363,7 +363,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
- [x] 手动运行不改变下次计划时间。
- [x] Scheduled Task 与普通消息共用 Conversation 级队列,不并发写入同一时间线。
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文。
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
通过队列项身份完成恢复对账,不会再次进入待发送队列。
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。
@@ -28,8 +28,8 @@ Linux x64 和 Linux arm64。
导航及重定向仍经过 DNS 解析和目标地址固定。
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
空闲回收和应用退出清理。
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
无进展保护。
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
累计输出边界继续防止失控执行。
### MCP
+6
View File
@@ -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: [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.11",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.11",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
@@ -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",
+136
View File
@@ -1,6 +1,142 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.11.11",
"releasedAt": "2026-08-30",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**远程会话完整保存。** 修复托管 SSH 远程项目的工具结果较长时,会话持久化失败并可能让已完成回复仍显示为生成中的问题。Runtime 已返回的工具输入、输出、错误和摘要现在会完整保存到本地会话,不再受额外的会话字段长度限制。",
"**魔法笔记列表快捷输入更易发现。** 编辑区现在提示输入 `-`、`1.`、`[ ]` 或 `[x]` 后按空格即可创建项目符号、编号或待办清单,并增加行为覆盖以防快捷输入回退。"
],
"notices": []
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Complete remote conversation saves.** Fixed conversation persistence failing for managed SSH Remote Projects when tool results were long, which could leave a completed reply displayed as still generating. Tool input, output, errors, and summaries already returned by the Runtime are now saved completely without additional conversation-field length limits.",
"**Discoverable Magic Notes list shortcuts.** The editor now explains that typing `-`, `1.`, `[ ]`, or `[x]` followed by Space creates a bullet, numbered, or checklist item, with behavior coverage to prevent regressions."
],
"notices": []
}
}
},
{
"version": "0.11.10",
"releasedAt": "2026-08-29",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.11.10 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
],
"features": [],
"fixes": [
"**Host 更新恢复可用。** 修复已发布 OpenCode Runtime 在采用阶段被桌面内置锁重复校验而失败的问题。Host 现在使用签名复合包中的版本锁和身份,并在同一条固定 SSH 连接上完成 Agent 启动、健康检查与 Runtime 激活。",
"**不再重复扫描远端包体。** 每种获取路径只对复合包执行一次完整 SHA-256;Host installer 在解包时验证全部签名与 payload digest,commit 只检查签名元数据。Main 不再通过 SFTP 逐文件读取 Agent/Runtime,后续健康检查、能力发现和每次提问只使用已登记 identity、manifest 与入口元数据。仍保留 SHA-256,提速来自消除重复读取。",
"**失败后直接重试。** 未完成操作只保留一个小型 staging 清理记录;下次更新先尽力清理旧暂存,再重新 prepare,不保存或回滚 Agent/Runtime 元数据副本。已健康环境不会因暂存清理失败而回滚,安装错误仍会显示具体的有界原因。"
],
"notices": [
"**技术预览与版本要求。** 远程项目仍默认关闭且为技术预览。Host 需要安装单独发布的 Agent `0.11.9`,提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime,本次不构建 LoongArch 预览。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.11.10 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
],
"features": [],
"fixes": [
"**Host updates work again.** Fixed published OpenCode Runtime adoption failing because it was redundantly checked against the desktop-bundled lock. The Host now uses the signed compound package's lock and identity, then starts and health-checks the Agent and activates Runtime on one pinned SSH connection.",
"**No repeated remote payload scans.** Each acquisition path performs one full SHA-256 pass over the compound package. The Host installer verifies every signature and payload digest while extracting, and commit checks only signed metadata. Main no longer reads Agent or Runtime file trees over SFTP. Later health, capability, and prompt paths use registered identities, manifests, and entrypoint metadata. SHA-256 remains; the speedup comes from eliminating rereads.",
"**Simple retry after failures.** An unfinished operation keeps only a small staging-cleanup receipt. The next update makes one best-effort cleanup attempt and performs a fresh prepare without saving or restoring copies of Agent or Runtime metadata. A healthy environment is not rolled back for staging cleanup failures, and bounded installer errors retain their concrete cause."
],
"notices": [
"**Technical preview and version requirement.** Remote Projects remain disabled by default and in technical preview. Hosts require the separately published Agent `0.11.9` for Linux x64 or arm64. Desktop packages do not embed the Agent or remote Runtime, and this release does not build a LoongArch preview."
]
}
}
},
{
"version": "0.11.7",
"releasedAt": "2026-08-29",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.11.7 让托管 SSH 项目切换与多会话使用更顺畅,重做 Host 远程环境准备,并修复中断后排队消息重复发送。"
],
"features": [
"**更清楚的 Host 远程环境准备。** “设置 → 平台功能 → 远程项目”的 Host 卡片现在通过一个主按钮完成安装、更新或重装,并可选择“自动”“Host 下载”或“GoodBuddy 传输”。“自动”会在 Host 可直连时由 Host 下载,否则通过 GoodBuddy 传输;已验证的本地或离线导入 Agent 包可以复用,已检查到更高的线上兼容版本时仍使用新版。只有点击主按钮才会开始准备。",
"**Host 与项目管理。** 可从项目选择器直接打开远程项目设置;即使 Host 不可达或远端目录已不存在,也能删除本地项目记录。删除 Host 会先列出关联项目,并在确认后清理这些项目及其本机会话、任务、成果、记忆、计划和心跳关联;不会连接 Host 或删除远端文件。"
],
"fixes": [
"**更快、更可靠的托管 SSH 会话。** 切换已有远程项目现在只更新本地选择,同一 Host 上兼容的项目与对话会共用连接。重连后空闲会话不再因 `stale-controller` 无法继续,正在执行的提问仍按原位置恢复或重放。",
"**排队输入不再重复发送。** 如果应用在消息已写入对话、但队列尚未完成时中断,重启恢复会识别已显示的用户消息并清除对应队列项,不会再次发送同一内容;会话分支也不会继承该队列身份。",
"**中文下载入口。** 中文官网下载卡片在北京 OSS 索引加载期间保持不可点击,不再短暂暴露 GitHub 回退;仅在索引请求或校验失败后切换到 GitHub。英文下载行为不变。"
],
"notices": [
"**技术预览与版本要求。** 远程项目仍默认关闭且为技术预览。守护进程端的重连恢复需要 Host 安装单独发布的 Agent `0.11.7`,提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime,本次不提供 LoongArch 预览。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.11.7 makes managed SSH project switching and multi-conversation use smoother, overhauls Host remote-environment setup, and prevents queued input from being sent twice after an interruption."
],
"features": [
"**Clearer Host remote-environment setup.** Under Settings → Platform Features → Remote Projects, each Host card uses one action for install, update, or reinstall, plus Automatic, Host download, and GoodBuddy transfer methods. Automatic downloads on the Host when direct access works; otherwise GoodBuddy transfers it. Verified local or imported Agent packages can be reused, but a known newer compatible online version still wins. Preparation starts only when you select the action.",
"**Host and project management.** Open a remote project's settings directly from the project switcher, and remove its local record even when the Host is unreachable or the remote folder is gone. Deleting a Host now lists linked projects first and, after confirmation, removes their local conversations, Tasks, results, memories, schedules, and Heartbeat links without connecting to the Host or deleting remote files."
],
"fixes": [
"**Faster, more resilient managed SSH sessions.** Switching an existing remote project is now local-only, and compatible projects and conversations share one Host connection. Reconnection no longer leaves idle sessions blocked by `stale-controller`, while active prompts preserve exact resume or replay behavior.",
"**Queued input is no longer sent twice.** If the app is interrupted after a message reaches conversation history but before its queue item completes, startup recovery recognizes the displayed user message and removes the matching queue item instead of sending it again. Conversation branches also discard that queue identity.",
"**Chinese download entry.** Download cards on the Chinese website now remain disabled while the Beijing OSS index is loading instead of briefly exposing the GitHub fallback. They switch to GitHub only if the OSS request or validation fails; English downloads are unchanged."
],
"notices": [
"**Technical preview and version requirement.** Remote Projects remain disabled by default and in technical preview. Daemon-side reconnection recovery requires the separately published Agent `0.11.7` for Linux x64 or arm64. Desktop packages do not embed the Agent or remote Runtime, and this release has no LoongArch preview."
]
}
}
},
{
"version": "0.11.6",
"releasedAt": "2026-08-27",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.11.6 新增侧栏品牌定制,并修复 Anthropic 输出上限与关键界面提示。"
],
"features": [
"**侧栏品牌定制。** “设置 → 外观 → 品牌定制”现在可修改左上角品牌名称、中英文副标题和本地 Logo,并提供实时预览、默认 Logo、恢复默认及本机持久化。"
],
"fixes": [
"**Anthropic 输出上限。** Anthropic 直连模型现在可单独配置单次最大输出,不再错误复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。",
"**输入框与品牌提示。** 恢复聊天和生图输入框中的发送、换行与粘贴快捷键说明,并恢复左上角品牌副标题。"
],
"notices": [
"品牌定制仅保存在当前设备并只影响左上角品牌区;安装包、更新、Agent 和协议身份仍为 GoodBuddy。",
"Anthropic 单次最大输出未填写时默认使用 32K Token;实际可用上限仍取决于所选模型和服务商。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.11.6 adds sidebar brand customization while fixing Anthropic output limits and essential interface guidance."
],
"features": [
"**Sidebar brand customization.** Settings → Appearance → Brand customization now lets you change the top-left brand name, Chinese and English subtitles, and a local logo, with live preview, default-logo, full-reset, and on-device persistence."
],
"fixes": [
"**Anthropic output limits.** Direct Anthropic connections now have an independent per-response maximum output setting instead of incorrectly reusing the context window. OpenAI direct requests and tool rounds receive no additional quota.",
"**Composer and brand guidance.** Restored send, newline, and paste shortcuts in chat and image-generation input hints, along with the subtitle beneath the top-left brand name."
],
"notices": [
"Brand customization is stored only on the current device and affects only the top-left brand area. Installer, update, Agent, and protocol identities remain GoodBuddy.",
"Anthropic per-response maximum output defaults to 32K tokens when left unset. The selected model and provider may enforce a lower limit."
]
}
}
},
{
"version": "0.11.5",
"releasedAt": "2026-08-26",
+63 -1
View File
@@ -4,7 +4,7 @@
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
首页将 GoodBuddy 定位为“首个支持龙芯的开源桌面 AI 助理产品”和
首页将 GoodBuddy 定位为“首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品”和
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
x64 / arm64 下载入口、独立编译的龙芯 loong64 预览版、统一 Agent Runtime,
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
@@ -71,6 +71,10 @@ Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类
整组按钮会以 fail-closed 方式继续指向 GitHub 最新正式 Release,不会混用
部分 OSS 数据:
中文页面等待 OSS 索引时,三个正式下载入口保持禁用并显示加载状态,不会先
短暂渲染或跳转到 GitHub。只有索引请求或完整校验明确失败后,正式下载入口
才会显示 GitHub Release 回退;英文页面仍始终直接使用 GitHub Release。
```text
https://github.com/mesalogo/goodbuddy/releases/latest
```
@@ -89,6 +93,64 @@ https://github.com/mesalogo/goodbuddy/releases/latest
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage、DEB 与
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
Linux arm64 的 AppImage/DEB 使用 `arm64`,RPM 使用 `aarch64`。
Linux 处理器选择器另外提供龙芯 `loong64` 实验预览;选中后安装包类型固定为
DEB,并改用下述独立预览索引,不会把 `loong64` 注入正式六目标索引。
### 龙芯实验预览通道
龙芯包不加入上述正式索引,而使用独立 OSS 前缀:
```text
releases/
└── loongarch-preview/
├── latest.json
└── v<GoodBuddy 版本>/
├── GoodBuddy-<版本>-linux-loong64-preview.deb
├── preview-manifest.json
└── SHA256SUMS
```
版本目录不可变。先上传并公开验证三个版本对象,最后才覆盖
`releases/loongarch-preview/latest.json`;不得把预览包放入 `releases/v*`、
`releases/latest.json`、标准 GitHub Release 资产或桌面自动更新索引。
版本对象建议使用 OSS Standard 存储、继承 Bucket 的公开读取策略,并设置长期
immutable 缓存;`latest.json` 应使用 `application/json` 和短缓存或
`no-cache`。`SHA256SUMS` 必须只记录 DEB 文件名,不得记录构建容器绝对路径。
官网只读取以下严格结构,且仅接受北京 OSS
`releases/loongarch-preview/v<goodBuddyVersion>/` 下的精确 HTTPS 地址:
```json
{
"formatVersion": 1,
"product": "GoodBuddy LoongArch Preview",
"goodBuddyVersion": "0.11.5",
"previewVersion": "0.11.5-loong64-preview.1",
"architecture": "loong64",
"format": "deb",
"artifact": {
"name": "GoodBuddy-0.11.5-linux-loong64-preview.deb",
"size": 186853872,
"sha256": "672321e314fbdb91c6d7b2b549a838e556bd7e597746a3e6d346b936f2ff8369",
"url": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb"
},
"manifestUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/preview-manifest.json",
"checksumUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/SHA256SUMS"
}
```
上传仍应使用 GitHub OIDC 获取的短期 STS 凭据和固定 `ossutil 2.3.0`,Bucket
为 `goodbuddy`,Endpoint 为 `https://oss-cn-beijing.aliyuncs.com`,Region
为 `cn-beijing`。上传版本对象前先读取目标对象元数据:不存在时才上传,已存在
且大小和 SHA-256 相同时跳过,任一字节不同时必须停止;`--update` 只用于传输
优化,不能代替不可变性检查。也可为版本前缀启用 OSS 保留策略/WORM。指针仅在
所有公开 GET、大小、SHA-256、manifest 和 DEB 元数据检查通过后使用
`--force` 更新。手工上传时也应遵循同一顺序,不使用长期 AccessKey。
若独立索引不存在、超时、过大、字段多缺、版本不稳定、摘要格式错误或任一
URL 不匹配受信任前缀,中文官网选择龙芯处理器时会保持下载按钮禁用;Linux
x64 / arm64 及另外两个正式平台入口不受影响。英文页面继续只展示正式
GitHub Release。
## 字体与可访问性
+223 -64
View File
@@ -17,6 +17,8 @@
const isEnglish = root.lang.toLowerCase().startsWith("en");
const releaseManifestUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
const loongArchPreviewManifestUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
const releaseFallbackUrl =
"https://github.com/mesalogo/goodbuddy/releases/latest";
const releaseRequestTimeoutMs = 10_000;
@@ -24,6 +26,10 @@
const downloadCards = [
...document.querySelectorAll("[data-download-card]"),
];
let verifiedRelease;
let verifiedLoongArchPreview;
let releaseLoadState = "loading";
let loongArchPreviewLoadState = "loading";
const interfaceCopy = isEnglish
? {
themeDark: "Switch to dark theme",
@@ -65,8 +71,7 @@
}
};
const readBoundedJson = async (response) => {
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
const readBoundedJson = async (response, maximumBytes) => {
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
throw new Error("发布索引大小上限无效");
}
@@ -111,30 +116,189 @@
return JSON.parse(new TextDecoder().decode(bytes));
};
const setReleaseLink = (link, url, visibleText) => {
const setLinkText = (link, visibleText) => {
const newWindowNotice = link.querySelector(".sr-only");
link.href = url;
link.replaceChildren(document.createTextNode(visibleText));
if (newWindowNotice) {
link.append(newWindowNotice);
}
};
const setFallbackDownloads = () => {
for (const card of downloadCards) {
const platform = card.dataset.downloadCard;
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
if (link instanceof HTMLAnchorElement) {
const setReleaseLink = (link, url, visibleText) => {
link.href = url;
link.target = "_blank";
link.rel = "noreferrer";
link.removeAttribute("aria-disabled");
link.removeAttribute("tabindex");
link.classList.remove("is-disabled");
setLinkText(link, visibleText);
};
const getDownloadCardControls = (card) => {
const platform = card.dataset.downloadCard;
const archSelect = card.querySelector("[data-download-arch]");
const formatSelect = card.querySelector("[data-download-format]");
const container = card.closest(".download-card");
const link = container?.querySelector("[data-release-link]");
const meta = container?.querySelector("[data-download-meta]");
if (
!platform ||
!(archSelect instanceof HTMLSelectElement) ||
!(formatSelect instanceof HTMLSelectElement) ||
!(link instanceof HTMLAnchorElement) ||
!(meta instanceof HTMLElement)
) {
throw new Error("下载卡片结构无效");
}
return { platform, archSelect, formatSelect, link, meta };
};
const setDownloadUnavailable = (link, meta) => {
link.removeAttribute("href");
link.removeAttribute("target");
link.removeAttribute("rel");
link.setAttribute("aria-disabled", "true");
link.tabIndex = -1;
link.classList.add("is-disabled");
setLinkText(link, "龙芯预览包暂不可用");
meta.textContent = "实验预览 · 仅 DEB · 索引尚未发布或校验失败。";
};
const setDownloadPending = (link, meta, visibleText) => {
link.removeAttribute("href");
link.removeAttribute("target");
link.removeAttribute("rel");
link.setAttribute("aria-disabled", "true");
link.tabIndex = -1;
link.classList.add("is-disabled");
setLinkText(link, visibleText);
meta.textContent = "正在从 OSS 获取最新版本。";
};
const setLoongArchFormat = (formatSelect, selected) => {
for (const option of formatSelect.options) {
const unavailable = selected && option.value !== "deb";
option.hidden = unavailable;
option.disabled = unavailable;
}
if (selected) {
formatSelect.value = "deb";
}
formatSelect.disabled = selected;
};
const updateDownloadCard = (card) => {
const {
platform,
archSelect,
formatSelect,
link,
meta,
} = getDownloadCardControls(card);
const loongArchSelected =
platform === "linux" && archSelect.value === "loong64";
setLoongArchFormat(formatSelect, loongArchSelected);
if (loongArchSelected) {
if (!verifiedLoongArchPreview) {
if (loongArchPreviewLoadState === "loading") {
setDownloadPending(link, meta, "正在获取龙芯预览版下载信息…");
} else {
setDownloadUnavailable(link, meta);
}
return;
}
setReleaseLink(
link,
verifiedLoongArchPreview.artifact.url,
"下载 Linux 龙芯 LoongArch DEB 预览版 →",
);
meta.textContent =
`GoodBuddy ${verifiedLoongArchPreview.goodBuddyVersion} · ` +
`${formatFileSize(verifiedLoongArchPreview.artifact.size)} · ` +
`实验预览 · SHA-256 ` +
`${verifiedLoongArchPreview.artifact.sha256.slice(0, 12)}…`;
return;
}
if (!verifiedRelease) {
if (releaseLoadState === "loading") {
setDownloadPending(
link,
meta,
`正在获取 ${platformNames[platform] ?? platform} 下载信息…`,
);
} else {
setReleaseLink(
link,
releaseFallbackUrl,
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`,
);
}
if (meta instanceof HTMLElement) {
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
}
return;
}
const target =
verifiedRelease.targets[`${platform}-${archSelect.value}`];
const file = target?.files?.[formatSelect.value];
if (!file) {
throw new Error("下载选项不在已校验的发布索引中");
}
const platformName = platformNames[platform] ?? platform;
const archName =
platform === "macos" && archSelect.value === "arm64"
? "Apple 芯片"
: archSelect.value === "arm64"
? "ARM64"
: "x64";
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
setReleaseLink(
link,
file.url,
`下载 ${platformName} ${archName} ${formatName} →`,
);
meta.textContent =
`GoodBuddy ${verifiedRelease.version} · ` +
`${formatFileSize(file.size)} · ` +
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
};
const updateAllDownloadCards = () => {
for (const card of downloadCards) {
updateDownloadCard(card);
}
};
const setFallbackDownloads = () => {
verifiedRelease = undefined;
releaseLoadState = "fallback";
updateAllDownloadCards();
};
const setLoongArchPreviewUnavailable = () => {
verifiedLoongArchPreview = undefined;
loongArchPreviewLoadState = "unavailable";
const linuxCard = downloadCards.find(
(card) => card.dataset.downloadCard === "linux",
);
if (linuxCard) {
updateDownloadCard(linuxCard);
}
};
const configureLoongArchPreview = (payload) => {
if (!releaseIndexApi?.validateLoongArchPreviewIndex) {
throw new Error("龙芯预览索引校验器不可用");
}
verifiedLoongArchPreview =
releaseIndexApi.validateLoongArchPreviewIndex(payload);
loongArchPreviewLoadState = "ready";
const linuxCard = downloadCards.find(
(card) => card.dataset.downloadCard === "linux",
);
if (linuxCard) {
updateDownloadCard(linuxCard);
}
};
@@ -142,71 +306,38 @@
if (!releaseIndexApi?.validateReleaseIndex) {
throw new Error("发布索引校验器不可用");
}
const release = releaseIndexApi.validateReleaseIndex(payload);
const updateCard = (card) => {
const platform = card.dataset.downloadCard;
const archSelect = card.querySelector("[data-download-arch]");
const formatSelect = card.querySelector("[data-download-format]");
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
if (
!platform ||
!(archSelect instanceof HTMLSelectElement) ||
!(formatSelect instanceof HTMLSelectElement) ||
!(link instanceof HTMLAnchorElement) ||
!(meta instanceof HTMLElement)
) {
throw new Error("下载卡片结构无效");
}
const target = release.targets[`${platform}-${archSelect.value}`];
const file = target?.files?.[formatSelect.value];
if (!file) {
throw new Error("下载选项不在已校验的发布索引中");
}
const platformName = platformNames[platform] ?? platform;
const archName =
platform === "macos" && archSelect.value === "arm64"
? "Apple 芯片"
: archSelect.value === "arm64"
? "ARM64"
: "x64";
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
setReleaseLink(
link,
file.url,
`下载 ${platformName} ${archName} ${formatName} →`,
);
meta.textContent =
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
};
verifiedRelease = releaseIndexApi.validateReleaseIndex(payload);
releaseLoadState = "ready";
updateAllDownloadCards();
};
const initializeDownloadCards = () => {
for (const card of downloadCards) {
const selects = card.querySelectorAll("select");
for (const select of selects) {
for (const select of card.querySelectorAll("select")) {
select.addEventListener("change", () => {
try {
updateCard(card);
updateDownloadCard(card);
} catch {
setFallbackDownloads();
}
});
}
updateCard(card);
updateDownloadCard(card);
}
};
const loadRelease = async () => {
const fetchBoundedIndex = async (
url,
maximumBytes,
errorLabel,
) => {
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
releaseRequestTimeoutMs,
);
try {
const response = await fetch(releaseManifestUrl, {
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
@@ -214,16 +345,42 @@
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`发布索引请求失败:${response.status}`);
throw new Error(`${errorLabel}请求失败:${response.status}`);
}
configureDownloads(await readBoundedJson(response));
} catch {
setFallbackDownloads();
return await readBoundedJson(response, maximumBytes);
} finally {
window.clearTimeout(timeout);
}
};
const loadRelease = async () => {
try {
configureDownloads(
await fetchBoundedIndex(
releaseManifestUrl,
releaseIndexApi?.maximumIndexBytes,
"发布索引",
),
);
} catch {
setFallbackDownloads();
}
};
const loadLoongArchPreview = async () => {
try {
configureLoongArchPreview(
await fetchBoundedIndex(
loongArchPreviewManifestUrl,
releaseIndexApi?.maximumPreviewIndexBytes,
"龙芯预览索引",
),
);
} catch {
setLoongArchPreviewUnavailable();
}
};
const getSavedTheme = () => {
try {
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
@@ -311,8 +468,10 @@
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
setHeaderState();
initializeDownloadCards();
if (!isEnglish) {
void loadRelease();
void loadLoongArchPreview();
}
themeToggle?.addEventListener("click", () => {
+23 -26
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手;龙芯 LoongArch 当前提供独立编译预览版。"
content="GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手。"
/>
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
<meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy|首个支持龙芯的开源桌面 AI 助理产品</title>
<title>GoodBuddy|首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</title>
<link
rel="icon"
href="./assets/goodbuddy-light.png"
@@ -124,9 +124,8 @@
<span>一站式 AI 助手。</span>
</h1>
<p class="hero-lead">
GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,龙芯 LoongArch
当前以独立编译预览版提供。作为桌面助手与 AI 编程工具台,GoodBuddy
管理对话、知识、笔记与任务,
GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品。作为桌面助手与
AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
无需反复配置命令行,选择工具和项目即可开始。
</p>
@@ -134,7 +133,7 @@
<a class="button button--primary" href="#download">立即下载</a>
<a class="button button--secondary" href="#features">了解 Agent Runtime</a>
</div>
<ul class="hero-facts" aria-label="产品特性概览">
<ul class="hero-facts hero-facts--aligned" aria-label="产品特性概览">
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
3 大桌面系统,x64 / arm64
@@ -272,14 +271,13 @@
</label>
</div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
class="button button--download is-disabled"
data-release-link
>下载 Windows 版 →<span class="sr-only">(在新窗口打开)</span></a>
aria-disabled="true"
tabindex="-1"
>正在获取 Windows 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
<p class="download-meta" data-download-meta aria-live="polite">
正在获取最新版本,暂时可前往 GitHub 下载。
正在从 OSS 获取最新版本。
</p>
</article>
<article class="download-card">
@@ -306,14 +304,13 @@
</label>
</div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
class="button button--download is-disabled"
data-release-link
>下载 macOS 版 →<span class="sr-only">(在新窗口打开)</span></a>
aria-disabled="true"
tabindex="-1"
>正在获取 macOS 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
<p class="download-meta" data-download-meta aria-live="polite">
正在获取最新版本,暂时可前往 GitHub 下载。
正在从 OSS 获取最新版本。
</p>
</article>
<article class="download-card">
@@ -327,6 +324,7 @@
<select data-download-arch aria-label="Linux 处理器架构">
<option value="x64">x64(Intel / AMD)</option>
<option value="arm64">ARM64</option>
<option value="loong64">龙芯 LoongArch(实验预览)</option>
</select>
</label>
<label>
@@ -339,21 +337,20 @@
</label>
</div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
class="button button--download is-disabled"
data-release-link
>下载 Linux 版 →<span class="sr-only">(在新窗口打开)</span></a>
aria-disabled="true"
tabindex="-1"
>正在获取 Linux 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
<p class="download-meta" data-download-meta aria-live="polite">
正在获取最新版本,暂时可前往 GitHub 下载。
正在从 OSS 获取最新版本。
</p>
</article>
</div>
<aside class="domestic-support" aria-labelledby="domestic-support-title">
<div>
<p class="kicker">信创软硬件支持</p>
<h3 id="domestic-support-title">首个支持龙芯的开源桌面 AI 助理产品</h3>
<h3 id="domestic-support-title">首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</h3>
<p>
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
loong64 预览版。
@@ -525,7 +522,7 @@
</span>
<span>GoodBuddy</span>
</a>
<p>首个支持龙芯的开源桌面 AI 助理产品</p>
<p>首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</p>
<div class="footer-links">
<a href="#download">下载</a>
<a href="#features">Agent Runtime</a>
+88
View File
@@ -3,9 +3,12 @@
const mirrorIndexUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
const loongArchPreviewIndexUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
const fallbackUrl =
"https://github.com/mesalogo/goodbuddy/releases/latest";
const maximumIndexBytes = 512 * 1024;
const maximumPreviewIndexBytes = 16 * 1024;
const semVerPattern =
/^(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 sha256Pattern = /^[a-f0-9]{64}$/u;
@@ -201,8 +204,93 @@
return index;
};
const validateLoongArchPreviewIndex = (index) => {
assert(
hasExactKeys(index, [
"formatVersion",
"product",
"goodBuddyVersion",
"previewVersion",
"architecture",
"format",
"artifact",
"manifestUrl",
"checksumUrl",
]),
"龙芯预览索引结构无效",
);
assert(index.formatVersion === 1, "龙芯预览索引版本无效");
assert(
index.product === "GoodBuddy LoongArch Preview",
"龙芯预览产品名称无效",
);
assert(
typeof index.goodBuddyVersion === "string" &&
index.goodBuddyVersion.length <= 256,
"龙芯预览 GoodBuddy 版本无效",
);
const parsedVersion = semVerPattern.exec(index.goodBuddyVersion);
assert(
parsedVersion && !parsedVersion[4],
"龙芯预览必须基于稳定 GoodBuddy 版本",
);
const previewPrefix = `${index.goodBuddyVersion}-loong64-preview.`;
assert(
typeof index.previewVersion === "string" &&
index.previewVersion.startsWith(previewPrefix) &&
/^[1-9]\d*$/u.test(index.previewVersion.slice(previewPrefix.length)),
"龙芯预览版本无效",
);
assert(index.architecture === "loong64", "龙芯预览架构无效");
assert(index.format === "deb", "龙芯预览格式无效");
assert(
hasExactKeys(index.artifact, ["name", "size", "sha256", "url"]),
"龙芯预览文件结构无效",
);
const expectedName =
`GoodBuddy-${index.goodBuddyVersion}-linux-loong64-preview.deb`;
assert(
index.artifact.name === expectedName &&
safeFileNamePattern.test(index.artifact.name),
"龙芯预览文件名无效",
);
assert(
Number.isSafeInteger(index.artifact.size) && index.artifact.size > 0,
"龙芯预览文件大小无效",
);
assert(
typeof index.artifact.sha256 === "string" &&
sha256Pattern.test(index.artifact.sha256),
"龙芯预览 SHA-256 无效",
);
const previewBase = new URL(
`v${index.goodBuddyVersion}/`,
loongArchPreviewIndexUrl,
);
assertExactUrl(
index.artifact.url,
new URL(encodeURIComponent(expectedName), previewBase).href,
"龙芯预览文件地址",
);
assertExactUrl(
index.manifestUrl,
new URL("preview-manifest.json", previewBase).href,
"龙芯预览 manifest 地址",
);
assertExactUrl(
index.checksumUrl,
new URL("SHA256SUMS", previewBase).href,
"龙芯预览校验清单地址",
);
return index;
};
window.GoodBuddyReleaseIndex = Object.freeze({
maximumIndexBytes,
maximumPreviewIndexBytes,
validateReleaseIndex,
validateLoongArchPreviewIndex,
});
})();
+195
View File
@@ -86,6 +86,99 @@ const renderApp = (legacy = false) => {
return { dom, queries, window };
};
const renderLoongArchPreview = (payload) => {
const dom = new JSDOM(
`<!doctype html>
<html lang="zh-CN">
<head><meta name="theme-color" content="#f6f8fb"></head>
<body>
<header data-site-header>
<button type="button" data-menu-toggle>菜单</button>
<nav data-navigation></nav>
<button type="button" data-theme-toggle>主题</button>
</header>
<div data-menu-backdrop></div>
<main>
<article class="download-card">
<div data-download-card="linux">
<select data-download-arch>
<option value="x64">x64</option>
<option value="arm64">ARM64</option>
<option value="loong64">龙芯 LoongArch(实验预览)</option>
</select>
<select data-download-format>
<option value="AppImage">AppImage</option>
<option value="deb">DEB</option>
<option value="rpm">RPM</option>
</select>
</div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>下载 Linux 版<span class="sr-only">(在新窗口打开)</span></a>
<p data-download-meta></p>
</article>
</main>
<span data-current-year></span>
</body>
</html>`,
{
runScripts: "outside-only",
url: "https://example.test/",
},
);
const { window } = dom;
createMediaQueries(window, false);
window.TextDecoder = TextDecoder;
window.GoodBuddyReleaseIndex = {
maximumIndexBytes: 512 * 1024,
maximumPreviewIndexBytes: 16 * 1024,
validateReleaseIndex() {
throw new Error("正式发布索引在此测试中不可用");
},
validateLoongArchPreviewIndex(value) {
return value;
},
};
window.fetch = async (url) => {
if (!String(url).includes("/loongarch-preview/")) {
throw new Error("正式发布索引在此测试中不可用");
}
if (!payload) {
throw new Error("龙芯预览索引不可用");
}
const bytes = new TextEncoder().encode(JSON.stringify(payload));
let delivered = false;
return {
ok: true,
status: 200,
headers: {
get(name) {
return name === "content-length" ? String(bytes.length) : null;
},
},
body: {
getReader() {
return {
async read() {
if (delivered) {
return { done: true, value: undefined };
}
delivered = true;
return { done: false, value: bytes };
},
};
},
},
};
};
window.eval(source);
return { dom, window };
};
for (const legacy of [false, true]) {
test(
`mobile menu isolates content and restores focus with ${
@@ -149,3 +242,105 @@ for (const legacy of [false, true]) {
},
);
}
test("enables only a validated LoongArch preview download", async () => {
const artifactUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb";
const { dom, window } = renderLoongArchPreview({
goodBuddyVersion: "0.11.5",
artifact: {
size: 186_853_872,
sha256: "a".repeat(64),
url: artifactUrl,
},
});
await new Promise((resolve) => window.setTimeout(resolve, 0));
const arch = window.document.querySelector("[data-download-arch]");
const format = window.document.querySelector(
"[data-download-format]",
);
const link = window.document.querySelector("[data-release-link]");
const meta = window.document.querySelector("[data-download-meta]");
arch.value = "loong64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.value, "deb");
assert.equal(format.disabled, true);
assert.equal(format.options[0].hidden, true);
assert.equal(format.options[1].hidden, false);
assert.equal(format.options[2].hidden, true);
assert.equal(link.getAttribute("href"), artifactUrl);
assert.equal(link.getAttribute("target"), "_blank");
assert.equal(link.getAttribute("rel"), "noreferrer");
assert.equal(link.hasAttribute("aria-disabled"), false);
assert.equal(link.classList.contains("is-disabled"), false);
assert.equal(
meta.textContent,
"GoodBuddy 0.11.5 · 178 MB · 实验预览 · SHA-256 aaaaaaaaaaaa…",
);
dom.window.close();
});
test("keeps Chinese downloads disabled while the OSS indexes are loading", async () => {
const { dom, window } = renderLoongArchPreview({
goodBuddyVersion: "0.11.5",
artifact: {
size: 186_853_872,
sha256: "a".repeat(64),
url: "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb",
},
});
const arch = window.document.querySelector("[data-download-arch]");
const link = window.document.querySelector("[data-release-link]");
const meta = window.document.querySelector("[data-download-meta]");
assert.equal(link.hasAttribute("href"), false);
assert.equal(link.getAttribute("aria-disabled"), "true");
assert.equal(link.tabIndex, -1);
assert.equal(link.classList.contains("is-disabled"), true);
assert.equal(link.textContent, "正在获取 Linux 下载信息…(在新窗口打开)");
assert.equal(meta.textContent, "正在从 OSS 获取最新版本。");
arch.value = "loong64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(link.hasAttribute("href"), false);
assert.equal(link.getAttribute("aria-disabled"), "true");
assert.equal(link.textContent, "正在获取龙芯预览版下载信息…(在新窗口打开)");
assert.equal(meta.textContent, "正在从 OSS 获取最新版本。");
await new Promise((resolve) => window.setTimeout(resolve, 0));
dom.window.close();
});
test("keeps the LoongArch preview disabled when its index is unavailable", async () => {
const { dom, window } = renderLoongArchPreview(null);
await new Promise((resolve) => window.setTimeout(resolve, 0));
const arch = window.document.querySelector("[data-download-arch]");
const format = window.document.querySelector(
"[data-download-format]",
);
const link = window.document.querySelector("[data-release-link]");
const meta = window.document.querySelector("[data-download-meta]");
arch.value = "loong64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.value, "deb");
assert.equal(format.disabled, true);
assert.equal(link.hasAttribute("href"), false);
assert.equal(link.getAttribute("aria-disabled"), "true");
assert.equal(link.tabIndex, -1);
assert.equal(link.classList.contains("is-disabled"), true);
assert.equal(
meta.textContent,
"实验预览 · 仅 DEB · 索引尚未发布或校验失败。",
);
arch.value = "x64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.disabled, false);
assert.equal(format.options[0].hidden, false);
assert.equal(link.getAttribute("href"), "https://github.com/mesalogo/goodbuddy/releases/latest");
assert.equal(link.hasAttribute("aria-disabled"), false);
dom.window.close();
});
+80 -1
View File
@@ -9,7 +9,10 @@ const { test } = process.env.VITEST
const source = await readFile(path.resolve("sites/release-index.js"), "utf8");
const context = vm.createContext({ URL, window: {} });
vm.runInContext(source, context, { filename: "release-index.js" });
const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex;
const {
validateReleaseIndex,
validateLoongArchPreviewIndex,
} = context.window.GoodBuddyReleaseIndex;
const definitions = [
["windows", "x64", ["nsis", "portable"]],
@@ -90,6 +93,36 @@ const expectRejected = (mutate) => {
assert.throws(() => validateReleaseIndex(index));
};
const validLoongArchPreviewIndex = () => {
const goodBuddyVersion = "1.2.3";
const previewBase =
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v${goodBuddyVersion}/`;
const name =
`GoodBuddy-${goodBuddyVersion}-linux-loong64-preview.deb`;
return {
formatVersion: 1,
product: "GoodBuddy LoongArch Preview",
goodBuddyVersion,
previewVersion: `${goodBuddyVersion}-loong64-preview.1`,
architecture: "loong64",
format: "deb",
artifact: {
name,
size: 186_000_000,
sha256: "a".repeat(64),
url: new URL(encodeURIComponent(name), previewBase).href,
},
manifestUrl: new URL("preview-manifest.json", previewBase).href,
checksumUrl: new URL("SHA256SUMS", previewBase).href,
};
};
const expectLoongArchPreviewRejected = (mutate) => {
const index = validLoongArchPreviewIndex();
mutate(index);
assert.throws(() => validateLoongArchPreviewIndex(index));
};
test("accepts the canonical stable six-target release index", () => {
const index = validIndex();
assert.equal(validateReleaseIndex(index), index);
@@ -204,3 +237,49 @@ test("requires exact checksum and GitHub fallback URLs", () => {
index.fallbackUrl = "https://github.com/mesalogo/goodbuddy/releases";
});
});
test("accepts the isolated immutable LoongArch preview index", () => {
const index = validLoongArchPreviewIndex();
assert.equal(validateLoongArchPreviewIndex(index), index);
});
test("binds the LoongArch preview to its stable version and exact OSS prefix", () => {
for (const version of ["v1.2.3", "1.2.3-rc.1", "1.2"]) {
expectLoongArchPreviewRejected((index) => {
index.goodBuddyVersion = version;
});
}
expectLoongArchPreviewRejected((index) => {
index.previewVersion = "1.2.3-loong64-preview.0";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.url = index.artifact.url.replace(
"/releases/loongarch-preview/v1.2.3/",
"/releases/v1.2.3/",
);
});
expectLoongArchPreviewRejected((index) => {
index.manifestUrl += "?download=1";
});
expectLoongArchPreviewRejected((index) => {
index.unexpected = true;
});
});
test("requires one canonical LoongArch DEB with bounded metadata", () => {
expectLoongArchPreviewRejected((index) => {
index.architecture = "arm64";
});
expectLoongArchPreviewRejected((index) => {
index.format = "rpm";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.name = "GoodBuddy-1.2.3-linux-loong64.deb";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.size = 0;
});
expectLoongArchPreviewRejected((index) => {
index.artifact.sha256 = "A".repeat(64);
});
});
+41 -6
View File
@@ -250,8 +250,9 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
const requiredCopy = [
"免注册",
"支持信创软硬件的一站式 AI 助手",
"首个支持龙芯的开源桌面 AI 助理产品",
"首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品",
"龙芯 LoongArch 独立编译预览版",
"龙芯 LoongArch(实验预览)",
"桌面助手",
"AI 编程工具台",
"Windows、macOS、Linux",
@@ -310,11 +311,12 @@ const releaseLinks = [
report(releaseLinks.length >= 3, "缺少三个桌面系统的官方下载入口");
for (const link of releaseLinks) {
report(
/href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest"/.test(link),
`下载入口必须指向官方最新 Release:${link}`,
!/\shref=/.test(link),
`中文下载入口加载期间不得预先指向 GitHub:${link}`,
);
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer:${link}`);
report(/aria-disabled="true"/.test(link), `中文下载入口加载期间必须禁用:${link}`);
report(/tabindex="-1"/.test(link), `禁用的中文下载入口不得进入 Tab 顺序:${link}`);
report(/\bis-disabled\b/.test(link), `中文下载入口缺少禁用样式:${link}`);
}
report(
(html.match(/data-download-card="(?:windows|macos|linux)"/g) ?? []).length === 3,
@@ -328,6 +330,18 @@ report(
(html.match(/data-download-format/g) ?? []).length === 3,
"每个平台必须提供安装包类型选择器",
);
report(
/<option\s+value="loong64">龙芯 LoongArch(实验预览)<\/option>/.test(
html,
),
"Linux 处理器选项必须包含龙芯实验预览",
);
report(
/const setLoongArchFormat =[\s\S]*option\.value !== "deb"[\s\S]*formatSelect\.value = "deb"[\s\S]*formatSelect\.disabled = selected/.test(
appJs,
),
"龙芯处理器必须只允许选择 DEB",
);
report(
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
"官网不得显示下载源状态提示",
@@ -362,6 +376,27 @@ report(
),
"官网必须从 GoodBuddy OSS 加载最新发布索引",
);
report(
appJs.includes(
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
),
"官网必须从独立 OSS 索引加载龙芯预览包",
);
report(
/validateLoongArchPreviewIndex\(payload\)/.test(appJs),
"龙芯预览下载链接必须先通过独立索引校验",
);
report(
/catch\s*\{[\s\S]*setLoongArchPreviewUnavailable\(\)/.test(appJs),
"龙芯预览索引失败时必须保持下载入口禁用",
);
report(
releaseIndexJs.includes(
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
) &&
/const validateLoongArchPreviewIndex =/.test(releaseIndexJs),
"龙芯预览索引必须使用独立受信任前缀和校验器",
);
report(
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
"官网必须保留 GitHub Release 回退地址",
@@ -502,7 +537,7 @@ const expectedDownloadOptions = {
formats: ["dmg", "zip"],
},
linux: {
arches: ["x64", "arm64"],
arches: ["x64", "arm64", "loong64"],
formats: ["AppImage", "deb", "rpm"],
},
};
+17 -1
View File
@@ -586,6 +586,11 @@ p {
list-style: none;
}
.hero-facts--aligned {
display: grid;
grid-template-columns: repeat(2, minmax(0, max-content));
}
.hero-facts li {
display: flex;
align-items: center;
@@ -1274,7 +1279,7 @@ p {
.download-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
@@ -1385,6 +1390,13 @@ p {
transform: translateX(2px);
}
.button--download.is-disabled {
background: transparent;
color: var(--text-muted);
cursor: not-allowed;
text-decoration: none;
}
.download-meta {
grid-column: 1 / -1;
min-height: 18px;
@@ -1832,6 +1844,10 @@ p {
gap: var(--space-2);
}
.hero-facts--aligned {
grid-template-columns: minmax(0, max-content);
}
.product-stage {
min-height: 365px;
margin-top: var(--space-4);
+95 -5
View File
@@ -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 () => {
@@ -184,11 +192,13 @@ describe('Agent CLI fixed command contract', () => {
state: 'ready' as const,
installationId
}))
const verifyInstallation = vi.fn(async () => verified)
const dependencies: AgentCliDependencies = {
installationPaths: () => installationPaths,
releaseKeyRegistry,
installationRegistry,
verifyInstallation: async () => verified,
verifyInstallation,
loadRegisteredInstallation: async () => verified,
createLifecycle: (() => ({
status,
bootstrap
@@ -219,6 +229,21 @@ describe('Agent CLI fixed command contract', () => {
).toBe(0)
expect(status).toHaveBeenCalledOnce()
expect(bootstrap).toHaveBeenCalledOnce()
expect(
installationRegistry.snapshot().current?.installationId
).toBe(installationId)
expect(
await runAgentCli(
buildFixedAgentCliArgv(installationId, {
kind: 'lifecycle',
action: 'adopt'
}),
{ ...dependencies, io: cliIo() }
)
).toBe(0)
expect(verifyInstallation).toHaveBeenCalledOnce()
expect(bootstrap).toHaveBeenCalledTimes(2)
})
it('dispatches generated attach argv using only derived managed paths', async () => {
@@ -274,6 +299,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 +310,8 @@ describe('Agent CLI fixed command contract', () => {
installationPaths: () => paths,
releaseKeyRegistry,
installationRegistry,
verifyInstallation: async () => verified,
verifyInstallation,
loadRegisteredInstallation,
io
}
)
@@ -297,6 +325,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', () => {
@@ -421,6 +486,31 @@ describe('Agent CLI fixed command contract', () => {
architecture: 'x64',
runtimeVersion: '1.18.9'
})
expect(
await runAgentCli(
buildFixedAgentCliArgv(
verifyAgentInstallationId('install-1'),
{
kind: 'runtime-activate',
runtimeId: 'opencode',
bundleDigest,
architecture: 'x64',
forceVerification: true
}
),
{
runtimePaths: () => paths,
runtimeReleaseKeyRegistry: releaseKeyRegistry,
runtimeLock: {} as RemoteRuntimeLock,
runtimeRegistry: registry,
verifyRuntime,
currentArchitecture: () => 'x64',
io: cliIo()
}
)
).toBe(0)
expect(verifyRuntime).toHaveBeenCalledTimes(2)
})
it('derives Runtime paths and rejects activation argv injection', async () => {
+257 -53
View File
@@ -28,8 +28,10 @@ import {
} from './private-endpoint'
import { createLinuxPeerIdentityProvider } from './peer-identity-provider'
import {
loadRegisteredAgentBundle,
readManagedAgentReleaseKeyRegistry,
verifyInstalledAgentBundle,
RegisteredAgentBundleError,
verifyPublishedAgentBundle,
type VerifiedInstalledAgentBundle
} from './installed-bundle-verifier'
import { InstallationRegistry } from './installation-registry'
@@ -37,8 +39,9 @@ import {
RuntimeBundleRegistry
} from './runtime-bundle-registry'
import {
loadRegisteredRuntimeBundle,
readRemoteRuntimeLock,
verifyRuntimeBundle,
verifyPublishedRuntimeBundle,
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
import { createProductionRuntimeProtocol } from './runtime-composition'
@@ -79,6 +82,7 @@ export type AgentCliDependencies = {
releaseKeyRegistry: AgentReleaseKeyRegistry
}
) => Promise<VerifiedInstalledAgentBundle>
loadRegisteredInstallation?: typeof loadRegisteredAgentBundle
runtimePaths?: (
installationId: string,
runtimeId: 'opencode',
@@ -87,7 +91,7 @@ export type AgentCliDependencies = {
runtimeReleaseKeyRegistry?: AgentReleaseKeyRegistry
runtimeLock?: RemoteRuntimeLock
runtimeRegistry?: RuntimeBundleRegistry
verifyRuntime?: typeof verifyRuntimeBundle
verifyRuntime?: typeof verifyPublishedRuntimeBundle
runtimeVerificationEnvironment?: 'production' | 'test'
currentArchitecture?: () => AgentArchitecture
runModelBridgeHelper?: typeof runOpenCodeModelBridgeHelper
@@ -108,6 +112,7 @@ export type ManagedRuntimePaths = {
}
const LIFECYCLE_ACTIONS = new Set([
'adopt',
'bootstrap',
'status',
'health',
@@ -124,8 +129,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 +156,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
}
}
@@ -213,7 +227,8 @@ async function runRuntime(
'installation-id',
'runtime-id',
'bundle-digest',
'architecture'
'architecture',
'force-verification'
])
requireOptions(options, [
'installation-id',
@@ -231,6 +246,16 @@ async function runRuntime(
const architecture = validateRuntimeArchitecture(
options.architecture!
)
const forceVerification =
options['force-verification'] === 'true'
if (
options['force-verification'] !== undefined &&
!forceVerification
) {
throw new Error(
'Invalid Runtime force-verification option'
)
}
const hostArchitecture =
dependencies.currentArchitecture?.() ??
currentAgentArchitecture()
@@ -259,15 +284,30 @@ async function runRuntime(
runtimeRoot: paths.runtimeRoot,
storagePath: paths.registryPath
})
const verified = await (
dependencies.verifyRuntime ?? verifyRuntimeBundle
)(paths.bundleDirectory, {
const verificationOptions = {
architecture,
releaseKeyRegistry,
runtimeLock,
verificationEnvironment:
dependencies.runtimeVerificationEnvironment ?? 'production'
})
} as const
const registered = registry.find(
runtimeId,
bundleDigest,
architecture
)
const verified =
registered === undefined || forceVerification
? await (
dependencies.verifyRuntime ?? verifyPublishedRuntimeBundle
)(paths.bundleDirectory, verificationOptions)
: await loadRegisteredRuntimeBundle(
registered.bundleDirectory,
{
...verificationOptions,
registered: registered.entry
}
)
assertActivatedRuntimeIdentity(
verified,
runtimeId,
@@ -294,12 +334,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<typeof AgentDaemon>[0] = {
installationId,
@@ -340,7 +379,9 @@ async function runDaemon(
await createDetachedLifecycle(
installationId,
paths,
dependencies
dependencies,
'registered',
verified
).recordCurrentDaemonReady(daemon.status().daemonBootId)
await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal())
} finally {
@@ -362,12 +403,11 @@ async function runAttach(
installationId,
dependencies
)
const { verified, registry } = await verifyManagedInstallation(
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
prepareManagedSocketDirectory(paths)
const stateDirectory = paths.stateDirectory
ensurePrivateDirectory(stateDirectory, { create: false })
@@ -387,7 +427,9 @@ async function runAttach(
await createDetachedLifecycle(
installationId,
paths,
dependencies
dependencies,
'registered',
verified
).bootstrap()
}
})
@@ -439,10 +481,17 @@ async function runDoctor(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const status = await createDetachedLifecycle(
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
const status = await createDetachedLifecycle(
installationId,
paths,
dependencies,
'registered',
verified
).status()
io.output.write(`${JSON.stringify({
platform: process.platform,
@@ -467,48 +516,155 @@ async function runLifecycle(
options['installation-id']!
)
const paths = resolveInstallationPaths(installationId, dependencies)
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies
)
switch (action) {
case 'bootstrap': {
registry.stageCandidate(verified)
case 'adopt': {
const { verified, registry } =
await verifyManagedInstallation(
installationId,
paths,
dependencies
)
const currentInstallationMatches =
registry.snapshot().current?.installationId ===
installationId
const alreadyCurrent = registry.isCurrent(verified)
if (!alreadyCurrent && !currentInstallationMatches) {
registry.stageCandidate(verified)
}
prepareManagedSocketDirectory(paths)
const status = await lifecycle.bootstrap()
const status = await createDetachedLifecycle(
installationId,
paths,
dependencies,
'verified',
verified
).bootstrap()
if (!alreadyCurrent) {
if (currentInstallationMatches) {
registry.refreshCurrent(verified)
} else {
registry.promoteCandidate(installationId)
}
}
io.output.write(`${JSON.stringify(status)}\n`)
break
}
case 'status': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
io.output.write(`${JSON.stringify(await lifecycle.status())}\n`)
break
}
case 'health': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
const status = await lifecycle.health()
if (
registry.snapshot().candidate?.installationId ===
case 'bootstrap': {
const agentRoot = dirname(
dirname(dirname(paths.executablePath))
)
const registry =
dependencies.installationRegistry ??
new InstallationRegistry({
storagePath: resolve(agentRoot, 'registry.json')
})
const snapshot = registry.snapshot()
const current = snapshot.current?.installationId ===
installationId
) {
? snapshot.current
: undefined
const candidate = snapshot.candidate?.installationId ===
installationId
? snapshot.candidate
: undefined
const registered = current ?? candidate
const isNewInstallation = registered === undefined
const verified = isNewInstallation
? (
await verifyManagedInstallation(
installationId,
paths,
dependencies
)
).verified
: await (
dependencies.loadRegisteredInstallation ??
loadRegisteredAgentBundle
)(dirname(paths.executablePath), {
installationId,
architecture: currentAgentArchitecture(),
registered
})
if (isNewInstallation) {
registry.stageCandidate(verified)
}
prepareManagedSocketDirectory(paths)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies,
'verified',
verified
)
const status = await lifecycle.bootstrap()
if (current === undefined) {
registry.promoteCandidate(installationId)
}
io.output.write(`${JSON.stringify(status)}\n`)
break
}
case 'status': {
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies,
'registered',
verified
)
io.output.write(`${JSON.stringify(await lifecycle.status())}\n`)
break
}
case 'health': {
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies,
'registered',
verified
)
const status = await lifecycle.health()
io.output.write(`${JSON.stringify(status)}\n`)
break
}
case 'stop': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies,
'registered',
verified
)
io.output.write(`${JSON.stringify(await lifecycle.stop())}\n`)
break
}
case 'retire': {
registry.assertVerifiedRole(verified, ['current', 'candidate'])
const { verified } = await loadManagedInstallation(
installationId,
paths,
dependencies
)
const lifecycle = createDetachedLifecycle(
installationId,
paths,
dependencies,
'registered',
verified
)
io.output.write(`${JSON.stringify(await lifecycle.retire())}\n`)
break
}
@@ -518,7 +674,9 @@ async function runLifecycle(
function createDetachedLifecycle(
installationId: string,
paths: ManagedInstallationPaths,
dependencies: AgentCliDependencies
dependencies: AgentCliDependencies,
identitySource: 'verified' | 'registered' = 'verified',
verifiedInstallation?: VerifiedInstalledAgentBundle
): DetachedAgentLifecycle {
const options: DetachedAgentLifecycleOptions = {
installationId,
@@ -526,12 +684,28 @@ function createDetachedLifecycle(
stateDirectory: paths.stateDirectory,
socketPath: paths.socketPath,
verifyInstallation: async () => {
const { verified, registry } = await verifyManagedInstallation(
installationId,
paths,
dependencies
)
registry.assertVerifiedRole(verified, ['current', 'candidate'])
if (verifiedInstallation !== undefined) {
return verifiedInstallation
}
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) =>
@@ -588,7 +762,7 @@ async function verifyManagedInstallation(
storagePath: resolve(agentRoot, 'registry.json')
})
const verified = await (
dependencies.verifyInstallation ?? verifyInstalledAgentBundle
dependencies.verifyInstallation ?? verifyPublishedAgentBundle
)(dirname(paths.executablePath), {
installationId,
architecture,
@@ -597,6 +771,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: {
+41 -1
View File
@@ -62,6 +62,45 @@ 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 same-installation legacy metadata after verification', () => {
const root = privateTemporaryDirectory()
const storagePath = resolve(root, 'registry.json')
writeFileSync(
storagePath,
`${JSON.stringify({
formatVersion: 1,
current: {
installationId: 'install-a',
agentVersion: '0.11.1',
manifestSha256: 'a'.repeat(64),
arch: 'x64'
}
}, null, 2)}\n`,
{ mode: 0o600 }
)
const registry = new InstallationRegistry({ storagePath })
const current = verified('install-a', '0.11.1', 'a')
expect(registry.isCurrent(current)).toBe(false)
registry.refreshCurrent(current)
expect(registry.snapshot().current).toMatchObject({
installationId: 'install-a',
protocol: { major: 1, minor: 0 }
})
})
it('replaces a stale candidate with the requested signed identity', () => {
@@ -104,7 +143,8 @@ describe('Agent installation registry', () => {
installationId: 'install-a',
agentVersion: '0.11.0',
manifestSha256: 'a'.repeat(64),
arch: 'x64'
arch: 'x64',
protocol: { major: 1, minor: 0 }
}
})
expect(readFileSync(storagePath, 'utf8')).toBe(legacyBytes)
+54 -9
View File
@@ -40,6 +40,14 @@ export class InstallationRegistry {
return structuredClone(this.#state)
}
isCurrent(verified: VerifiedInstalledAgentBundle): boolean {
const current = this.#state.current
return (
current !== undefined &&
entryEquals(current, entryFromVerified(verified))
)
}
stageCandidate(
verified: VerifiedInstalledAgentBundle
): InstallationRegistryEntry {
@@ -100,24 +108,58 @@ export class InstallationRegistry {
return this.snapshot()
}
refreshCurrent(
verified: VerifiedInstalledAgentBundle
): InstallationRegistryState {
const entry = entryFromVerified(verified)
if (
this.#state.current?.installationId !==
entry.installationId
) {
throw new Error(
'Verified Agent installation is not the current identity'
)
}
this.#commit({
formatVersion: 1,
current: entry
})
return this.snapshot()
}
assertVerifiedRole(
verified: VerifiedInstalledAgentBundle,
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
@@ -206,7 +248,8 @@ function entryFromVerified(
installationId: verified.installationId,
agentVersion: verified.manifest.agentVersion,
manifestSha256: verified.manifestSha256,
arch: verified.manifest.arch
arch: verified.manifest.arch,
protocol: verified.manifest.protocol
})
}
@@ -218,7 +261,9 @@ function entryEquals(
left.installationId === right.installationId &&
left.agentVersion === right.agentVersion &&
left.manifestSha256 === right.manifestSha256 &&
left.arch === right.arch
left.arch === right.arch &&
JSON.stringify(left.protocol) ===
JSON.stringify(right.protocol)
)
}
@@ -23,6 +23,8 @@ import {
import {
canonicalInstalledAgentManifestBytes,
installedAgentManifestSignaturePayload,
loadRegisteredAgentBundle,
verifyPublishedAgentBundle,
verifyInstalledAgentBundle
} from './installed-bundle-verifier'
@@ -87,7 +89,7 @@ describe('installed Agent bundle verifier', () => {
'tampered'
)
await expect(verifyBundle()).rejects.toThrow(
/size mismatch|hash mismatch/u
/entrypoint metadata changed|size mismatch|hash mismatch/u
)
resetInstallation()
@@ -111,7 +113,9 @@ describe('installed Agent bundle verifier', () => {
if (process.platform !== 'win32') {
writeBundle()
chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777)
await expect(verifyBundle()).rejects.toThrow('mode mismatch')
await expect(verifyBundle()).rejects.toThrow(
'entrypoint metadata changed'
)
resetInstallation()
}
@@ -132,6 +136,80 @@ describe('installed Agent bundle verifier', () => {
)
})
it('adopts signed published metadata without rehashing unrelated payload', async () => {
writeBundle()
writeFileSync(
join(installationDirectory, 'licenses', 'GoodBuddy-0BSD.txt'),
'changed after extraction'
)
await expect(verifyPublishedBundle()).resolves.toMatchObject({
installationId: 'install-10',
manifest: { agentVersion: '0.11.0' }
})
writeFileSync(
join(installationDirectory, 'lib', 'agent.cjs'),
'changed entrypoint'
)
await expect(verifyPublishedBundle()).rejects.toThrow(
'entrypoint metadata changed'
)
})
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 {
@@ -214,6 +292,16 @@ function verifyBundle() {
})
}
function verifyPublishedBundle() {
return verifyPublishedAgentBundle(installationDirectory, {
installationId: 'install-10',
architecture: 'x64',
releaseKeyRegistry,
verificationEnvironment: 'test',
enforceFilesystemMode: process.platform !== 'win32'
})
}
function elf(architecture: 'x64' | 'arm64'): Buffer {
const header = Buffer.alloc(64)
header.set([0x7f, 0x45, 0x4c, 0x46])
+245 -44
View File
@@ -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 } = {}
@@ -90,6 +110,66 @@ export async function readManagedAgentReleaseKeyRegistry(
export async function verifyInstalledAgentBundle(
installationDirectoryInput: string,
options: VerifyInstalledAgentBundleOptions
): Promise<VerifiedInstalledAgentBundle> {
const verified = await verifyPublishedAgentBundle(
installationDirectoryInput,
options
)
const {
installationDirectory,
manifest
} = verified
const declaredPaths = new Set(
manifest.files.map((file) => file.path)
)
for (const file of manifest.files) {
const filePath = join(
installationDirectory,
...file.path.split('/')
)
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, file.path, options.uid)
if (stat.size !== file.size) {
throw new Error(`Agent payload size mismatch: ${file.path}`)
}
if ((await sha256File(filePath)) !== file.sha256) {
throw new Error(`Agent payload hash mismatch: ${file.path}`)
}
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== file.mode
) {
throw new Error(`Agent payload mode mismatch: ${file.path}`)
}
}
const actualPaths = new Set(
await listInstallationFiles(installationDirectory, options)
)
const expectedPaths = new Set([
...declaredPaths,
MANIFEST_FILE_NAME,
SIGNATURE_FILE_NAME
])
if (
actualPaths.size !== expectedPaths.size ||
[...actualPaths].some((path) => !expectedPaths.has(path))
) {
throw new Error(
'Agent installation contains undeclared or missing files'
)
}
return verified
}
/**
* Authenticates a package-installer-published Agent from signed metadata and
* bounded entrypoint metadata. The installer already hashed every payload
* file while extracting it.
*/
export async function verifyPublishedAgentBundle(
installationDirectoryInput: string,
options: VerifyInstalledAgentBundleOptions
): Promise<VerifiedInstalledAgentBundle> {
const installationId = validateInstallationId(options.installationId)
const installationDirectory = resolve(installationDirectoryInput)
@@ -142,47 +222,11 @@ export async function verifyInstalledAgentBundle(
)
}
const declaredPaths = new Set(
manifest.files.map((file) => file.path)
const executablePath = await assertAgentEntrypoints(
installationDirectory,
manifest,
options
)
for (const file of manifest.files) {
const filePath = join(
installationDirectory,
...file.path.split('/')
)
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, file.path, options.uid)
if (stat.size !== file.size) {
throw new Error(`Agent payload size mismatch: ${file.path}`)
}
if ((await sha256File(filePath)) !== file.sha256) {
throw new Error(`Agent payload hash mismatch: ${file.path}`)
}
if (
shouldEnforceMode(options) &&
modeString(stat.mode) !== file.mode
) {
throw new Error(`Agent payload mode mismatch: ${file.path}`)
}
}
const actualPaths = new Set(
await listInstallationFiles(installationDirectory, options)
)
const expectedPaths = new Set([
...declaredPaths,
MANIFEST_FILE_NAME,
SIGNATURE_FILE_NAME
])
if (
actualPaths.size !== expectedPaths.size ||
[...actualPaths].some((path) => !expectedPaths.has(path))
) {
throw new Error(
'Agent installation contains undeclared or missing files'
)
}
await assertElfArchitecture(
join(installationDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
@@ -192,16 +236,132 @@ export async function verifyInstalledAgentBundle(
return {
installationId,
installationDirectory,
executablePath: join(
installationDirectory,
manifest.entrypoint.path
),
executablePath,
manifest,
manifestSha256,
binaryDigest: `sha256:${manifestSha256}`
}
}
/**
* 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<VerifiedInstalledAgentBundle> {
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<VerifiedInstalledAgentBundle> {
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 ||
(
registered.protocol !== undefined &&
(
manifest.protocol.major !==
registered.protocol.major ||
manifest.protocol.minor !==
registered.protocol.minor
)
)
) {
throw new Error(
'Agent manifest does not match the Host-managed registry'
)
}
const executablePath = await assertAgentEntrypoints(
installationDirectory,
manifest,
options
)
return {
installationId,
installationDirectory,
executablePath,
manifest,
manifestSha256: registered.manifestSha256,
binaryDigest: `sha256:${registered.manifestSha256}`
}
}
export function canonicalInstalledAgentManifestBytes(
manifest: AgentBundleManifest
): Buffer {
@@ -351,6 +511,47 @@ function importPublicKey(key: AgentReleaseKey) {
return publicKey
}
async function assertAgentEntrypoints(
installationDirectory: string,
manifest: AgentBundleManifest,
options: Pick<
VerifyInstalledAgentBundleOptions,
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
>
): Promise<string> {
for (const path of [
manifest.entrypoint.path,
manifest.entrypoint.runtimePath,
manifest.entrypoint.scriptPath
]) {
const record = manifest.files.find((file) => file.path === path)
if (record === undefined) {
throw new Error(`Agent entrypoint is not declared: ${path}`)
}
const filePath = join(
installationDirectory,
...path.split('/')
)
const stat = await lstat(filePath)
assertOwnedRegularFile(stat, filePath, options.uid)
if (
stat.size !== record.size ||
(
shouldEnforceMode(options) &&
modeString(stat.mode) !== record.mode
)
) {
throw new Error(
`Agent entrypoint metadata changed: ${basename(filePath)}`
)
}
}
return join(
installationDirectory,
manifest.entrypoint.path
)
}
async function readMetadataFile(
filePath: string,
label: string,
+49
View File
@@ -673,6 +673,55 @@ describe('AgentProtocolServer connection bounds', () => {
harness.close()
})
it('marks method context only after exact controller takeover', async () => {
const takeoverStates: Array<boolean | undefined> = []
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 }],
+2
View File
@@ -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') {
+173 -12
View File
@@ -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<void>
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<void>
} = {}) {
const workMode = input.workMode ?? 'ask'
let now = input.now ?? 1_000
@@ -261,7 +268,7 @@ function harness(input: {
const backend = new RuntimeAcpBackend({
journal,
resolveRuntimeBundle: vi.fn(async () => resolved),
reverifyRuntimeBundle: vi.fn(async () => verifiedBundle()),
loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()),
resolveWorkspace: vi.fn(async (preparation) => ({
workspaceIdentity: preparation.workspaceIdentity,
workspaceDirectory: '/workspace',
@@ -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<void>((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<void>((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<void>((resolve) => setImmediate(resolve))
await invoke(fixture, 'runtime/completePrompt', {
bindingId: 'binding-1',
operationId: 'request-1',
requestId: 'request-1'
})
fixture.context.abort.abort()
await new Promise<void>((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<void>((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<void>((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<Record<string, unknown>> {
+26 -4
View File
@@ -227,7 +227,7 @@ export type RuntimeAcpBackendOptions = {
runtimeId: string,
runtimeBundleDigest: string
): RuntimeAcpResolvedBundle | Promise<RuntimeAcpResolvedBundle>
reverifyRuntimeBundle(
loadRegisteredRuntimeBundle(
resolved: RuntimeAcpResolvedBundle,
preparation: RemotePromptOperationPreparation
): RuntimeAcpVerifiedBundle | Promise<RuntimeAcpVerifiedBundle>
@@ -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()
@@ -925,7 +947,7 @@ export class RuntimeAcpBackend {
let verified: RuntimeAcpVerifiedBundle
try {
verified = await this.#options.reverifyRuntimeBundle(
verified = await this.#options.loadRegisteredRuntimeBundle(
binding.resolvedBundle,
preparation
)
+74 -53
View File
@@ -25,7 +25,7 @@ import {
} from './managed-paths'
import type { InstalledBundleVerificationEnvironment } from './installed-bundle-verifier'
import {
verifyRuntimeBundle,
loadRegisteredRuntimeBundle,
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
@@ -120,6 +120,27 @@ export class RuntimeBundleRegistry {
entry: RuntimeRegistryEntry
bundleDirectory: string
} {
const resolved = this.find(
runtimeIdInput,
bundleDigestInput,
architecture
)
if (resolved === undefined) {
throw new Error(
'Runtime bundle is not the verified current bundle'
)
}
return resolved
}
find(
runtimeIdInput: string,
bundleDigestInput: string,
architecture: AgentArchitecture
): {
entry: RuntimeRegistryEntry
bundleDirectory: string
} | undefined {
const runtimeId = agentIdentifierSchema.parse(runtimeIdInput)
const bundleDigest = sha256DigestSchema.parse(bundleDigestInput)
this.#state = this.#load({ createIfMissing: false })
@@ -130,9 +151,7 @@ export class RuntimeBundleRegistry {
candidate.architecture === architecture
)
if (entry === undefined) {
throw new Error(
'Runtime bundle is not the verified current bundle'
)
return undefined
}
return {
entry: { ...entry },
@@ -231,7 +250,7 @@ export class RuntimeBundleRegistry {
}
}
export function createVerifiedRuntimeCapabilitySource(options: {
type VerifiedRuntimeCapabilitySourceOptions = {
registry: RuntimeBundleRegistry
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
@@ -241,64 +260,65 @@ export function createVerifiedRuntimeCapabilitySource(options: {
filesystemPlatform?: NodeJS.Platform
uid?: number
reportError?: (message: string, error: unknown) => void
}): () => Promise<DaemonCapabilities['runtimes']> {
loadRegistered?: (
entry: RuntimeRegistryEntry,
bundleDirectory: string
) => Promise<VerifiedRuntimeBundle>
}
export function createVerifiedRuntimeCapabilitySource(
options: VerifiedRuntimeCapabilitySourceOptions
): () => Promise<DaemonCapabilities['runtimes']> {
return async () => await loadVerifiedRuntimeCapabilities(options)
}
async function loadVerifiedRuntimeCapabilities(options: {
registry: RuntimeBundleRegistry
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
verificationEnvironment?: InstalledBundleVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
reportError?: (message: string, error: unknown) => void
}): Promise<DaemonCapabilities['runtimes']> {
async function loadVerifiedRuntimeCapabilities(
options: VerifiedRuntimeCapabilitySourceOptions
): Promise<DaemonCapabilities['runtimes']> {
const capabilities: DaemonCapabilities['runtimes'] = []
for (const entry of options.registry.current(options.architecture)) {
try {
const verified = await verifyRuntimeBundle(
const bundleDirectory =
options.registry.bundleDirectory(
entry.runtimeId,
entry.bundleDigest
),
{
architecture: options.architecture,
releaseKeyRegistry: options.releaseKeyRegistry,
runtimeLock: options.runtimeLock,
...(options.verificationEnvironment === undefined
? {}
: {
verificationEnvironment:
options.verificationEnvironment
}),
...(options.enforceFilesystemMode === undefined
? {}
: {
enforceFilesystemMode:
options.enforceFilesystemMode
}),
...(options.filesystemPlatform === undefined
? {}
: {
filesystemPlatform: options.filesystemPlatform
}),
...(options.uid === undefined ? {} : { uid: options.uid })
}
)
if (
verified.manifest.runtimeVersion !== entry.runtimeVersion ||
verified.manifestDigest !== entry.manifestDigest ||
verified.manifest.bundleDigest !== entry.bundleDigest ||
verified.manifest.acpCapabilitiesDigest !==
entry.acpCapabilitiesDigest
) {
throw new Error(
'Runtime bundle no longer matches its registry identity'
)
}
await (options.loadRegistered === undefined
? loadRegisteredRuntimeBundle(
bundleDirectory,
{
registered: entry,
architecture: options.architecture,
releaseKeyRegistry:
options.releaseKeyRegistry,
runtimeLock: options.runtimeLock,
...(options.verificationEnvironment === undefined
? {}
: {
verificationEnvironment:
options.verificationEnvironment
}),
...(options.enforceFilesystemMode === undefined
? {}
: {
enforceFilesystemMode:
options.enforceFilesystemMode
}),
...(options.filesystemPlatform === undefined
? {}
: {
filesystemPlatform:
options.filesystemPlatform
}),
...(options.uid === undefined
? {}
: { uid: options.uid })
}
)
: options.loadRegistered(
entry,
bundleDirectory
))
capabilities.push({
runtimeId: entry.runtimeId,
version: entry.runtimeVersion,
@@ -326,6 +346,7 @@ function entryFromVerified(
architecture: verified.manifest.architecture,
bundleDigest: verified.manifest.bundleDigest,
manifestDigest: verified.manifestDigest,
runtimeAdapterDigest: verified.manifest.adapterDigest,
acpCapabilitiesDigest:
verified.manifest.acpCapabilitiesDigest
})
@@ -8,7 +8,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import { verifyRuntimeBundle } from './runtime-bundle-verifier'
import {
verifyPublishedRuntimeBundle,
verifyRuntimeBundle
} from './runtime-bundle-verifier'
import {
RuntimeBundleRegistry,
createVerifiedRuntimeCapabilitySource
@@ -155,7 +158,7 @@ describe('Remote Runtime bundle verifier', () => {
).rejects.toThrow(/managed directory/iu)
})
it('advertises only a bundle that still verifies against its registry', async () => {
it('uses registered metadata without rereading Runtime payload files', async () => {
const fixture = await createBundle()
const runtimeRoot = dirname(dirname(fixture.bundleDirectory))
const registry = new RuntimeBundleRegistry({
@@ -198,12 +201,63 @@ describe('Remote Runtime bundle verifier', () => {
join(fixture.bundleDirectory, 'licenses', 'opencode.txt'),
'mutated'
)
await expect(capabilities()).resolves.toHaveLength(1)
expect(reportError).not.toHaveBeenCalled()
writeFileSync(
join(fixture.bundleDirectory, 'bin', 'opencode'),
Buffer.concat([
readFileSync(
join(fixture.bundleDirectory, 'bin', 'opencode')
),
Buffer.from('changed')
])
)
await expect(capabilities()).resolves.toEqual([])
expect(reportError).toHaveBeenCalledWith(
'Verified Runtime unavailable: opencode',
expect.any(Error)
)
})
it('adopts signed published metadata without rehashing unrelated payload', async () => {
const fixture = await createBundle()
const options = {
architecture: 'x64' as const,
releaseKeyRegistry: fixture.registry,
runtimeLock: lock,
verificationEnvironment: 'test' as const,
filesystemPlatform: 'win32' as const
}
writeFileSync(
join(fixture.bundleDirectory, 'licenses', 'opencode.txt'),
'changed after extraction'
)
await expect(
verifyPublishedRuntimeBundle(
fixture.bundleDirectory,
options
)
).resolves.toMatchObject({
manifest: { runtimeVersion: '1.18.9' }
})
writeFileSync(
join(fixture.bundleDirectory, 'bin', 'opencode'),
Buffer.concat([
readFileSync(
join(fixture.bundleDirectory, 'bin', 'opencode')
),
Buffer.from('changed')
])
)
await expect(
verifyPublishedRuntimeBundle(
fixture.bundleDirectory,
options
)
).rejects.toThrow('entrypoint metadata changed')
})
})
async function createBundle(
@@ -1,12 +1,15 @@
export {
canonicalRuntimeManifestBytes,
loadRegisteredRuntimeBundle,
readRemoteRuntimeLock,
runtimeManifestSignaturePayload,
verifyPublishedRuntimeBundle,
verifyRuntimeBundle,
verifyRuntimeManifestSignature
} from '../shared/node/runtime-bundle-verifier'
export type {
VerifiedRuntimeBundle,
LoadRegisteredRuntimeBundleOptions,
VerifyRuntimeBundleOptions
} from '../shared/node/runtime-bundle-verifier'
+3 -6
View File
@@ -37,7 +37,7 @@ afterEach(() => {
})
describe('production Runtime composition', () => {
it('keeps ACP live and reloads managed metadata installed after daemon startup', async () => {
it('loads metadata installed after startup and caches the registered Runtime identity', async () => {
const fixture = await createRuntimeBundleTestFixture()
const root = fixture.root
temporaryPaths.push(root)
@@ -121,11 +121,8 @@ describe('production Runtime composition', () => {
}, null, 2)}\n`,
{ mode: 0o600 }
)
await expect(protocol.runtimes()).resolves.toEqual([])
expect(reportError).toHaveBeenCalledWith(
'Verified Runtime unavailable: opencode',
expect.any(Error)
)
await expect(protocol.runtimes()).resolves.toHaveLength(1)
expect(reportError).not.toHaveBeenCalled()
await expect(protocol.dispose()).resolves.toBeUndefined()
events.close()
+88 -41
View File
@@ -7,6 +7,9 @@ import type {
AgentReleaseKeyRegistry
} from '../shared/agent-installation-contracts'
import type { AgentFrame } from '../shared/agent-protocol'
import type {
RuntimeRegistryEntry
} from '../shared/remote-environment-registry-contracts'
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
import type { EventJournal } from './event-journal'
import {
@@ -22,7 +25,8 @@ import {
} from './runtime-bundle-registry'
import {
readRemoteRuntimeLock,
verifyRuntimeBundle
loadRegisteredRuntimeBundle,
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
import { RuntimeAcpBackend } from './runtime-acp-backend'
import type { ProtocolMethodContext } from './protocol-server'
@@ -111,37 +115,90 @@ export async function createProductionRuntimeProtocol(
}
return registry
}
const currentVerificationOptions = async () => {
const metadataOptions =
options.filesystemPlatform === undefined
? {}
: { filesystemPlatform: options.filesystemPlatform }
const [releaseKeyRegistry, runtimeLock] = await Promise.all([
options.releaseKeyRegistry ??
readManagedAgentReleaseKeyRegistry(
join(runtimeRoot, 'release-keys.json'),
metadataOptions
),
options.runtimeLock ??
readRemoteRuntimeLock(
join(runtimeRoot, 'remote-runtime-lock.json'),
metadataOptions
)
])
return {
architecture: options.architecture,
releaseKeyRegistry,
runtimeLock,
verificationEnvironment,
...(options.filesystemPlatform === undefined
? {}
: { filesystemPlatform: options.filesystemPlatform })
} as const
let verificationOptionsPromise:
| Promise<{
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
verificationEnvironment: 'production' | 'test'
filesystemPlatform?: NodeJS.Platform
}>
| undefined
const currentVerificationOptions = () => {
verificationOptionsPromise ??= (async () => {
const metadataOptions =
options.filesystemPlatform === undefined
? {}
: {
filesystemPlatform:
options.filesystemPlatform
}
const [releaseKeyRegistry, runtimeLock] =
await Promise.all([
options.releaseKeyRegistry ??
readManagedAgentReleaseKeyRegistry(
join(runtimeRoot, 'release-keys.json'),
metadataOptions
),
options.runtimeLock ??
readRemoteRuntimeLock(
join(
runtimeRoot,
'remote-runtime-lock.json'
),
metadataOptions
)
])
return {
architecture: options.architecture,
releaseKeyRegistry,
runtimeLock,
verificationEnvironment,
...(options.filesystemPlatform === undefined
? {}
: {
filesystemPlatform:
options.filesystemPlatform
})
}
})()
void verificationOptionsPromise.catch(() => {
verificationOptionsPromise = undefined
})
return verificationOptionsPromise
}
const verifiedRuntimeCache =
new Map<string, Promise<VerifiedRuntimeBundle>>()
const loadRegistered = (
entry: RuntimeRegistryEntry,
bundleDirectory: string
): Promise<VerifiedRuntimeBundle> => {
const key = `${bundleDirectory}\0${JSON.stringify(entry)}`
const cached = verifiedRuntimeCache.get(key)
if (cached !== undefined) {
return cached
}
const loading = (async () =>
await loadRegisteredRuntimeBundle(
bundleDirectory,
{
...(await currentVerificationOptions()),
registered: entry
}
))()
verifiedRuntimeCache.set(key, loading)
void loading.catch(() => {
if (verifiedRuntimeCache.get(key) === loading) {
verifiedRuntimeCache.delete(key)
}
})
return loading
}
const verifiedCapabilities = async () => {
const source = createVerifiedRuntimeCapabilitySource({
registry: currentRegistry(),
...(await currentVerificationOptions()),
loadRegistered,
reportError: options.reportError
})
return await source()
@@ -182,21 +239,11 @@ export async function createProductionRuntimeProtocol(
bundleDigest,
options.architecture
),
reverifyRuntimeBundle: async (resolved) => {
const verified = await verifyRuntimeBundle(
resolved.bundleDirectory,
await currentVerificationOptions()
loadRegisteredRuntimeBundle: async (resolved) => {
return await loadRegistered(
resolved.entry,
resolved.bundleDirectory
)
if (
verified.manifestDigest !== resolved.entry.manifestDigest ||
verified.manifest.bundleDigest !==
resolved.entry.bundleDigest
) {
throw new Error(
'Runtime bundle no longer matches its registered identity'
)
}
return verified
},
resolveWorkspace: async (preparation, context) =>
resolveCurrentWorkspace(
+4 -1
View File
@@ -96,7 +96,8 @@ function resolveContextCompression(
'openai-images-generations'
>,
authentication: summaryProfile.authentication,
contextWindowTokens: summaryProfile.contextWindowTokens
contextWindowTokens: summaryProfile.contextWindowTokens,
maximumOutputTokens: summaryProfile.maximumOutputTokens
}
}
: {})
@@ -120,6 +121,7 @@ export function createDefaultModelRuntime(
protocol: settings.modelProtocol,
authentication: settings.modelAuthentication,
supportsImageInput: settings.supportsImageInput,
maximumOutputTokens: currentProfile?.maximumOutputTokens,
defaultWorkspace: settings.workspacePath || defaultWorkspace,
contextCompression: resolveContextCompression(
settings,
@@ -141,6 +143,7 @@ export function createModelProfileRuntime(
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
+3
View File
@@ -476,6 +476,7 @@ describe('ModelAgentRuntime', () => {
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
maximumOutputTokens: 48_000,
skillInstructions: '# 文档写作',
fetcher
})
@@ -500,11 +501,13 @@ describe('ModelAgentRuntime', () => {
const body = JSON.parse(init?.body as string) as {
model: string
max_tokens: number
stream: boolean
system: string
}
expect(body).toMatchObject({
model: 'sonnet-5',
max_tokens: 48_000,
stream: true
})
expect(body.system).toMatch(
+8 -5
View File
@@ -8,6 +8,7 @@ import type {
ModelProtocol,
RuntimeConversationCompactInput
} from '../../shared/contracts'
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { WorkspaceAccess } from '../workspace'
@@ -233,8 +234,10 @@ export type ModelRuntimeOptions = {
protocol: Exclude<ModelProtocol, 'openai-images-generations'>
authentication: ModelAuthentication
contextWindowTokens?: number
maximumOutputTokens?: number
}
}
maximumOutputTokens?: number
}
function getErrorMessage(value: unknown): string | undefined {
@@ -1794,10 +1797,9 @@ export class ModelAgentRuntime implements AgentRuntime {
}
private get anthropicProtocolMaximumTokens(): number {
return Math.max(
this.options.contextCompression?.contextWindowTokens ??
minimumModelContextWindowTokens,
minimumModelContextWindowTokens
return (
this.options.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens
)
}
@@ -1877,7 +1879,8 @@ export class ModelAgentRuntime implements AgentRuntime {
ModelProtocol,
'openai-images-generations'
>,
authentication: this.options.authentication
authentication: this.options.authentication,
maximumOutputTokens: this.options.maximumOutputTokens
}
const summaryRuntime = new ModelAgentRuntime({
...summaryModel,
+1
View File
@@ -386,6 +386,7 @@ export function createResolvedModelProfileDigest(
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null
})
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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> = {}
): AssistantProject {
return project({
rootPath: '/srv/project',
executionSpace: {
kind: 'ssh',
hostId: '00000000-0000-4000-8000-000000000201',
remoteRootPath: '/srv/project'
},
runtimeSelection: { provider: 'opencode' },
...overrides
})
}
@@ -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
)
@@ -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('缺少完整')
})
})
@@ -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 缺少完整的当前验证,请重新验证')
}
}
+1 -30
View File
@@ -1,4 +1,5 @@
import { extname } from 'node:path'
export { detectSupportedImage } from '../shared/image-media-type'
const mimeTypes: Readonly<Record<string, string>> = {
'.c': 'text/x-c',
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
): string {
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
}
export function detectSupportedImage(data: Buffer): {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
} {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
data.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+33 -9
View File
@@ -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,21 @@ if (hasSingleInstanceLock) {
database: startupAssistantDatabase,
notify: sendRemoteProjectSaveProgress
})
const remoteEnvironmentPreparer =
new RemoteEnvironmentPreparer({
resolver: startupRemoteAgentServices.targetResolver,
sshPool: startupRemoteAgentServices.sshPool,
agentPackageManager,
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)
]
+96 -71
View File
@@ -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)
@@ -3880,7 +3917,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
role: 'assistant' as const,
content: '增量内容',
createdAt: 1,
state: 'streaming' as const
state: 'streaming' as const,
tools: [
{
callId: 'remote-tool-call',
name: '远程工具',
state: 'completed' as const,
summary: '完整远程工具结果',
output: '远程输出'.repeat(10_000)
}
]
}
]
}
@@ -7887,7 +7933,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 +7945,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',
+39 -33
View File
@@ -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?.()
}
+15 -5
View File
@@ -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<Response> {
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
)
@@ -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 = () => {
@@ -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 +
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,860 @@
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 archiveVersion = archive.toString('utf8')
const version =
archiveVersion === '1.9.0' ||
archiveVersion === '2.1.0'
? archiveVersion
: '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<typeof fetch>()).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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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('prefers a verified local package over an older available online catalog for GoodBuddy transfer', async () => {
const fixture = await createFixture('mirror')
const transport = vi.fn<typeof fetch>(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 importDirectory = await mkdtemp(
join(tmpdir(), 'goodbuddy-agent-local-newer-')
)
temporaryDirectories.push(importDirectory)
const localArchive = join(
importDirectory,
'goodbuddy-agent-2.1.0-linux-x64.gbagent'
)
await writeFile(localArchive, '2.1.0')
const manager = fixture.manager(transport)
await manager.importArchive(localArchive)
await manager.getSnapshot()
const lease = await manager.acquireGoodBuddyInstallArchive('x64')
expect(lease.candidate).toMatchObject({
architecture: 'x64',
version: '2.1.0',
urls: []
})
expect(
transport.mock.calls.some(([input]) =>
input.toString().endsWith(fixture.archive)
)
).toBe(false)
await expect(readFile(lease.path)).resolves.toEqual(
Buffer.from('2.1.0')
)
lease.release()
})
it('downloads a newer online package instead of transferring a stale local package', async () => {
const onlineArchive = Buffer.from('online-2.0.0')
const fixture = await createFixture(
'mirror',
undefined,
onlineArchive
)
const entry = fixture.catalog.entries[0]!
let archiveRequests = 0
const transport = vi.fn<typeof fetch>(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(entry.archive)) {
archiveRequests += 1
return response(onlineArchive, {
headers: {
'content-length': String(onlineArchive.byteLength)
}
})
}
throw new Error(`Unexpected URL ${url}`)
})
const importDirectory = await mkdtemp(
join(tmpdir(), 'goodbuddy-agent-local-stale-')
)
temporaryDirectories.push(importDirectory)
const localArchive = join(
importDirectory,
'goodbuddy-agent-1.9.0-linux-x64.gbagent'
)
await writeFile(localArchive, '1.9.0')
const manager = fixture.manager(transport)
await manager.importArchive(localArchive)
await manager.getSnapshot()
const lease = await manager.acquireGoodBuddyInstallArchive('x64')
expect(lease.candidate).toMatchObject({
architecture: 'x64',
version: '2.0.0',
sha256: entry.sha256
})
expect(archiveRequests).toBe(1)
await expect(readFile(lease.path)).resolves.toEqual(onlineArchive)
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<typeof fetch>(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<Response>((_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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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}`]
})
})
})
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -0,0 +1,21 @@
export function boundedDiagnostic(
value: string,
maximumCharacters = 800
): string {
let printable = ''
for (const character of value) {
const code = character.charCodeAt(0)
if (
code === 9 ||
code === 10 ||
code === 13 ||
(code >= 32 && code !== 127)
) {
printable += character
}
}
return printable
.replace(/\s+/gu, ' ')
.trim()
.slice(0, maximumCharacters)
}
@@ -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')
)
@@ -35,6 +35,17 @@ export function resolveBundledAgentResourcePaths(
}
}
export function resolveControlPlanePackageInstallerPath(
environment: Pick<BundledAgentResourceEnvironment, 'appPath'>
): string {
return join(
environment.appPath,
'out',
'main',
'remote-package-installer.mjs'
)
}
export function getBundledAgentDirectory(
paths: BundledAgentResourcePaths,
architecture: AgentArchitecture
@@ -0,0 +1,3 @@
import { runPackageInstallerCli } from './control-plane-package-installer'
process.exitCode = runPackageInstallerCli()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
import { settleBoundedly } from './bounded-settlement'
const DISPOSE_WAIT_TIMEOUT_MS = 5_000
type ActiveActivation<TValue, TPhase extends string> = {
promise: Promise<TValue>
controller: AbortController
progressCallbacks: Set<(phase: TPhase) => void>
waiters: number
settled: boolean
}
export class HostActivationCoordinator<
TValue,
TPhase extends string
> {
readonly #maximumConcurrentHosts: number
readonly #capacityError: () => Error
readonly #disposeMessage: string
readonly #active =
new Map<string, ActiveActivation<TValue, TPhase>>()
readonly #current = new Map<
string,
{
targetKey: string
variantKey: string
value: TValue
}
>()
readonly #cacheGenerations = new Map<string, number>()
#closed = false
#disposePromise: Promise<void> | undefined
constructor(options: {
maximumConcurrentHosts?: number
capacityError: () => Error
disposeMessage: string
}) {
this.#maximumConcurrentHosts =
options.maximumConcurrentHosts ?? 8
this.#capacityError = options.capacityError
this.#disposeMessage = options.disposeMessage
if (
!Number.isSafeInteger(this.#maximumConcurrentHosts) ||
this.#maximumConcurrentHosts <= 0 ||
this.#maximumConcurrentHosts > 32
) {
throw new Error(
'Host activation concurrency limit is invalid'
)
}
}
assertAvailable(): void {
if (this.#closed) {
throw new Error(this.#disposeMessage)
}
}
cached(
hostId: string,
targetKey: string,
variantKey: string
): TValue | undefined {
this.assertAvailable()
const cached = this.#current.get(hostId)
return (
cached?.targetKey === targetKey &&
cached.variantKey === variantKey
)
? cached.value
: undefined
}
cachedForHost(
hostId: string,
variantKey: string
): TValue | undefined {
this.assertAvailable()
const cached = this.#current.get(hostId)
return cached?.variantKey === variantKey
? cached.value
: undefined
}
run(options: {
hostId: string
targetKey: string
variantKey: string
signal?: AbortSignal
onProgress?: (phase: TPhase) => void
operation: (
signal: AbortSignal,
progress: (phase: TPhase) => void
) => Promise<TValue>
}): Promise<TValue> {
this.assertAvailable()
options.signal?.throwIfAborted()
const operationKey =
`${options.targetKey}\0${options.variantKey}`
const existing = this.#active.get(operationKey)
if (existing) {
return this.#waitForActive(existing, options)
}
if (this.#active.size >= this.#maximumConcurrentHosts) {
throw this.#capacityError()
}
const cacheGeneration =
this.#cacheGenerations.get(options.hostId) ?? 0
const progressCallbacks = new Set<
(phase: TPhase) => void
>()
if (options.onProgress) {
progressCallbacks.add(options.onProgress)
}
const progress = (phase: TPhase): void => {
for (const callback of progressCallbacks) {
emitProgress(callback, phase)
}
}
const controller = new AbortController()
const promise = options.operation(
controller.signal,
progress
).then((value) => {
if (
(this.#cacheGenerations.get(options.hostId) ?? 0) ===
cacheGeneration
) {
this.#current.set(options.hostId, {
targetKey: options.targetKey,
variantKey: options.variantKey,
value
})
}
return value
})
const active: ActiveActivation<TValue, TPhase> = {
promise,
controller,
progressCallbacks,
waiters: 0,
settled: false
}
this.#active.set(operationKey, active)
void promise.finally(() => {
active.settled = true
if (this.#active.get(operationKey) === active) {
this.#active.delete(operationKey)
}
}).catch(() => undefined)
return this.#waitForActive(active, options)
}
invalidate(hostId: string): void {
this.#current.delete(hostId)
this.#cacheGenerations.set(
hostId,
(this.#cacheGenerations.get(hostId) ?? 0) + 1
)
}
dispose(): Promise<void> {
if (this.#disposePromise) {
return this.#disposePromise
}
this.#closed = true
this.#current.clear()
this.#cacheGenerations.clear()
const operations = [...this.#active.values()]
const reason = new DOMException(
this.#disposeMessage,
'AbortError'
)
for (const operation of operations) {
operation.controller.abort(reason)
}
this.#disposePromise = settleBoundedly(
operations.map((operation) => operation.promise),
DISPOSE_WAIT_TIMEOUT_MS
)
return this.#disposePromise
}
async #waitForActive(
active: ActiveActivation<TValue, TPhase>,
options: {
signal?: AbortSignal
onProgress?: (phase: TPhase) => void
}
): Promise<TValue> {
active.waiters += 1
if (options.onProgress) {
active.progressCallbacks.add(options.onProgress)
}
try {
return await waitForOperation(
active.promise,
options.signal
)
} finally {
if (options.onProgress) {
active.progressCallbacks.delete(options.onProgress)
}
active.waiters -= 1
if (
active.waiters === 0 &&
!active.settled &&
!active.controller.signal.aborted
) {
active.controller.abort(
new DOMException(
'Host activation has no remaining waiters',
'AbortError'
)
)
}
}
}
}
function emitProgress<TPhase extends string>(
callback: (phase: TPhase) => void,
phase: TPhase
): void {
try {
callback(phase)
} catch {
// Progress observers cannot alter activation state.
}
}
function waitForOperation<T>(
operation: Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (!signal) {
return operation
}
signal.throwIfAborted()
return new Promise<T>((resolve, reject) => {
const abort = (): void => reject(
signal.reason ??
new DOMException(
'The operation was aborted',
'AbortError'
)
)
signal.addEventListener('abort', abort, { once: true })
void operation.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', abort)
})
})
}
+1 -4
View File
@@ -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
@@ -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<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
}> = []
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()
})
@@ -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<AgentRuntime> {
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<ManagedRemoteWorkspaceAccessFactory['create']>
handle: RemoteWorkspaceHandle
}
| undefined
#workspaceTail: Promise<void> = Promise.resolve()
#disposePromise?: Promise<void>
constructor(
private readonly workspaceFactory:
Pick<ManagedRemoteWorkspaceAccessFactory, 'create'>,
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<RemoteRuntimeInstallationManager['ensureInstalled']>
>,
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<ManagedRemoteTransportIdentity> {
async prepareTransport(
preparedCapabilities?: Awaited<
ReturnType<
RemoteAgentServices['connectionManager']['acquire']
>
>['capabilities']
): Promise<ManagedRemoteTransportIdentity> {
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<void> {
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<void> {
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<RemoteAgentServices['installationManager']['ensureInstalled']>
>
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<RemoteRuntimeInstallationManager['ensureInstalled']>
>
): 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<RemoteAgentServices['connectionManager']['acquire']>
>
@@ -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<RemoteRuntimeInstallationManager['ensureInstalled']>
>,
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'
)
}
}
@@ -25,9 +25,7 @@ import {
createManagedModelBridge,
reconcileStartupModelCalls
} from './managed-model-bridge'
import type {
AgentPackageManager
} from './agent-package-manager'
import { boundedDiagnostic } from './bounded-diagnostic'
const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite'
const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite'
@@ -46,10 +44,6 @@ export type ManagedRemoteExecutionServicesOptions = {
appPath: string
resourcesPath: string
packaged: boolean
agentPackageManager: Pick<
AgentPackageManager,
'loadRuntimeBundle' | 'loadRuntimeMetadata'
>
resolveModelProfile(
selection: AgentRuntimeSelection
): Promise<ResolvedModelProfile | undefined>
@@ -93,32 +87,25 @@ 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
)
signal.throwIfAborted()
if (
runtimeId !== 'opencode' ||
current.host.id !== lease.identity.hostId ||
current.hostRevision !== lease.identity.hostRevision ||
current.hostKeyGeneration !==
lease.identity.hostKeyGeneration ||
agent.architecture !== architecture
) {
if (runtimeId !== 'opencode') {
throw new Error(
'Remote Agent or SSH host identity changed before Runtime activation'
'Remote Runtime identity is unsupported'
)
}
const result = await lease.runAgentRuntimeAction(
agent.installationId,
agentInstallationId,
{
kind: 'runtime-activate',
runtimeId,
@@ -128,17 +115,20 @@ export class ManagedRemoteExecutionServices {
signal
)
if (result.exitCode !== 0) {
throw new Error('Remote Runtime activation failed')
const detail = boundedDiagnostic(
`${result.stderr}\n${result.stdout}`
)
throw new Error(
detail.length > 0
? `Remote Runtime activation failed: ${detail}`
: `Remote Runtime activation failed (exit ${String(result.exitCode)})`
)
}
}
this.runtimeInstallationManager =
new RemoteRuntimeInstallationManager({
resolver: options.agentServices.targetResolver,
sshPool: options.agentServices.sshPool,
loadVerifiedBundle:
options.agentPackageManager.loadRuntimeBundle,
loadVerificationMetadata:
options.agentPackageManager.loadRuntimeMetadata,
activate
})
this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({
@@ -147,9 +137,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 +178,10 @@ export class ManagedRemoteExecutionServices {
await this.#readiness
}
invalidateHost(hostId: string): void {
this.runtimeInstallationManager.invalidateHost(hostId)
}
async createRuntime(
options: Pick<
ManagedRemoteAcpRuntimeOptions,
@@ -206,14 +200,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
})
}
@@ -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()
})
@@ -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<ResolvedModelProfile | undefined>
@@ -34,16 +30,14 @@ export class ManagedRemoteProjectRuntimeValidator
constructor(options: {
installationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
'activateInstalled'
>
resolveModelProfile(
selection: RemoteProjectRuntimeValidationInput['selection']
): Promise<ResolvedModelProfile | undefined>
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<RemoteProjectRuntimeValidationLease> {
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(
@@ -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<AgentInstallationIdentity>
}
function createHarness(options: HarnessOptions = {}): {
factory: ManagedRemoteWorkspaceAccessFactory
events: string[]
protocolMethods: string[]
ensureInstalled: ReturnType<typeof vi.fn>
acquire: ReturnType<typeof vi.fn>
activateInstalled: ReturnType<typeof vi.fn>
connectionRelease: ReturnType<typeof vi.fn>
} {
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<AgentInstallationManager, 'ensureInstalled'>,
connectionManager: {
acquire
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>
} as unknown as Pick<RemoteAgentConnectionManager, 'acquire'>,
installationManager: {
activateInstalled
}
} satisfies ManagedRemoteWorkspaceAccessFactoryOptions
return {
factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions),
events,
protocolMethods,
ensureInstalled,
acquire,
activateInstalled,
connectionRelease
}
}
@@ -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<RemoteAgentConnectionManager, 'acquire'>
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
}
/**
* 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<AgentInstallationManager['ensureInstalled']>
>
): 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'
)
}
}
@@ -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'
@@ -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<RemoteWorkspaceInstallationResolution>
}
@@ -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',
@@ -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<unknown | undefined> {
return structuredClone(this.value)
}
async write(value: PersistedControllerState): Promise<void> {
this.writes += 1
this.value = structuredClone(value)
}
}
@@ -152,7 +157,8 @@ const installation = {
function harness(
connectGate?: Promise<void>,
reconnectResumeGate?: Promise<void>
reconnectResumeGate?: Promise<void>,
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<void>((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<void>((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()
})
@@ -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<ActiveConnection>
reconnectPromise?: Promise<void>
capabilityRefreshPromise?: Promise<DaemonCapabilities>
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<DaemonCapabilities> {
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(
@@ -37,6 +37,8 @@ const target: SshConnectionPoolTarget = {
hostRevision: 1,
hostKeyGeneration: 1
}
const controlPlanePackageInstaller =
'module.exports = { source: "GoodBuddy" }\n'
function harness(overrides: {
installationDispose?: () => Promise<void>
@@ -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?.()
+13 -18
View File
@@ -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<RemoteAgentServiceFactories>
}
@@ -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<void> {
this.installationManager.invalidateHost(hostId)
return this.connectionManager.invalidateHost(hostId)
}
@@ -0,0 +1,65 @@
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'
}
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(4 * 1024 + 1)
)
await expect(store.load(operation.hostId)).rejects.toThrow(
'超过大小限制'
)
})
})
@@ -0,0 +1,123 @@
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 = 4 * 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
}
export class RemoteEnvironmentOperationStore {
readonly #directory: string
constructor(userDataPath: string) {
this.#directory = resolve(
userDataPath,
'remote-components',
'pending-environment-operations'
)
}
async load(
hostId: string
): Promise<PendingRemoteEnvironmentOperation | undefined> {
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<void> {
return writeJsonFileAtomically(
this.#path(operation.hostId),
parsePendingOperation(operation, operation.hostId)
)
}
remove(hostId: string): Promise<void> {
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) {
throw new Error('远程环境恢复记录无效')
}
const record = value as Record<string, unknown>
const expectedKeys = [
'hostId',
'operationId',
'targetIdentity',
'version'
]
if (
Object.keys(value).sort().join('\0') !==
expectedKeys.sort().join('\0')
) {
throw new Error('远程环境恢复记录无效')
}
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)
) {
throw new Error('远程环境恢复记录无效')
}
return {
version: 1,
hostId: expectedHostId,
targetIdentity: record.targetIdentity,
operationId: record.operationId
}
}
@@ -0,0 +1,799 @@
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 {
AgentPackageArchiveLease,
VerifiedRemoteAgentInstallCandidate
} from './agent-package-manager'
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> = {}
): 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<typeof vi.fn>
prepareUploaded?: ReturnType<typeof vi.fn>
commit?: ReturnType<typeof vi.fn>
cleanup?: ReturnType<typeof vi.fn>
uploadFile?: ReturnType<typeof vi.fn>
activateAgent?: ReturnType<typeof vi.fn>
activateRuntime?: ReturnType<typeof vi.fn>
pendingOperation?: PendingRemoteEnvironmentOperation
}
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 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 activateAgent = options.activateAgent ?? vi.fn(async () => {
calls.push('agent')
return { exitCode: 0, stdout: '{}', stderr: '' }
})
const activateRuntime = options.activateRuntime ?? vi.fn(async () => {
calls.push('runtime')
return { exitCode: 0, stdout: '{}', stderr: '' }
})
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 () => {
calls.push('open-upload')
return uploadSftp
}),
runAgentLifecycleAction: vi.fn(async (
installationId: string,
action: string
) => (activateAgent as (
installationId: string,
action: string
) => Promise<unknown>)(installationId, action)),
runAgentRuntimeAction: vi.fn(async (
installationId: string,
action: unknown
) => (activateRuntime as (
installationId: string,
action: unknown
) => Promise<unknown>)(installationId, action)),
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 = options.cleanup ?? vi.fn(async (operationId: string) => {
calls.push('cleanup')
return { cleaned: true, operationId }
})
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,
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
})
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
},
operationStore
})
return {
preparer,
calls,
resolver,
regularLease,
bootstrapLease,
uploadFile,
probe,
createUploadStaging,
prepare,
prepareUploaded,
commit,
cleanup,
activateAgent,
activateRuntime,
acquireInstallArchive,
archiveRelease,
regularRelease,
bootstrapRelease,
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',
'resolve',
'commit',
'agent',
'runtime',
'cleanup',
'remove-operation',
'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(
identity.agent.installationId,
'adopt'
)
expect(value.activateRuntime).toHaveBeenCalledWith(
identity.agent.installationId,
{
kind: 'runtime-activate',
runtimeId: identity.runtime.runtimeId,
bundleDigest: identity.runtime.bundleDigest,
architecture: identity.runtime.architecture,
forceVerification: true
}
)
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('keeps an adopted environment healthy when staging cleanup must be retried', async () => {
const value = fixture({
cleanup: vi.fn(async (operationId: string) => ({
cleaned: false,
operationId,
reason: 'cleanup-failed'
}))
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)).resolves.toBeUndefined()
expect(value.activateAgent).toHaveBeenCalledOnce()
expect(value.activateRuntime).toHaveBeenCalledOnce()
expect(value.operationStore.remove).not.toHaveBeenCalled()
})
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('cleans staging when cancellation arrives with a successful commit result', async () => {
const controller = new AbortController()
const cancellation =
new DOMException('cancelled after commit', 'AbortError')
const value = fixture({
commit: vi.fn(async (input: { operationId: string }) => {
controller.abort(cancellation)
return {
committed: true,
operationId: input.operationId,
identity
}
})
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
controller.signal
)).rejects.toBe(cancellation)
expect(value.cleanup).toHaveBeenCalledOnce()
expect(value.operationStore.remove).toHaveBeenCalledOnce()
expect(value.activateAgent).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('cleans pending staging and starts a fresh authenticated prepare', async () => {
const originalTarget = target()
const pendingOperationId =
'00000000-0000-4000-8000-000000000780'
const value = fixture({
targets: [originalTarget],
pendingOperation: {
version: 1,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId: pendingOperationId
}
})
await value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)
expect(value.cleanup.mock.calls[0]?.[0]).toBe(
pendingOperationId
)
expect(value.prepare).toHaveBeenCalledOnce()
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
})
it('does not block a fresh prepare when stale staging cleanup fails', async () => {
const originalTarget = target()
let cleanupCalls = 0
const value = fixture({
targets: [originalTarget],
pendingOperation: {
version: 1,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId:
'00000000-0000-4000-8000-000000000779'
},
cleanup: vi.fn(async (operationId: string) => {
cleanupCalls += 1
return cleanupCalls === 1
? {
cleaned: false as const,
reason: 'cleanup-failed' as const
}
: {
cleaned: true as const,
operationId
}
})
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)).resolves.toBeUndefined()
expect(value.prepare).toHaveBeenCalledOnce()
expect(value.cleanup).toHaveBeenCalledTimes(2)
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
})
it('drops stale cleanup state when the SSH target changed and starts fresh', async () => {
const originalTarget = target()
const changedTarget = target({
host: {
...originalTarget.host,
hostname: 'different.example'
}
})
const pendingOperationId =
'00000000-0000-4000-8000-000000000778'
const value = fixture({
targets: [changedTarget],
pendingOperation: {
version: 1,
hostId: HOST_ID,
targetIdentity:
remoteHostRecoveryIdentityKey(originalTarget),
operationId: pendingOperationId
}
})
await value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)
expect(value.prepare).toHaveBeenCalledOnce()
expect(value.cleanup.mock.calls.some(
(call) => call[0] === pendingOperationId
)).toBe(false)
expect(value.operationStore.remove).toHaveBeenCalledTimes(2)
})
it('cleans staging and leaves a fresh retry after Runtime adoption fails', async () => {
const runtimeFailure = new Error('runtime failed')
const value = fixture({
activateRuntime: vi.fn(async () => {
throw runtimeFailure
})
})
await expect(value.preparer.prepare(
HOST_ID,
'remote-download',
undefined,
new AbortController().signal
)).rejects.toBe(runtimeFailure)
expect(value.cleanup).toHaveBeenCalledOnce()
expect(value.operationStore.remove).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,745 @@
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 { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type {
AgentDiagnosticResult,
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 {
AgentInstallationTargetResolver
} from './agent-installation-manager'
import type {
AgentPackageArchiveLease,
AgentPackageManager,
VerifiedRemoteAgentInstallCandidate
} from './agent-package-manager'
import type {
PendingRemoteEnvironmentOperation,
RemoteEnvironmentOperationStore
} from './remote-environment-operation-store'
import type {
RemoteEnvironmentPreparer as Preparer,
RemoteEnvironmentUpdateProgressObserver
} from './remote-environment-update-service'
import {
remoteHostRecoveryIdentityKey
} from './remote-host-target-identity'
import { boundedDiagnostic } from './bounded-diagnostic'
const MAXIMUM_PACKAGE_BYTES = 512 * 1024 * 1024
const MAXIMUM_DIAGNOSTIC_CHARACTERS = 800
function assertAgentCommandSucceeded(
result: AgentDiagnosticResult,
label: string
): void {
if (result.exitCode === 0) {
return
}
const detail = `${result.stderr}\n${result.stdout}`
const bounded = boundedDiagnostic(
detail,
MAXIMUM_DIAGNOSTIC_CHARACTERS
)
throw new Error(
bounded.length > 0
? `${label}失败:${bounded}`
: `${label}失败,退出码 ${String(result.exitCode)}`
)
}
export type RemoteEnvironmentPreparerOptions = {
resolver: AgentInstallationTargetResolver
sshPool: Pick<
SshConnectionPool,
'acquire' | 'acquireRemotePackageBootstrap'
>
agentPackageManager: Pick<
AgentPackageManager,
| 'getRemoteInstallCandidate'
| 'acquireInstallArchive'
| 'acquireGoodBuddyInstallArchive'
>
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, health checks, and cleanup.
*/
export class RemoteEnvironmentPreparer implements Preparer {
readonly #resolver: AgentInstallationTargetResolver
readonly #sshPool: RemoteEnvironmentPreparerOptions['sshPool']
readonly #agentPackageManager:
RemoteEnvironmentPreparerOptions['agentPackageManager']
readonly #operationStore:
RemoteEnvironmentPreparerOptions['operationStore']
constructor(options: RemoteEnvironmentPreparerOptions) {
this.#resolver = options.resolver
this.#sshPool = options.sshPool
this.#agentPackageManager = options.agentPackageManager
this.#operationStore = options.operationStore
}
async prepare(
hostId: string,
requestedMethod: RemoteEnvironmentPreparationMethod,
observer: RemoteEnvironmentUpdateProgressObserver | undefined,
signal: AbortSignal
): Promise<void> {
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.#cleanupPendingOperation(
hostId,
target,
emit,
signal
)
let probeLease: SshConnectionLease | undefined
let bootstrapLease:
| SshRemotePackageBootstrapLease
| undefined
let archiveLease: AgentPackageArchiveLease | undefined
let operationId: string | undefined
let operationCleaned = false
let pendingOperationSaved = false
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')
// Keep only the operation ID needed to clean stale staging before the
// next fresh attempt. No remote environment state is copied locally.
await this.#operationStore.save({
version: 1,
hostId,
targetIdentity: remoteHostRecoveryIdentityKey(target),
operationId
})
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)
// 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))
}
}
)
if (!committed.committed) {
if (
committed.reason ===
'installer-rollback-incomplete'
) {
throw new SshRemotePackageCommitIndeterminateError(
'远端环境发布失败且回滚结果不完整,请重新检查 Host 状态'
)
}
throw new Error(
`远端安装提交失败:${committed.detail ?? committed.reason}`
)
}
if (
committed.operationId !== operationId ||
!isDeepStrictEqual(
committed.identity,
prepared.identity
)
) {
throw new Error('远端安装提交身份与准备身份不匹配')
}
assertPackageIdentity(committed.identity, candidate)
signal.throwIfAborted()
await this.#adoptCommittedEnvironment(
probeLease,
committed.identity,
method,
emit,
signal
)
emit(method, 'finalizing')
try {
const cleanup = await bootstrapLease.cleanup(
operationId,
{ signal }
)
if (
cleanup.cleaned ||
cleanup.reason === 'operation-unavailable'
) {
operationCleaned = true
try {
await this.#operationStore.remove(hostId)
} catch {
// A stale local receipt is harmless after remote cleanup.
}
}
} catch {
// The environment is already healthy. The next update will retry
// cleanup before starting a fresh operation.
}
} catch (error) {
if (
operationId &&
bootstrapLease &&
!operationCleaned &&
!(error instanceof SshRemotePackageCommitIndeterminateError)
) {
operationCleaned = await cleanupBestEffort(
bootstrapLease,
operationId
)
if (operationCleaned && pendingOperationSaved) {
await this.#operationStore.remove(hostId).catch(() => undefined)
}
}
throw error
} finally {
archiveLease?.release()
bootstrapLease?.release()
probeLease?.release()
}
}
async #adoptCommittedEnvironment(
lease: SshConnectionLease,
identity: SshRemotePackageIdentity,
method: RemoteEnvironmentPreparationMethod,
emit: (
method: RemoteEnvironmentPreparationMethod,
phase: RemoteEnvironmentUpdateProgress['phase']
) => void,
signal: AbortSignal
): Promise<void> {
const installationId = verifyAgentInstallationId(
identity.agent.installationId
)
emit(method, 'installing-agent')
assertAgentCommandSucceeded(
await lease.runAgentLifecycleAction(
installationId,
'adopt',
signal
),
'GoodBuddy Agent 启动与健康检查'
)
signal.throwIfAborted()
emit(method, 'installing-runtime')
assertAgentCommandSucceeded(
await lease.runAgentRuntimeAction(
installationId,
{
kind: 'runtime-activate',
runtimeId: identity.runtime.runtimeId,
bundleDigest: identity.runtime.bundleDigest,
architecture: identity.runtime.architecture,
forceVerification: true
},
signal
),
'OpenCode Runtime 激活'
)
signal.throwIfAborted()
}
async #prepareUploadedArchive(options: {
probeLease: SshConnectionLease
bootstrapLease: SshRemotePackageBootstrapLease
archive: AgentPackageArchiveLease
remoteCandidate: SshRemotePackageCandidate
method: 'goodbuddy-transfer'
emit: (
method: RemoteEnvironmentPreparationMethod,
phase: RemoteEnvironmentUpdateProgress['phase']
) => void
signal: AbortSignal
}): Promise<SshRemotePackageBootstrapPrepareResult> {
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<AgentPackageArchiveLease> {
return this.#agentPackageManager.acquireGoodBuddyInstallArchive(
architecture,
{
signal,
onProgress: (progress) => {
emit(
'goodbuddy-transfer',
progress.phase === 'downloading'
? 'downloading'
: progress.phase === 'catalog'
? 'probing'
: 'verifying'
)
}
}
)
}
async #cleanupPendingOperation(
hostId: string,
target: SshConnectionTarget,
emit: (
method: RemoteEnvironmentPreparationMethod,
phase: RemoteEnvironmentUpdateProgress['phase']
) => void,
signal: AbortSignal
): Promise<void> {
let pending: PendingRemoteEnvironmentOperation | undefined
try {
pending = await this.#operationStore.load(hostId)
} catch {
await this.#operationStore.remove(hostId)
return
}
if (!pending) {
return
}
if (
pending.targetIdentity !==
remoteHostRecoveryIdentityKey(target)
) {
await this.#operationStore.remove(hostId)
return
}
emit('auto', 'applying')
const bootstrapLease =
await this.#sshPool.acquireRemotePackageBootstrap(
target,
signal
).catch(() => undefined)
try {
if (!bootstrapLease) {
return
}
assertLeaseMatchesTarget(bootstrapLease, target)
await bootstrapLease.cleanup(
pending.operationId,
{ signal }
).catch(() => undefined)
} finally {
bootstrapLease?.release()
await this.#operationStore.remove(hostId)
}
}
}
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.detail ?? prepared.reason}`
)
}
if (prepared.operationId !== operationId) {
throw new Error('远端环境准备操作身份不匹配')
}
}
async function cleanupBestEffort(
lease: SshRemotePackageBootstrapLease,
operationId: string
): Promise<boolean> {
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 assertTargetCurrent(
resolver: AgentInstallationTargetResolver,
expected: SshConnectionTarget,
signal: AbortSignal
): Promise<void> {
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<SshConnectionLease, 'identity'>,
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<SshConnectionLease, 'identity'>,
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':
return 'verifying'
case 'extracting':
case 'preparing':
case 'prepared':
case 'committing':
case 'committed':
case 'persisting-prepared-state':
case 'extracting-payload':
case 'publishing-content':
case 'publishing-metadata':
case 'cleaning':
return 'applying'
}
}
@@ -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<never> {
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<never> {
}
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<void>((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'
])
})
})
@@ -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<void>
}
type ActiveUpdate = {
hostId: string
request: RemoteEnvironmentUpdateRequest
controller: AbortController
observers: Set<RemoteEnvironmentUpdateProgressObserver>
waiters: Set<UpdateWaiter>
promise: Promise<void>
settled: boolean
lastEmittedPhase?: RemoteEnvironmentUpdateProgress['phase']
lastEmittedMethod?: RemoteEnvironmentPreparationMethod
}
type UpdateWaiter = {
owner: RemoteEnvironmentUpdateOwner
active: ActiveUpdate
observer?: RemoteEnvironmentUpdateProgressObserver
promise: Promise<void>
resolve: () => void
reject: (reason: unknown) => void
settled: boolean
}
type OwnerState = {
listener: () => void
waitersByHost: Map<string, Set<UpdateWaiter>>
}
export class RemoteEnvironmentUpdateService {
readonly #agentInstallationManager: Pick<
AgentInstallationManager,
'ensureInstalled'
>
readonly #runtimeInstallationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
>
readonly #preparer: RemoteEnvironmentPreparer
readonly #afterUpdate?: (hostId: string) => void | Promise<void>
readonly #active = new Map<
RemoteEnvironmentUpdateOwner,
ActiveUpdate
>()
readonly #activeByHost = new Map<string, ActiveUpdate>()
readonly #owners = new Map<RemoteEnvironmentUpdateOwner, OwnerState>()
#disposed = false
#disposePromise?: Promise<void>
constructor(
agentInstallationManager: Pick<
AgentInstallationManager,
'ensureInstalled'
>,
runtimeInstallationManager: Pick<
RemoteRuntimeInstallationManager,
'ensureInstalled'
>,
preparer: RemoteEnvironmentPreparer,
afterUpdate?: (hostId: string) => void | Promise<void>
) {
this.#agentInstallationManager = agentInstallationManager
this.#runtimeInstallationManager = runtimeInstallationManager
this.#preparer = preparer
this.#afterUpdate = afterUpdate
}
update(
owner: RemoteEnvironmentUpdateOwner,
hostId: string,
onProgress?: RemoteEnvironmentUpdateProgressObserver
request: RemoteEnvironmentUpdateRequest,
observer?: RemoteEnvironmentUpdateProgressObserver
): Promise<void> {
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<void> {
@@ -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<RemoteEnvironmentUpdateProgressObserver>(),
waiters: new Set<UpdateWaiter>(),
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<void> {
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<void>((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<void> {
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<void> {
this.#disposed = true
const operations = [...this.#active.values()]
const operations = [...this.#activeByHost.values()]
for (const active of operations) {
active.controller.abort(
new DOMException(
@@ -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')
}
@@ -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> = {}
): 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<AgentInstallationIdentity>
validateWorkspace?: () => unknown
create?: () => AssistantProject
type HarnessOptions = {
getProject?: () => AssistantProject
validateWorkspace?: (signal?: AbortSignal) => unknown | Promise<unknown>
create?: () => AssistantProject
update?: () => AssistantProject
resolveRuntimeSelection?: (
selection: AgentRuntimeSelection
) => Promise<AgentRuntimeSelection>
} = {}) {
}
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<void>((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' })
})
})
@@ -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<void>
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<AgentInstallationManager, 'ensureInstalled'>
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
@@ -151,10 +144,6 @@ export class RemoteProjectSaveService {
readonly #notify?: RemoteProjectSaveServiceOptions['notify']
readonly #cleanupTimeoutMs: number
readonly #active = new Map<RemoteProjectSaveOwner, ActiveSave>()
readonly #activations = new Map<
RemoteProjectSaveOwner,
{ projectId: string; promise: Promise<AssistantProject> }
>()
#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<void>((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<AssistantProject> {
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<AssistantProject> {
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<PreparedRemoteProject> {
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<void>((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<void> {
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<void> {
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
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<RemoteRuntimeBundleManifest> {
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')
}
@@ -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<VerifiedRemoteRuntimeInstallationBundle> {
): Promise<VerifiedRemoteRuntimeResourceBundle> {
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<VerifiedRemoteRuntimeInstallationBundle> {
): Promise<VerifiedRemoteRuntimeResourceBundle> {
const manifest = remoteRuntimeBundleManifestSchema.parse(
verified.manifest
)
+5 -2
View File
@@ -283,6 +283,7 @@ describe('RuntimeSettingsStore', () => {
authentication: 'none',
supportsImageInput: false,
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto',
apiKey: { action: 'keep' }
}
@@ -302,7 +303,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
@@ -316,7 +318,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
+16
View File
@@ -170,6 +170,12 @@ const currentStoredModelProfileSchema = storedModelProfileSchema.extend({
.int()
.min(8_000)
.max(10_000_000)
.optional(),
maximumOutputTokens: z
.number()
.int()
.min(1)
.max(10_000_000)
.optional()
})
@@ -434,6 +440,7 @@ export type ResolvedModelProfile = {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput?: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
apiKey?: string
}
@@ -1468,6 +1475,7 @@ export class RuntimeSettingsStore {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
credentialSource: RuntimeSettings['credentialSource']
} {
@@ -1529,6 +1537,7 @@ export class RuntimeSettingsStore {
: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
credentialSource: credential.source
}
@@ -1552,6 +1561,7 @@ export class RuntimeSettingsStore {
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
contextWindowTokens: effective.contextWindowTokens,
maximumOutputTokens: effective.maximumOutputTokens,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey
}
@@ -1569,6 +1579,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: credential.activeApiKey
}
@@ -1651,6 +1662,7 @@ export class RuntimeSettingsStore {
authentication: resolved.authentication,
supportsImageInput: resolved.supportsImageInput,
contextWindowTokens: resolved.contextWindowTokens,
maximumOutputTokens: resolved.maximumOutputTokens,
imageGenerationQuality:
resolved.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1672,6 +1684,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1966,6 +1979,7 @@ export class RuntimeSettingsStore {
authentication: input.modelAuthentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: input.imageGenerationQuality,
apiKey: input.apiKey
}
@@ -1978,6 +1992,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
}
@@ -2029,6 +2044,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput ?? false,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality
}
if (
+242 -4
View File
@@ -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'

Some files were not shown because too many files have changed in this diff Show More