From 607bb33249cc6d06409efce097cb86dc0df00b64 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 24 Aug 2026 23:02:18 +0800 Subject: [PATCH] feat: add managed SSH execution spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI. Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts. Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。 --- .github/workflows/packages.yml | 271 +- .gitignore | 2 + AGENTS.md | 78 +- BUILD.md | 85 +- FEATURES.md | 7 +- agent-runtime-lock.json | 29 + build/agent-bundle.cjs | 1214 ++++++++ build/binary-architecture.cjs | 71 + build/build-portable.cjs | 340 ++- build/build-release.cjs | 191 +- build/electron-builder.release.cjs | 36 + build/remote-runtime-bundle.cjs | 1122 ++++++++ docs/README.md | 1 + .../remote-host-and-goodbuddy-agent-design.md | 187 ++ ...cross-platform-assistant-product-design.md | 1114 -------- ...istant-workbar-and-execution-spaces-prd.md | 159 +- .../wechat-clawbot-channel-project-prd.md | 4 +- docs/roadmap/long-term-assistant-roadmap.md | 2 +- package-lock.json | 141 + package.json | 26 + remote-runtime-lock.json | 39 + resources/agent-release-keys.json | 21 + src/agent-daemon/async-utils.ts | 93 + src/agent-daemon/bundle-file-verification.ts | 63 + src/agent-daemon/channel-state.ts | 1 + src/agent-daemon/cli.test.ts | 566 ++++ src/agent-daemon/cli.ts | 861 ++++++ src/agent-daemon/controller-registry.ts | 371 +++ src/agent-daemon/daemon.test.ts | 217 ++ src/agent-daemon/daemon.ts | 405 +++ .../detached-agent-lifecycle.test.ts | 447 +++ src/agent-daemon/detached-agent-lifecycle.ts | 706 +++++ .../direct-linux-stdio-process-owner.test.ts | 345 +++ .../direct-linux-stdio-process-owner.ts | 1063 +++++++ src/agent-daemon/entry.ts | 15 + src/agent-daemon/errors.ts | 12 + src/agent-daemon/event-journal.test.ts | 842 ++++++ src/agent-daemon/event-journal.ts | 1738 ++++++++++++ src/agent-daemon/index.ts | 32 + src/agent-daemon/installation-challenge.ts | 113 + .../installation-registry.test.ts | 231 ++ src/agent-daemon/installation-registry.ts | 234 ++ .../installed-bundle-verifier.test.ts | 223 ++ src/agent-daemon/installed-bundle-verifier.ts | 488 ++++ src/agent-daemon/lifecycle.test.ts | 300 ++ .../linux-process-identity.test.ts | 141 + src/agent-daemon/linux-process-identity.ts | 168 ++ src/agent-daemon/managed-paths.test.ts | 189 ++ src/agent-daemon/managed-paths.ts | 373 +++ src/agent-daemon/model-bridge-broker.ts | 805 ++++++ src/agent-daemon/model-bridge-client.test.ts | 376 +++ src/agent-daemon/model-bridge-client.ts | 519 ++++ src/agent-daemon/model-bridge-helper.ts | 900 ++++++ src/agent-daemon/model-bridge.test.ts | 658 +++++ .../opencode-runtime-profile.test.ts | 446 +++ src/agent-daemon/opencode-runtime-profile.ts | 352 +++ .../peer-identity-provider.test.ts | 106 + src/agent-daemon/peer-identity-provider.ts | 158 ++ src/agent-daemon/private-endpoint.test.ts | 576 ++++ src/agent-daemon/private-endpoint.ts | 635 +++++ src/agent-daemon/protocol-server.test.ts | 970 +++++++ src/agent-daemon/protocol-server.ts | 1118 ++++++++ src/agent-daemon/queue-and-channel.test.ts | 138 + src/agent-daemon/runtime-acp-backend.test.ts | 1111 ++++++++ src/agent-daemon/runtime-acp-backend.ts | 2080 ++++++++++++++ .../runtime-bundle-registry.test.ts | 289 ++ src/agent-daemon/runtime-bundle-registry.ts | 343 +++ .../runtime-bundle-test-fixture.ts | 242 ++ .../runtime-bundle-verifier.test.ts | 220 ++ src/agent-daemon/runtime-bundle-verifier.ts | 12 + src/agent-daemon/runtime-composition.test.ts | 272 ++ src/agent-daemon/runtime-composition.ts | 474 ++++ .../runtime-owner-registry.test.ts | 68 + src/agent-daemon/runtime-owner-registry.ts | 232 ++ .../runtime-process-identity.test.ts | 43 + src/agent-daemon/runtime-process-identity.ts | 164 ++ src/agent-daemon/structured-command-runner.ts | 182 ++ .../workspace-git-service.test.ts | 160 ++ src/agent-daemon/workspace-git-service.ts | 1198 ++++++++ .../workspace-path-access.test.ts | 149 + src/agent-daemon/workspace-path-access.ts | 765 ++++++ .../workspace-protocol-methods.test.ts | 133 + .../workspace-protocol-methods.ts | 309 +++ src/agent-daemon/workspace-registry.test.ts | 411 +++ src/agent-daemon/workspace-registry.ts | 622 +++++ src/main/agent/acp-remote-runtime.test.ts | 1930 +++++++++++++ src/main/agent/acp-remote-runtime.ts | 2435 +++++++++++++++++ src/main/agent/bounded-response.ts | 62 +- src/main/agent/create-runtime.test.ts | 29 + src/main/agent/create-runtime.ts | 36 +- .../agent/goodbuddy-harness-control-plane.ts | 2 +- .../agent/model-call-operation-store.test.ts | 649 +++++ src/main/agent/model-call-operation-store.ts | 1071 ++++++++ src/main/agent/model-runtime.test.ts | 161 +- src/main/agent/model-runtime.ts | 112 +- src/main/agent/model-tool-provider.test.ts | 211 ++ src/main/agent/model-tool-provider.ts | 189 +- .../agent/private-sqlite-database.test.ts | 104 + src/main/agent/private-sqlite-database.ts | 4 + src/main/agent/remote-model-gateway.test.ts | 683 +++++ src/main/agent/remote-model-gateway.ts | 714 +++++ src/main/agent/remote-runtime-channel.ts | 153 ++ src/main/agent/runtime-controller.test.ts | 239 +- src/main/agent/runtime-controller.ts | 309 ++- src/main/agent/runtime-e2e.manual.test.ts | 5 +- .../runtime-session-binding-store.test.ts | 738 +++++ .../agent/runtime-session-binding-store.ts | 906 ++++++ .../agent/selected-runtime-manager.test.ts | 296 +- src/main/agent/selected-runtime-manager.ts | 208 +- src/main/assistant/assistant-database.test.ts | 1067 +++++++- src/main/assistant/assistant-database.ts | 1416 +++++++++- src/main/assistant/heartbeat-database.test.ts | 7 +- .../workspace-changes-service.test.ts | 68 +- .../assistant/workspace-changes-service.ts | 366 +-- .../execution-space-resolver.test.ts | 372 +++ .../execution-space-resolver.ts | 167 ++ src/main/execution-space/index.ts | 9 + .../remote-runtime-request-validation.test.ts | 91 + .../remote-runtime-request-validation.ts | 38 + src/main/index.ts | 153 +- src/main/ipc.test.ts | 555 +++- src/main/ipc.ts | 360 ++- src/main/knowledge/knowledge-database.test.ts | 4 +- .../agent-attach-transport.test.ts | 387 +++ .../remote-agent/agent-attach-transport.ts | 643 +++++ .../agent-bundle-verifier.test.ts | 439 +++ .../remote-agent/agent-bundle-verifier.ts | 639 +++++ .../agent-installation-manager.test.ts | 1549 +++++++++++ .../agent-installation-manager.ts | 1749 ++++++++++++ .../agent-protocol-client.test.ts | 569 ++++ .../remote-agent/agent-protocol-client.ts | 1079 ++++++++ .../bundled-agent-resources.test.ts | 65 + .../remote-agent/bundled-agent-resources.ts | 43 + .../bundled-remote-runtime-resources.test.ts | 79 + .../bundled-remote-runtime-resources.ts | 78 + .../controller-state-store.test.ts | 241 ++ .../remote-agent/controller-state-store.ts | 432 +++ src/main/remote-agent/index.ts | 97 + .../main-model-bridge-dispatcher.test.ts | 54 + .../main-model-bridge-dispatcher.ts | 85 + .../main-model-bridge-session.test.ts | 572 ++++ .../remote-agent/main-model-bridge-session.ts | 582 ++++ .../remote-agent/managed-model-bridge.test.ts | 330 +++ src/main/remote-agent/managed-model-bridge.ts | 247 ++ .../managed-remote-acp-runtime.test.ts | 485 ++++ .../managed-remote-acp-runtime.ts | 704 +++++ .../managed-remote-execution-services.ts | 247 ++ ...d-remote-project-runtime-validator.test.ts | 401 +++ ...anaged-remote-project-runtime-validator.ts | 232 ++ ...ed-remote-workspace-access-factory.test.ts | 266 ++ ...managed-remote-workspace-access-factory.ts | 84 + .../protocol-remote-runtime-channel.test.ts | 1528 +++++++++++ .../protocol-remote-runtime-channel.ts | 1769 ++++++++++++ ...rotocol-remote-workspace-transport.test.ts | 823 ++++++ .../protocol-remote-workspace-transport.ts | 562 ++++ .../remote-agent-connection-manager.test.ts | 588 ++++ .../remote-agent-connection-manager.ts | 1090 ++++++++ .../remote-agent-services.test.ts | 285 ++ .../remote-agent/remote-agent-services.ts | 142 + .../remote-project-save-service.test.ts | 510 ++++ .../remote-project-save-service.ts | 673 +++++ ...emote-runtime-installation-manager.test.ts | 1024 +++++++ .../remote-runtime-installation-manager.ts | 1649 +++++++++++ .../remote-runtime-resource-loader.test.ts | 384 +++ .../remote-runtime-resource-loader.ts | 314 +++ src/main/ssh/bounded-directory-sftp.test.ts | 379 +++ src/main/ssh/bounded-directory-sftp.ts | 383 +++ src/main/ssh/bounded-sftp.test.ts | 900 ++++++ src/main/ssh/bounded-sftp.ts | 846 ++++++ src/main/ssh/ssh-agent-command.test.ts | 252 ++ src/main/ssh/ssh-agent-command.ts | 252 ++ src/main/ssh/ssh-connection-pool.test.ts | 610 +++++ src/main/ssh/ssh-connection-pool.ts | 878 ++++++ .../ssh/ssh-host-directory-browser.test.ts | 264 ++ src/main/ssh/ssh-host-directory-browser.ts | 99 + .../ssh/ssh-host-remote-environment.test.ts | 229 ++ src/main/ssh/ssh-host-remote-environment.ts | 341 +++ src/main/ssh/ssh-host-service.test.ts | 736 +++++ src/main/ssh/ssh-host-service.ts | 346 +++ src/main/ssh/ssh-host-store.test.ts | 554 ++++ src/main/ssh/ssh-host-store.ts | 669 +++++ src/main/ssh/ssh-transport.test.ts | 432 +++ src/main/ssh/ssh-transport.ts | 551 ++++ src/main/workspace/index.ts | 31 + src/main/workspace/local-workspace-access.ts | 817 ++++++ .../workspace/remote-workspace-access.test.ts | 502 ++++ src/main/workspace/remote-workspace-access.ts | 712 +++++ .../unsupported-remote-workspace-access.ts | 73 + src/main/workspace/workspace-access.test.ts | 385 +++ src/main/workspace/workspace-access.ts | 100 + src/preload/index.ts | 83 + src/preload/preload-sandbox.test.ts | 51 + src/renderer/src/ActivityPanel.test.tsx | 8 + src/renderer/src/App.test.tsx | 678 ++++- src/renderer/src/App.tsx | 447 ++- .../src/ChannelProjectSettingsFields.tsx | 220 +- .../src/ChannelSettingsSection.test.tsx | 4 + src/renderer/src/HeartbeatCenter.test.tsx | 4 + src/renderer/src/ProjectRuntimeSelector.tsx | 192 ++ src/renderer/src/ProjectSwitcher.test.tsx | 780 ++++++ src/renderer/src/ProjectSwitcher.tsx | 1134 +++++++- src/renderer/src/ProjectWorkModeFields.tsx | 37 + src/renderer/src/SettingsPanel.test.tsx | 76 +- src/renderer/src/SettingsPanel.tsx | 25 +- src/renderer/src/SshHostDialog.tsx | 854 ++++++ .../src/SshHostsSettingsSection.test.tsx | 577 ++++ src/renderer/src/SshHostsSettingsSection.tsx | 654 +++++ src/renderer/src/WorkspacePrimitives.test.tsx | 10 +- src/renderer/src/i18n/locales/en-US/app.ts | 15 + .../src/i18n/locales/en-US/settings.ts | 7 + .../i18n/locales/en-US/settingsSections.ts | 166 ++ .../src/i18n/locales/en-US/workspace.ts | 68 + src/renderer/src/i18n/locales/zh-CN/app.ts | 12 + .../src/i18n/locales/zh-CN/settings.ts | 5 + .../i18n/locales/zh-CN/settingsSections.ts | 160 ++ .../src/i18n/locales/zh-CN/workspace.ts | 65 + src/renderer/src/project-display.test.ts | 4 + src/renderer/src/settings-categories.ts | 4 + src/renderer/src/ssh-host-ui.ts | 8 + src/renderer/src/styles.css | 940 ++++++- .../agent-installation-contracts.test.ts | 174 ++ src/shared/agent-installation-contracts.ts | 290 ++ src/shared/agent-protocol/canonical.ts | 128 + src/shared/agent-protocol/channel-state.ts | 293 ++ src/shared/agent-protocol/contracts.ts | 247 ++ .../agent-protocol/control-contracts.ts | 521 ++++ .../agent-protocol/failure-diagnostic.ts | 26 + src/shared/agent-protocol/frame.ts | 286 ++ src/shared/agent-protocol/index.ts | 6 + src/shared/agent-protocol/protocol.test.ts | 402 +++ src/shared/assistant-contracts.test.ts | 190 ++ src/shared/assistant-contracts.ts | 85 + src/shared/contracts.test.ts | 99 + src/shared/contracts.ts | 49 + src/shared/ipc-channels.ts | 17 + src/shared/model-bridge-contracts.test.ts | 229 ++ src/shared/model-bridge-contracts.ts | 403 +++ .../model-call-operation-contracts.test.ts | 135 + src/shared/model-call-operation-contracts.ts | 323 +++ src/shared/node/private-sqlite-database.ts | 168 ++ src/shared/node/runtime-bundle-verifier.ts | 612 +++++ src/shared/remote-agent-contracts.test.ts | 314 +++ src/shared/remote-agent-contracts.ts | 1079 ++++++++ .../remote-environment-registry-contracts.ts | 250 ++ .../remote-model-gateway-contracts.test.ts | 80 + src/shared/remote-model-gateway-contracts.ts | 105 + ...remote-project-candidate-contracts.test.ts | 120 + .../remote-project-candidate-contracts.ts | 94 + .../remote-runtime-launch-contracts.test.ts | 548 ++++ src/shared/remote-runtime-launch-contracts.ts | 793 ++++++ src/shared/ssh-host-contracts.test.ts | 372 +++ src/shared/ssh-host-contracts.ts | 464 ++++ tests/agent-bundle.test.ts | 575 ++++ tests/build-portable.test.ts | 279 ++ tests/build-release.test.ts | 551 +++- tests/packages-workflow.test.ts | 289 ++ tests/remote-runtime-bundle.test.ts | 360 +++ tests/remote-runtime-lock.test.ts | 40 + 258 files changed, 99449 insertions(+), 2427 deletions(-) create mode 100644 agent-runtime-lock.json create mode 100644 build/agent-bundle.cjs create mode 100644 build/binary-architecture.cjs create mode 100644 build/electron-builder.release.cjs create mode 100644 build/remote-runtime-bundle.cjs create mode 100644 docs/architecture/remote-host-and-goodbuddy-agent-design.md delete mode 100644 docs/design/cross-platform-assistant-product-design.md create mode 100644 remote-runtime-lock.json create mode 100644 resources/agent-release-keys.json create mode 100644 src/agent-daemon/async-utils.ts create mode 100644 src/agent-daemon/bundle-file-verification.ts create mode 100644 src/agent-daemon/channel-state.ts create mode 100644 src/agent-daemon/cli.test.ts create mode 100644 src/agent-daemon/cli.ts create mode 100644 src/agent-daemon/controller-registry.ts create mode 100644 src/agent-daemon/daemon.test.ts create mode 100644 src/agent-daemon/daemon.ts create mode 100644 src/agent-daemon/detached-agent-lifecycle.test.ts create mode 100644 src/agent-daemon/detached-agent-lifecycle.ts create mode 100644 src/agent-daemon/direct-linux-stdio-process-owner.test.ts create mode 100644 src/agent-daemon/direct-linux-stdio-process-owner.ts create mode 100644 src/agent-daemon/entry.ts create mode 100644 src/agent-daemon/errors.ts create mode 100644 src/agent-daemon/event-journal.test.ts create mode 100644 src/agent-daemon/event-journal.ts create mode 100644 src/agent-daemon/index.ts create mode 100644 src/agent-daemon/installation-challenge.ts create mode 100644 src/agent-daemon/installation-registry.test.ts create mode 100644 src/agent-daemon/installation-registry.ts create mode 100644 src/agent-daemon/installed-bundle-verifier.test.ts create mode 100644 src/agent-daemon/installed-bundle-verifier.ts create mode 100644 src/agent-daemon/lifecycle.test.ts create mode 100644 src/agent-daemon/linux-process-identity.test.ts create mode 100644 src/agent-daemon/linux-process-identity.ts create mode 100644 src/agent-daemon/managed-paths.test.ts create mode 100644 src/agent-daemon/managed-paths.ts create mode 100644 src/agent-daemon/model-bridge-broker.ts create mode 100644 src/agent-daemon/model-bridge-client.test.ts create mode 100644 src/agent-daemon/model-bridge-client.ts create mode 100644 src/agent-daemon/model-bridge-helper.ts create mode 100644 src/agent-daemon/model-bridge.test.ts create mode 100644 src/agent-daemon/opencode-runtime-profile.test.ts create mode 100644 src/agent-daemon/opencode-runtime-profile.ts create mode 100644 src/agent-daemon/peer-identity-provider.test.ts create mode 100644 src/agent-daemon/peer-identity-provider.ts create mode 100644 src/agent-daemon/private-endpoint.test.ts create mode 100644 src/agent-daemon/private-endpoint.ts create mode 100644 src/agent-daemon/protocol-server.test.ts create mode 100644 src/agent-daemon/protocol-server.ts create mode 100644 src/agent-daemon/queue-and-channel.test.ts create mode 100644 src/agent-daemon/runtime-acp-backend.test.ts create mode 100644 src/agent-daemon/runtime-acp-backend.ts create mode 100644 src/agent-daemon/runtime-bundle-registry.test.ts create mode 100644 src/agent-daemon/runtime-bundle-registry.ts create mode 100644 src/agent-daemon/runtime-bundle-test-fixture.ts create mode 100644 src/agent-daemon/runtime-bundle-verifier.test.ts create mode 100644 src/agent-daemon/runtime-bundle-verifier.ts create mode 100644 src/agent-daemon/runtime-composition.test.ts create mode 100644 src/agent-daemon/runtime-composition.ts create mode 100644 src/agent-daemon/runtime-owner-registry.test.ts create mode 100644 src/agent-daemon/runtime-owner-registry.ts create mode 100644 src/agent-daemon/runtime-process-identity.test.ts create mode 100644 src/agent-daemon/runtime-process-identity.ts create mode 100644 src/agent-daemon/structured-command-runner.ts create mode 100644 src/agent-daemon/workspace-git-service.test.ts create mode 100644 src/agent-daemon/workspace-git-service.ts create mode 100644 src/agent-daemon/workspace-path-access.test.ts create mode 100644 src/agent-daemon/workspace-path-access.ts create mode 100644 src/agent-daemon/workspace-protocol-methods.test.ts create mode 100644 src/agent-daemon/workspace-protocol-methods.ts create mode 100644 src/agent-daemon/workspace-registry.test.ts create mode 100644 src/agent-daemon/workspace-registry.ts create mode 100644 src/main/agent/acp-remote-runtime.test.ts create mode 100644 src/main/agent/acp-remote-runtime.ts create mode 100644 src/main/agent/model-call-operation-store.test.ts create mode 100644 src/main/agent/model-call-operation-store.ts create mode 100644 src/main/agent/private-sqlite-database.test.ts create mode 100644 src/main/agent/private-sqlite-database.ts create mode 100644 src/main/agent/remote-model-gateway.test.ts create mode 100644 src/main/agent/remote-model-gateway.ts create mode 100644 src/main/agent/remote-runtime-channel.ts create mode 100644 src/main/agent/runtime-session-binding-store.test.ts create mode 100644 src/main/agent/runtime-session-binding-store.ts create mode 100644 src/main/execution-space/execution-space-resolver.test.ts create mode 100644 src/main/execution-space/execution-space-resolver.ts create mode 100644 src/main/execution-space/index.ts create mode 100644 src/main/execution-space/remote-runtime-request-validation.test.ts create mode 100644 src/main/execution-space/remote-runtime-request-validation.ts create mode 100644 src/main/remote-agent/agent-attach-transport.test.ts create mode 100644 src/main/remote-agent/agent-attach-transport.ts create mode 100644 src/main/remote-agent/agent-bundle-verifier.test.ts create mode 100644 src/main/remote-agent/agent-bundle-verifier.ts create mode 100644 src/main/remote-agent/agent-installation-manager.test.ts create mode 100644 src/main/remote-agent/agent-installation-manager.ts create mode 100644 src/main/remote-agent/agent-protocol-client.test.ts create mode 100644 src/main/remote-agent/agent-protocol-client.ts create mode 100644 src/main/remote-agent/bundled-agent-resources.test.ts create mode 100644 src/main/remote-agent/bundled-agent-resources.ts create mode 100644 src/main/remote-agent/bundled-remote-runtime-resources.test.ts create mode 100644 src/main/remote-agent/bundled-remote-runtime-resources.ts create mode 100644 src/main/remote-agent/controller-state-store.test.ts create mode 100644 src/main/remote-agent/controller-state-store.ts create mode 100644 src/main/remote-agent/index.ts create mode 100644 src/main/remote-agent/main-model-bridge-dispatcher.test.ts create mode 100644 src/main/remote-agent/main-model-bridge-dispatcher.ts create mode 100644 src/main/remote-agent/main-model-bridge-session.test.ts create mode 100644 src/main/remote-agent/main-model-bridge-session.ts create mode 100644 src/main/remote-agent/managed-model-bridge.test.ts create mode 100644 src/main/remote-agent/managed-model-bridge.ts create mode 100644 src/main/remote-agent/managed-remote-acp-runtime.test.ts create mode 100644 src/main/remote-agent/managed-remote-acp-runtime.ts create mode 100644 src/main/remote-agent/managed-remote-execution-services.ts create mode 100644 src/main/remote-agent/managed-remote-project-runtime-validator.test.ts create mode 100644 src/main/remote-agent/managed-remote-project-runtime-validator.ts create mode 100644 src/main/remote-agent/managed-remote-workspace-access-factory.test.ts create mode 100644 src/main/remote-agent/managed-remote-workspace-access-factory.ts create mode 100644 src/main/remote-agent/protocol-remote-runtime-channel.test.ts create mode 100644 src/main/remote-agent/protocol-remote-runtime-channel.ts create mode 100644 src/main/remote-agent/protocol-remote-workspace-transport.test.ts create mode 100644 src/main/remote-agent/protocol-remote-workspace-transport.ts create mode 100644 src/main/remote-agent/remote-agent-connection-manager.test.ts create mode 100644 src/main/remote-agent/remote-agent-connection-manager.ts create mode 100644 src/main/remote-agent/remote-agent-services.test.ts create mode 100644 src/main/remote-agent/remote-agent-services.ts create mode 100644 src/main/remote-agent/remote-project-save-service.test.ts create mode 100644 src/main/remote-agent/remote-project-save-service.ts create mode 100644 src/main/remote-agent/remote-runtime-installation-manager.test.ts create mode 100644 src/main/remote-agent/remote-runtime-installation-manager.ts create mode 100644 src/main/remote-agent/remote-runtime-resource-loader.test.ts create mode 100644 src/main/remote-agent/remote-runtime-resource-loader.ts create mode 100644 src/main/ssh/bounded-directory-sftp.test.ts create mode 100644 src/main/ssh/bounded-directory-sftp.ts create mode 100644 src/main/ssh/bounded-sftp.test.ts create mode 100644 src/main/ssh/bounded-sftp.ts create mode 100644 src/main/ssh/ssh-agent-command.test.ts create mode 100644 src/main/ssh/ssh-agent-command.ts create mode 100644 src/main/ssh/ssh-connection-pool.test.ts create mode 100644 src/main/ssh/ssh-connection-pool.ts create mode 100644 src/main/ssh/ssh-host-directory-browser.test.ts create mode 100644 src/main/ssh/ssh-host-directory-browser.ts create mode 100644 src/main/ssh/ssh-host-remote-environment.test.ts create mode 100644 src/main/ssh/ssh-host-remote-environment.ts create mode 100644 src/main/ssh/ssh-host-service.test.ts create mode 100644 src/main/ssh/ssh-host-service.ts create mode 100644 src/main/ssh/ssh-host-store.test.ts create mode 100644 src/main/ssh/ssh-host-store.ts create mode 100644 src/main/ssh/ssh-transport.test.ts create mode 100644 src/main/ssh/ssh-transport.ts create mode 100644 src/main/workspace/index.ts create mode 100644 src/main/workspace/local-workspace-access.ts create mode 100644 src/main/workspace/remote-workspace-access.test.ts create mode 100644 src/main/workspace/remote-workspace-access.ts create mode 100644 src/main/workspace/unsupported-remote-workspace-access.ts create mode 100644 src/main/workspace/workspace-access.test.ts create mode 100644 src/main/workspace/workspace-access.ts create mode 100644 src/renderer/src/ProjectRuntimeSelector.tsx create mode 100644 src/renderer/src/ProjectSwitcher.test.tsx create mode 100644 src/renderer/src/ProjectWorkModeFields.tsx create mode 100644 src/renderer/src/SshHostDialog.tsx create mode 100644 src/renderer/src/SshHostsSettingsSection.test.tsx create mode 100644 src/renderer/src/SshHostsSettingsSection.tsx create mode 100644 src/renderer/src/ssh-host-ui.ts create mode 100644 src/shared/agent-installation-contracts.test.ts create mode 100644 src/shared/agent-installation-contracts.ts create mode 100644 src/shared/agent-protocol/canonical.ts create mode 100644 src/shared/agent-protocol/channel-state.ts create mode 100644 src/shared/agent-protocol/contracts.ts create mode 100644 src/shared/agent-protocol/control-contracts.ts create mode 100644 src/shared/agent-protocol/failure-diagnostic.ts create mode 100644 src/shared/agent-protocol/frame.ts create mode 100644 src/shared/agent-protocol/index.ts create mode 100644 src/shared/agent-protocol/protocol.test.ts create mode 100644 src/shared/contracts.test.ts create mode 100644 src/shared/model-bridge-contracts.test.ts create mode 100644 src/shared/model-bridge-contracts.ts create mode 100644 src/shared/model-call-operation-contracts.test.ts create mode 100644 src/shared/model-call-operation-contracts.ts create mode 100644 src/shared/node/private-sqlite-database.ts create mode 100644 src/shared/node/runtime-bundle-verifier.ts create mode 100644 src/shared/remote-agent-contracts.test.ts create mode 100644 src/shared/remote-agent-contracts.ts create mode 100644 src/shared/remote-environment-registry-contracts.ts create mode 100644 src/shared/remote-model-gateway-contracts.test.ts create mode 100644 src/shared/remote-model-gateway-contracts.ts create mode 100644 src/shared/remote-project-candidate-contracts.test.ts create mode 100644 src/shared/remote-project-candidate-contracts.ts create mode 100644 src/shared/remote-runtime-launch-contracts.test.ts create mode 100644 src/shared/remote-runtime-launch-contracts.ts create mode 100644 src/shared/ssh-host-contracts.test.ts create mode 100644 src/shared/ssh-host-contracts.ts create mode 100644 tests/agent-bundle.test.ts create mode 100644 tests/build-portable.test.ts create mode 100644 tests/remote-runtime-bundle.test.ts create mode 100644 tests/remote-runtime-lock.test.ts diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index aead7382..8d18bc05 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 24.19.0 cache: npm - name: Verify release tag version @@ -50,7 +50,9 @@ jobs: run: npm run build:bundle - name: Upload production bundle - if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' + if: >- + github.ref_type == 'tag' || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') uses: actions/upload-artifact@v7 with: name: goodbuddy-production-bundle @@ -58,10 +60,240 @@ jobs: if-no-files-found: error retention-days: 1 + authorize-agent-signing: + name: Authorize production Agent signing + if: >- + github.ref_type == 'tag' || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + authorized: ${{ steps.policy.outputs.authorized }} + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Verify immutable release ref policy + id: policy + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" + git merge-base --is-ancestor \ + "$GITHUB_SHA" refs/remotes/origin/main + + case "$GITHUB_EVENT_NAME" in + push) + test "$GITHUB_REF_TYPE" = "tag" + test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).created)")" = "true" + test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).forced)")" = "false" + test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).deleted)")" = "false" + expected="v$(node -p "require('./package.json').version")" + test "$GITHUB_REF" = "refs/tags/$expected" + test "$GITHUB_REF_NAME" = "$expected" + git fetch --no-tags origin \ + "+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" + test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag" + test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" + ;; + workflow_dispatch) + test "$GITHUB_REF" = "refs/heads/main" + test "$GITHUB_REF_TYPE" = "branch" + test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)" + ;; + *) + echo "Production Agent signing is limited to immutable version-tag pushes or a manual run of refs/heads/main." >&2 + exit 1 + ;; + esac + + echo "authorized=true" >> "$GITHUB_OUTPUT" + + agent-runtime: + name: Acquire Linux ${{ matrix.arch }} Agent runtime + if: >- + needs.authorize-agent-signing.result == 'success' && + needs.authorize-agent-signing.outputs.authorized == 'true' + needs: + - validate + - authorize-agent-signing + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + node-url: https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.gz + node-archive: node-v24.19.0-linux-x64.tar.gz + node-sha256: f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 + - arch: arm64 + node-url: https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.gz + node-archive: node-v24.19.0-linux-arm64.tar.gz + node-sha256: d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Verify locked Node runtime metadata + env: + AGENT_ARCH: ${{ matrix.arch }} + NODE_ARCHIVE: ${{ matrix.node-archive }} + NODE_SHA256: ${{ matrix.node-sha256 }} + NODE_URL: ${{ matrix.node-url }} + run: | + node - <<'NODE' + const lock = require('./agent-runtime-lock.json') + const input = lock.node?.targets?.[`linux-${process.env.AGENT_ARCH}`] + const expectedUrl = `${lock.node?.source ?? ''}${input?.archive ?? ''}` + if ( + input?.archive !== process.env.NODE_ARCHIVE || + input?.sha256 !== process.env.NODE_SHA256 || + expectedUrl !== process.env.NODE_URL || + !expectedUrl.startsWith('https://') + ) { + throw new Error('Agent runtime acquisition metadata does not match agent-runtime-lock.json') + } + NODE + + - name: Restore verified Node runtime cache + id: node-runtime-cache + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/goodbuddy-agent-node + key: goodbuddy-agent-node-${{ matrix.arch }}-${{ matrix.node-sha256 }} + + - name: Download locked official Node runtime + if: steps.node-runtime-cache.outputs.cache-hit != 'true' + shell: bash + env: + NODE_ARCHIVE: ${{ matrix.node-archive }} + NODE_URL: ${{ matrix.node-url }} + run: | + set -euo pipefail + directory="$RUNNER_TEMP/goodbuddy-agent-node" + archive="$directory/$NODE_ARCHIVE" + mkdir -p "$directory" + curl --proto '=https' --tlsv1.2 \ + --fail --silent --show-error --location \ + --retry 3 --retry-all-errors \ + --connect-timeout 15 --max-time 300 \ + --max-filesize 104857600 \ + "$NODE_URL" --output "$archive.partial" + mv "$archive.partial" "$archive" + + - name: Verify locked Node runtime digest + shell: bash + env: + NODE_ARCHIVE: ${{ matrix.node-archive }} + NODE_SHA256: ${{ matrix.node-sha256 }} + run: | + set -euo pipefail + archive="$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" + test -f "$archive" + printf '%s %s\n' "$NODE_SHA256" "$archive" | + sha256sum --check --strict - + + - name: Save verified Node runtime cache + if: steps.node-runtime-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/goodbuddy-agent-node + key: goodbuddy-agent-node-${{ matrix.arch }}-${{ matrix.node-sha256 }} + + - name: Upload verified Node runtime input + uses: actions/upload-artifact@v7 + with: + name: goodbuddy-agent-node-${{ matrix.arch }} + path: ${{ runner.temp }}/goodbuddy-agent-node/${{ matrix.node-archive }} + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + agent-signing: + name: Linux ${{ matrix.arch }} signed Agent bundle + if: >- + needs.authorize-agent-signing.result == 'success' && + needs.authorize-agent-signing.outputs.authorized == 'true' + needs: + - validate + - authorize-agent-signing + - agent-runtime + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: ubuntu-24.04 + node-archive: node-v24.19.0-linux-x64.tar.gz + node-sha256: f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 + - arch: arm64 + runner: ubuntu-24.04-arm + node-archive: node-v24.19.0-linux-arm64.tar.gz + node-sha256: d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + environment: + name: agent-signing + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24.19.0 + + - name: Install dependencies + run: npm ci + + - name: Preflight production Agent signing registry + env: + GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }} + run: node build/agent-bundle.cjs preflight + + - name: Download verified official Node runtime input + uses: actions/download-artifact@v8 + with: + name: goodbuddy-agent-node-${{ matrix.arch }} + path: ${{ runner.temp }}/goodbuddy-agent-node + + - name: Build and sign Agent bundle + shell: bash + env: + GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }} + GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }} + run: | + set -euo pipefail + set +x + node build/agent-bundle.cjs build \ + --arch "${{ matrix.arch }}" \ + --runtime-archive "$RUNNER_TEMP/goodbuddy-agent-node/${{ matrix.node-archive }}" \ + --archive "dist/agent-bundles/goodbuddy-agent-linux-${{ matrix.arch }}.tar" + + - name: Upload signed Agent bundle + uses: actions/upload-artifact@v7 + with: + name: goodbuddy-agent-linux-${{ matrix.arch }} + path: dist/agent-bundles/goodbuddy-agent-linux-${{ matrix.arch }}.tar + if-no-files-found: error + compression-level: 0 + retention-days: 1 + package: name: ${{ matrix.platform }} ${{ matrix.arch }} - if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' - needs: validate + if: >- + needs.authorize-agent-signing.result == 'success' && + needs.authorize-agent-signing.outputs.authorized == 'true' + needs: + - validate + - authorize-agent-signing + - agent-signing strategy: fail-fast: false matrix: @@ -92,7 +324,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 24.19.0 cache: npm - name: Cache packaging toolsets @@ -115,6 +347,24 @@ jobs: name: goodbuddy-production-bundle path: out + - name: Download signed Agent bundles + uses: actions/download-artifact@v8 + with: + pattern: goodbuddy-agent-linux-* + path: dist/agent-downloads + merge-multiple: true + + - name: Import and verify both Agent bundles + shell: bash + run: | + set -euo pipefail + node build/agent-bundle.cjs import \ + --arch x64 \ + --archive dist/agent-downloads/goodbuddy-agent-linux-x64.tar + node build/agent-bundle.cjs import \ + --arch arm64 \ + --archive dist/agent-downloads/goodbuddy-agent-linux-arm64.tar + - name: Resolve macOS signing mode id: macos-signing if: matrix.platform == 'macos' @@ -229,8 +479,13 @@ jobs: release: name: Publish GitHub and OSS release - if: github.event_name == 'push' && github.ref_type == 'tag' - needs: package + if: >- + github.event_name == 'push' && + github.ref_type == 'tag' && + needs.authorize-agent-signing.outputs.authorized == 'true' + needs: + - authorize-agent-signing + - package runs-on: ubuntu-24.04 timeout-minutes: 35 environment: @@ -247,7 +502,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 24.19.0 - name: Verify release tag shell: bash diff --git a/.gitignore b/.gitignore index c852b907..94b15408 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist/ coverage/ .vite/ .runtime-resources/ +.remote-runtime-resources/ +.agent-resources/ *.log *.tsbuildinfo .env diff --git a/AGENTS.md b/AGENTS.md index 1c86bacc..32985ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,35 @@ 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. +## Product Priorities + +Use this order when requirements or implementation choices compete: + +1. Working product functionality. +2. Performance and responsiveness. +3. Clear, low-friction user experience. +4. Simple, conventional, maintainable implementation. +5. Security controls proportional to a concrete, realistic risk. + +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. + +When the user asks to implement a feature, deliver the complete usable user +workflow unless the user explicitly requests a prototype, scaffold, staged +landing, or partial implementation. Internal contracts, managers, protocols, +disabled UI, unavailable catalog entries, placeholders, and tests around +injected fakes do not count as completion when the real product path still +cannot be used. If required metadata, dependencies, or integration details are +missing, investigate and resolve them as part of the implementation. Do not +turn missing implementation work into a product "security gate", permanent +unavailable state, or acceptance criterion. If a genuine external blocker +cannot be resolved, stop and ask the user rather than presenting the partial +work as the implemented feature. + ## Architecture - `src/main`: privileged Electron main process, runtimes, persistence, IPC, @@ -30,10 +59,17 @@ Keep Electron security boundaries intact: ## Runtime Behavior - Ask mode must remain read-only at the runtime boundary. -- Execute mode may use tools only through the existing approval controls. +- Execute mode is the user's full authorization for the selected SSH account. + It may use all tools, processes, network access, and writable paths available + to that account. Do not add T2/T3 trust tiers, separate consent checklists, + per-tool approvals, or a second "controlled execution" concept. - Preserve cancellation, timeout, bounded-output, and shutdown behavior. -- Treat OpenCode and Continue as untrusted child runtimes. Preserve environment - allowlists, sandbox checks, and per-tool approval enforcement. +- Keep model/provider credentials in Main, but do not otherwise reduce Execute + permissions with extra product policy gates. Keep child-process cleanup + straightforward and reliable. +- On test Hosts, GoodBuddy may install and start its own test resources and + create dedicated work directories. Never delete or overwrite unrelated Host + files. - A successful image-model configuration check does not prove generation works. Only an actual generation request verifies the provider path. - Do not fetch provider-returned image URLs. Accept and validate bounded inline @@ -52,6 +88,14 @@ 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. +- 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 + intended result. +- Security work must remain proportional to a concrete risk and must not + replace missing product functionality. Use established platform and + repository mechanisms instead of inventing extra gates, ceremonies, or + disabled states. - Add or update focused tests for behavioral changes and regressions. - After completing any functional change, inspect the affected product, architecture, design, feature, setup, and operational documentation and @@ -151,6 +195,25 @@ Release note: 修复左上角项目设置与消息通道项目设置不一致的 - `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. +- Ordinary `build` and `dist*` commands package the Agent and remote Runtime + version locks plus the public signing-key registry without embedding + installable Linux bundles. Local `portable` builds also discover and strictly + verify any existing `.agent-resources/linux-{x64,arm64}` bundles before + embedding them; `GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS` can require an exact + local architecture set. A metadata-only package may reuse an + already-installed Host Agent/Runtime only after the installed signed + manifests, locked identities, managed ownership and modes, critical Agent + payload, Host-side full payload verification, and lifecycle health all pass; + it cannot install a missing or mismatched Host component. Manage Agent + artifacts only through `agent:build`, `agent:import`, and `agent:verify`. +- Build Linux x64 and arm64 Agent artifacts on native GitHub Actions runners + from the exact desktop source commit/tag. Prefer dedicated jobs or a + reusable/manual workflow over a long-lived divergent Agent source branch. +- `release:package` is the only release path that requires the complete Agent + matrix. It must verify both signed Linux x64 and arm64 bundles before + Electron Builder, then verify the embedded copies in the unpacked + application. A missing or invalid architecture must fail the release rather + than producing a partial package. - 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. @@ -351,8 +414,13 @@ npm run lint ``` Run `npm run build` for production build changes. Use `npm run portable` only -when a current Windows portable package is requested. Gated runtime tests may -make paid or external calls, so run them only with explicit authorization. +when a current Windows portable package is requested. The user has granted +standing authorization in this repository for bounded real text-model calls +needed to validate changed product paths; do not ask again for each such call. +Keep each validation call minimal, disable tools and attachments where +possible, and report the exact call count. This standing authorization does +not cover bulk, high-cost, destructive, publishing, messaging, purchasing, or +other consequential external actions. Before committing or pushing, inspect `git status`, `git diff`, and `git diff --cached`. Do not commit secrets, local databases, logs, generated diff --git a/BUILD.md b/BUILD.md index cd95dea2..0e86420a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -131,9 +131,18 @@ npm run dist:linux:arm64 打包产物位于 `dist`。 +以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带 +`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的 +`agent-release-keys.json`。普通 `dist*` 仍为 metadata-only;本地 `portable` +若发现 `.agent-resources/linux-x64` 或 `linux-arm64`,会先按当前 lock 与公钥 +registry 完整校验,再把已存在的 Agent 工件嵌入 Portable。设置 +`GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS=x64`(或 `x64,arm64`)可要求指定架构 +必须存在并成功嵌入,否则构建失败。没有本地工件时,Portable 仍可验证并复用 Host 上 +与 lock 完全匹配的签名 Agent,但不能安装缺失或不匹配的 Agent。 + ## Runtime 资源 -发布包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime: +桌面包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime: - OpenCode 平台二进制来自 `.runtime-resources/`。 - Continue Runtime 来自锁定版本的 `@continuedev/cli`。 @@ -143,12 +152,72 @@ npm run dist:linux:arm64 跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。 +## GoodBuddy Agent 工件 + +Linux Agent 工件与普通桌面构建相互独立,由专用命令管理: + +```bash +# 只能在对应 Linux 架构上使用锁定的 Node Runtime 原生构建并签名 +npm run agent:build -- --arch --runtime-archive + +# 导入由原生 CI 生成的签名归档,导入时会完整校验 +npm run agent:import -- --arch x64 --archive +npm run agent:import -- --arch arm64 --archive + +# 独立重新校验本地工件 +npm run agent:verify -- --arch x64 +npm run agent:verify -- --arch arm64 +``` + +默认位置为 `.agent-resources/linux-x64` 和 `.agent-resources/linux-arm64`,该目录是 +生成内容,不提交到仓库。普通命令不会隐式构建或导入 Agent;`npm run portable` +只会校验并嵌入已经存在的本地 Agent 工件。 + +### Agent 原生 CI 策略 + +Agent 的 Linux x64 与 arm64 构建应使用同一源码 commit/tag 上的原生 GitHub Actions +job 或可复用 workflow,不维护长期分叉的 Agent 源码分支。这样桌面源码、协议、lock +文件和签名 manifest 的来源保持一致。若后续把 Agent 构建从统一发布 workflow 拆出, +应使用 `workflow_dispatch`、可复用 workflow 或 Agent tag 触发,明确 checkout 待发布 +桌面的精确 commit,并输出供发布 workflow 下载、`agent:import` 和 `agent:verify` 的 +两套签名归档;发布 job 仍必须在嵌入前后验证两种架构,不能信任分支名或未校验 artifact。 + +### 远程 OpenCode Runtime 基础 + +`remote-runtime-lock.json` 独立锁定首个远程 Runtime:OpenCode 1.18.9 的 Linux x64 +baseline 与 arm64 官方包 integrity、`bin/opencode` 入口和固定 `acp` 参数。它不同于 +`agent-runtime-lock.json`,后者锁定 Agent 自带 Node 以及用于 Linux `SO_PEERCRED` +的 Koffi 版本。Agent bundle 将 Koffi loader 作为 external module,并只携带目标 +Linux 架构的 glibc/musl 原生 binding;构建与导入都会校验其 ELF 架构和 MIT 许可证。 + +源码已经实现 Runtime bundle build/import/verify、Daemon 侧 +manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership、 +Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket +按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限 +启动签名 Runtime;Ask 才使用只读 bubblewrap。 + +OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型 +网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。Anthropic/OpenAI SDK 只使用 +固定非秘密本地兼容标记,helper 核对后丢弃。 + +`agent-runtime-lock.json` 维护独立于桌面应用的 Agent 版本, +`remote-runtime-lock.json` 维护 OpenCode Runtime 版本。Agent 内容变化时必须更新 +`agentVersion`;Runtime 更新时使用 Runtime 自身版本。签名 manifest 或 bundle digest +唯一标识对应版本的精确工件。变更后必须重新构建、签名并导入 Linux x64/arm64 两套工件; +旧版本或旧 digest 会按设计校验失败。不要手工把本机 `.runtime-resources` 当作远程 bundle。 + +桌面应用更新后,用户下次打开托管 SSH 项目会使用该桌面版本内置的签名 Agent/Runtime +执行一次完整激活。所需版本健康且项目事务提交成功后才刷新项目绑定;失败不会覆盖旧安装或 +把未提交的新 identity 暴露给 Workspace/Runtime。 + ## 跨平台 CI 与 GitHub Release `.github/workflows/packages.yml` 是统一发布工作流。它先验证并生成一次 `out` 生产 bundle,再在六个原生 Runner 上分别打包 Windows、macOS 和 Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifact -供打包任务复用,不会上传到 GitHub Release。 +供打包任务复用,不会上传到 GitHub Release。工作流另行在 Linux x64/arm64 +Runner 上原生构建并签名 Agent,桌面矩阵下载、导入并校验两套归档后才调用 +`release:package`;桌面 Runner 不重新构建 Agent。 本地构建单个平台目标: @@ -156,6 +225,18 @@ Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifac npm run release:package -- --platform --arch ``` +`release:package` 是唯一会嵌入远程 Agent 和远程 OpenCode Runtime 的桌面打包入口。公开 +签名 key registry 已由基础打包配置统一携带;运行前 +必须已经导入 Linux x64 与 arm64 两套签名 Agent/Runtime 工件;脚本会在启动 +Electron Builder 前验证完整矩阵, +使用 `build/electron-builder.release.cjs` 加入两套 bundle,并连同基础配置中的 +`agent-release-keys.json`、`agent-runtime-lock.json` 与 `remote-runtime-lock.json` +在打包后再次验证 +应用内副本。任一架构缺失、签名无效、组件版本、digest、ELF 架构、许可证或 lock +不匹配都会失败,不会生成不完整的发布包。当前仓库已包含 production 公钥 registry; +production preflight 仍要求外部安全供应与当前源码、lock 完全匹配的 Linux x64/arm64 +签名工件,缺少任一架构时会故意失败。 + 在 macOS 上明确生成未签名、未公证的开发验证包: ```bash diff --git a/FEATURES.md b/FEATURES.md index 7e8c56d1..47e63a03 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -13,7 +13,7 @@ - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 - [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。 -- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 +- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,并显示对应目录或来源。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 @@ -23,9 +23,10 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 +- [x] **托管 SSH OpenCode 闭环(签名工件待发布)**:项目创建/设置可选择已保存 SSH Host、远端工作目录和 Ask/Execute;新建项目可手工输入路径,或通过 Main 管理的只读、有界 SFTP 目录选择器浏览,选择结果仍需通过正常保存验证,普通非 Git 目录也可作为 Workspace,Git 状态/差异能力仅在仓库可安全解析时启用。应用重启后固定进入项目列表中的第一个普通本地项目,不会自动激活上次远程项目;只有用户主动切换到托管 SSH 项目时才开始远端验证,相同项目的重复激活共享同一操作。Host 地址、用户或固定 Host Key 变化时会关闭旧连接并定向退役该 Host 的 Runtime 缓存;所有引用项目保持待重新激活状态,当前项目自动重新激活并刷新验证,其他项目在下次选择时刷新,重新选择当前 SSH 项目也会强制验证。失败或取消后 Composer 继续禁用,用户排队消息保留在 Main 持久队列中,直到成功激活后继续。主动切换期间侧栏显示 Host、Agent、Workspace、Runtime、Saving 五阶段进度和取消入口,并暂时禁用冲突的项目切换、创建与设置操作。Main 验证并安装签名 Agent/OpenCode Runtime,通过按需启动的 detached Agent、私有 Unix socket 和 ACP v3 保持长任务不受 SSH 抖动影响;Main 持久化单调恢复 cursor,重连后恢复同一 binding、重放未确认输出并继续同一 OpenCode Session,只有确认远端 close 后才删除恢复 identity,Agent 终态事务会清空 active journal frame。Agent、固定 Node Runtime 与 OpenCode Runtime 分别维护精确工件;Agent 升级会在签名、registry、owner/mode/size 与 digest 全部一致时复用现有 Node,否则上传候选 Node。桌面更新后用户首次打开对应托管 SSH 项目会自动升级内置 Agent/Runtime,并在完整验证成功后原子刷新项目绑定,失败时保留旧绑定。普通 Portable 携带两个 lock 与 production 公钥 registry;若本地存在 Agent 工件,构建会先严格校验再嵌入,运行时可自动安装或升级,不存在工件时仍保持 metadata-only,只复用完整验证通过的匹配组件。SSH Host 设置以只读卡片显示 Host 已安装版本与当前 GoodBuddy 所需版本,查看设置不会触发安装。Ask 使用 bubblewrap 只读 Workspace;Execute 直接使用所选 SSH 账号的完整权限,不要求额外 consent 或逐工具审批。模型凭据和 Provider URL 留在 Main;模型桥不限制 Prompt 内的模型调用轮数、工具调用次数或 Token,只保留取消、Runtime deadline、有界传输与结果不确定时禁止自动重放。Windows 到 Linux x64 的安装、metadata-only Portable 项目保存、provider-free 模型桥、断线重放、Session 续接和零残留清理已使用 production 签名工件完成真实 Host 验证;production 公钥 registry 已供应,剩余发布门槛是当前 Linux x64/arm64 正式签名工件完整矩阵与经明确授权的有界真实模型调用。 - [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 运行已启用且受边界约束的工具。 +- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 @@ -92,7 +93,7 @@ - [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。 - [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。 -- [ ] **SSH 主机与远程执行空间**(规划中):管理 Host Key 固定和 Main-only 加密凭据,通过版本化远程 Helper 提供有界工作区、终端、受管进程和 Agent Runtime;远程执行继续遵循 Ask/Execute、审批、取消、超时和审计边界。 +- [ ] **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 续接与清理已经通过;production signing key registry 已供应。剩余门槛是生成并导入当前 Agent/Runtime 版本的 Linux x64/arm64 正式签名工件完整矩阵,并在明确授权后完成一次有界真实模型调用验收。门槛未满足时不提供未验证 SSH fallback。 - [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。 ## 规划原则 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json new file mode 100644 index 00000000..9d55b2d8 --- /dev/null +++ b/agent-runtime-lock.json @@ -0,0 +1,29 @@ +{ + "formatVersion": 1, + "agentVersion": "0.11.2-e2e.10", + "protocol": { + "major": 2, + "minor": 0 + }, + "node": { + "version": "24.19.0", + "source": "https://nodejs.org/dist/v24.19.0/", + "targets": { + "linux-x64": { + "archive": "node-v24.19.0-linux-x64.tar.gz", + "sha256": "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4", + "binaryPath": "node-v24.19.0-linux-x64/bin/node", + "licensePath": "node-v24.19.0-linux-x64/LICENSE" + }, + "linux-arm64": { + "archive": "node-v24.19.0-linux-arm64.tar.gz", + "sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f", + "binaryPath": "node-v24.19.0-linux-arm64/bin/node", + "licensePath": "node-v24.19.0-linux-arm64/LICENSE" + } + } + }, + "koffi": { + "version": "3.1.4" + } +} diff --git a/build/agent-bundle.cjs b/build/agent-bundle.cjs new file mode 100644 index 00000000..1111e74b --- /dev/null +++ b/build/agent-bundle.cjs @@ -0,0 +1,1214 @@ +const { + createHash, + createPublicKey, + sign, + verify +} = require('node:crypto') +const { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync +} = require('node:fs') +const { tmpdir } = require('node:os') +const { + basename, + dirname, + join, + posix, + relative, + resolve, + sep +} = require('node:path') +const tar = require('tar') +const { + detectElfArchitecture +} = require('./binary-architecture.cjs') + +const root = join(__dirname, '..') +const domainSeparator = Buffer.from( + 'GoodBuddy Agent Bundle Manifest Signature v1\0', + 'utf8' +) +const supportedArchitectures = Object.freeze(['x64', 'arm64']) +const allowedModes = new Set(['0644', '0755']) +const manifestFileName = 'manifest.json' +const signatureFileName = 'manifest.sig' +const koffiPackageName = 'koffi' +const signingPrivateKeyEnvironment = + 'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY' +const signingKeyIdEnvironment = + 'GOODBUDDY_AGENT_SIGNING_KEY_ID' +const semanticVersionPattern = + /^(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 + +function sha256Bytes(contents) { + return createHash('sha256').update(contents).digest('hex') +} + +function sha256FileSync(filePath) { + return sha256Bytes(readFileSync(filePath)) +} + +function readJson(filePath, description) { + let value + try { + value = JSON.parse(readFileSync(filePath, 'utf8')) + } catch (error) { + throw new Error( + `${description} is missing or invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error } + ) + } + return value +} + +function readRuntimeLock(projectRoot = root) { + const lock = readJson( + join(projectRoot, 'agent-runtime-lock.json'), + 'Agent runtime lock' + ) + if ( + lock.formatVersion !== 1 || + typeof lock.agentVersion !== 'string' || + !semanticVersionPattern.test(lock.agentVersion) || + lock.node?.version !== '24.19.0' || + lock.koffi?.version !== '3.1.4' || + !Number.isSafeInteger(lock.protocol?.major) || + !Number.isSafeInteger(lock.protocol?.minor) + ) { + throw new Error('Agent runtime lock contract is invalid') + } + return lock +} + +function readTrustedKeyRegistry(projectRoot = root) { + const registry = readJson( + join(projectRoot, 'resources', 'agent-release-keys.json'), + 'Agent trusted key registry' + ) + if ( + registry.formatVersion !== 1 || + !Array.isArray(registry.keys) || + !Array.isArray(registry.revocations) + ) { + throw new Error('Agent trusted key registry contract is invalid') + } + const ids = new Set() + for (const key of registry.keys) { + if ( + typeof key.keyId !== 'string' || + !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(key.keyId) || + ids.has(key.keyId) || + typeof key.publicKeySpkiBase64 !== 'string' || + !['production', 'test'].includes(key.environment) + ) { + throw new Error('Agent trusted key entry is invalid') + } + ids.add(key.keyId) + try { + createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + } catch { + throw new Error(`Agent trusted public key is invalid: ${key.keyId}`) + } + } + return registry +} + +function targetName(arch) { + if (!supportedArchitectures.includes(arch)) { + throw new Error(`Unsupported Agent architecture: ${arch}`) + } + return `linux-${arch}` +} + +function lockedRuntimeInput(lock, arch, archivePath) { + const target = targetName(arch) + const input = lock.node?.targets?.[target] + if ( + !input || + typeof input.archive !== 'string' || + typeof input.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(input.sha256) || + typeof input.binaryPath !== 'string' || + typeof input.licensePath !== 'string' + ) { + throw new Error(`Locked Node runtime input is missing for ${target}`) + } + if (!archivePath || !existsSync(archivePath)) { + throw new Error( + `Verified official Node ${lock.node.version} runtime input is required for ${target}: ${input.archive}` + ) + } + if (!statSync(archivePath).isFile()) { + throw new Error(`Node runtime input is not a file: ${archivePath}`) + } + if (basename(archivePath) !== input.archive) { + throw new Error( + `Node runtime input filename mismatch: expected ${input.archive}` + ) + } + const actualDigest = sha256FileSync(archivePath) + if (actualDigest !== input.sha256) { + throw new Error( + `Node runtime input digest mismatch for ${target}: expected ${input.sha256}, received ${actualDigest}` + ) + } + return { ...input, target } +} + +function assertElfArchitecture( + filePath, + expectedArchitecture, + description = 'Agent Node runtime' +) { + const actual = detectElfArchitecture(readFileSync(filePath).subarray(0, 64)) + if (actual !== expectedArchitecture) { + throw new Error( + `${description} architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}` + ) + } +} + +function koffiNativePackageName(arch) { + return `@koromix/koffi-linux-${arch}` +} + +function koffiPayloadPaths(arch) { + const packageRoot = `lib/node_modules/${koffiPackageName}` + const nativeRoot = + `lib/node_modules/${koffiNativePackageName(arch)}` + return { + packageRoot, + nativeRoot, + required: [ + `${packageRoot}/package.json`, + `${packageRoot}/index.js`, + `${packageRoot}/src/koffi/index.js`, + `${packageRoot}/src/koffi/src/static.js`, + `${nativeRoot}/package.json`, + `${nativeRoot}/index.js`, + `${nativeRoot}/linux_${arch}/koffi.node`, + `${nativeRoot}/musl_${arch}/koffi.node` + ], + native: [ + `${nativeRoot}/linux_${arch}/koffi.node`, + `${nativeRoot}/musl_${arch}/koffi.node` + ] + } +} + +function assertSafeManifestPath(filePath) { + if ( + typeof filePath !== 'string' || + filePath.length === 0 || + filePath.length > 240 || + filePath.includes('\\') || + filePath.includes('\0') || + posix.isAbsolute(filePath) || + filePath.split('/').some( + (part) => part.length === 0 || part === '.' || part === '..' + ) + ) { + throw new Error(`Unsafe Agent manifest path: ${String(filePath)}`) + } +} + +function compareUtf8(left, right) { + return Buffer.compare( + Buffer.from(left, 'utf8'), + Buffer.from(right, 'utf8') + ) +} + +function listFiles(directory) { + const files = [] + const pending = [directory] + while (pending.length > 0) { + const current = pending.pop() + const entries = readdirSync(current, { withFileTypes: true }) + .sort((left, right) => compareUtf8(right.name, left.name)) + for (const entry of entries) { + const absolutePath = join(current, entry.name) + if (entry.isSymbolicLink()) { + throw new Error(`Agent bundle cannot contain a symlink: ${absolutePath}`) + } + if (entry.isDirectory()) { + pending.push(absolutePath) + } else if (entry.isFile()) { + files.push(absolutePath) + } else { + throw new Error( + `Agent bundle contains an unsupported file: ${absolutePath}` + ) + } + } + } + return files.sort(compareUtf8) +} + +function posixRelative(directory, filePath) { + return relative(directory, filePath).split(sep).join('/') +} + +function modeString(filePath) { + return (statSync(filePath).mode & 0o777).toString(8).padStart(4, '0') +} + +function expectedManifestMode(path) { + return path === 'node' || + path === 'goodbuddy-agent' || + path.startsWith('helpers/') + ? '0755' + : '0644' +} + +function canonicalManifestBytes(manifest) { + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +} + +function createManifest(bundleDirectory, metadata) { + const files = listFiles(bundleDirectory) + .filter((filePath) => { + const name = posixRelative(bundleDirectory, filePath) + return name !== manifestFileName && name !== signatureFileName + }) + .map((filePath) => { + const path = posixRelative(bundleDirectory, filePath) + assertSafeManifestPath(path) + const mode = expectedManifestMode(path) + if ( + process.platform !== 'win32' && + modeString(filePath) !== mode + ) { + throw new Error( + `Agent bundle mode must be ${mode}: ${path}` + ) + } + return { + path, + size: statSync(filePath).size, + sha256: sha256FileSync(filePath), + mode + } + }) + const licenses = [ + { + package: 'GoodBuddy Agent', + version: metadata.agentVersion, + spdx: '0BSD', + path: 'licenses/GoodBuddy-0BSD.txt' + }, + { + package: 'Node.js', + version: metadata.nodeVersion, + spdx: 'MIT', + path: 'licenses/Node.js-MIT.txt' + }, + { + package: 'zod', + version: metadata.zodVersion, + spdx: 'MIT', + path: 'licenses/zod-MIT.txt' + }, + { + package: koffiPackageName, + version: metadata.koffiVersion, + spdx: 'MIT', + path: 'licenses/koffi-MIT.txt' + }, + { + package: metadata.koffiNativePackage, + version: metadata.koffiVersion, + spdx: 'MIT', + path: 'licenses/koffi-native-MIT.txt' + } + ] + return { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion: metadata.agentVersion, + platform: 'linux', + arch: metadata.arch, + protocol: metadata.protocol, + signingKeyId: metadata.signingKeyId, + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files, + licenses + } +} + +function signaturePayload(manifestBytes) { + return Buffer.concat([domainSeparator, manifestBytes]) +} + +function signManifestForTest(manifestBytes, privateKey) { + return sign(null, signaturePayload(manifestBytes), privateKey) +} + +function productionSigningKey(environment = process.env) { + const keyId = environment[signingKeyIdEnvironment] + const privateKey = environment[signingPrivateKeyEnvironment] + if (!keyId || !privateKey) { + throw new Error( + `${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required for production Agent signing` + ) + } + return { keyId, privateKey } +} + +function preflightProductionSigningKey(options = {}) { + const projectRoot = options.projectRoot ?? root + const environment = options.environment ?? process.env + const keyId = options.keyId ?? environment[signingKeyIdEnvironment] + if (!keyId) { + throw new Error( + `${signingKeyIdEnvironment} is not configured; provision the production Agent signing key ID before building a release` + ) + } + const registry = + options.registry ?? readTrustedKeyRegistry(projectRoot) + const signingRecord = registry.keys.find( + (key) => key.keyId === keyId + ) + if (!signingRecord) { + throw new Error( + `Production Agent public signing key ID "${keyId}" is absent from resources/agent-release-keys.json; provision the matching public key before building a release` + ) + } + if (signingRecord.environment !== 'production') { + throw new Error( + `Production Agent signing key ID "${keyId}" is not registered for production` + ) + } + if ( + registry.revocations.some( + (revocation) => revocation.keyId === keyId + ) + ) { + throw new Error( + `Production Agent signing key ID "${keyId}" is revoked` + ) + } + return signingRecord +} + +function publicKeySpkiBase64(key) { + const publicKey = key?.type === 'public' + ? key + : createPublicKey(key) + return publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64') +} + +function trustedKeyForManifest( + manifest, + registry, + verificationEnvironment = 'production' +) { + const key = registry.keys.find( + (candidate) => candidate.keyId === manifest.signingKeyId + ) + if (!key) { + throw new Error( + `Agent manifest uses an unknown signing key: ${manifest.signingKeyId}` + ) + } + if ( + verificationEnvironment === 'production' && + key.environment !== 'production' + ) { + throw new Error( + `Production Agent verification rejects non-production key: ${key.keyId}` + ) + } + const revocation = registry.revocations.find( + (candidate) => candidate.keyId === key.keyId + ) + if (revocation) { + throw new Error(`Agent signing key is revoked: ${key.keyId}`) + } + return key +} + +function validateManifestShape(manifest, expected) { + if ( + manifest?.formatVersion !== 1 || + manifest.product !== 'GoodBuddy' || + typeof manifest.agentVersion !== 'string' || + !semanticVersionPattern.test(manifest.agentVersion) || + manifest.platform !== 'linux' || + !supportedArchitectures.includes(manifest.arch) || + !Number.isSafeInteger(manifest.protocol?.major) || + !Number.isSafeInteger(manifest.protocol?.minor) || + typeof manifest.signingKeyId !== 'string' || + !Array.isArray(manifest.files) || + !Array.isArray(manifest.licenses) + ) { + throw new Error('Agent manifest contract is invalid') + } + if ( + manifest.entrypoint?.path !== 'goodbuddy-agent' || + manifest.entrypoint?.runtimePath !== 'node' || + manifest.entrypoint?.scriptPath !== 'lib/agent.cjs' + ) { + throw new Error('Agent manifest entrypoint is invalid') + } + if (expected) { + for (const [field, value] of Object.entries(expected)) { + if (field === 'protocol') { + if ( + manifest.protocol.major !== value.major || + manifest.protocol.minor !== value.minor + ) { + throw new Error('Agent manifest protocol does not match the lock') + } + } else if (manifest[field] !== value) { + throw new Error( + `Agent manifest ${field} does not match the locked value` + ) + } + } + } +} + +function verifyManifestSignature( + manifestBytes, + signatureBytes, + registry, + options = {} +) { + let manifest + try { + manifest = JSON.parse(manifestBytes.toString('utf8')) + } catch { + throw new Error('Agent manifest JSON is invalid') + } + validateManifestShape(manifest, options.expected) + if (!canonicalManifestBytes(manifest).equals(manifestBytes)) { + throw new Error('Agent manifest is not in canonical deterministic form') + } + const key = trustedKeyForManifest( + manifest, + registry, + options.verificationEnvironment + ) + const publicKey = createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + if ( + signatureBytes.length !== 64 || + !verify( + null, + signaturePayload(manifestBytes), + publicKey, + signatureBytes + ) + ) { + throw new Error('Agent manifest signature verification failed') + } + return manifest +} + +function verifyBundleDirectory(bundleDirectory, options = {}) { + const manifestPath = join(bundleDirectory, manifestFileName) + const signaturePath = join(bundleDirectory, signatureFileName) + const manifestBytes = readFileSync(manifestPath) + const signatureText = readFileSync(signaturePath, 'utf8') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) { + throw new Error('Agent detached signature encoding is invalid') + } + const registry = + options.registry ?? readTrustedKeyRegistry(options.projectRoot) + const manifest = verifyManifestSignature( + manifestBytes, + Buffer.from(signatureText.trim(), 'base64'), + registry, + options + ) + const declaredPaths = new Set() + for (const file of manifest.files) { + assertSafeManifestPath(file.path) + if ( + declaredPaths.has(file.path) || + !Number.isSafeInteger(file.size) || + file.size < 0 || + !/^[a-f0-9]{64}$/u.test(file.sha256) || + !allowedModes.has(file.mode) + ) { + throw new Error(`Agent manifest file entry is invalid: ${file.path}`) + } + if (file.mode !== expectedManifestMode(file.path)) { + throw new Error(`Agent payload mode mismatch: ${file.path}`) + } + declaredPaths.add(file.path) + const filePath = join(bundleDirectory, ...file.path.split('/')) + const entryStat = lstatSync(filePath) + if (!entryStat.isFile() || entryStat.isSymbolicLink()) { + throw new Error(`Agent payload is not a regular file: ${file.path}`) + } + if (entryStat.size !== file.size) { + throw new Error(`Agent payload size mismatch: ${file.path}`) + } + if (sha256FileSync(filePath) !== file.sha256) { + throw new Error(`Agent payload hash mismatch: ${file.path}`) + } + if ( + options.enforceFilesystemMode !== false && + process.platform !== 'win32' && + modeString(filePath) !== file.mode + ) { + throw new Error(`Agent payload mode mismatch: ${file.path}`) + } + } + const actualPaths = new Set( + listFiles(bundleDirectory).map((filePath) => + posixRelative(bundleDirectory, filePath) + ) + ) + const expectedPaths = new Set([ + ...declaredPaths, + manifestFileName, + signatureFileName + ]) + if ( + actualPaths.size !== expectedPaths.size || + [...actualPaths].some((filePath) => !expectedPaths.has(filePath)) + ) { + throw new Error('Agent bundle contains undeclared or missing files') + } + const licensedPaths = new Set() + for (const license of manifest.licenses) { + assertSafeManifestPath(license.path) + if ( + typeof license.package !== 'string' || + typeof license.version !== 'string' || + typeof license.spdx !== 'string' || + licensedPaths.has(license.path) || + !declaredPaths.has(license.path) + ) { + throw new Error('Agent manifest license entry is invalid') + } + licensedPaths.add(license.path) + } + for (const required of [ + manifest.entrypoint.path, + manifest.entrypoint.runtimePath, + manifest.entrypoint.scriptPath + ]) { + if (!declaredPaths.has(required)) { + throw new Error(`Agent entrypoint payload is missing: ${required}`) + } + } + const koffiPaths = koffiPayloadPaths(manifest.arch) + for (const required of koffiPaths.required) { + if (!declaredPaths.has(required)) { + throw new Error(`Agent Koffi payload is missing: ${required}`) + } + } + const koffiLicense = manifest.licenses.find( + (license) => license.package === koffiPackageName + ) + const nativePackage = koffiNativePackageName(manifest.arch) + const nativeLicense = manifest.licenses.find( + (license) => license.package === nativePackage + ) + if ( + koffiLicense?.spdx !== 'MIT' || + koffiLicense.path !== 'licenses/koffi-MIT.txt' || + nativeLicense?.spdx !== 'MIT' || + nativeLicense.path !== 'licenses/koffi-native-MIT.txt' || + nativeLicense.version !== koffiLicense.version + ) { + throw new Error('Agent Koffi license declaration is invalid') + } + assertElfArchitecture( + join(bundleDirectory, manifest.entrypoint.runtimePath), + manifest.arch + ) + for (const nativePath of koffiPaths.native) { + assertElfArchitecture( + join(bundleDirectory, ...nativePath.split('/')), + manifest.arch, + 'Agent Koffi native binding' + ) + } + return { + manifest, + manifestSha256: sha256Bytes(manifestBytes) + } +} + +function assertReplaceableBundle(directory) { + if (!existsSync(directory) || readdirSync(directory).length === 0) { + return + } + try { + const manifest = readJson( + join(directory, manifestFileName), + 'Existing Agent manifest' + ) + if ( + manifest.formatVersion === 1 && + manifest.product === 'GoodBuddy' && + manifest.platform === 'linux' + ) { + return + } + } catch { + // Use the explicit refusal below. + } + throw new Error(`Refusing to replace unrecognized Agent directory: ${directory}`) +} + +function replaceBundle(staging, destination) { + assertReplaceableBundle(destination) + const backup = `${destination}.previous-${process.pid}` + rmSync(backup, { recursive: true, force: true }) + if (existsSync(destination)) { + renameSync(destination, backup) + } + try { + renameSync(staging, destination) + } catch (error) { + if (!existsSync(destination) && existsSync(backup)) { + renameSync(backup, destination) + } + throw error + } + rmSync(backup, { recursive: true, force: true }) +} + +function copyLicense(source, destination) { + if (!existsSync(source) || !statSync(source).isFile()) { + throw new Error(`Required Agent bundle license is missing: ${source}`) + } + copyFileSync(source, destination) + chmodSync(destination, 0o644) +} + +function copyRegularFile(source, destination) { + if ( + !existsSync(source) || + !lstatSync(source).isFile() || + lstatSync(source).isSymbolicLink() + ) { + throw new Error(`Required Agent dependency file is missing: ${source}`) + } + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) + chmodSync(destination, 0o644) +} + +function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) { + const sourceRoot = join(projectRoot, 'node_modules', koffiPackageName) + const packageMetadata = readJson( + join(sourceRoot, 'package.json'), + 'Koffi package metadata' + ) + const nativePackage = koffiNativePackageName(arch) + const nativeSourceRoot = join( + projectRoot, + 'node_modules', + ...nativePackage.split('/') + ) + const nativeMetadata = readJson( + join(nativeSourceRoot, 'package.json'), + 'Koffi native package metadata' + ) + if ( + packageMetadata.name !== koffiPackageName || + packageMetadata.version !== lockedVersion || + nativeMetadata.name !== nativePackage || + nativeMetadata.version !== lockedVersion || + !Array.isArray(nativeMetadata.os) || + !nativeMetadata.os.includes('linux') || + !Array.isArray(nativeMetadata.cpu) || + !nativeMetadata.cpu.includes(arch) + ) { + throw new Error( + `Koffi dependencies do not match the locked ${nativePackage}@${lockedVersion}` + ) + } + + const paths = koffiPayloadPaths(arch) + const files = [ + ['package.json', `${paths.packageRoot}/package.json`], + ['index.js', `${paths.packageRoot}/index.js`], + [ + 'src/koffi/index.js', + `${paths.packageRoot}/src/koffi/index.js` + ], + [ + 'src/koffi/src/static.js', + `${paths.packageRoot}/src/koffi/src/static.js` + ] + ] + for (const [source, destination] of files) { + copyRegularFile( + join(sourceRoot, ...source.split('/')), + join(staging, ...destination.split('/')) + ) + } + for (const source of [ + 'package.json', + 'index.js', + `linux_${arch}/koffi.node`, + `musl_${arch}/koffi.node` + ]) { + const destination = `${paths.nativeRoot}/${source}` + copyRegularFile( + join(nativeSourceRoot, ...source.split('/')), + join(staging, ...destination.split('/')) + ) + } + for (const nativePath of paths.native) { + assertElfArchitecture( + join(staging, ...nativePath.split('/')), + arch, + 'Agent Koffi native binding' + ) + } + return { + version: lockedVersion, + nativePackage + } +} + +function buildAgentBundle(options) { + const projectRoot = options.projectRoot ?? root + const arch = options.arch + const lock = options.lock ?? readRuntimeLock(projectRoot) + const runtimeInput = lockedRuntimeInput( + lock, + arch, + options.runtimeArchive + ) + const registry = + options.registry ?? readTrustedKeyRegistry(projectRoot) + const signingEnvironment = options.environment ?? process.env + const signingIdentity = + options.testSigningIdentity ?? productionSigningKey(signingEnvironment) + const signingRecord = options.testSigningIdentity + ? registry.keys.find( + (key) => key.keyId === signingIdentity.keyId + ) + : preflightProductionSigningKey({ + projectRoot, + registry, + lock, + environment: signingEnvironment, + keyId: signingIdentity.keyId + }) + if (!signingRecord) { + throw new Error( + `Agent test signing key is not registered: ${signingIdentity.keyId}` + ) + } + if ( + publicKeySpkiBase64(signingIdentity.privateKey) !== + signingRecord.publicKeySpkiBase64 + ) { + throw new Error('Agent signing private key does not match the trusted key') + } + if ( + !options.testSigningIdentity && + signingEnvironment === process.env + ) { + delete process.env[signingPrivateKeyEnvironment] + delete process.env[signingKeyIdEnvironment] + } + + const destination = + options.outputDirectory ?? + join(projectRoot, '.agent-resources', targetName(arch)) + mkdirSync(dirname(destination), { recursive: true }) + const staging = mkdtempSync( + join(dirname(destination), `.stage-${targetName(arch)}-`) + ) + const runtimeStaging = mkdtempSync( + join(tmpdir(), `goodbuddy-node-${targetName(arch)}-`) + ) + try { + tar.x({ + file: options.runtimeArchive, + cwd: runtimeStaging, + sync: true, + strict: true, + filter(path) { + return ( + path === runtimeInput.binaryPath || + path === runtimeInput.licensePath + ) + } + }) + const runtimeBinary = join( + runtimeStaging, + ...runtimeInput.binaryPath.split('/') + ) + const runtimeLicense = join( + runtimeStaging, + ...runtimeInput.licensePath.split('/') + ) + assertElfArchitecture(runtimeBinary, arch) + copyFileSync(runtimeBinary, join(staging, 'node')) + chmodSync(join(staging, 'node'), 0o755) + + mkdirSync(join(staging, 'lib'), { recursive: true }) + const esbuild = require('esbuild') + esbuild.buildSync({ + stdin: { + contents: [ + "import { runAgentCli } from './src/agent-daemon/cli.ts'", + 'void runAgentCli(process.argv.slice(2)).then(', + ' (code) => { process.exitCode = code },', + ' () => {', + " process.stderr.write('Agent startup failed\\n')", + ' process.exitCode = 2', + ' }', + ')', + '' + ].join('\n'), + loader: 'ts', + resolveDir: projectRoot, + sourcefile: 'goodbuddy-agent-cjs-entry.ts' + }, + outfile: join(staging, 'lib', 'agent.cjs'), + bundle: true, + platform: 'node', + target: ['node24.19'], + format: 'cjs', + packages: 'bundle', + external: [koffiPackageName], + sourcemap: false, + legalComments: 'none', + charset: 'utf8', + logLevel: 'silent' + }) + chmodSync(join(staging, 'lib', 'agent.cjs'), 0o644) + const koffi = copyKoffiRuntime( + projectRoot, + staging, + arch, + lock.koffi.version + ) + const launcher = [ + '#!/bin/sh', + 'set -eu', + 'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)', + 'exec "$SCRIPT_DIR/node" "$SCRIPT_DIR/lib/agent.cjs" "$@"', + '' + ].join('\n') + writeFileSync(join(staging, 'goodbuddy-agent'), launcher, 'utf8') + chmodSync(join(staging, 'goodbuddy-agent'), 0o755) + + mkdirSync(join(staging, 'licenses'), { recursive: true }) + copyLicense( + join(projectRoot, 'LICENSE'), + join(staging, 'licenses', 'GoodBuddy-0BSD.txt') + ) + copyLicense( + runtimeLicense, + join(staging, 'licenses', 'Node.js-MIT.txt') + ) + copyLicense( + join(projectRoot, 'node_modules', 'zod', 'LICENSE'), + join(staging, 'licenses', 'zod-MIT.txt') + ) + copyLicense( + join(projectRoot, 'node_modules', 'koffi', 'LICENSE.txt'), + join(staging, 'licenses', 'koffi-MIT.txt') + ) + copyLicense( + join(projectRoot, 'node_modules', 'koffi', 'LICENSE.txt'), + join(staging, 'licenses', 'koffi-native-MIT.txt') + ) + const zodPackage = readJson( + join(projectRoot, 'node_modules', 'zod', 'package.json'), + 'zod package metadata' + ) + const manifest = createManifest(staging, { + agentVersion: lock.agentVersion, + nodeVersion: lock.node.version, + zodVersion: zodPackage.version, + koffiVersion: koffi.version, + koffiNativePackage: koffi.nativePackage, + arch, + protocol: lock.protocol, + signingKeyId: signingIdentity.keyId + }) + const manifestBytes = canonicalManifestBytes(manifest) + const signatureBytes = sign( + null, + signaturePayload(manifestBytes), + signingIdentity.privateKey + ) + writeFileSync(join(staging, manifestFileName), manifestBytes) + writeFileSync( + join(staging, signatureFileName), + `${signatureBytes.toString('base64')}\n`, + 'utf8' + ) + chmodSync(join(staging, manifestFileName), 0o644) + chmodSync(join(staging, signatureFileName), 0o644) + verifyBundleDirectory(staging, { + registry, + verificationEnvironment: options.testSigningIdentity + ? 'test' + : 'production', + expected: { + agentVersion: lock.agentVersion, + arch, + protocol: lock.protocol + } + }) + replaceBundle(staging, destination) + return destination + } finally { + rmSync(staging, { recursive: true, force: true }) + rmSync(runtimeStaging, { recursive: true, force: true }) + } +} + +function createAgentArchive(bundleDirectory, archivePath) { + const entries = listFiles(bundleDirectory) + .map((filePath) => posixRelative(bundleDirectory, filePath)) + .sort(compareUtf8) + mkdirSync(dirname(archivePath), { recursive: true }) + rmSync(archivePath, { force: true }) + tar.c({ + cwd: bundleDirectory, + file: archivePath, + sync: true, + portable: true, + noPax: true, + mtime: new Date(0) + }, entries) +} + +function importAgentArchive(archivePath, options = {}) { + if (!existsSync(archivePath)) { + throw new Error(`Agent artifact archive is missing: ${archivePath}`) + } + const projectRoot = options.projectRoot ?? root + const arch = options.arch + const destination = + options.outputDirectory ?? + join(projectRoot, '.agent-resources', targetName(arch)) + mkdirSync(dirname(destination), { recursive: true }) + const staging = mkdtempSync( + join(dirname(destination), `.import-${targetName(arch)}-`) + ) + try { + tar.x({ + cwd: staging, + file: archivePath, + sync: true, + strict: true, + preservePaths: false + }) + verifyLockedBundle(staging, arch, { + projectRoot, + registry: options.registry + }) + replaceBundle(staging, destination) + return destination + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +function verifyLockedBundle(bundleDirectory, arch, options = {}) { + const projectRoot = options.projectRoot ?? root + const lock = options.lock ?? readRuntimeLock(projectRoot) + return verifyBundleDirectory(bundleDirectory, { + registry: + options.registry ?? readTrustedKeyRegistry(projectRoot), + verificationEnvironment: options.verificationEnvironment ?? 'production', + expected: { + agentVersion: lock.agentVersion, + arch, + protocol: lock.protocol + } + }) +} + +function verifyAgentBundleMatrix(resourcesRoot, options = {}) { + const projectRoot = options.projectRoot ?? root + const registryPath = + options.registryPath ?? + join(projectRoot, 'resources', 'agent-release-keys.json') + const lockPath = + options.lockPath ?? + join(projectRoot, 'agent-runtime-lock.json') + const registry = readJson(registryPath, 'Agent trusted key registry') + const lock = readJson(lockPath, 'Agent runtime lock') + for (const arch of supportedArchitectures) { + verifyLockedBundle( + join(resourcesRoot, targetName(arch)), + arch, + { + projectRoot, + registry, + lock, + verificationEnvironment: 'production' + } + ) + } +} + +function redactSecrets(value, environment = process.env) { + let redacted = String(value) + for (const secret of [ + environment[signingPrivateKeyEnvironment], + environment[signingKeyIdEnvironment] + ]) { + if (secret && secret.length >= 4) { + redacted = redacted.split(secret).join('[redacted]') + } + } + return redacted + .replace( + /-----BEGIN (?:PRIVATE KEY|OPENSSH PRIVATE KEY)-----[\s\S]*?-----END (?:PRIVATE KEY|OPENSSH PRIVATE KEY)-----/gu, + '[redacted private key]' + ) + .replace( + /((?:private[_-]?key|signing[_-]?key|authorization|password|token)\s*[:=]\s*)\S+/giu, + '$1[redacted]' + ) +} + +function parseArguments(argv) { + const command = argv[0] + const options = {} + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index] + if (['--arch', '--runtime-archive', '--archive', '--output'].includes(argument)) { + const value = argv[index + 1] + if (!value) { + throw new Error(`${argument} requires a value`) + } + options[argument.slice(2).replaceAll('-', '')] = value + index += 1 + } else { + throw new Error(`Unknown Agent bundle argument: ${argument}`) + } + } + if (!['build', 'import', 'verify', 'preflight'].includes(command)) { + throw new Error('Usage: agent-bundle.cjs --arch ') + } + if (command === 'preflight') { + if (Object.keys(options).length > 0) { + throw new Error('Agent signing preflight does not accept arguments') + } + return { command } + } + targetName(options.arch) + return { command, ...options } +} + +function main(argv = process.argv.slice(2)) { + const options = parseArguments(argv) + if (options.command === 'preflight') { + const signingRecord = preflightProductionSigningKey() + console.log( + `Production Agent signing registry preflight passed: ${signingRecord.keyId}` + ) + return + } + if (options.command === 'build') { + const outputDirectory = buildAgentBundle({ + arch: options.arch, + runtimeArchive: resolve(options.runtimearchive ?? ''), + outputDirectory: options.output + ? resolve(options.output) + : undefined + }) + const archivePath = options.archive + ? resolve(options.archive) + : join( + root, + 'dist', + 'agent-bundles', + `goodbuddy-agent-${targetName(options.arch)}.tar` + ) + createAgentArchive(outputDirectory, archivePath) + console.log(`Agent bundle built and verified: ${targetName(options.arch)}`) + return + } + if (options.command === 'import') { + importAgentArchive(resolve(options.archive ?? ''), { + arch: options.arch, + outputDirectory: options.output + ? resolve(options.output) + : undefined + }) + console.log(`Agent bundle imported and verified: ${targetName(options.arch)}`) + return + } + verifyLockedBundle( + options.output + ? resolve(options.output) + : join(root, '.agent-resources', targetName(options.arch)), + options.arch + ) + console.log(`Agent bundle verified: ${targetName(options.arch)}`) +} + +module.exports = { + assertElfArchitecture, + assertSafeManifestPath, + buildAgentBundle, + canonicalManifestBytes, + createAgentArchive, + createManifest, + detectElfArchitecture, + importAgentArchive, + lockedRuntimeInput, + preflightProductionSigningKey, + productionSigningKey, + publicKeySpkiBase64, + readRuntimeLock, + readTrustedKeyRegistry, + redactSecrets, + signManifestForTest, + signaturePayload, + supportedArchitectures, + targetName, + verifyAgentBundleMatrix, + verifyBundleDirectory, + verifyLockedBundle, + verifyManifestSignature +} + +if (require.main === module) { + try { + main() + } catch (error) { + console.error( + redactSecrets(error instanceof Error ? error.message : String(error)) + ) + process.exitCode = 1 + } +} diff --git a/build/binary-architecture.cjs b/build/binary-architecture.cjs new file mode 100644 index 00000000..26502119 --- /dev/null +++ b/build/binary-architecture.cjs @@ -0,0 +1,71 @@ +function detectElfArchitecture(buffer) { + if ( + buffer.length < 20 || + buffer[0] !== 0x7f || + buffer.toString('ascii', 1, 4) !== 'ELF' || + ![1, 2].includes(buffer[5]) + ) { + return undefined + } + const machine = + buffer[5] === 2 + ? buffer.readUInt16BE(18) + : buffer.readUInt16LE(18) + if (machine === 62) { + return 'x64' + } + if (machine === 183) { + return 'arm64' + } + return undefined +} + +function detectBinaryArchitecture(buffer) { + if ( + buffer.length >= 64 && + buffer[0] === 0x4d && + buffer[1] === 0x5a + ) { + const peOffset = buffer.readUInt32LE(0x3c) + if ( + peOffset + 6 <= buffer.length && + buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0' + ) { + const machine = buffer.readUInt16LE(peOffset + 4) + if (machine === 0x8664) { + return 'x64' + } + if (machine === 0xaa64) { + return 'arm64' + } + } + } + + const elfArchitecture = detectElfArchitecture(buffer) + if (elfArchitecture) { + return elfArchitecture + } + + if (buffer.length >= 8) { + const littleMagic = buffer.readUInt32LE(0) + const bigMagic = buffer.readUInt32BE(0) + const cpuType = + littleMagic === 0xfeedfacf + ? buffer.readUInt32LE(4) + : bigMagic === 0xfeedfacf + ? buffer.readUInt32BE(4) + : undefined + if (cpuType === 0x01000007) { + return 'x64' + } + if (cpuType === 0x0100000c) { + return 'arm64' + } + } + return undefined +} + +module.exports = { + detectBinaryArchitecture, + detectElfArchitecture +} diff --git a/build/build-portable.cjs b/build/build-portable.cjs index 8a2b4432..0bf14d5a 100644 --- a/build/build-portable.cjs +++ b/build/build-portable.cjs @@ -11,6 +11,11 @@ const { writeFileSync } = require('node:fs') const { dirname, join, parse, resolve } = require('node:path') +const { + supportedArchitectures, + targetName, + verifyLockedBundle +} = require('./agent-bundle.cjs') const root = join(__dirname, '..') const packageJson = JSON.parse( @@ -29,19 +34,6 @@ const portablePath = process.env.GOODBUDDY_OUT_DIR const markerName = '.goodbuddy-portable.json' const portableLocales = new Set(['zh-CN.pak', 'en-US.pak']) -if (process.platform !== 'win32' || process.arch !== 'x64') { - throw new Error('Portable 目录当前必须在 Windows x64 上构建') -} -if ( - portablePath === parse(portablePath).root || - portablePath === root || - portablePath === outputRoot -) { - throw new Error( - `拒绝使用不安全的输出目录:${portablePath}` - ) -} - function assertOutputUnlocked(directory) { if (!existsSync(directory)) { return @@ -138,10 +130,106 @@ function pruneLocales(directory) { } } -function portableRequiredPaths(directory) { +function parseRequiredAgentArchitectures(value) { + if (value === undefined) { + return [] + } + const architectures = value + .split(',') + .map((architecture) => architecture.trim()) + if ( + architectures.length === 0 || + architectures.some( + (architecture) => + !supportedArchitectures.includes(architecture) + ) + ) { + throw new Error( + 'GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS 仅支持逗号分隔的 x64 和 arm64' + ) + } + return [...new Set(architectures)] +} + +function discoverLocalAgentResources(projectRoot = root) { + return supportedArchitectures.filter((architecture) => + statSync( + join( + projectRoot, + '.agent-resources', + targetName(architecture) + ), + { throwIfNoEntry: false } + )?.isDirectory() + ) +} + +function embedPortableAgentResources(directory, options = {}) { + const projectRoot = options.projectRoot ?? root + const requiredArchitectures = + options.requiredArchitectures ?? + parseRequiredAgentArchitectures( + process.env.GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS + ) + const verifyBundle = + options.verifyLockedBundle ?? verifyLockedBundle + const availableArchitectures = + discoverLocalAgentResources(projectRoot) + const missingArchitectures = requiredArchitectures.filter( + (architecture) => + !availableArchitectures.includes(architecture) + ) + if (missingArchitectures.length > 0) { + throw new Error( + `Portable 构建缺少要求的 Agent 资源:${missingArchitectures + .map(targetName) + .join(', ')}` + ) + } + + for (const architecture of availableArchitectures) { + verifyBundle( + join( + projectRoot, + '.agent-resources', + targetName(architecture) + ), + architecture, + { projectRoot } + ) + } + for (const architecture of availableArchitectures) { + const target = targetName(architecture) + const source = join( + projectRoot, + '.agent-resources', + target + ) + const destination = join( + directory, + 'resources', + 'agents', + target + ) + rmSync(destination, { recursive: true, force: true }) + cpSync(source, destination, { + recursive: true, + force: true + }) + } + return availableArchitectures +} + +function portableRequiredPaths( + directory, + agentArchitectures = [] +) { return [ join(directory, 'GoodBuddy.exe'), join(directory, 'resources', 'app.asar'), + join(directory, 'resources', 'agent-runtime-lock.json'), + join(directory, 'resources', 'remote-runtime-lock.json'), + join(directory, 'resources', 'agent-release-keys.json'), join(directory, 'resources', 'icon.ico'), join(directory, 'resources', 'tray-icon.png'), join( @@ -157,12 +245,33 @@ function portableRequiredPaths(directory) { 'runtimes', 'continue', 'package.json' - ) + ), + ...agentArchitectures.flatMap((architecture) => { + const agentDirectory = join( + directory, + 'resources', + 'agents', + targetName(architecture) + ) + return [ + join(agentDirectory, 'manifest.json'), + join(agentDirectory, 'manifest.sig'), + join(agentDirectory, 'goodbuddy-agent'), + join(agentDirectory, 'node'), + join(agentDirectory, 'lib', 'agent.cjs') + ] + }) ] } -function assertPortableOutput(directory) { - const requiredPaths = portableRequiredPaths(directory) +function assertPortableOutput( + directory, + agentArchitectures = [] +) { + const requiredPaths = portableRequiredPaths( + directory, + agentArchitectures + ) const missing = requiredPaths.filter( (filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile() ) @@ -219,7 +328,11 @@ function writePortableMarker(directory) { ) } -function replacePortableOutput(source, destination) { +function replacePortableOutput( + source, + destination, + agentArchitectures = [] +) { const replacement = `${destination}.replacement-${process.pid}` const backup = `${destination}.previous-${process.pid}` mkdirSync(dirname(destination), { recursive: true }) @@ -228,7 +341,7 @@ function replacePortableOutput(source, destination) { copyDirectoryContents(source, replacement) try { writePortableMarker(replacement) - assertPortableOutput(replacement) + assertPortableOutput(replacement, agentArchitectures) if (!existsSync(destination)) { renameSync(replacement, destination) @@ -250,7 +363,7 @@ function replacePortableOutput(source, destination) { } renameSync(preservedData, destinationData) } - assertPortableOutput(destination) + assertPortableOutput(destination, agentArchitectures) } catch (error) { try { const movedData = join(destination, 'data') @@ -279,36 +392,80 @@ function replacePortableOutput(source, destination) { } } -const electronDist = ensureElectronRuntime() -mkdirSync(outputRoot, { recursive: true }) -rmSync(stagingRoot, { recursive: true, force: true }) +function main() { + if (process.platform !== 'win32' || process.arch !== 'x64') { + throw new Error('Portable 目录当前必须在 Windows x64 上构建') + } + if ( + portablePath === parse(portablePath).root || + portablePath === root || + portablePath === outputRoot + ) { + throw new Error( + `拒绝使用不安全的输出目录:${portablePath}` + ) + } + const requiredAgentArchitectures = + parseRequiredAgentArchitectures( + process.env.GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS + ) + const electronDist = ensureElectronRuntime() + mkdirSync(outputRoot, { recursive: true }) + rmSync(stagingRoot, { recursive: true, force: true }) -for (const [label, script, args] of [ - [ - 'Node 类型检查', - join(root, 'node_modules', 'typescript', 'bin', 'tsc'), - ['--noEmit', '-p', 'tsconfig.node.json'] - ], - [ - 'Renderer 类型检查', - join(root, 'node_modules', 'typescript', 'bin', 'tsc'), - ['--noEmit', '-p', 'tsconfig.web.json'] - ], - [ - 'Production bundle', - join( - root, - 'node_modules', - 'electron-vite', - 'bin', - 'electron-vite.js' - ), - ['build'] - ] -]) { - const buildResult = spawnSync( + for (const [label, script, args] of [ + [ + 'Node 类型检查', + join(root, 'node_modules', 'typescript', 'bin', 'tsc'), + ['--noEmit', '-p', 'tsconfig.node.json'] + ], + [ + 'Renderer 类型检查', + join(root, 'node_modules', 'typescript', 'bin', 'tsc'), + ['--noEmit', '-p', 'tsconfig.web.json'] + ], + [ + 'Production bundle', + join( + root, + 'node_modules', + 'electron-vite', + 'bin', + 'electron-vite.js' + ), + ['build'] + ] + ]) { + const buildResult = spawnSync( + process.execPath, + [script, ...args], + { + cwd: root, + env: process.env, + shell: false, + stdio: 'inherit', + windowsHide: true + } + ) + if (buildResult.error) { + throw buildResult.error + } + if (buildResult.status !== 0) { + throw new Error( + `${label}失败(code ${buildResult.status ?? 1})` + ) + } + } + + const result = spawnSync( process.execPath, - [script, ...args], + [ + join(root, 'node_modules', 'electron-builder', 'cli.js'), + '--dir', + '--x64', + `--config.directories.output=${stagingRoot}`, + `--config.electronDist=${electronDist}` + ], { cwd: root, env: process.env, @@ -317,55 +474,54 @@ for (const [label, script, args] of [ windowsHide: true } ) - if (buildResult.error) { - throw buildResult.error + + if (result.error) { + rmSync(stagingRoot, { recursive: true, force: true }) + throw result.error } - if (buildResult.status !== 0) { + if (result.status !== 0) { + rmSync(stagingRoot, { recursive: true, force: true }) throw new Error( - `${label}失败(code ${buildResult.status ?? 1})` + `Electron Builder 构建失败(code ${result.status ?? 1})` ) } -} - -const result = spawnSync( - process.execPath, - [ - join(root, 'node_modules', 'electron-builder', 'cli.js'), - '--dir', - '--x64', - `--config.directories.output=${stagingRoot}`, - `--config.electronDist=${electronDist}` - ], - { - cwd: root, - env: process.env, - shell: false, - stdio: 'inherit', - windowsHide: true + if (!statSync(unpackedPath, { + throwIfNoEntry: false + })?.isDirectory()) { + rmSync(stagingRoot, { recursive: true, force: true }) + throw new Error('Electron Builder 未生成 portable 目录') + } + try { + pruneLocales(unpackedPath) + const embeddedAgentArchitectures = + embedPortableAgentResources(unpackedPath, { + requiredArchitectures: requiredAgentArchitectures + }) + assertPortableOutput( + unpackedPath, + embeddedAgentArchitectures + ) + replacePortableOutput( + unpackedPath, + portablePath, + embeddedAgentArchitectures + ) + } finally { + rmSync(stagingRoot, { recursive: true, force: true }) } -) -if (result.error) { - rmSync(stagingRoot, { recursive: true, force: true }) - throw result.error -} -if (result.status !== 0) { - rmSync(stagingRoot, { recursive: true, force: true }) - throw new Error( - `Electron Builder 构建失败(code ${result.status ?? 1})` - ) -} -if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) { - rmSync(stagingRoot, { recursive: true, force: true }) - throw new Error('Electron Builder 未生成 portable 目录') -} -try { - pruneLocales(unpackedPath) - assertPortableOutput(unpackedPath) - replacePortableOutput(unpackedPath, portablePath) -} finally { - rmSync(stagingRoot, { recursive: true, force: true }) + console.log(`Portable 目录构建完成:${portablePath}`) + console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`) } -console.log(`Portable 目录构建完成:${portablePath}`) -console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`) +module.exports = { + assertPortableOutput, + discoverLocalAgentResources, + embedPortableAgentResources, + parseRequiredAgentArchitectures, + portableRequiredPaths +} + +if (require.main === module) { + main() +} diff --git a/build/build-release.cjs b/build/build-release.cjs index 67ff78a3..65126677 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -5,6 +5,7 @@ const { createWriteStream, existsSync, closeSync, + lstatSync, mkdirSync, mkdtempSync, openSync, @@ -35,6 +36,16 @@ const { } = require('@electron/asar') const { Zip, ZipDeflate } = require('fflate') const { sha256File } = require('./file-hash.cjs') +const { + detectBinaryArchitecture +} = require('./binary-architecture.cjs') +const { + verifyAgentBundleMatrix +} = require('./agent-bundle.cjs') +const { + supportedArchitectures: remoteRuntimeArchitectures, + verifyBundleDirectory: verifyRemoteRuntimeBundle +} = require('./remote-runtime-bundle.cjs') const root = join(__dirname, '..') const packageJson = JSON.parse( @@ -45,6 +56,11 @@ const packageLock = JSON.parse( ) const productName = packageJson.build?.productName ?? packageJson.name const releaseRoot = join(root, 'dist', 'release') +const releaseBuilderConfig = join( + root, + 'build', + 'electron-builder.release.cjs' +) const manifestName = 'release-manifest.json' const portableMarkerName = '.goodbuddy-portable.json' const harnessHostEntry = @@ -328,6 +344,8 @@ function buildElectronBuilderArguments(options, outputDirectory) { )] const builderArguments = [ join(root, 'node_modules', 'electron-builder', 'cli.js'), + '--config', + releaseBuilderConfig, definition.builderFlag, ...builderFormats, `--${options.arch}`, @@ -382,59 +400,101 @@ function electronBuilderEnvironment( return builderEnvironment } -function detectBinaryArchitecture(buffer) { - if ( - buffer.length >= 64 && - buffer[0] === 0x4d && - buffer[1] === 0x5a - ) { - const peOffset = buffer.readUInt32LE(0x3c) - if ( - peOffset + 6 <= buffer.length && - buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0' - ) { - const machine = buffer.readUInt16LE(peOffset + 4) - if (machine === 0x8664) { - return 'x64' +function verifyReleaseAgentResources( + projectRoot = root, + verifyBundleMatrix = verifyAgentBundleMatrix +) { + verifyBundleMatrix( + join(projectRoot, '.agent-resources'), + { projectRoot } + ) +} + +function verifyRemoteRuntimeBundleMatrix( + resourcesRoot, + options = {} +) { + const projectRoot = options.projectRoot ?? root + const verifyBundleDirectory = + options.verifyBundleDirectory ?? verifyRemoteRuntimeBundle + const registry = readJsonFile( + options.registryPath ?? + join(projectRoot, 'resources', 'agent-release-keys.json'), + 'Remote Runtime trusted key registry' + ) + const lock = readJsonFile( + options.lockPath ?? + join(projectRoot, 'remote-runtime-lock.json'), + 'Remote Runtime lock' + ) + for (const architecture of remoteRuntimeArchitectures) { + const runtimeRoot = join( + resourcesRoot, + `linux-${architecture}`, + 'opencode' + ) + let rootStat + try { + rootStat = lstatSync(runtimeRoot) + } catch (error) { + throw new Error( + `Remote Runtime bundle root is missing for ${architecture}: ${runtimeRoot}`, + { cause: error } + ) + } + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new Error( + `Remote Runtime bundle root must be a real directory for ${architecture}: ${runtimeRoot}` + ) + } + let entries + try { + entries = readdirSync(runtimeRoot, { withFileTypes: true }) + } catch (error) { + throw new Error( + `Remote Runtime bundle root cannot be read for ${architecture}: ${runtimeRoot}`, + { cause: error } + ) + } + for (const entry of entries) { + if (!/^[a-f0-9]{64}$/u.test(entry.name)) { + throw new Error( + `Remote Runtime bundle root contains an unexpected entry for ${architecture}: ${entry.name}` + ) } - if (machine === 0xaa64) { - return 'arm64' + const entryStat = lstatSync(join(runtimeRoot, entry.name)) + if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) { + throw new Error( + `Remote Runtime bundle entry must be a real directory for ${architecture}: ${entry.name}` + ) } } + if (entries.length !== 1) { + throw new Error( + `Remote Runtime bundle root must contain exactly one digest directory for ${architecture}; found ${entries.length}` + ) + } + verifyBundleDirectory( + join(runtimeRoot, entries[0].name), + { + projectRoot, + architecture, + registry, + lock, + verificationEnvironment: 'production' + } + ) } - if ( - buffer.length >= 20 && - buffer[0] === 0x7f && - buffer.toString('ascii', 1, 4) === 'ELF' - ) { - const machine = - buffer[5] === 2 - ? buffer.readUInt16BE(18) - : buffer.readUInt16LE(18) - if (machine === 62) { - return 'x64' - } - if (machine === 183) { - return 'arm64' - } - } - if (buffer.length >= 8) { - const littleMagic = buffer.readUInt32LE(0) - const bigMagic = buffer.readUInt32BE(0) - const cpuType = - littleMagic === 0xfeedfacf - ? buffer.readUInt32LE(4) - : bigMagic === 0xfeedfacf - ? buffer.readUInt32BE(4) - : undefined - if (cpuType === 0x01000007) { - return 'x64' - } - if (cpuType === 0x0100000c) { - return 'arm64' - } - } - return undefined +} + +function verifyReleaseRemoteRuntimeResources( + projectRoot = root, + verifyBundleMatrix = verifyRemoteRuntimeBundleMatrix +) { + verifyBundleMatrix( + join(projectRoot, '.remote-runtime-resources'), + { projectRoot } + ) } function binaryArchitecture(filePath) { @@ -1086,6 +1146,34 @@ function verifyUnpackedOutput(directory, options) { assertFile(applicationExecutable, '应用主程序') assertFile(join(resources, 'app.asar'), '应用 ASAR') assertFile(join(resources, 'release-notes.json'), '版本更新说明') + assertFile( + join(resources, 'agent-release-keys.json'), + 'Agent 受信任签名密钥' + ) + assertFile( + join(resources, 'agent-runtime-lock.json'), + 'Agent Runtime 锁定清单' + ) + verifyAgentBundleMatrix(join(resources, 'agents'), { + projectRoot: root, + registryPath: join(resources, 'agent-release-keys.json'), + lockPath: join(resources, 'agent-runtime-lock.json') + }) + assertFile( + join(resources, 'remote-runtime-lock.json'), + 'Remote Runtime 锁定清单' + ) + verifyRemoteRuntimeBundleMatrix( + join(resources, 'remote-runtimes'), + { + projectRoot: root, + registryPath: join( + resources, + 'agent-release-keys.json' + ), + lockPath: join(resources, 'remote-runtime-lock.json') + } + ) assertFile(runtimeExecutable, 'OpenCode Runtime') assertFile( join(resources, 'runtimes', 'continue', 'dist', 'index.js'), @@ -1655,6 +1743,8 @@ async function main(argv = process.argv.slice(2)) { ) } + verifyReleaseAgentResources() + verifyReleaseRemoteRuntimeResources() rmSync(stagingDirectory, { recursive: true, force: true }) let cleanupTargetDependencies = () => undefined try { @@ -1724,6 +1814,9 @@ module.exports = { verifyArtifacts, verifyArtifactSignature, verifyPortableZip, + verifyReleaseAgentResources, + verifyReleaseRemoteRuntimeResources, + verifyRemoteRuntimeBundleMatrix, writeManifest } diff --git a/build/electron-builder.release.cjs b/build/electron-builder.release.cjs new file mode 100644 index 00000000..b9798181 --- /dev/null +++ b/build/electron-builder.release.cjs @@ -0,0 +1,36 @@ +const packageJson = require('../package.json') +const { + supportedArchitectures, + targetName +} = require('./agent-bundle.cjs') + +const remoteRuntimeResources = supportedArchitectures.map( + (architecture) => { + const target = targetName(architecture) + return { + from: `.remote-runtime-resources/${target}`, + to: `remote-runtimes/${target}`, + filter: ['**/*'] + } + } +) + +const agentResources = [ + ...supportedArchitectures.map((architecture) => { + const target = targetName(architecture) + return { + from: `.agent-resources/${target}`, + to: `agents/${target}`, + filter: ['**/*'] + } + }) +] + +module.exports = { + ...packageJson.build, + extraResources: [ + ...packageJson.build.extraResources, + ...agentResources, + ...remoteRuntimeResources + ] +} diff --git a/build/remote-runtime-bundle.cjs b/build/remote-runtime-bundle.cjs new file mode 100644 index 00000000..2aacb141 --- /dev/null +++ b/build/remote-runtime-bundle.cjs @@ -0,0 +1,1122 @@ +const { + createHash, + createPublicKey, + sign, + verify +} = require('node:crypto') +const { + chmodSync, + closeSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync +} = require('node:fs') +const { tmpdir } = require('node:os') +const { + basename, + dirname, + join, + posix, + relative, + resolve, + sep +} = require('node:path') +const tar = require('tar') +const { + detectElfArchitecture +} = require('./binary-architecture.cjs') + +const root = join(__dirname, '..') +const supportedArchitectures = Object.freeze(['x64', 'arm64']) +const manifestFileName = 'manifest.json' +const signatureFileName = 'manifest.sig' +const signingPrivateKeyEnvironment = + 'GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY' +const signingKeyIdEnvironment = + 'GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID' +const signatureDomain = Buffer.from( + 'GoodBuddy Remote Runtime Bundle Manifest Signature v1\0', + 'utf8' +) +const profileDigests = Object.freeze({ + adapter: + 'sha256:ac7696abe504bbea444d8ad80dd8faa96437e6dbb27ed987ce46e1045f3dd365', + acp: + 'sha256:0e3764ab897258bc0234162357c2eefc3faeb9918080ae81b60e2084ed7996a6' +}) +const fixedLimits = Object.freeze({ + maximumPromptRuntimeMilliseconds: 10 * 60 * 1000, + maximumPromptInputBytes: 16 * 1024 * 1024, + maximumPromptOutputBytes: 8 * 1024 * 1024 +}) + +function readJson(filePath, description) { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch (error) { + throw new Error( + `${description} is missing or invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error } + ) + } +} + +function readRemoteRuntimeLock(projectRoot = root) { + const lock = readJson( + join(projectRoot, 'remote-runtime-lock.json'), + 'Remote Runtime lock' + ) + const runtime = lock?.runtimes?.opencode + if ( + lock?.formatVersion !== 1 || + runtime?.version !== '1.18.9' || + runtime.provider !== 'opencode' || + runtime.entrypoint !== 'bin/opencode' || + runtime.entrypointIdentity !== 'opencode-acp' || + JSON.stringify(runtime.argvPrefix) !== JSON.stringify(['acp']) || + !Number.isSafeInteger(runtime.protocol?.major) || + !Number.isSafeInteger(runtime.protocol?.minor) + ) { + throw new Error('Remote Runtime lock contract is invalid') + } + for (const architecture of supportedArchitectures) { + const target = runtime.targets?.[architecture] + if ( + typeof target?.package !== 'string' || + typeof target.integrity !== 'string' || + !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(target.integrity) + ) { + throw new Error( + `Remote Runtime lock target is invalid: ${architecture}` + ) + } + } + return lock +} + +function readTrustedKeyRegistry(projectRoot = root) { + const registry = readJson( + join(projectRoot, 'resources', 'agent-release-keys.json'), + 'Runtime trusted key registry' + ) + if ( + registry?.formatVersion !== 1 || + !Array.isArray(registry.keys) || + !Array.isArray(registry.revocations) + ) { + throw new Error('Runtime trusted key registry contract is invalid') + } + const keyIds = new Set() + for (const key of registry.keys) { + if ( + typeof key?.keyId !== 'string' || + !/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(key.keyId) || + keyIds.has(key.keyId) || + typeof key.publicKeySpkiBase64 !== 'string' || + !['production', 'test'].includes(key.environment) + ) { + throw new Error('Runtime trusted key registry entry is invalid') + } + const publicKey = createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error(`Runtime trusted key is not Ed25519: ${key.keyId}`) + } + keyIds.add(key.keyId) + } + return registry +} + +function targetName(architecture) { + if (!supportedArchitectures.includes(architecture)) { + throw new Error(`Unsupported Runtime architecture: ${architecture}`) + } + return `linux-${architecture}` +} + +function sha256(contents) { + return createHash('sha256').update(contents).digest('hex') +} + +function sha512FileIntegrity(filePath) { + const hash = createHash('sha512') + const descriptor = openSync(filePath, 'r') + const chunk = Buffer.allocUnsafe(1024 * 1024) + try { + while (true) { + const bytesRead = readSync( + descriptor, + chunk, + 0, + chunk.byteLength, + null + ) + if (bytesRead === 0) { + break + } + hash.update(chunk.subarray(0, bytesRead)) + } + } finally { + closeSync(descriptor) + } + return `sha512-${hash.digest('base64')}` +} + +function sha256FileSync(filePath) { + return sha256(readFileSync(filePath)) +} + +function canonicalJson(value) { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return JSON.stringify(value) + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('Canonical Runtime value is not finite') + } + return JSON.stringify(Object.is(value, -0) ? 0 : value) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]` + } + if ( + typeof value !== 'object' || + Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new Error('Canonical Runtime value is not plain JSON') + } + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalJson(value[key])}` + ) + .join(',')}}` +} + +function digestCanonicalOperation({ method, scope, payload }) { + return `sha256:${sha256( + Buffer.from(canonicalJson({ method, payload, scope }), 'utf8') + )}` +} + +function digestRuntimeBundleIdentity(manifest) { + const { bundleDigest: _bundleDigest, ...identity } = manifest + void _bundleDigest + return digestCanonicalOperation({ + method: 'runtime/bundleIdentity', + scope: { + kind: 'installation', + installationId: manifest.runtimeId + }, + payload: identity + }) +} + +function digestRuntimeBundleManifest(manifest) { + return digestCanonicalOperation({ + method: 'runtime/bundleManifest', + scope: { + kind: 'installation', + installationId: manifest.runtimeId + }, + payload: manifest + }) +} + +function canonicalManifestBytes(manifest) { + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +} + +function signaturePayload(manifestBytes) { + return Buffer.concat([signatureDomain, manifestBytes]) +} + +function publicKeySpkiBase64(key) { + return createPublicKey(key) + .export({ format: 'der', type: 'spki' }) + .toString('base64') +} + +function productionSigningIdentity(environment = process.env) { + const keyId = environment[signingKeyIdEnvironment] + const privateKey = environment[signingPrivateKeyEnvironment] + if (!keyId || !privateKey) { + throw new Error( + `${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required for production Runtime signing` + ) + } + return { keyId, privateKey } +} + +function trustedKeyForSigning( + signingIdentity, + registry, + verificationEnvironment +) { + const record = registry.keys.find( + (key) => key.keyId === signingIdentity.keyId + ) + if (!record) { + throw new Error( + `Runtime signing key is not registered: ${signingIdentity.keyId}` + ) + } + if ( + verificationEnvironment === 'production' && + record.environment !== 'production' + ) { + throw new Error( + `Runtime signing key is not registered for production: ${record.keyId}` + ) + } + if ( + registry.revocations.some( + (revocation) => revocation.keyId === record.keyId + ) + ) { + throw new Error('Runtime signing key is outside its trusted interval') + } + if ( + publicKeySpkiBase64(signingIdentity.privateKey) !== + record.publicKeySpkiBase64 + ) { + throw new Error( + 'Runtime signing private key does not match the trusted registry' + ) + } + return record +} + +function preflightProductionSigningKey(options = {}) { + const projectRoot = options.projectRoot ?? root + const environment = options.environment ?? process.env + const registry = + options.registry ?? readTrustedKeyRegistry(projectRoot) + const identity = options.signingIdentity ?? + productionSigningIdentity(environment) + return trustedKeyForSigning( + identity, + registry, + 'production' + ) +} + +function assertSafeManifestPath(filePath) { + if ( + typeof filePath !== 'string' || + filePath.length === 0 || + filePath.length > 240 || + filePath.includes('\\') || + filePath.includes('\0') || + posix.isAbsolute(filePath) || + filePath + .split('/') + .some( + (part) => part.length === 0 || part === '.' || part === '..' + ) + ) { + throw new Error(`Unsafe Runtime manifest path: ${String(filePath)}`) + } +} + +function compareUtf8(left, right) { + return Buffer.compare( + Buffer.from(left, 'utf8'), + Buffer.from(right, 'utf8') + ) +} + +function listFiles(directory) { + const files = [] + const pending = [directory] + while (pending.length > 0) { + const current = pending.pop() + const entries = readdirSync(current, { + withFileTypes: true + }).sort((left, right) => compareUtf8(right.name, left.name)) + for (const entry of entries) { + const absolutePath = join(current, entry.name) + if (entry.isSymbolicLink()) { + throw new Error( + `Runtime bundle cannot contain a symlink: ${absolutePath}` + ) + } + if (entry.isDirectory()) { + pending.push(absolutePath) + } else if (entry.isFile()) { + files.push(absolutePath) + } else { + throw new Error( + `Runtime bundle contains an unsupported file: ${absolutePath}` + ) + } + } + } + return files.sort(compareUtf8) +} + +function posixRelative(directory, filePath) { + return relative(directory, filePath).split(sep).join('/') +} + +function modeString(filePath) { + return (statSync(filePath).mode & 0o777) + .toString(8) + .padStart(4, '0') +} + +function assertElfArchitecture(filePath, expectedArchitecture) { + const actual = detectElfArchitecture( + readFileSync(filePath).subarray(0, 64) + ) + if (actual !== expectedArchitecture) { + throw new Error( + `Runtime architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}` + ) + } +} + +function validateManifestShape(manifest) { + if ( + manifest?.formatVersion !== 2 || + manifest.product !== 'GoodBuddy' || + manifest.runtimeId !== 'opencode' || + manifest.runtimeVersion !== '1.18.9' || + manifest.provider !== 'opencode' || + manifest.platform !== 'linux' || + !supportedArchitectures.includes(manifest.architecture) || + typeof manifest.signingKeyId !== 'string' || + !/^sha256:[a-f0-9]{64}$/u.test(manifest.bundleDigest) || + !/^sha256:[a-f0-9]{64}$/u.test(manifest.adapterDigest) || + !/^sha256:[a-f0-9]{64}$/u.test( + manifest.acpCapabilitiesDigest + ) || + !Array.isArray(manifest.files) || + !Array.isArray(manifest.licenses) || + !Array.isArray(manifest.allowedEnvironmentNames) + ) { + throw new Error('Runtime manifest contract is invalid') + } + if ( + !Number.isSafeInteger( + manifest.limits?.maximumPromptRuntimeMilliseconds + ) || + manifest.limits.maximumPromptRuntimeMilliseconds < 1 || + !Number.isSafeInteger(manifest.limits.maximumPromptInputBytes) || + manifest.limits.maximumPromptInputBytes < 1 || + !Number.isSafeInteger(manifest.limits.maximumPromptOutputBytes) || + manifest.limits.maximumPromptOutputBytes < 0 || + Object.keys(manifest.limits).length !== 3 || + 'quotas' in manifest + ) { + throw new Error('Runtime manifest limits contract is invalid') + } + if ( + manifest.entrypoint?.identity !== 'opencode-acp' || + manifest.entrypoint.path !== 'bin/opencode' || + !/^[a-f0-9]{64}$/u.test(manifest.entrypoint.sha256) || + JSON.stringify(manifest.entrypoint.argvPrefix) !== + JSON.stringify(['acp']) + ) { + throw new Error('Runtime manifest entrypoint is invalid') + } +} + +function trustedKeyForManifest( + manifest, + registry, + verificationEnvironment +) { + const key = registry.keys.find( + (candidate) => candidate.keyId === manifest.signingKeyId + ) + if (!key) { + throw new Error( + `Runtime manifest uses an unknown signing key: ${manifest.signingKeyId}` + ) + } + if ( + verificationEnvironment === 'production' && + key.environment !== 'production' + ) { + throw new Error( + `Production Runtime verification rejects non-production key: ${key.keyId}` + ) + } + if ( + registry.revocations.some( + (revocation) => revocation.keyId === key.keyId + ) + ) { + throw new Error('Runtime signing key is outside its trusted interval') + } + return key +} + +function assertManifestMatchesLock(manifest, lock, architecture) { + const expected = lock.runtimes.opencode + const target = expected.targets[architecture] + if ( + manifest.runtimeId !== 'opencode' || + manifest.runtimeVersion !== expected.version || + manifest.provider !== expected.provider || + manifest.architecture !== architecture || + manifest.sourcePackage?.name !== target.package || + manifest.sourcePackage.integrity !== target.integrity || + manifest.entrypoint.path !== expected.entrypoint || + manifest.entrypoint.identity !== expected.entrypointIdentity || + JSON.stringify(manifest.entrypoint.argvPrefix) !== + JSON.stringify(expected.argvPrefix) || + JSON.stringify(manifest.allowedEnvironmentNames) !== + JSON.stringify(expected.allowedEnvironmentNames) || + manifest.protocol?.major !== expected.protocol.major || + manifest.protocol?.minor !== expected.protocol.minor || + manifest.adapterDigest !== profileDigests.adapter || + manifest.acpCapabilitiesDigest !== profileDigests.acp + ) { + throw new Error( + 'Runtime manifest does not match the locked OpenCode profile' + ) + } +} + +function verifyBundleDirectory(bundleDirectoryInput, options = {}) { + const bundleDirectory = resolve(bundleDirectoryInput) + const projectRoot = options.projectRoot ?? root + const lock = options.lock ?? readRemoteRuntimeLock(projectRoot) + const registry = + options.registry ?? readTrustedKeyRegistry(projectRoot) + const architecture = options.architecture + targetName(architecture) + const manifestBytes = readFileSync( + join(bundleDirectory, manifestFileName) + ) + const signatureText = readFileSync( + join(bundleDirectory, signatureFileName), + 'utf8' + ) + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) { + throw new Error('Runtime detached signature encoding is invalid') + } + const manifest = JSON.parse(manifestBytes.toString('utf8')) + validateManifestShape(manifest) + if (!canonicalManifestBytes(manifest).equals(manifestBytes)) { + throw new Error( + 'Runtime manifest is not in canonical deterministic form' + ) + } + assertManifestMatchesLock(manifest, lock, architecture) + const environment = + options.verificationEnvironment ?? 'production' + const key = trustedKeyForManifest( + manifest, + registry, + environment + ) + const publicKey = createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + const signatureBytes = Buffer.from(signatureText.trim(), 'base64') + if ( + signatureBytes.length !== 64 || + !verify( + null, + signaturePayload(manifestBytes), + publicKey, + signatureBytes + ) + ) { + throw new Error('Runtime manifest signature verification failed') + } + if (digestRuntimeBundleIdentity(manifest) !== manifest.bundleDigest) { + throw new Error('Runtime bundle identity digest does not match') + } + if ( + options.requireDigestDirectory !== false && + basename(bundleDirectory) !== + manifest.bundleDigest.slice('sha256:'.length) + ) { + throw new Error( + 'Runtime bundle digest does not match its managed directory' + ) + } + + const declared = new Set() + for (const file of manifest.files) { + assertSafeManifestPath(file.path) + if ( + declared.has(file.path) || + !Number.isSafeInteger(file.size) || + file.size < 0 || + !/^[a-f0-9]{64}$/u.test(file.sha256) || + !['0644', '0755'].includes(file.mode) + ) { + throw new Error(`Runtime manifest file entry is invalid: ${file.path}`) + } + declared.add(file.path) + const filePath = join(bundleDirectory, ...file.path.split('/')) + const stat = lstatSync(filePath) + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Runtime payload is not a regular file: ${file.path}`) + } + if (stat.size !== file.size) { + throw new Error(`Runtime payload size mismatch: ${file.path}`) + } + if (sha256FileSync(filePath) !== file.sha256) { + throw new Error(`Runtime payload hash mismatch: ${file.path}`) + } + if ( + options.enforceFilesystemMode !== false && + process.platform !== 'win32' && + modeString(filePath) !== file.mode + ) { + throw new Error(`Runtime payload mode mismatch: ${file.path}`) + } + } + const actual = new Set( + listFiles(bundleDirectory).map((filePath) => + posixRelative(bundleDirectory, filePath) + ) + ) + const expected = new Set([ + ...declared, + manifestFileName, + signatureFileName + ]) + if ( + actual.size !== expected.size || + [...actual].some((filePath) => !expected.has(filePath)) + ) { + throw new Error( + 'Runtime bundle contains undeclared or missing files' + ) + } + for (const license of manifest.licenses) { + assertSafeManifestPath(license.path) + if (!declared.has(license.path)) { + throw new Error('Runtime manifest license is not declared') + } + } + if ( + !declared.has(manifest.entrypoint.path) || + manifest.entrypoint.sha256 !== + manifest.files.find( + (file) => file.path === manifest.entrypoint.path + )?.sha256 + ) { + throw new Error('Runtime entrypoint payload is missing') + } + assertElfArchitecture( + join(bundleDirectory, ...manifest.entrypoint.path.split('/')), + architecture + ) + return { + bundleDirectory, + manifest, + manifestDigest: digestRuntimeBundleManifest(manifest) + } +} + +function lockedArchive(lock, architecture, archivePath) { + targetName(architecture) + const runtime = lock.runtimes.opencode + const target = runtime.targets[architecture] + if (!archivePath || !existsSync(archivePath)) { + throw new Error( + `Locked OpenCode archive is required: ${target.package}-${runtime.version}.tgz` + ) + } + if (!statSync(archivePath).isFile()) { + throw new Error(`Runtime archive is not a file: ${archivePath}`) + } + const expectedName = `${target.package}-${runtime.version}.tgz` + if (basename(archivePath) !== expectedName) { + throw new Error( + `Runtime archive filename mismatch: expected ${expectedName}` + ) + } + const actualIntegrity = sha512FileIntegrity(archivePath) + if (actualIntegrity !== target.integrity) { + throw new Error( + `Runtime archive integrity mismatch for ${targetName(architecture)}` + ) + } + return target +} + +function createManifest(bundleDirectory, metadata) { + const files = listFiles(bundleDirectory) + .filter((filePath) => { + const name = posixRelative(bundleDirectory, filePath) + return name !== manifestFileName && name !== signatureFileName + }) + .map((filePath) => { + const path = posixRelative(bundleDirectory, filePath) + assertSafeManifestPath(path) + return { + path, + size: statSync(filePath).size, + sha256: sha256FileSync(filePath), + mode: path === 'bin/opencode' ? '0755' : '0644' + } + }) + const entrypoint = files.find( + (file) => file.path === 'bin/opencode' + ) + if (!entrypoint) { + throw new Error('Runtime entrypoint is missing') + } + const initial = { + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: metadata.runtime.version, + provider: metadata.runtime.provider, + platform: 'linux', + architecture: metadata.architecture, + signingKeyId: metadata.signingKeyId, + bundleDigest: `sha256:${'0'.repeat(64)}`, + adapterDigest: profileDigests.adapter, + sourcePackage: metadata.sourcePackage, + entrypoint: { + identity: metadata.runtime.entrypointIdentity, + path: metadata.runtime.entrypoint, + sha256: entrypoint.sha256, + argvPrefix: metadata.runtime.argvPrefix + }, + files, + licenses: [ + { + package: 'opencode-ai', + version: metadata.runtime.version, + spdx: 'MIT', + path: 'licenses/opencode-MIT.txt' + } + ], + allowedEnvironmentNames: + metadata.runtime.allowedEnvironmentNames, + protocol: metadata.runtime.protocol, + acpCapabilitiesDigest: profileDigests.acp, + limits: fixedLimits + } + return { + ...initial, + bundleDigest: digestRuntimeBundleIdentity(initial) + } +} + +function defaultOutputRoot(projectRoot = root) { + return join(projectRoot, '.remote-runtime-resources') +} + +function bundleDirectory(outputRoot, architecture, digest) { + return join( + outputRoot, + targetName(architecture), + 'opencode', + digest.slice('sha256:'.length) + ) +} + +function buildRuntimeBundle(options) { + const projectRoot = options.projectRoot ?? root + const architecture = options.architecture + const lock = options.lock ?? readRemoteRuntimeLock(projectRoot) + const runtime = lock.runtimes.opencode + const sourcePackage = lockedArchive( + lock, + architecture, + options.runtimeArchive + ) + const registry = + options.registry ?? readTrustedKeyRegistry(projectRoot) + const signingIdentity = + options.testSigningIdentity ?? + productionSigningIdentity(options.environment ?? process.env) + const verificationEnvironment = + options.testSigningIdentity ? 'test' : 'production' + trustedKeyForSigning( + signingIdentity, + registry, + verificationEnvironment + ) + const outputRoot = + options.outputRoot ?? defaultOutputRoot(projectRoot) + mkdirSync(outputRoot, { recursive: true }) + const staging = mkdtempSync( + join(outputRoot, `.stage-${targetName(architecture)}-`) + ) + const extracted = mkdtempSync( + join(tmpdir(), `goodbuddy-opencode-${targetName(architecture)}-`) + ) + try { + tar.x({ + file: options.runtimeArchive, + cwd: extracted, + sync: true, + strict: true, + filter(path) { + return ( + path === 'package/package.json' || + path === 'package/bin/opencode' + ) + } + }) + const sourceMetadata = readJson( + join(extracted, 'package', 'package.json'), + 'OpenCode source package metadata' + ) + if ( + sourceMetadata.name !== sourcePackage.package && + sourceMetadata.name !== sourcePackage.name + ) { + // The lock target itself carries the canonical package name. + if ( + sourceMetadata.name !== + runtime.targets[architecture].package + ) { + throw new Error('OpenCode source package name does not match the lock') + } + } + if (sourceMetadata.version !== runtime.version) { + throw new Error( + 'OpenCode source package version does not match the lock' + ) + } + mkdirSync(join(staging, 'bin'), { mode: 0o700 }) + copyFileSync( + join(extracted, 'package', 'bin', 'opencode'), + join(staging, 'bin', 'opencode') + ) + chmodSync(join(staging, 'bin', 'opencode'), 0o755) + assertElfArchitecture( + join(staging, 'bin', 'opencode'), + architecture + ) + mkdirSync(join(staging, 'licenses'), { mode: 0o700 }) + copyFileSync( + join(projectRoot, 'node_modules', 'opencode-ai', 'LICENSE'), + join(staging, 'licenses', 'opencode-MIT.txt') + ) + chmodSync( + join(staging, 'licenses', 'opencode-MIT.txt'), + 0o644 + ) + const manifest = createManifest(staging, { + architecture, + runtime, + sourcePackage: { + name: runtime.targets[architecture].package, + integrity: runtime.targets[architecture].integrity + }, + signingKeyId: signingIdentity.keyId + }) + const manifestBytes = canonicalManifestBytes(manifest) + const signatureBytes = sign( + null, + signaturePayload(manifestBytes), + signingIdentity.privateKey + ) + writeFileSync( + join(staging, manifestFileName), + manifestBytes, + { mode: 0o644 } + ) + writeFileSync( + join(staging, signatureFileName), + `${signatureBytes.toString('base64')}\n`, + { mode: 0o644 } + ) + chmodSync(join(staging, manifestFileName), 0o644) + chmodSync(join(staging, signatureFileName), 0o644) + const destination = bundleDirectory( + outputRoot, + architecture, + manifest.bundleDigest + ) + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }) + if (existsSync(destination)) { + const existing = verifyBundleDirectory(destination, { + projectRoot, + architecture, + lock, + registry, + verificationEnvironment, + enforceFilesystemMode: options.enforceFilesystemMode + }) + if ( + canonicalJson(existing.manifest) !== canonicalJson(manifest) + ) { + throw new Error( + 'Existing Runtime digest directory has different content' + ) + } + return existing + } + chmodSync(staging, 0o700) + renameSync(staging, destination) + return verifyBundleDirectory(destination, { + projectRoot, + architecture, + lock, + registry, + verificationEnvironment, + enforceFilesystemMode: options.enforceFilesystemMode + }) + } finally { + rmSync(staging, { recursive: true, force: true }) + rmSync(extracted, { recursive: true, force: true }) + } +} + +function createRuntimeArchive(bundleDirectoryInput, archivePath) { + const directory = resolve(bundleDirectoryInput) + const entries = listFiles(directory) + .map((filePath) => posixRelative(directory, filePath)) + .sort(compareUtf8) + mkdirSync(dirname(archivePath), { recursive: true }) + rmSync(archivePath, { force: true }) + tar.c( + { + cwd: directory, + file: archivePath, + sync: true, + portable: true, + noPax: true, + mtime: new Date(0) + }, + entries + ) +} + +function importRuntimeArchive(archivePath, options) { + if (!existsSync(archivePath)) { + throw new Error(`Runtime artifact archive is missing: ${archivePath}`) + } + const projectRoot = options.projectRoot ?? root + const architecture = options.architecture + const outputRoot = + options.outputRoot ?? defaultOutputRoot(projectRoot) + mkdirSync(outputRoot, { recursive: true }) + const staging = mkdtempSync( + join(outputRoot, `.import-${targetName(architecture)}-`) + ) + try { + tar.x({ + cwd: staging, + file: archivePath, + sync: true, + strict: true, + preservePaths: false + }) + const verified = verifyBundleDirectory(staging, { + ...options, + projectRoot, + architecture, + requireDigestDirectory: false + }) + const destination = bundleDirectory( + outputRoot, + architecture, + verified.manifest.bundleDigest + ) + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }) + if (existsSync(destination)) { + return verifyBundleDirectory(destination, { + ...options, + projectRoot, + architecture + }) + } + chmodSync(staging, 0o700) + renameSync(staging, destination) + return verifyBundleDirectory(destination, { + ...options, + projectRoot, + architecture + }) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +function findOnlyBundle(outputRoot, architecture) { + const runtimeRoot = join( + outputRoot, + targetName(architecture), + 'opencode' + ) + const entries = existsSync(runtimeRoot) + ? readdirSync(runtimeRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(runtimeRoot, entry.name)) + : [] + if (entries.length !== 1) { + throw new Error( + `Expected exactly one built Runtime bundle for ${targetName(architecture)}` + ) + } + return entries[0] +} + +function redactSecrets(value, environment = process.env) { + let output = String(value) + for (const secret of [ + environment[signingPrivateKeyEnvironment], + environment[signingKeyIdEnvironment] + ]) { + if (secret && secret.length >= 4) { + output = output.split(secret).join('[redacted]') + } + } + return output.replace( + /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/gu, + '[redacted private key]' + ) +} + +function parseArguments(argv) { + const command = argv[0] + const options = {} + const allowed = new Set([ + '--arch', + '--runtime-archive', + '--archive', + '--output-root', + '--bundle' + ]) + for (let index = 1; index < argv.length; index += 2) { + const flag = argv[index] + const value = argv[index + 1] + if (!allowed.has(flag) || !value) { + throw new Error('Invalid Remote Runtime bundle argument') + } + options[ + flag.slice(2).replace(/-([a-z])/gu, (_match, value) => + value.toUpperCase() + ) + ] = value + } + if (!['build', 'import', 'verify', 'preflight'].includes(command)) { + throw new Error( + 'Usage: remote-runtime-bundle.cjs --arch ' + ) + } + if (command === 'preflight') { + return { command } + } + targetName(options.arch) + return { command, ...options } +} + +function main(argv = process.argv.slice(2)) { + const options = parseArguments(argv) + if (options.command === 'preflight') { + const record = preflightProductionSigningKey() + console.log( + `Production Runtime signing preflight passed: ${record.keyId}` + ) + return + } + const outputRoot = options.outputRoot + ? resolve(options.outputRoot) + : defaultOutputRoot() + if (options.command === 'build') { + const verified = buildRuntimeBundle({ + architecture: options.arch, + runtimeArchive: resolve(options.runtimeArchive ?? ''), + outputRoot + }) + const archivePath = options.archive + ? resolve(options.archive) + : join( + root, + 'dist', + 'remote-runtime-bundles', + `goodbuddy-opencode-${targetName(options.arch)}.tar` + ) + createRuntimeArchive(verified.bundleDirectory, archivePath) + console.log( + `Remote Runtime bundle built and verified: ${targetName(options.arch)}` + ) + return + } + if (options.command === 'import') { + importRuntimeArchive(resolve(options.archive ?? ''), { + architecture: options.arch, + outputRoot + }) + console.log( + `Remote Runtime bundle imported and verified: ${targetName(options.arch)}` + ) + return + } + verifyBundleDirectory( + options.bundle + ? resolve(options.bundle) + : findOnlyBundle(outputRoot, options.arch), + { architecture: options.arch } + ) + console.log( + `Remote Runtime bundle verified: ${targetName(options.arch)}` + ) +} + +module.exports = { + buildRuntimeBundle, + bundleDirectory, + canonicalJson, + canonicalManifestBytes, + createManifest, + createRuntimeArchive, + defaultOutputRoot, + digestRuntimeBundleIdentity, + digestRuntimeBundleManifest, + fixedLimits, + importRuntimeArchive, + preflightProductionSigningKey, + profileDigests, + publicKeySpkiBase64, + readRemoteRuntimeLock, + readTrustedKeyRegistry, + redactSecrets, + signaturePayload, + supportedArchitectures, + targetName, + verifyBundleDirectory +} + +if (require.main === module) { + try { + main() + } catch (error) { + console.error( + redactSecrets( + error instanceof Error ? error.message : String(error) + ) + ) + process.exitCode = 1 + } +} diff --git a/docs/README.md b/docs/README.md index 8f43e130..958753e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先 - [全双工实时语音交互设计](./architecture/full-duplex-voice-design.md) - [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) - [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md) - [长期助手路线图](./roadmap/long-term-assistant-roadmap.md) - [电脑控制实施状态](./status/computer-control-implementation-status.md) diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md new file mode 100644 index 00000000..a70c7319 --- /dev/null +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -0,0 +1,187 @@ +# SSH 远程主机与 GoodBuddy Agent 实现说明 + +## 状态 + +本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接、终态清理,以及 metadata-only Portable 对已安装 production 签名 Agent/Runtime 的验证复用已经在真实 Host 上完成 provider-free 验证。远程 OpenCode 功能仍处于发布前验证阶段;production 公钥 registry 已供应,正式发布前仍需提供并校验 Linux x64 与 Linux arm64 的当前签名 Agent/Runtime 工件完整矩阵,并完成一次经明确授权的有界真实模型调用。 + +## 产品语义 + +远程项目只有两种工作模式: + +- **Ask**:Runtime 在操作系统边界以只读方式访问项目 Workspace。 +- **Execute**:用户已授权使用所选 SSH 账号的完整权限。Runtime 可以使用该账号可访问的文件、进程、网络和工具,不再要求额外 trust tier、consent checklist、逐工具审批或“受控执行”授权。 + +Execute 不获得 root 或 SSH 账号本身没有的权限。模型 API Key 与 Provider 认证仍保留在 Electron Main,不传给远端 Runtime。 + +## 组件 + +```text +Renderer + -> 窄 Preload / Main IPC +Main + -> SSH Host 与 Host Key 管理 + -> SSH connection pool + -> 签名 Agent / Runtime 安装 + -> 远程项目验证与 SQLite 事务 + -> Main-owned model bridge +SSH attach + -> 远端私有 Unix socket +Detached GoodBuddy Agent + -> Workspace / Git 协议 + -> OpenCode ACP channel + -> 直接拥有的 Runtime 进程 +``` + +不提供把远端路径交给本机 Runtime 的伪本地模式,也不提供 SSH stdio Runtime fallback。 + +## SSH Host + +- Renderer 只管理脱敏 Host 快照,不接触密码、密文、SSH Client 或任意命令接口。 +- 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 项目也会显式触发相同验证,而不是复用旧项目快照。 + +## 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 不匹配不会阻止升级,也不会覆盖旧安装。 +- 桌面版本携带所需的签名 Agent 与 Runtime。Agent 和 Runtime 各自维护组件版本,签名摘要标识对应版本的精确工件。应用重启固定进入第一个普通本地项目,不自动连接上次远程项目;用户主动切换并首次打开托管 SSH 项目时,Main 才从当前桌面资源选择目标架构工件,Host 尚未安装该 identity 时自动上传并启动。相同 Renderer 对同一项目的并发激活共享一个 Main 操作。 +- Agent、Workspace 和 Runtime 全部验证成功后,Main 才在同一 SQLite 事务中刷新项目保存的 Agent/Runtime evidence,然后把项目设为当前项目。上传、启动、验证或事务失败时,项目继续绑定旧 identity,不能用未提交的新 identity 绕过持久化绑定校验。 +- SSH 连接先尝试 attach;Agent 不存在或未运行时执行幂等 bootstrap,然后重新 attach。 +- Agent 是按需启动的 detached process,不注册开机服务,不依赖 systemd、D-Bus 或 Linger。 +- Agent 监听当前用户拥有的私有 Unix socket。SSH 中断只关闭 relay,不拥有 Agent 和活动 Runtime 的生命周期。 +- SSH Host 设置通过固定探针和有界 SFTP 只读显示 Host 已登记的 Agent/OpenCode 版本,并与当前 GoodBuddy 所需版本比较;打开设置不安装或切换远端组件。 +- 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。 + +## Workspace + +Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、访问模式、Git 状态和有界能力。 + +- 创建项目时,用户可以直接输入远端工作目录,或通过输入框右侧的文件夹按钮浏览并选择目录。 +- 项目保存前的目录浏览使用 Main 管理的只读、有界 SFTP,只返回目录;每次请求重新解析并校验当前 Host revision 与 Host Key generation,不安装 Agent、不暴露任意 shell/SFTP 接口。 +- 浏览从 SSH 账号 Home 或当前有效绝对路径开始,限制扫描次数、返回条目和总时限;取消、Host 变化、超时或连接失败时关闭 SFTP 并保留用户原先输入。 +- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。 +- Ask Workspace handle 不暴露写入方法。 +- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。 +- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。 + +## Runtime + +首个远程 Runtime 固定为签名 OpenCode ACP bundle。 + +### Ask + +Ask 使用系统 `bwrap`: + +- Workspace 和 Runtime bundle 只读 bind; +- 独立可写 scratch HOME、TMPDIR 和 XDG 目录; +- 固定环境名和固定 Runtime argv; +- 通过只读边界阻止 Workspace 修改。 + +Host 缺少 `bwrap` 时 Ask 不可用。 + +### Execute + +Execute 不经过 Ask 的 bubblewrap profile: + +- 直接启动已签名 Runtime entrypoint; +- 使用 Main-only 模型桥时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的 + Node Runtime;完全相同且已验证的 Node 可在 Agent 版本间复用。进程 owner 校验 + 最终 Node executable,而不是已经被替换的 shell launcher 路径; +- `cwd` 为项目 Workspace; +- 继承 SSH 账号的正常环境、文件系统、进程和网络能力; +- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。 + +两种模式都保留请求 deadline、输入/输出字节上限、取消和进程组清理。 + +## ACP 与断线 + +- 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责 + ACP/blob 数据与 Agent 本机 journal。二者共享同一个经过 Host Key 校验和认证的 + SSH attach 字节流,但不共享生命周期:管控连接关闭不会隐式取消 Agent 已接受的 + Runtime 动作。 +- 帧只保留连接 identity、channel identity、单调 sequence、类型和长度。Main 与 + Agent 都按调用顺序 FIFO 写入,输入队列达到固定字节或条目上限时暂停 SSH stream。 + 顺序、可靠传输、分包和网络背压全部交给 SSH;协议不实现 credit/window、优先级 + 调度、跨 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 会被确认但不会再次交给上层。 +- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。 +- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。 +- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。 +- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。 +- 断线不主动终止正在运行的 Runtime。用户取消、deadline、显式关闭或 Agent shutdown 才触发停止。 +- Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller + identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的 + 指令和结果不确定的 Provider 请求都不自动重放。 + +## 模型桥 + +- Provider URL、API Key 和认证头只存在于 Main。 +- 远端 OpenCode 通过 GoodBuddy Agent helper 和每次 Prompt 的私有 Unix socket 使用模型桥。 +- Main 校验固定模型 profile、协议、请求路径和有界传输格式,并记录 Provider + 返回的实际 usage;不限制 Prompt 内的模型调用轮数、工具调用次数、累计 Token + 或单次模型输出 Token。取消、Runtime deadline、请求/响应字节上限和结果不确定时 + 禁止自动重放仍然保留。 +- OpenCode 同一 Prompt 内的标题请求和主请求可以并发到达本机 helper;helper 在 + 单一稳定模型桥上按到达顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 + HTTP 完整 flush 后才发送 delivery ACK。 +- 一条模型桥消息编码为一个 canonical JSON blob frame;blob frame 最大 2 MiB, + Provider 请求与响应 body 仍各自限制为 768 KiB。每条模型消息只调用一次 SSH + write,不再二次分片、等待逐帧 credit 或合并多个协议帧。凭据不进入 Renderer、 + SSH 命令参数或远端环境。 + +## 项目保存 + +远程项目持久化稳定配置和当前验证结果: + +- Host ID、Host revision 与 Host Key generation; +- 远端工作目录和 Workspace identity; +- Agent installation identity; +- Runtime selection、bundle digest、adapter digest; +- `ask | execute`。 + +打开托管 SSH 项目会从这些已保存字段重建验证输入,不接受 Renderer 回传的旧项目草稿。这样桌面更新后的第一次打开可以自动安装对应 Agent/Runtime,并原子刷新以上字段,同时保留项目名称、说明、Host、远端工作目录、Runtime selection 和默认工作模式。 + +Renderer 在用户主动打开托管 SSH 项目时订阅同一次 Main 保存操作的进度,依次显示 +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 不再暴露这些字段。 + +## 资源与发布 + +- 普通 `build` 和 `dist*` 携带 + `agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的 + `agent-release-keys.json`,但不嵌入可安装的 Linux Agent/Runtime bundle。本地 + `portable` 会自动完整校验并嵌入已存在的 `.agent-resources` Agent 工件,也可通过 + `GOODBUDDY_PORTABLE_REQUIRE_AGENT_ARCHS` 强制要求指定架构。若包内没有工件而 Host + 已有与 lock 匹配的当前组件,Main 会验证签名 manifest、安装与 + registry identity、owner/mode/size 和 Agent 关键 payload,并要求签名 Agent 在 + lifecycle/Runtime activation 边界完整复验 payload 后才复用;缺失或不匹配时不会 + 降级信任或尝试无 bundle 安装。 +- `agent:build`、`agent:import`、`agent:verify` 管理 Agent 工件。 +- 远程 Runtime 使用 `remote-runtime-lock.json` 和一个当前 manifest 格式。 +- `release:package` 在 Electron Builder 前后校验 Linux x64 与 arm64 的签名 Agent/Runtime 完整矩阵;任一架构缺失或无效时失败。 + +## 发布前验证 + +1. `npm test` +2. `npm run typecheck` +3. `npm run lint` +4. `npm run build` +5. 校验并导入双架构签名资源。 +6. 在已固定 Host Key 的 Linux x64 测试 Host 上验证 attach-or-bootstrap。 +7. 在 GoodBuddy 专用测试目录验证 Ask 无法修改文件。 +8. 验证 Execute 可以写文件、启动进程和访问网络,同时不触碰无关 Host 文件。 +9. 运行一次有界的真实模型调用,确认凭据只由 Main 使用。 +10. 中断并恢复 SSH,确认活动 Runtime 不被网络抖动终止。 diff --git a/docs/design/cross-platform-assistant-product-design.md b/docs/design/cross-platform-assistant-product-design.md deleted file mode 100644 index 1a140f26..00000000 --- a/docs/design/cross-platform-assistant-product-design.md +++ /dev/null @@ -1,1114 +0,0 @@ -# GoodBuddy 跨平台 AI 桌面助手功能方案设计 - -## 1. 文档信息 - -| 项目 | 内容 | -| --- | --- | -| 文档类型 | 产品设计基线 | -| 状态 | 初始方案 | -| 版本 | 0.1 | -| 日期 | 2026-07-29 | -| 适用产品 | GoodBuddy | -| 产品形态 | 常驻型跨平台 AI 桌面助手 | -| 目标平台 | Windows、macOS、Linux(含统信 UOS、银河麒麟) | -| 目标架构 | x86_64、ARM64(含鲲鹏、飞腾) | -| 推荐技术栈 | Electron + React + TypeScript + Vite | -| 可选扩展 | Rust Sidecar,用于本地索引、OCR、文档解析等性能敏感任务 | - -本文定义产品范围、功能模块、关键交互、权限安全、跨平台策略、非功能指标、版本路线及验收要求。产品参考通用 AI 桌面助手形态,不依赖任何第三方产品的私有实现。 - ---- - -## 2. 产品定位 - -### 2.1 产品愿景 - -打造一款随时可唤起、能够理解用户显式提供的桌面上下文、可以安全调用工具的 AI 助手。用户无需频繁切换应用,即可完成问答、写作、翻译、总结、截图分析、文件问答、知识检索和轻量任务自动化。 - -### 2.2 核心价值 - -1. **随时可用**:通过全局快捷键、系统托盘或悬浮助手快速唤起。 -2. **理解当前工作**:在用户明确授权后读取选中文本、截图、剪贴板和文件。 -3. **回答有依据**:知识库回答提供文件、页码或段落引用。 -4. **执行可控制**:工具调用必须经过权限校验、参数校验和必要的用户确认。 -5. **跨平台可交付**:在 Windows、macOS、Linux x64/ARM64 上保持核心任务一致。 -6. **企业可治理**:支持模型、网络、权限、数据和更新策略的集中管控。 - -### 2.3 目标用户 - -| 用户类型 | 典型需求 | -| --- | --- | -| 知识工作者 | 写作、翻译、总结、资料整理、文件问答 | -| 研发人员 | 代码解释、日志分析、技术资料检索、生成代码草稿 | -| 企业员工 | 查询内部知识、调用企业技能、生成业务内容 | -| IT 管理员 | 批量部署、模型配置、权限控制、审计和版本治理 | -| 国产化环境用户 | 在统信 UOS、银河麒麟、鲲鹏、飞腾设备上稳定运行 | - -### 2.4 产品原则 - -- 默认最小权限,不在首次启动时集中申请全部系统权限。 -- 默认只处理用户主动输入或显式添加的上下文。 -- 任何即将发送给模型的上下文都必须可见、可预览、可移除。 -- 模型输出不等同于执行授权,工具权限由独立权限层判定。 -- 核心体验保持一致,受系统限制的能力采用渐进增强和明确降级。 -- 不静默替换用户选择的 Provider、模型、Runtime、数据处理位置、工作模式、权限范围或 - 质量档位。语义等价的内部恢复可以自动进行;涉及隐私、成本、能力或可感知质量的替代 - 路径必须明确显示并由用户决定。 -- 安全、权限、更新签名和数据生命周期属于基础能力,不延期补做。 - -### 2.5 当前非目标 - -- 不替代浏览器、IDE、办公软件或操作系统 Shell。 -- MVP 不提供任意 Shell、任意代码执行和无人值守高风险操作。 -- 不持续录屏、持续监听输入或默认保存完整剪贴板历史。 -- 不默认上传桌面内容、全部文件或用户未选择的数据。 -- 暂不支持龙芯 LoongArch、申威 Sunway 等架构。 -- MVP 不建设开放插件市场、跨设备同步和完整企业设备管理平台。 - ---- - -## 3. 版本范围 - -### 3.1 优先级定义 - -| 优先级 | 定义 | -| --- | --- | -| P0 / MVP | 首个正式版本必须具备,构成核心使用闭环 | -| P1 | MVP 后优先建设,提升效率、知识能力和企业可用性 | -| P2 | 平台稳定后建设,涉及生态、复杂代理或更高系统权限 | - -### 3.2 MVP 核心闭环 - -```text -安装并完成模型配置 -→ 使用快捷键唤起 -→ 输入问题或添加文本、截图、剪贴板、文件 -→ 查看本次将发送的上下文 -→ 获得流式回答和来源引用 -→ 复制结果或确认执行低风险工具 -→ 在历史记录中继续对话 -``` - -### 3.3 版本能力概览 - -#### MVP - -- 主窗口、快捷面板、系统托盘、全局快捷键。 -- 可关闭的悬浮助手。 -- 多轮 AI 对话、流式输出、停止、重试和历史记录。 -- 用户主动添加的截图、剪贴板、选中文本和文件上下文。 -- 单个本地知识库、向量检索和来源引用。 -- 白名单内置工具及逐次授权。 -- 基础通知、设置、权限中心、诊断日志和更新检查。 -- Windows、macOS、Linux x64/ARM64 安装包与兼容性验证。 - -#### P1 - -- 多知识库、文件夹同步、增量索引、OCR、混合检索和重排。 -- 企业私有模型、代理网络、企业技能目录和签名策略。 -- 多步骤任务、人工检查点、失败重试和任务历史。 -- 更新灰度、回滚、企业更新源和审计导出。 -- 更完善的活动窗口、选中文本及上下文白名单能力。 - -#### P2 - -- 定时低风险自动化和多步骤智能代理。 -- 插件 SDK、技能市场和团队知识库。 -- SSO、SCIM、集中设备管理和跨设备会话同步。 -- 离线本地模型及云端、本地混合推理。 - ---- - -## 4. 产品信息架构 - -### 4.1 产品入口 - -| 入口 | 主要功能 | -| --- | --- | -| 系统托盘/菜单栏 | 打开助手、新建对话、暂停上下文能力、设置、退出 | -| 全局快捷键 | 唤起或收起快捷面板 | -| 悬浮助手 | 查看状态、打开快捷面板、进入常用动作 | -| 主窗口 | 管理对话、知识库、技能、任务、通知和设置 | -| 系统通知 | 跳转到已完成、失败或待确认的任务 | -| 深度链接 | 在可信来源下打开指定会话或功能页面 | - -### 4.2 主窗口导航 - -```text -主窗口 -├── 对话 -│ ├── 会话列表 -│ ├── 消息区域 -│ ├── 上下文附件 -│ └── 输入与快捷动作 -├── 知识库 -│ ├── 知识库列表 -│ ├── 文档与索引状态 -│ └── 检索测试 -├── 技能与任务 -│ ├── 可用技能 -│ ├── 权限范围 -│ └── 任务历史 -├── 通知中心 -└── 设置 - ├── 通用与外观 - ├── 模型服务 - ├── 快捷键与悬浮助手 - ├── 权限与隐私 - ├── 存储与知识库 - ├── 网络与代理 - ├── 通知与更新 - └── 诊断与关于 -``` - -### 4.3 核心状态 - -- **未配置**:没有可用模型,引导用户完成配置或登录。 -- **空闲**:等待输入。 -- **生成中**:模型正在流式输出,可停止。 -- **工具待确认**:等待用户检查参数并授权。 -- **任务执行中**:展示当前步骤、进度和取消入口。 -- **离线**:允许查看本地历史和知识库状态,禁用网络模型调用。 -- **权限受限**:展示缺失权限和降级方案。 -- **需要更新**:显示可用版本,不强制中断当前任务。 - ---- - -## 5. 详细功能设计 - -### 5.1 首次启动与初始化 - -#### 用户目标 - -用户能够了解产品能力、完成模型配置、按需启用快捷键,并在不被强制索取权限的情况下开始第一次对话。 - -#### 功能项 - -- 服务条款、隐私说明和数据处理方式确认。 -- 选择个人模式或企业模式。 -- 登录企业账号,或配置兼容的模型 API。 -- 检测网络、代理和模型连接。 -- 设置默认快捷键及冲突检测。 -- 可选设置开机启动和悬浮助手。 -- 权限按首次使用能力时申请,不在向导内强制一次性申请。 -- 提供示例问题和快速功能介绍。 - -#### 异常与降级 - -- 模型连接失败时保留配置并展示可诊断的错误原因。 -- 系统安全存储不可用时禁止静默保存明文密钥。 -- 快捷键冲突时建议其他组合,并允许跳过。 -- 企业策略未拉取成功时,只使用最近一次有效且签名正确的策略。 - -#### MVP 验收 - -- 新用户在三分钟内能够完成配置并发出第一条消息。 -- 跳过非必要设置不影响基础对话。 -- 连接测试不会在日志中输出 API Key。 - -### 5.2 快捷唤起与快捷面板 - -#### 用户场景 - -用户在任意应用中工作时,通过快捷键快速提问或处理当前内容,无需切换到完整主窗口。 - -#### 功能项 - -- 全局快捷键注册、修改和冲突检测。 -- 快捷面板显示、隐藏、失焦自动收起。 -- 再次按快捷键切换显示状态。 -- `Esc` 收起,`Enter` 发送,组合键换行。 -- 快速动作:总结、翻译、润色、解释、截图问答、文件问答。 -- 当前会话继续或新建临时会话。 -- 根据鼠标位置或活动显示器选择展示屏幕。 -- 可展开为主窗口,并保留输入和上下文。 - -#### 边界条件 - -- 快捷键被系统或其他软件占用。 -- Wayland 不允许直接注册全局快捷键。 -- 多显示器缩放比例不同或显示器在运行中断开。 -- 全屏应用、安全桌面、锁屏界面不允许覆盖。 -- 输入法组合状态下不能误触发送。 - -#### 降级策略 - -- 快捷键注册失败时使用托盘入口。 -- Wayland 优先使用 Global Shortcuts Portal,不可用时提示用户在桌面环境中配置启动命令。 -- 任意定位受限时显示普通居中窗口,不阻断核心功能。 - -#### MVP 验收 - -- 热唤起本地 UI 的 P95 不超过 300ms。 -- 快捷键冲突有明确提示且不覆盖已有注册。 -- 多显示器切换后窗口始终处于可见区域。 -- 展开主窗口后输入、附件和生成状态不丢失。 - -### 5.3 系统托盘与应用生命周期 - -#### 功能项 - -- 打开/隐藏主窗口。 -- 新建对话。 -- 显示当前运行状态。 -- 暂停上下文采集能力。 -- 开启/关闭悬浮助手。 -- 检查更新、设置、退出。 -- 单实例运行,第二次启动时激活已有实例。 -- 用户关闭主窗口时按设置退出或最小化到托盘。 -- 系统启动后按用户选择自动运行。 - -#### 边界条件 - -- Linux 桌面环境没有托盘服务。 -- 应用更新、系统关机或崩溃时存在未完成任务。 -- 第二实例携带深度链接或文件参数。 - -#### MVP 验收 - -- 无托盘环境仍能通过应用菜单和主窗口完成全部核心操作。 -- 退出前停止网络流、结束任务并清理临时截图。 -- 单实例参数经过校验后再交给已有实例。 - -### 5.4 悬浮助手 - -#### 功能项 - -- 悬浮球或迷你条显示。 -- 单击打开快捷面板,右键打开快捷菜单。 -- 拖动、贴边、隐藏和恢复。 -- 状态展示:空闲、生成中、任务中、错误、未读。 -- 置顶、透明度和全屏自动隐藏设置。 -- 用户可完全关闭悬浮助手。 - -#### 边界条件 - -- Wayland 禁止任意窗口定位。 -- macOS 多空间、全屏窗口和多屏切换。 -- Linux 窗口管理器忽略置顶或透明区域点击穿透。 -- DPI 变化导致保存坐标越界。 - -#### MVP 验收 - -- 重启后恢复到当前可见屏幕范围。 -- 透明区域不拦截其他应用的鼠标事件。 -- 不支持悬浮定位时自动降级为普通迷你窗口或托盘入口。 - -### 5.5 AI 对话 - -#### 功能项 - -- 新建、重命名、置顶、搜索、删除会话,以及从稳定的本地会话复制当前聊天内容到独立分支。 -- 多轮上下文和会话级模型选择。 -- 流式输出、停止生成、重新生成、编辑后重发。 -- Markdown、表格、代码块、公式和引用渲染。 -- 代码复制、消息复制、反馈和导出。 -- 会话标题自动生成,允许手动修改。 -- 消息附件、工具调用步骤和来源引用。 -- 上下文窗口及预计使用量提示。 -- 网络失败后的重试和续接策略。 - -#### 关键交互 - -1. 用户发送后立即创建用户消息和助手占位消息。 -2. 首段内容到达后进行增量渲染。 -3. 工具调用显示为独立步骤卡片,不与普通文本混合隐藏。 -4. 用户停止后立即中断网络请求和后续工具步骤。 -5. 引用标记可打开原始文件、页码或文本片段。 -6. 创建会话分支前先保存当前稳定内容;分支使用新会话与消息 ID,显示来源徽标,不继承 - Task、队列、运行或成果归属。 - -#### 异常与边界 - -- 模型超时、限流、拒答或返回格式异常。 -- 流式连接中断,仅收到部分内容。 -- 上下文超过模型限制。 -- 用户重复发送、快速切换模型或删除生成中的会话。 -- 超长消息导致渲染性能下降。 - -#### MVP 验收 - -- 用户停止后不再产生模型费用或工具调用。 -- 网络中断时保留已生成内容并提供重试。 -- 上下文超限时明确展示裁剪或摘要策略。 -- 长会话采用虚拟列表或分段渲染,不持续阻塞 UI。 - -### 5.6 上下文采集与上下文胶囊 - -#### 上下文来源 - -- 用户输入文本。 -- 用户主动添加的选中文本。 -- 单次读取的剪贴板文本、图片或文件路径。 -- 区域截图或窗口截图。 -- 用户选择或拖入的文件。 -- 用户选择的知识库。 -- P1:经授权的活动应用名称、窗口标题和页面片段。 - -#### 交互规则 - -- 每个上下文显示为独立胶囊或附件卡片。 -- 卡片展示类型、来源、大小、解析状态和数据去向。 -- 用户发送前可以预览、删除或替换。 -- 移除后,请求体和临时缓存不得继续包含对应数据。 -- 高敏感内容在发送前提示风险。 -- 会话授权不得自动升级为永久授权。 - -#### 上下文等级 - -| 等级 | 行为 | -| --- | --- | -| 无上下文 | 只发送用户输入 | -| 显式上下文 | 用户逐项触发采集,作为 MVP 默认方式 | -| 会话授权 | 当前会话可使用指定来源,关闭会话后失效 | -| 持续上下文 | P2 可选能力,默认关闭并持续显示采集指示 | - -#### 禁止行为 - -- 不读取密码框和安全输入区域。 -- 不在后台静默采集未授权窗口内容。 -- 不因模型请求而自动扩大文件或目录访问范围。 -- 不将应用名称、窗口标题默认视为非敏感信息。 - -### 5.7 截图与 OCR - -#### 功能项 - -- 全屏、区域、窗口截图。 -- 多显示器和高 DPI 支持。 -- 截图前自动隐藏助手窗口。 -- 截图预览、重截、删除、标注和打码。 -- P1:本地 OCR、版面识别和文本复制。 -- 系统权限状态检测和设置入口。 - -#### 平台策略 - -- Windows 使用系统屏幕捕获能力,避开 UAC 安全桌面。 -- macOS 按需申请屏幕录制权限。 -- X11 使用可验证的截图能力。 -- Wayland 优先使用 Screenshot/ScreenCast Portal 和 PipeWire。 - -#### MVP 验收 - -- 截图结果不包含自动隐藏的助手窗口。 -- 多屏负坐标、不同缩放比例下选区与输出一致。 -- 用户取消时不生成可持久化附件。 -- 权限拒绝时提供文字输入、文件上传等替代方式。 -- 临时截图在会话结束、清理或退出时按策略删除。 - -### 5.8 剪贴板 - -#### 功能项 - -- 正常粘贴。 -- 用户点击“从剪贴板添加”后单次读取。 -- 识别文本、图片和文件路径。 -- 显示预览、格式、大小和移除操作。 -- 敏感模式下完全禁用读取。 - -#### 边界条件 - -- 剪贴板为空、格式不支持或内容过大。 -- 密码管理器生成的临时内容。 -- Linux 主选择区与常规剪贴板差异。 -- 远程桌面共享剪贴板。 - -#### MVP 验收 - -- 未触发添加动作时不保存剪贴板内容。 -- 读取操作不修改或清空系统剪贴板。 -- 超过限制时不上传,并展示清晰原因。 - -### 5.9 文件处理 - -#### MVP 支持格式 - -- 文本:TXT、Markdown、JSON、CSV。 -- 文档:PDF。 -- 图片:PNG、JPEG、WebP。 -- 常见源代码和配置文件。 - -#### P1 支持格式 - -- DOCX、PPTX、XLSX。 -- 文件夹和批量文件。 -- 扫描 PDF OCR。 - -#### 功能项 - -- 文件选择、拖放和最近文件。 -- 类型、大小、数量和访问权限校验。 -- 文本解析、页码或段落定位。 -- 解析进度、取消、重试和失败原因。 -- 只发送用户问题需要的片段,而非默认上传完整文件。 -- 文件发生变化后标记缓存或索引过期。 - -#### 安全要求 - -- 不执行附件中的脚本、宏或嵌入对象。 -- 防止压缩炸弹、路径穿越和符号链接越权。 -- 解析进程配置 CPU、内存、时间和输出大小限制。 -- 网络盘离线或文件无权限时不得无限重试。 - -#### MVP 验收 - -- 文件解析不阻塞 Renderer 主线程。 -- 解析失败可重试或移除,且不破坏当前会话。 -- 引用能够定位到文件、页码或段落。 - -### 5.10 本地知识库 - -#### 用户场景 - -用户导入常用资料,并在后续对话中获得基于资料、带原文引用的回答。 - -#### MVP 功能 - -- 创建一个本地知识库。 -- 导入、删除和重新索引文件。 -- 展示解析、切块、嵌入、完成和失败状态。 -- 在对话中启用或停用知识库。 -- 向量检索和来源引用。 -- 查看引用原文。 -- 展示存储占用并支持完整清除。 - -#### P1 功能 - -- 多知识库。 -- 文件夹同步和增量索引。 -- 关键词与向量混合检索。 -- 重排、OCR、重复文件识别。 -- 企业知识库及权限继承。 - -#### 推荐检索链路 - -```text -文档解析 -→ 结构化清洗 -→ 保留标题、页码、偏移的分块 -→ 嵌入生成 -→ 向量/关键词混合检索 -→ 重排 -→ 上下文预算裁剪 -→ 带引用生成 -→ 引用一致性检查 -``` - -#### 边界条件 - -- 嵌入模型变化导致向量不兼容。 -- 文件更新、重复导入、磁盘空间不足。 -- 中文分块效果、扫描 PDF 和复杂表格。 -- ARM64 原生向量依赖没有稳定构建。 - -#### MVP 验收 - -- 每个检索片段包含文件、页码/章节和文本偏移元数据。 -- 删除知识库时联动删除正文缓存、向量和元数据。 -- 索引失败可以重试且不破坏已有可用索引。 -- 回答中的引用可以打开并展示对应原文。 - -### 5.11 模型服务 - -#### 支持方式 - -- OpenAI 兼容 API。 -- 企业私有模型服务。 -- 后续接入特定云模型厂商。 -- P2 接入本地模型。 - -#### 模型网关职责 - -- 统一流式请求、停止、超时、重试和错误格式。 -- 描述模型的文本、视觉、工具调用和上下文能力。 -- Token 预算与上下文裁剪。 -- 模型路由、降级和可用性检测。 -- 凭据注入,不向 Renderer 暴露密钥。 -- 企业域名白名单、TLS 和代理策略。 -- 统计用量,但默认不记录完整提示词和回答正文。 - -#### 配置项 - -- 服务地址、模型名称、API Key。 -- 默认模型及视觉模型。 -- 超时、重试、代理和自定义请求头。 -- 企业模式下由策略锁定的模型列表。 - -#### MVP 验收 - -- 保存配置前完成连接测试。 -- API Key 使用平台安全存储。 -- 模型不支持图片或工具时,在发送前给出提示。 -- 请求取消能够传递到网络层和工具调度层。 - -### 5.12 技能与工具调用 - -#### MVP 内置工具 - -- 纯文本转换:总结、翻译、润色、结构化。 -- 读取用户明确选择的文件。 -- 打开经过校验的网页链接。 -- 在用户选择的位置生成文件草稿。 -- 查询本地知识库。 - -#### 工具风险分级 - -| 等级 | 示例 | 默认策略 | -| --- | --- | --- | -| R0 纯计算 | 文本格式化、计算、编码转换 | 可自动执行 | -| R1 本地只读 | 读取用户本次选择的文件 | 首次或会话授权 | -| R2 外部只读 | 网络搜索、查询企业系统 | 展示数据去向,可按策略授权 | -| R3 可逆写入 | 创建草稿、生成新文件 | 执行前确认 | -| R4 外部副作用 | 发送消息、提交工单、修改远程数据 | 每次确认并展示完整参数 | -| R5 高风险 | Shell、提权、删除、支付 | MVP 禁止 | - -#### 调用流程 - -```text -模型建议调用 -→ 检查工具是否注册 -→ JSON Schema 参数校验 -→ 风险等级判定 -→ 用户权限和企业策略判定 -→ 必要时展示确认卡片 -→ 执行并支持取消 -→ 限制和过滤工具输出 -→ 将结果返回模型 -→ 写入脱敏审计记录 -``` - -#### 安全要求 - -- 模型文本不能绕过权限层直接执行。 -- 工具输出视为不可信数据,不能自动提升为系统指令。 -- 文件路径必须规范化并限制在授权范围。 -- URL 只允许 `https` 等白名单协议,并按策略校验域名。 -- 不拼接 Shell 命令,Sidecar 使用结构化参数。 - -#### MVP 验收 - -- 所有参数通过 Schema 校验后才能执行。 -- R3 及以上工具必须逐次确认。 -- 每次调用记录工具、参数摘要、授权方式、结果和时间。 -- 超时或取消能够终止请求或子进程。 - -### 5.13 Task 与自动化 - -每个 Task 只关联一条 Conversation,一条 Conversation 可以承载多个 Task;关联只增加 -Task 身份和左侧行首展开入口,不改变 Conversation 类型或复制会话内容。内部步骤、委派、 -并行分支和定时触发使用 Job/Subjob,执行尝试使用 Run,但当前 UI 只展示到 Task。 - -#### P1 功能 - -- 将多步工具调用保存为 Task。 -- 新建定制 Task 时选择当前或新 Conversation,默认 Execute,并明确显示 Runtime、Project、 - 工作目录、工具和审批范围。 -- 执行前展示步骤计划、输入和权限。 -- 逐步执行、暂停、取消和人工检查点。 -- 失败重试和从安全检查点继续。 -- 任务历史、输出物和失败原因。 -- 常用任务模板。 - -#### P2 功能 - -- 定时触发。 -- 无人值守的低风险任务。 -- 条件分支和循环。 -- 企业审批流。 - -#### 安全和一致性要求 - -- 有外部副作用的步骤使用幂等键或显示重复执行警告。 -- 应用崩溃后将任务标记为中断,不自动重放副作用步骤。 -- 输出文件存在时要求选择覆盖、重命名或取消。 -- 系统休眠和网络中断后重新确认任务状态。 - -### 5.14 通知与任务中心 - -#### 功能项 - -- 系统通知和应用内通知。 -- 保留 Task Center 作为 Task 入口,显示范围、状态、最近进展和需要关注信息。 -- 左侧会话列表对关联 Task 显示行首展开按钮,展开后的 Task 子项左侧显示名称、右侧显示 - 状态标记,父行不重复任务标签,展开层级只到 Task。 -- 普通 Conversation、Job、Run、工具步骤和智能心跳记录不作为顶层 Task。 -- 生成完成、任务完成、任务失败和等待确认。 -- 未读数量、全部已读和按类别过滤。 -- 勿扰模式及通知级别设置。 -- 点击通知跳转到对应会话或任务。 - -#### 隐私要求 - -- 锁屏通知默认隐藏提示词、文件名和工具参数等敏感内容。 -- 待确认通知只提示存在待办,不显示完整数据。 - -#### MVP 验收 - -- 系统通知不可用时仍有应用内通知。 -- 同一事件不重复发送。 -- 目标会话已删除时进入通知详情并提示对象不存在。 - -### 5.15 设置与权限中心 - -#### 设置分组 - -| 分组 | 配置内容 | -| --- | --- | -| 通用 | 开机启动、关闭行为、语言、主题、缩放 | -| 模型 | 服务地址、模型、凭据、超时、连接测试 | -| 快捷键 | 唤起、截图、快捷动作及冲突检测 | -| 悬浮助手 | 启用、位置、置顶、透明度、全屏隐藏 | -| 权限与隐私 | 截图、辅助功能、剪贴板、文件、通知状态 | -| 数据与存储 | 会话保留、缓存、知识库目录、数据清除 | -| 网络 | 系统代理、自定义代理、证书和域名策略 | -| 通知 | 通知类别、锁屏内容、勿扰 | -| 更新 | 更新渠道、自动检查和下载策略 | -| 诊断 | 日志等级、诊断包、版本和系统信息 | - -#### 交互要求 - -- 高风险设置显示影响说明。 -- 企业锁定项显示策略来源和锁定原因。 -- 清除数据前列出会删除的数据范围。 -- 配置写入采用原子替换,损坏时可恢复默认配置。 - -#### 本地助理数据兼容性 - -- 助理 SQLite 当前 schema 为 `user_version = 25`。`projects.built_in_default` - 是 Main 维护的只读内置身份,默认值为 `0`;普通项目创建、更新输入和 IPC 均不能设置它。 -- 全新数据库只通过 Main 内部种子路径将自动创建的本地默认项目标记为 `1`。界面仅在该标记 - 存在且原始种子名称、说明仍精确匹配时本地化展示,不改写持久化名称、说明或历史快照。 -- 从旧 schema 升级时,只有恰好一个项目满足完整旧种子签名(本地用户项目、活动状态、 - Ask、无 Runtime、原始名称和说明精确匹配、创建与更新时间相同),且该项目仍是数据库 - 最初插入的 Project 时才回填标记。零个、多个、原始项目已编辑或删除后出现的同名候选均 - 不标记,避免把用户后来独立创建的同名项目误认为内置项目。迁移在单一事务中完成。 - -### 5.16 自动更新 - -#### 功能项 - -- 启动后后台检查和手动检查。 -- 下载进度、暂停、重试和重启安装。 -- 更新说明、稳定/测试/企业渠道。 -- P1:灰度发布、失败回滚和企业更新源。 -- 企业可禁用客户端自动更新。 - -#### 平台策略 - -- Windows 支持 NSIS 或 MSIX,安装包和更新包使用 Authenticode 签名。 -- macOS 使用 Developer ID 签名、公证和 Hardened Runtime。 -- Linux DEB/RPM 优先遵循系统包管理器;AppImage 使用独立更新策略。 -- 更新元数据和安装包均需验证签名、版本、平台与架构。 - -#### MVP 验收 - -- 下载或安装失败后旧版本仍可运行。 -- 任务执行中不强制退出。 -- 架构或平台不匹配时拒绝安装。 -- 更新日志不包含下载凭据和敏感请求头。 - -### 5.17 企业管理 - -#### P1 能力 - -- 签名企业策略文件。 -- 模型、域名和技能白名单。 -- 代理、私有证书和私有模型配置。 -- 数据保留、遥测、日志和更新策略。 -- 工具风险上限和授权方式。 -- 审计记录导出。 - -#### P2 能力 - -- SSO、SCIM。 -- 集中设备管理。 -- 团队知识库和权限同步。 -- 远程策略控制台。 - -#### 策略规则 - -- 企业策略优先于本地用户配置。 -- 策略必须验签,验签失败时不生效。 -- 离线使用最近一次有效策略,并支持有效期。 -- 策略变更产生审计事件。 -- UI 和本地配置文件都不能绕过锁定项。 - ---- - -## 6. 权限与隐私 - -### 6.1 权限矩阵 - -| 能力 | Windows | macOS | X11 | Wayland | 默认策略 | -| --- | --- | --- | --- | --- | --- | -| 全局快捷键 | 原生支持 | 原生支持 | 通常支持 | 优先 Portal | 开启,失败时降级 | -| 屏幕截图 | 系统能力 | 需录屏权限 | 常见接口 | Screenshot/ScreenCast Portal | 用户主动触发 | -| 选中文本 | 按应用能力降级 | 可能需辅助功能权限 | 依应用和无障碍能力 | 通常受限 | 默认关闭 | -| 剪贴板 | 支持 | 支持 | Clipboard | 受合成器管理 | 单次触发 | -| 文件读取 | 文件选择器 | 文件选择器 | 文件选择器 | FileChooser Portal 优先 | 仅选择范围 | -| 通知 | 系统通知 | 通知权限 | 通知服务 | Portal/通知服务 | 首次需要时申请 | -| 开机启动 | 系统启动项 | Login Items | XDG Autostart | 桌面环境相关 | 用户开启 | - -### 6.2 权限申请原则 - -1. 在首次使用具体能力前解释用途并申请。 -2. 提供一次、当前会话、长期允许和拒绝选项。 -3. 长期授权可在权限中心撤销。 -4. 拒绝权限后不重复打扰,并提供替代路径。 -5. 操作系统权限被撤销后立即停止能力并更新 UI 状态。 -6. 高风险能力不能通过一次授权永久放行。 - -### 6.3 敏感信息保护 - -- 本地检测 API Key、访问令牌、密码、身份证号、银行卡号等常见敏感模式。 -- 检测结果只作为风险提示,不宣称完全准确。 -- 企业策略可阻止特定数据发送给外部模型。 -- 日志不记录完整提示词、附件正文、截图、密钥和令牌。 -- 提供隐私模式:不保存会话、不保留附件、不写入知识库。 - -### 6.4 数据生命周期 - -| 数据 | 默认策略 | 用户控制 | -| --- | --- | --- | -| 对话消息 | 本地保存,企业策略可覆盖 | 删除单条、会话或全部历史 | -| 临时截图 | 请求或会话结束后清理 | 立即删除 | -| 文件解析缓存 | 文件仍被引用时保存 | 单文件或统一清理 | -| 知识库正文与向量 | 知识库存在期间保存 | 删除知识库时联动清理 | -| 工具审计 | 有限期限、脱敏保存 | 企业策略控制 | -| 诊断日志 | 滚动、限额、脱敏 | 查看、导出和清除 | - ---- - -## 7. 技术架构 - -### 7.1 逻辑架构 - -```text -React Renderer - │ - │ 强类型、白名单 IPC - ▼ -Electron Main Process - ├── Window / Tray / Shortcut - ├── Permission Broker - ├── Context Broker - ├── Model Gateway - ├── Tool Orchestrator - ├── Task Runtime - ├── Update Manager - └── Secure Storage - │ - ├── SQLite - ├── Attachment Storage - ├── Vector Index - └── Optional Rust Sidecar -``` - -### 7.2 进程职责 - -#### Renderer - -- 页面和组件渲染。 -- 用户交互和状态展示。 -- 不直接访问 Node.js、文件系统、密钥和子进程。 - -#### Preload - -- 通过 `contextBridge` 暴露最小、稳定、强类型 API。 -- 不暴露通用 `ipcRenderer`、文件系统和命令执行接口。 - -#### Main Process - -- 窗口、托盘、快捷键、权限、系统集成和应用生命周期。 -- 模型请求、工具编排、安全存储和更新。 -- IPC 来源、参数和权限校验。 - -#### Rust Sidecar - -仅在以下场景引入: - -- 文档解析、OCR、向量索引等性能敏感工作。 -- Electron/Node 无法稳定支持的系统能力。 -- 需要跨平台统一实现的受控本地服务。 - -Sidecar 必须具备版本握手、生命周期管理、超时取消、崩溃恢复、结构化协议和签名/哈希校验。 - -### 7.3 Electron 安全基线 - -- `contextIsolation: true`。 -- `nodeIntegration: false`。 -- 启用严格 CSP,禁止远程脚本执行。 -- IPC 使用白名单,并校验请求和响应 Schema。 -- 外部链接使用系统浏览器打开,并校验协议和域名。 -- 不可信页面不能使用特权 preload。 -- 深度链接、自定义协议和文件路径必须规范化及校验来源。 -- Sidecar 使用固定可信路径,不拼接 Shell 命令。 - -### 7.4 本地存储 - -| 类型 | 建议存储 | -| --- | --- | -| 会话、消息、任务、权限、审计 | SQLite | -| 附件、解析缓存、缩略图 | 应用数据目录 | -| 向量索引 | SQLite 扩展或独立索引 | -| API Key 和令牌 | 系统安全存储 | - -系统安全存储优先级: - -- Windows:Credential Manager 或 DPAPI。 -- macOS:Keychain。 -- Linux:Secret Service。 -- Linux 安全存储不可用时,提示用户配置或使用受密码保护的加密存储,不得静默降级为明文。 - -### 7.5 核心数据实体 - -| 实体 | 关键字段 | -| --- | --- | -| Conversation | ID、标题、模型、创建/更新时间、隐私模式 | -| Message | ID、会话 ID、角色、内容、状态、Token 信息 | -| Attachment | ID、类型、来源、路径、哈希、大小、生命周期 | -| ContextItem | ID、消息 ID、来源、摘要、授权范围 | -| KnowledgeBase | ID、名称、嵌入模型、索引版本、状态 | -| KnowledgeDocument | ID、知识库 ID、文件哈希、解析状态、版本 | -| ToolDefinition | 名称、版本、参数 Schema、风险等级、权限 | -| ToolInvocation | 工具、参数摘要、授权方式、状态、结果摘要 | -| Task | ID、计划、当前步骤、状态、输出物 | -| PermissionGrant | 能力、作用域、有效期、来源 | -| AppSetting | 键、值、策略锁定状态 | - ---- - -## 8. 跨平台兼容方案 - -### 8.1 发布矩阵 - -| 平台 | 架构 | 安装格式 | 优先级 | -| --- | --- | --- | --- | -| Windows 10/11 | x64 | NSIS/MSIX | P0 | -| Windows 11 | ARM64 | NSIS/MSIX | P0 | -| macOS | Intel x64 | DMG/PKG | P0 | -| macOS | Apple Silicon | DMG/PKG | P0 | -| Debian/Ubuntu/UOS/麒麟 | x64 | DEB/AppImage | P0 | -| Debian/Ubuntu/UOS/麒麟 | ARM64 | DEB/AppImage | P0 | -| RPM 系 Linux | x64/ARM64 | RPM | P1 | - -### 8.2 国产化环境要求 - -- 不假定统信 UOS、银河麒麟使用相同桌面环境、Wayland 版本和系统组件。 -- 维护鲲鹏、飞腾 ARM64 真机或稳定远程测试环境。 -- 检查 Electron、SQLite、OCR、向量库及 Sidecar 的 ARM64 构建。 -- 尽量避免只提供 x64 预编译包的 Node 原生模块。 -- 验证中文输入法、系统字体、多屏缩放、系统代理、证书存储和 Secret Service。 -- 明确最低 glibc 和发行版基线,使用兼容构建环境产出 Linux 包。 - -### 8.3 X11 与 Wayland 差异 - -| 能力 | X11 | Wayland | -| --- | --- | --- | -| 任意窗口定位 | 通常可用 | 合成器可能禁止 | -| 全局快捷键 | 通常可用 | 依赖 Portal 或桌面设置 | -| 屏幕捕获 | 多种方式 | 优先 Portal/PipeWire | -| 活动窗口信息 | 相对容易 | 通常受限制 | -| 模拟输入 | 技术上可行但高风险 | 通常禁止 | -| 置顶与穿透 | 依窗口管理器 | 行为不一致 | - -实现时应使用能力检测,不只依赖操作系统名称判断。每个受限能力都必须提供替代入口。 - ---- - -## 9. 安全设计 - -### 9.1 主要威胁与控制 - -| 风险 | 场景 | 控制措施 | -| --- | --- | --- | -| 提示注入 | 文档诱导 AI 上传数据或执行工具 | 文档视为不可信数据;独立权限层;高风险确认 | -| 越权文件访问 | 模型构造任意文件路径 | 文件句柄和授权范围;路径规范化;拒绝目录穿越 | -| 参数注入 | URL、文件名或参数包含恶意内容 | Schema 校验;不拼接 Shell;协议和域名白名单 | -| 凭据泄漏 | 日志、Renderer 或崩溃报告暴露密钥 | 安全存储;Main 注入;日志脱敏 | -| 恶意附件 | 宏、脚本、压缩炸弹 | 只解析不执行;资源限制;格式验证 | -| 更新供应链攻击 | 安装包或更新元数据被篡改 | 代码签名;元数据签名;TLS;回滚保护 | -| IPC 攻击 | 被污染 Renderer 调用特权 API | 隔离;最小桥接;来源和 Schema 校验 | -| 深度链接攻击 | 恶意协议参数打开本地资源 | 来源、协议、参数和路径校验 | -| 数据残留 | 临时截图和解析缓存未清除 | 明确生命周期;退出清理;可验证删除 | -| 自动化误操作 | 重复发送或覆盖文件 | 人工检查点;幂等键;冲突检测;审计 | - -### 9.2 安全测试范围 - -- IPC 参数伪造和越权调用。 -- 路径穿越、符号链接和文件授权绕过。 -- 深度链接及外部 URL 协议注入。 -- 恶意 PDF、图片、压缩文件和超大文件。 -- 提示注入导致工具越权。 -- 更新签名失败、降级攻击和架构混装。 -- Renderer XSS、CSP 绕过和不可信导航。 -- 日志、崩溃报告和诊断包敏感信息泄漏。 - ---- - -## 10. 非功能需求 - -### 10.1 性能 - -| 指标 | MVP 目标 | -| --- | --- | -| 快捷面板热唤起 | P95 ≤ 300ms | -| 冷启动到可交互 | P95 ≤ 3s,低配设备单独设基线 | -| 输入与滚动 | 无持续主线程阻塞,目标 50–60 FPS | -| 首段内容展示 | 服务端返回首段后 500ms 内渲染 | -| 文件解析 | 后台执行,不阻塞 UI | -| 空闲 CPU | 不持续产生明显 CPU 占用 | -| 崩溃率 | MVP 会话崩溃率 < 0.5%,正式目标 < 0.1% | - -### 10.2 可靠性 - -- 配置、会话和任务状态采用事务或原子写入。 -- 主进程、Renderer 和 Sidecar 崩溃分别记录和恢复。 -- 中断的副作用任务不得自动重放。 -- 网络错误采用有上限的指数退避,避免重试风暴。 -- 更新失败后可继续运行旧版本。 -- 磁盘空间不足时停止写入并提示清理,不损坏已有数据。 - -### 10.3 可访问性 - -- 完整键盘导航和可见焦点。 -- 基础屏幕阅读器语义。 -- 字号、缩放和高对比度支持。 -- 状态变化不只通过颜色表达。 -- 动画支持减少动态效果设置。 - -### 10.4 国际化 - -- MVP 支持简体中文。 -- 文案、日期、数字和快捷键展示使用国际化资源。 -- 预留英文支持。 -- 不在代码中拼接不可翻译文案。 - -### 10.5 可观测性 - -监控以下脱敏指标: - -- 冷启动、热唤起和窗口创建耗时。 -- 首 Token、完整响应、取消成功率。 -- 模型超时、限流和错误率。 -- 文件解析、索引和检索耗时及成功率。 -- 工具调用成功、拒绝、取消和超时率。 -- 主进程、Renderer、Sidecar 崩溃率。 -- 更新检查、下载、安装和回滚结果。 -- 按平台、架构、X11/Wayland 拆分的兼容性数据。 - -遥测默认最小化,不采集消息全文、附件正文、剪贴板内容和截图,并允许用户或企业关闭。 - ---- - -## 11. 发布、测试与验收 - -### 11.1 测试层级 - -- **单元测试**:模型网关、权限判定、参数校验、路径处理、数据转换。 -- **组件测试**:对话、附件、权限确认、工具步骤和设置界面。 -- **集成测试**:Renderer、Preload、Main IPC;数据库;安全存储;Sidecar。 -- **端到端测试**:首次启动、快捷唤起、对话、截图、文件、知识库和更新。 -- **安全测试**:恶意附件、IPC、深链、XSS、提示注入、凭据和更新链路。 -- **兼容性测试**:操作系统、架构、显示协议、DPI、输入法和权限状态。 - -### 11.2 最低兼容性矩阵 - -- Windows 10 x64、Windows 11 x64。 -- Windows 11 ARM64。 -- macOS Intel、macOS Apple Silicon。 -- Ubuntu x64 的 X11 和 Wayland。 -- 统信 UOS x64/ARM64。 -- 银河麒麟 x64/ARM64。 -- 鲲鹏与飞腾真机至少各一类。 -- 单屏、多屏和不同 DPI。 -- 常见中文输入法。 -- 直连、系统代理、自定义代理和离线状态。 -- 权限允许、拒绝和运行中撤销。 -- 全新安装、覆盖升级、更新失败恢复和卸载。 - -### 11.3 MVP 功能验收 - -- 全局快捷键、托盘和主窗口都能进入核心对话。 -- 文本、截图、剪贴板和文件都形成可见、可移除的上下文。 -- 流式回答、停止、重试、复制和历史记录可用。 -- 知识库回答带可打开的来源引用。 -- 所有工具调用经过注册、参数校验和权限层。 -- 权限拒绝后有明确降级方案,不崩溃、不循环申请。 -- 用户可以清除会话、附件、知识库和诊断数据。 -- 安装包与更新链路完成签名验证。 - -### 11.4 发布阻断项 - -- Windows、macOS 安装包或更新包未完成代码签名。 -- macOS 未完成公证或 Hardened Runtime 验证。 -- x64/ARM64 架构混装或原生依赖缺失。 -- 目标国产系统没有真机验证记录。 -- X11/Wayland 关键能力没有降级路径。 -- API Key 存在明文存储或日志泄露。 -- 工具调用可绕过参数校验、确认或审计。 -- 临时截图、附件和知识库无法完整删除。 -- 更新失败会破坏当前可运行版本。 -- Renderer 可直接访问 Node.js 或不可信页面获得特权 API。 - ---- - -## 12. 迭代路线 - -### 阶段 0:技术验证 - -- 初始化 Electron + React + TypeScript + Vite 工程。 -- 验证 x64/ARM64 多架构构建。 -- 打通 Windows、macOS、Linux 安装与签名链路。 -- 验证 X11/Wayland 快捷键、截图和窗口能力。 -- 验证 UOS、麒麟、鲲鹏、飞腾原生依赖。 -- 建立 Electron 安全基线、IPC Schema 和安全存储。 -- 接入一个模型服务并完成流式对话。 - -**退出条件**:目标平台能够安装、启动、唤起、截图并完成一次对话,所有受限能力已有降级方案。 - -### 阶段 1:MVP - -- 快捷面板、托盘、悬浮助手。 -- AI 对话和历史。 -- 截图、剪贴板、文件和显式上下文。 -- 单知识库 RAG。 -- 白名单工具调用。 -- 通知、设置、诊断、数据清理和更新。 - -**退出条件**:通过核心功能、兼容性、安全和性能验收。 - -### 阶段 2:效率与企业增强 - -- 多知识库、目录同步、OCR、混合检索和重排。 -- 企业技能目录。 -- 多步骤任务和人工检查点。 -- 私有模型、企业代理、签名策略和审计。 -- 灰度更新、回滚和企业更新源。 - -### 阶段 3:平台化 - -- 定时低风险自动化。 -- 插件 SDK 和技能市场。 -- 团队知识库、SSO、SCIM 和集中设备管理。 -- 跨设备同步。 -- 本地模型与混合推理。 - ---- - -## 13. MVP 建议开发拆分 - -| 模块 | 主要交付物 | 前置依赖 | -| --- | --- | --- | -| 工程基础 | Electron 工程、构建、日志、配置、CI | 无 | -| 桌面外壳 | 窗口、托盘、快捷键、单实例、开机启动 | 工程基础 | -| 安全与 IPC | Preload API、Schema、CSP、安全存储 | 工程基础 | -| 对话核心 | 会话、消息、流式渲染、模型网关 | 安全与 IPC | -| 上下文 | 附件、截图、剪贴板、文件解析 | 桌面外壳、安全与 IPC | -| 知识库 | 分块、嵌入、索引、检索、引用 | 文件解析、模型网关 | -| 工具系统 | 注册、风险分级、确认、执行、审计 | 安全与 IPC、对话核心 | -| 设置与权限 | 设置页、权限中心、数据清理 | 各系统模块 | -| 更新与发布 | 签名、自动更新、多平台安装包 | 工程基础 | -| 质量体系 | 单元、集成、E2E、安全和兼容测试 | 全部模块 | - -建议先完成阶段 0 的平台验证,再冻结 MVP 的详细交互稿和接口契约。国产 Linux 上的快捷键、截图、悬浮窗和原生依赖验证应前置,避免功能完成后才发现平台能力无法交付。 diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 14ed4ed3..7fdb22ab 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -5,10 +5,10 @@ | 项目 | 内容 | | --- | --- | | 状态 | 设计中 | -| 版本 | 0.4 | -| 日期 | 2026-08-22 | +| 版本 | 0.5 | +| 日期 | 2026-08-21 | | 适用产品 | GoodBuddy 桌面端 | -| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) | +| 相关设计 | [统一界面设计系统](../../../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) | ## 1. 背景 @@ -52,7 +52,7 @@ Runtime 统一抽象为“执行空间”。 执行空间描述工作区、终端、受管进程和 Agent Runtime 实际运行的位置: ```ts -type ExecutionSpace = +type ExecutionSpaceSelection = | { kind: 'local' rootPath?: string @@ -65,7 +65,9 @@ type ExecutionSpace = ``` 执行空间可以来自当前项目,也可以由用户在工作栏中临时选择。临时选择不会静默修改项目 -设置,只有用户显式保存时才成为项目默认值。 +设置,也不直接授予文件、终端或 Runtime 能力。Main 必须先把选择草稿验证为绑定窗口、 +controller、Host/Workspace identity、信任 revision、能力和 lease 的 opaque 临时 Grant; +后续 IPC 只引用 Grant ID。只有用户显式保存并重新通过 Project 候选验证时才成为项目默认值。 ## 3. 核心产品原则 @@ -132,7 +134,8 @@ Runtime - 当前 Runtime 不支持后台 Job 时,Runtime 能力仍可发现;打开后说明当前可监督的内容。 - 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,Git 区域显示不可用原因。 - 没有活动进程时,进程面板提供创建终端或启动 Runtime 的入口。 -- 没有项目时,终端和工作区允许用户选择本机目录或远程主机。 +- 没有项目时,终端和工作区允许用户选择本机目录或远程主机,但必须先取得有界临时 + Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。 - 监督未启用时,监督面板提供目标、模式和“开始监督”,而不是隐藏能力。 不得渲染成排没有解释的禁用按钮,也不得把“进程连通”描述为已经支持完整原生监督。 @@ -175,8 +178,8 @@ Runtime - 不允许 Agent 未经现有 Runtime 边界直接向用户终端注入输入。 - 不自动执行 HTML 中的脚本或访问网络。 - 不让监督器自动替用户发言、批准工具、扩大范围或修改安全策略。 -- 不在首期承诺网络断开后远程任务一定可恢复。 -- 不在首期支持任意 ProxyCommand、任意端口转发或 SSH Agent Forwarding。 +- 不提供以 SSH 连接为生命周期的临时远程 Agent;远程执行必须由用户级常驻 Daemon 承载。 +- 不支持任意 ProxyCommand、任意端口转发或 SSH Agent Forwarding。 - 不要求所有 Runtime 提供相同的 Subagent、Job、Hook 或会话能力。 ## 6. 信息架构 @@ -285,6 +288,8 @@ Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录 Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness 和后续 Runtime。 能力在目录中稳定可发现,打开的面板实例依据所选 Runtime 的真实能力显示状态。 +远程实现先只交付 OpenCode 官方 ACP 闭环;Continue 与 DeepSeek Harness 后续复用同一 +面板和通道契约。产品不为并行建设多套后端而增加用户可见模式或临时入口。 共同区域: @@ -322,7 +327,7 @@ Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness 进程面板只展示 GoodBuddy 创建、托管或明确接管的进程: - 用户终端 Shell。 -- Runtime Host、Server、Utility 和远程 Helper。 +- Runtime Host、Server、Utility 和远程 GoodBuddy Agent。 - Runtime 后台 Job。 - 用户通过工作栏显式启动的长运行命令。 - 浏览器或自动化中属于 GoodBuddy 的受管子进程摘要。 @@ -354,7 +359,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 - 显式打开、下载副本或在终端中打开。 - HTML 文件的源码与安全预览。 -本机和远程访问都必须由 Main 或远程 Helper 在对应文件系统上执行路径规范化、相对路径和 +本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和 符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。 ### 7.7 浏览器 @@ -463,6 +468,11 @@ type WorkbarPanelInstance = { ## 9. 主机管理与远程执行空间 +本节定义产品入口和用户行为。Host Key、凭据、远程 Agent 协议、模型请求代理、签名更新、 +断线和 Runtime 适配的技术边界以 +[SSH 远程主机与 GoodBuddy Agent 稳定终态设计](../../architecture/remote-host-and-goodbuddy-agent-design.md) +为准。 + ### 9.1 设置入口 设置中心增加“主机与远程执行”分类,管理: @@ -470,45 +480,97 @@ type WorkbarPanelInstance = { - 主机名称、地址、端口和用户名。 - 认证方式和凭据配置状态。 - Host Key 算法与 SHA-256 指纹。 -- 连接测试、远程系统和架构。 -- Helper、Runtime 和能力状态。 +- 引导式身份/认证验证、远程系统和架构。 +- GoodBuddy Agent、Runtime 和能力状态。 - 删除、重新验证或更新 Host Key。 主机配置是全局资源。项目或工作栏只引用主机 ID,不能复制凭据。 +当前实施状态:设置中心已经实现主机 CRUD 和模态验证向导。新增或目标/认证编辑依次完成 +连接信息、认证前 Host Key 核对、密码/系统 SSH Agent 认证和平台/架构/Shell/Home 有界 +探针,全部成功后才原子保存主机、加密凭据和完整 Host Key。认证失败可在弹窗内修改凭据 +重试;取消或失败不保留新主机。Key 变化显示旧/新指纹并要求显式高风险确认。项目创建和 +设置现可选择托管 SSH、已保存 Host、远端工作目录、OpenCode 和 Ask/Execute,不要求 +额外 trust tier 或 consent checklist。候选完成签名 Agent/Runtime、Workspace、ACP、模型桥 +和文本模型 profile 验证后原子提交。私钥导入仍未开放。旧流程已保存但 +没有成功探针记录的主机继续读取,但在完成新向导前显示为“需要重新验证”。 + +应用重启后固定进入项目列表中的第一个普通本地项目,不自动激活上次远程项目。用户主动 +打开已有托管 SSH 项目时,Main 会用数据库中的项目字段重新执行同一套 +Host/Agent/Workspace/Runtime 验证。桌面版本携带的 Agent 或 Runtime identity 已变化时, +先 side-by-side 安装新候选,全部成功后再原子刷新项目 evidence 并切换当前项目;失败保留 +此前项目和安装绑定,不由 Renderer 用旧草稿重建更新请求。同一 Renderer 对同一项目的 +重复激活共享一个操作,不会并行启动第二次保存。 +主动打开期间,项目切换器在原位置展示 Host、Agent、Workspace、Runtime、Saving +五个阶段及原生 `progressbar` 语义;用户可以取消,项目切换、创建和设置入口在操作结束前 +保持禁用。成功后才提交项目选择,失败或取消继续保留此前项目。 + +已保存 Host 的地址、用户或固定 Host Key 变化后,Main 关闭旧连接并定向退役该 Host 的 +Runtime 缓存。当前项目引用该 Host 时,设置保存成功会立即运行上述完整激活流程并刷新 +项目验证;其他项目在下次选择时刷新。用户再次选择当前 SSH 项目也会执行完整激活, +不能因为项目已经选中而跳过验证。从 Host 保存到项目成功激活之间,项目持续保持需要 +重新连接状态;失败或取消不会重新开放 Composer。已由 Main 派发的用户排队消息退回原 +持久队列,不生成失败消息、不丢失附件,并在成功激活后的 queue-ready 通知中继续派发。 + +新建托管 SSH 项目时,远端工作目录既可手工输入,也可通过文件夹按钮打开 Main 管理的 +只读、有界 SFTP 目录选择器。选择器从 SSH 账号 Home 或当前有效绝对路径开始,只返回目录, +支持上级、刷新、取消和选择当前目录;Host 变化、连接错误或取消都保留草稿,选择结果不会 +绕过正常项目验证与保存事务。 + ### 9.2 凭据和主机验证 - 优先支持系统 SSH Agent 或 OpenSSH 证书。 - 导入私钥或密码时使用 Electron `safeStorage` 加密。 - 凭据绑定主机 ID、地址、端口、用户名和认证类型。 - Renderer 只接收 `credentialConfigured`、来源和错误状态。 -- 首次连接展示 Host Key 算法和 SHA-256 指纹,必须由用户显式接受。 +- Host Key 检查使用 Main 内五分钟有效、目标绑定的临时候选,检查阶段不接收认证凭据; + 认证和系统探针成功前不持久化新增主机,取消或失败不留下主机或密码。 +- 首次连接必须在发送密码、私钥签名或 SSH Agent 签名前取得并展示 Host Key 算法和 + SHA-256 指纹,由用户显式接受;持久化完整 Host Key 或可信 Host CA,不能只保存展示指纹。 - Host Key 变化硬失败,并通过独立高风险流程替换。 - 禁止 `StrictHostKeyChecking=no` 和默认 SSH Agent Forwarding。 -- 命令参数、URL、日志、SQLite 和 IPC 中不得出现私钥或密码。 +- 命令参数、URL、日志、SQLite 明文和 IPC 响应中不得出现私钥或密码;用户在受信任表单 + 输入的密码只通过严格校验的单用途 IPC 请求交给 Main,Renderer 不得读取已保存密码。 -### 9.3 远程 Helper +### 9.3 远程 GoodBuddy Agent -远程能力通过版本化 GoodBuddy Helper 提供: +远程能力通过版本化 GoodBuddy Agent 提供: -- 使用 SSH exec 或受控通道启动,不依赖字符串拼接 Shell 命令。 -- 安装到远程用户级受管目录,不要求 root。 -- 上传内容使用固定版本、大小和 SHA-256 校验,临时写入后原子替换。 -- 握手报告协议版本、系统、架构和能力。 +- 安装到远程当前用户的 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 + 安装、自检和健康切换,失败时不改变当前项目绑定。 +- 握手报告 installation、Daemon boot identity、协议、系统、架构、用户监督器和能力。 - 在远程执行路径规范化、Git、文件、PTY、进程组和 Runtime 管理。 -- 对事件、日志、文件、帧、超时、并发和总传输量设置上限。 -- 断开或租约过期后终止孤儿进程。 +- 持久化有界 Agent 到 Main 输出;SSH 断开后 Agent 继续运行,同一 Agent 重连时只恢复 + 可确认的输出。Main 到 Runtime 的输入、工具、模型和 blob 请求不自动重放。 +- 对有副作用的请求使用稳定 operation ID 和 payload digest 去重;结果无法证明时进入 + `outcome-unknown`,禁止自动重放。 +- 对事件、日志、journal、文件、帧、超时、并发、缓存和总传输量设置硬上限。 +- Daemon、Runtime、受管进程和用户终端分别具有明确 lease、取消、drain 和回收语义。 +- Agent 不可用或身份不匹配时远程执行失败,不退化为 SSH stdio Runtime。 -首期断线后把活动运行标记为 `interrupted`,撤销短期能力并要求用户重试;在事件序列、租约、 -重放和幂等附加完成前,不宣称可以无损恢复。 +Host Key、远端用户或目标变化会关闭旧连接,并使绑定旧 Host identity 的 Workspace 和 +Runtime 会话失效。产品不维护 T1/T2/T3 等附加信任层级。 ### 9.4 远程 Runtime - Runtime 在远程执行空间内运行,不能让本机 Runtime 对远程路径进行伪本地操作。 -- Main 保持可信控制面,远程 Helper 只接受有范围、有期限的请求。 -- Ask 的只读限制在远程 Helper 和 Runtime 适配层共同强制。 -- Execute 继续经过 Runtime 工具策略、审批、取消、超时和审计。 -- 模型凭据优先留在 Main,通过仅绑定远程回环的 SSH 隧道和请求级代理提供。 +- Main 保持可信控制面,远程 GoodBuddy Agent 只接受有范围、有期限的请求。 +- GoodBuddy 控制协议负责 Agent、Workspace、Git、Process、Blob 和 Runtime supervision; + Runtime Session、stream、tool、permission、usage 和取消优先复用 ACP。 +- Ask 的只读限制在远程 GoodBuddy Agent 和 Runtime 适配层共同强制。 +- Execute 是用户对所选 SSH 账号完整权限的授权,直接以该账号可用的文件、进程、网络和 + 工具能力运行,不要求逐工具审批或第二个“受控执行”模式。取消、超时、输出上限和活动 + 记录继续保留。 +- GoodBuddy 模型凭据和 Provider URL 必须留在 Main。远程 Runtime 只获得不含凭据的模型 + policy、私有 Unix socket、隔离 loopback endpoint 和 Prompt-bound blob channel; + Main 按 durable round、profile digest、调用次数、最坏输出 token 预留和最终 delivery ACK + 代发,不得把原始 API Key 或认证头发送到远端。 - 不向远程 Runtime 暴露通用本机 MCP、浏览器、文件系统或其他未分配能力。 ## 10. Runtime 与进程统一生命周期 @@ -521,11 +583,13 @@ type ManagedLifecycleState = | 'running' | 'waiting_approval' | 'paused' + | 'reconciling' | 'stopping' | 'completed' | 'failed' | 'cancelled' | 'interrupted' + | 'outcome_unknown' ``` 每个 Runtime 会话、Task 级执行、终端或受管进程公开: @@ -586,13 +650,16 @@ terminal_sessions managed_processes runtime_sessions runtime_jobs +remote_agent_installations +remote_operations ``` 其中: - 工作栏偏好只保存能力目录顺序、面板实例、停靠布局、尺寸和目标引用。 - 主机表只保存非敏感元数据和加密凭据引用。 -- 活动终端和进程在应用重启时标记为中断,除非对应远程租约可验证恢复。 +- 应用重启后先通过远程 Daemon 的 journal、lease、boot identity 和 event cursor 核对活动 + 终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。 - 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。 - Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。 - 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。 @@ -602,7 +669,7 @@ runtime_jobs Renderer 只通过显式方法访问: - 工作栏偏好和目标绑定。 -- 主机 CRUD、测试和 Host Key 确认。 +- 主机 CRUD、临时 Host Key 检查、验证并原子保存。 - 终端创建、输入、调整大小、关闭和有界输出订阅。 - 受管进程列表、日志和允许的控制动作。 - Runtime Inspector 快照、事件和允许的控制动作。 @@ -653,7 +720,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - HTML、文件、目录、浏览器画面和远程传输沿用或收紧现有大小限制。 - Runtime Snapshot 与事件流分离,重新打开时先取权威快照,再接增量事件。 - 监督使用独立低优先级并发池和预算,不延迟前台回答。 -- 应用退出时停止新操作,取消订阅,关闭终端、隧道和 Helper,并在期限内标记未完成对象。 +- 应用退出时停止新操作并让用户选择取消、等待或 detach;关闭 SSH attach 不卸载或终止 + 用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为 + 可以无人值守继续。 ## 15. 无障碍与响应式 @@ -668,6 +737,10 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ## 16. 分阶段实施 +本节只表示内部依赖顺序,不表示可以发布临时架构。SSH 远程执行只使用用户级 Daemon, +不发布连接期 stdio Agent。OpenCode Ask、OpenCode Execute 和后续 Runtime 分别通过自身的 +安装、权限、取消和重连门槛后开放,未完成的能力继续显示不可用。 + ### 阶段 0:应用级工作栏壳层 - 将当前聊天专属右栏提升为应用级壳层。 @@ -703,15 +776,22 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 ### 阶段 4:SSH 主机与远程执行空间 -- 主机管理、加密凭据和 Host Key 固定。 -- Linux x64/arm64 Helper 安装与握手。 +- [x] 引导式主机管理、先验证后保存、密码安全存储、系统 SSH Agent、Host Key 固定和 + 有界系统探针。 +- [ ] 私钥与受限 OpenSSH config 导入。 +- 签名、side-by-side GoodBuddy Agent 安装和按需 detached Daemon。 +- SSH connection pool、固定 attach relay、Agent 控制协议、Agent 本地 ACP 日志和断线重连同步。 - 远程工作区、Git、终端和受管进程。 -- 远程 Runtime 执行、取消、超时和审计。 -- 首期断线明确标记中断,不承诺恢复。 +- 先完成签名 OpenCode bundle、Ask bubblewrap、Execute 直接进程、官方 ACP channel、 + Main 模型网关、取消、超时和活动记录。 +- OpenCode 闭环稳定后,Continue 与 DeepSeek Harness 复用相同面板和通道契约逐个接入。 +- 一次远程项目保存请求完成 Host/Agent/Workspace/Runtime 全量验证和 SQLite 原子提交。 +- 桌面更新后,用户主动首次打开远程项目时自动安装当前签名 Agent/Runtime,并在成功后原子刷新项目绑定;应用启动本身不连接远程项目。 +- SSH、Main、Daemon、Runtime 和远端重启后的核对、恢复与 `outcome_unknown`。 +- 支持平台的安装、Ask、Execute、取消和重连冒烟测试通过后开放对应 OpenCode 能力。 -### 阶段 5:恢复与扩展 +### 阶段 5:开放后的扩展 -- 远程租约、事件重放和幂等重连。 - 更多远程系统和架构。 - PDF、Office 和数据成果预览。 - Conversation、Task 和实验的完整监督。 @@ -747,11 +827,16 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 - [ ] Supervisor 不能自动发送消息、批准工具、切换工作模式或扩大范围。 - [ ] SSH 首次连接和 Host Key 变化均经过明确验证流程。 - [ ] 凭据不进入 Renderer、日志、SQLite 明文或命令参数。 +- [ ] Host 地址、用户或 Host Key 变化会关闭旧连接,并使旧 Workspace 和 Runtime 会话失效。 ### 17.4 生命周期与恢复 - [ ] Runtime、终端、Task 级执行和进程具有权威 Main 快照和有序增量事件。 - [ ] 取消、终止、失败、断线和应用退出都有确定终态。 +- [ ] 远程执行只通过按需 detached Daemon 提供,SSH attach 断开不销毁 Daemon 状态。 +- [ ] 重连先恢复 event cursor 并查询 operation;有副作用操作不会自动重放,无法确认的 + 结果显示为 `outcome_unknown`。 +- [ ] Agent side-by-side 更新支持所需签名版本的健康切换,失败时保留旧项目绑定。 - [ ] 切换跟随目标后不显示上一对象的过期状态。 - [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。 - [ ] 日志、终端、文件、成果和远程传输均有明确上限和背压。 diff --git a/docs/prd/channels/wechat-clawbot-channel-project-prd.md b/docs/prd/channels/wechat-clawbot-channel-project-prd.md index 2fed2cd2..3f19dd44 100644 --- a/docs/prd/channels/wechat-clawbot-channel-project-prd.md +++ b/docs/prd/channels/wechat-clawbot-channel-project-prd.md @@ -19,7 +19,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 3. 远程执行任务使用哪个工作目录。 4. 执行请求、处理后端、工具调用和结果归属于哪个范围。 -腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、只读对话与受控执行已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。 +腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、Ask 只读对话与 Execute 完整授权已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。 为避免把通道来源、发送者身份和执行范围混在一起,本功能使用“通道项目 + 远程会话”的两级结构: @@ -51,7 +51,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 - 在项目切换器中一眼识别微信、企业微信和钉钉来源。 - 在通道项目中区分不同发送者和群聊。 - 在设置卡片中完成连接、启停、处理后端、模式和工作目录配置。 -- 通过微信进行只读问答或发起受控执行任务。 +- 通过微信进行 Ask 只读问答或发起 Execute 任务。 - 选择可信的执行后端,并查看完整执行记录。 - 保留通道关闭前后的历史上下文和审计记录。 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index f574690a..aa6f2c82 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -23,7 +23,7 @@ GoodBuddy 应能够: 1. 持续组织项目、会话、任务、成果和记忆,而不是只保存聊天记录。 2. 在明确授权下理解文件、知识库、截图、应用窗口和浏览器上下文。 -3. 以只读问答、计划审查和受控执行三种模式完成工作。 +3. 以 Ask 只读问答和 Execute 完整授权两种模式完成工作;计划审查是 Ask 中的交互方式,不是第三种权限模式。 4. 在应用级助手工作栏中持续提供任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果。 5. 支持后台任务、定时任务、失败恢复和桌面通知。 6. 让所有记忆、权限、上下文和远程传输可见、可审查、可撤销。 diff --git a/package-lock.json b/package-lock.json index 7c44c319..ad1e08a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "i18next": "^25.10.10", "json5": "^2.2.3", "katex": "^0.16.47", + "koffi": "3.1.4", "lucide-react": "^1.27.0", "mermaid": "^11.16.1", "npm": "11.19.0", @@ -63,6 +64,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sherpa-onnx": "1.13.4", + "ssh2": "^1.17.0", "undici": "^7.29.0", "yaml": "^2.9.0", "zod": "^4.4.3" @@ -80,10 +82,12 @@ "@types/qrcode": "^1.5.5", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", "@vitejs/plugin-react": "^5.2.0", "electron": "^43.2.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "esbuild": "0.25.12", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.8.0", @@ -3419,6 +3423,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -5411,6 +5416,33 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5842,6 +5874,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -6180,6 +6213,15 @@ "dequal": "^2.0.3" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -6296,6 +6338,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -6343,6 +6386,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6425,6 +6477,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6486,6 +6539,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", @@ -6667,6 +6729,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -6723,6 +6786,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -6739,6 +6803,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, "funding": [ { "type": "github", @@ -6858,6 +6923,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -6940,6 +7006,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -8741,6 +8821,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/express": { @@ -8882,6 +8963,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9133,6 +9215,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -9277,6 +9360,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -9310,12 +9394,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -9326,6 +9412,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9466,6 +9553,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/graphlib": { @@ -9493,6 +9581,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9773,6 +9862,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" @@ -9785,6 +9875,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -9797,6 +9888,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC" }, "node_modules/html-encoding-sniffer": { @@ -10053,6 +10145,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -11737,6 +11830,7 @@ "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.8" @@ -11762,6 +11856,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -11771,6 +11866,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -11838,6 +11934,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -11970,6 +12073,7 @@ "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", @@ -11994,6 +12098,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12003,6 +12108,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=20" @@ -12012,6 +12118,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12024,6 +12131,7 @@ "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.17" @@ -12033,6 +12141,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -12075,6 +12184,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, "license": "ISC", "dependencies": { "abbrev": "^4.0.0" @@ -14357,6 +14467,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14435,6 +14546,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -14718,6 +14830,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15587,6 +15700,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15896,6 +16010,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -16033,6 +16164,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -16058,6 +16190,7 @@ "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -16074,6 +16207,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -16154,6 +16288,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -16315,6 +16450,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index a3953259..9310e9da 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,12 @@ "build:bundle": "electron-vite build && 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", + "agent:verify": "node build/agent-bundle.cjs verify", + "agent:import": "node build/agent-bundle.cjs import", + "remote-runtime:build": "node build/remote-runtime-bundle.cjs build", + "remote-runtime:verify": "node build/remote-runtime-bundle.cjs verify", + "remote-runtime:import": "node build/remote-runtime-bundle.cjs import", "release:notes:verify": "node build/release-notes.cjs", "dist": "npm run build && electron-builder", "dist:win": "npm run build && electron-builder --win nsis --x64 --arm64", @@ -85,6 +91,18 @@ "from": "resources/release-notes.json", "to": "release-notes.json" }, + { + "from": "agent-runtime-lock.json", + "to": "agent-runtime-lock.json" + }, + { + "from": "remote-runtime-lock.json", + "to": "remote-runtime-lock.json" + }, + { + "from": "resources/agent-release-keys.json", + "to": "agent-release-keys.json" + }, { "from": "build/icon-taskbar.ico", "to": "icon.ico" @@ -132,6 +150,10 @@ "from": "node_modules/node-pty/LICENSE", "to": "licenses/node-pty-MIT.txt" }, + { + "from": "node_modules/ssh2/LICENSE", + "to": "licenses/ssh2-MIT.txt" + }, { "from": "node_modules/koffi/LICENSE.txt", "to": "licenses/koffi-MIT.txt" @@ -273,6 +295,7 @@ "i18next": "^25.10.10", "json5": "^2.2.3", "katex": "^0.16.47", + "koffi": "3.1.4", "lucide-react": "^1.27.0", "mermaid": "^11.16.1", "npm": "11.19.0", @@ -289,6 +312,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sherpa-onnx": "1.13.4", + "ssh2": "^1.17.0", "undici": "^7.29.0", "yaml": "^2.9.0", "zod": "^4.4.3" @@ -306,10 +330,12 @@ "@types/qrcode": "^1.5.5", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/ssh2": "^1.15.5", "@vitejs/plugin-react": "^5.2.0", "electron": "^43.2.0", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", + "esbuild": "0.25.12", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.8.0", diff --git a/remote-runtime-lock.json b/remote-runtime-lock.json new file mode 100644 index 00000000..6e6dcce3 --- /dev/null +++ b/remote-runtime-lock.json @@ -0,0 +1,39 @@ +{ + "formatVersion": 1, + "runtimes": { + "opencode": { + "version": "1.18.9", + "provider": "opencode", + "entrypoint": "bin/opencode", + "entrypointIdentity": "opencode-acp", + "argvPrefix": [ + "acp" + ], + "allowedEnvironmentNames": [ + "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==" + }, + "arm64": { + "package": "opencode-linux-arm64", + "integrity": "sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A==" + } + } + } + } +} diff --git a/resources/agent-release-keys.json b/resources/agent-release-keys.json new file mode 100644 index 00000000..95c83d46 --- /dev/null +++ b/resources/agent-release-keys.json @@ -0,0 +1,21 @@ +{ + "formatVersion": 1, + "keys": [ + { + "keyId": "e2e-20260823-020914-ce95d6f6", + "publicKeySpkiBase64": "MCowBQYDK2VwAyEA8w12MNcxOrU18MArvcuJmVkGKzJsa/r93q14HGX7DUU=", + "environment": "production" + }, + { + "keyId": "goodbuddy-e2e-agent-8b0b39d2e63d7cf163c23bb7", + "publicKeySpkiBase64": "MCowBQYDK2VwAyEAx5lS8/stctKe/ri3kSNLR3nmkTV1eTpS0c1TYtJuHoM=", + "environment": "production" + }, + { + "keyId": "goodbuddy-e2e-v8-de8355f998071143411bc075", + "publicKeySpkiBase64": "MCowBQYDK2VwAyEAY7rOjqPT3FAw2+F5roTqyssz4Zzzx0iJhk6tGhCVivc=", + "environment": "production" + } + ], + "revocations": [] +} diff --git a/src/agent-daemon/async-utils.ts b/src/agent-daemon/async-utils.ts new file mode 100644 index 00000000..d521376c --- /dev/null +++ b/src/agent-daemon/async-utils.ts @@ -0,0 +1,93 @@ +export async function raceWithAbort( + pending: Promise, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + return await new Promise((resolve, reject) => { + const onAbort = (): void => { + cleanup() + reject(signal.reason) + } + const cleanup = (): void => { + signal.removeEventListener('abort', onAbort) + } + signal.addEventListener('abort', onAbort, { once: true }) + void pending.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +export function closeServer(server: { + close(callback: (error?: Error) => void): unknown +}): Promise { + return new Promise((resolve, reject) => { + try { + server.close((error) => { + if (error === undefined) { + resolve() + } else { + reject(error) + } + }) + } catch (error) { + if ( + isNodeError(error) && + error.code === 'ERR_SERVER_NOT_RUNNING' + ) { + resolve() + } else { + reject(error) + } + } + }) +} + +export async function settleWithin( + pending: Promise, + timeoutMs: number +): Promise { + let timeout: ReturnType | undefined + try { + await Promise.race([ + pending, + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + timeout.unref?.() + }) + ]) + } finally { + if (timeout !== undefined) { + clearTimeout(timeout) + } + } +} + +export function boundedInteger( + value: number, + minimum: number, + maximum: number, + label: string +): number { + if ( + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + throw new RangeError(`${label} is invalid`) + } + return value +} + +export function isNodeError( + error: unknown +): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/bundle-file-verification.ts b/src/agent-daemon/bundle-file-verification.ts new file mode 100644 index 00000000..5c71dc14 --- /dev/null +++ b/src/agent-daemon/bundle-file-verification.ts @@ -0,0 +1,63 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { open } from 'node:fs/promises' +import type { + AgentArchitecture +} from '../shared/agent-installation-contracts' + +export async function sha256File(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer) + } + return hash.digest('hex') +} + +export async function assertElfArchitecture( + filePath: string, + expected: AgentArchitecture, + label: string +): Promise { + const handle = await open(filePath, 'r') + try { + const header = Buffer.alloc(64) + const { bytesRead } = await handle.read( + header, + 0, + header.length, + 0 + ) + const actual = detectElfArchitecture( + header.subarray(0, bytesRead) + ) + if (actual !== expected) { + throw new Error( + `${label} architecture mismatch: expected ${expected}, received ${actual ?? 'unknown'}` + ) + } + } finally { + await handle.close() + } +} + +function detectElfArchitecture( + header: Buffer +): AgentArchitecture | undefined { + if ( + header.length < 20 || + header[0] !== 0x7f || + header.toString('ascii', 1, 4) !== 'ELF' || + (header[5] !== 1 && header[5] !== 2) + ) { + return undefined + } + const machine = + header[5] === 2 + ? header.readUInt16BE(18) + : header.readUInt16LE(18) + return machine === 62 + ? 'x64' + : machine === 183 + ? 'arm64' + : undefined +} diff --git a/src/agent-daemon/channel-state.ts b/src/agent-daemon/channel-state.ts new file mode 100644 index 00000000..c92ed438 --- /dev/null +++ b/src/agent-daemon/channel-state.ts @@ -0,0 +1 @@ +export * from '../shared/agent-protocol/channel-state' diff --git a/src/agent-daemon/cli.test.ts b/src/agent-daemon/cli.test.ts new file mode 100644 index 00000000..dbbacc14 --- /dev/null +++ b/src/agent-daemon/cli.test.ts @@ -0,0 +1,566 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_FAILURE_RECORD_NAME, + AGENT_PROTOCOL_FAILURE_STDERR_PREFIX, + AGENT_PROTOCOL_VERSION +} from '../shared/agent-protocol' +import { + buildFixedAgentCliArgv, + verifyAgentInstallationId +} from '../main/ssh/ssh-agent-command' +import { + deriveManagedInstallationPaths, + deriveManagedRuntimePaths, + runAgentCli, + type AgentCliDependencies, + type ManagedInstallationPaths +} from './cli' +import { InstallationRegistry } from './installation-registry' +import 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' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { force: true, recursive: true }) + } +}) + +describe('Agent CLI fixed command contract', () => { + it('accepts only the fixed model bridge helper argv', async () => { + const runModelBridgeHelper = vi.fn(async () => 7) + await expect( + runAgentCli( + [ + 'model-bridge-helper', + '--socket-path', + '/private/bridge/model-bridge.sock', + '--protocol', + 'openai-responses', + '--model', + 'private-model', + '--supports-image-input', + 'false', + '--opencode-entrypoint', + '/runtime/bin/opencode' + ], + { runModelBridgeHelper, io: cliIo() } + ) + ).resolves.toBe(7) + expect(runModelBridgeHelper).toHaveBeenCalledWith({ + socketPath: '/private/bridge/model-bridge.sock', + protocol: 'openai-responses', + model: 'private-model', + supportsImageInput: false, + opencodeEntrypoint: '/runtime/bin/opencode' + }) + await expect( + runAgentCli( + [ + 'model-bridge-helper', + '--socket-path', + '/private/bridge/model-bridge.sock', + '--protocol', + 'openai-responses', + '--model', + 'private-model', + '--supports-image-input', + 'false', + '--opencode-entrypoint', + '/runtime/bin/opencode', + '--api-key', + 'secret' + ], + { runModelBridgeHelper, io: cliIo() } + ) + ).resolves.toBe(2) + expect(runModelBridgeHelper).toHaveBeenCalledOnce() + }) + + it('passes verified manifest version and protocol into the daemon', async () => { + const root = privateTemporaryDirectory() + const paths: ManagedInstallationPaths = { + executablePath: resolve(root, 'install-1', 'goodbuddy-agent'), + stateDirectory: resolve(root, 'state'), + socketPath: resolve(root, 'run', 'agent.sock') + } + const releaseKeyRegistry = { + formatVersion: 1 as const, + keys: [], + revocations: [] + } + const verified = verifiedInstallation(paths, 'install-1') + const registry = new InstallationRegistry({ + storagePath: resolve(root, 'registry.json') + }) + registry.stageCandidate(verified) + const start = vi.fn(async () => undefined) + const stop = vi.fn(async () => undefined) + const createDaemon = vi.fn(() => ({ + start, + stop, + status: () => ({ daemonBootId: 'daemon-boot-1' }) + })) + const recordCurrentDaemonReady = vi.fn(async () => undefined) + const io = cliIo() + + const result = await runAgentCli( + ['daemon', '--installation-id', 'install-1'], + { + installationPaths: () => paths, + releaseKeyRegistry, + installationRegistry: registry, + verifyInstallation: async () => verified, + peerIdentityProvider: { + async getPeerIdentity() { + return { uid: 1 } + } + }, + createDaemon: + createDaemon as unknown as NonNullable< + AgentCliDependencies['createDaemon'] + >, + createLifecycle: (() => ({ + recordCurrentDaemonReady + })) as unknown as NonNullable< + AgentCliDependencies['createLifecycle'] + >, + waitForShutdown: async () => undefined, + io + } + ) + expect(result, io.error.read()?.toString()).toBe(0) + expect(createDaemon).toHaveBeenCalledWith( + expect.objectContaining({ + installationId: 'install-1', + binaryDigest: verified.binaryDigest, + agentVersion: verified.manifest.agentVersion, + protocol: verified.manifest.protocol + }) + ) + expect(start).toHaveBeenCalledOnce() + expect(stop).toHaveBeenCalledOnce() + expect(recordCurrentDaemonReady).toHaveBeenCalledWith( + 'daemon-boot-1' + ) + }) + + it('dispatches generated doctor and lifecycle argv through the real parser', async () => { + const installationId = verifyAgentInstallationId('install-1') + const registryRoot = privateTemporaryDirectory() + const installationPaths = { + executablePath: resolve(registryRoot, 'agent', 'goodbuddy-agent'), + stateDirectory: resolve(registryRoot, 'state', 'install-1'), + socketPath: resolve(registryRoot, 'run', 'install-1.sock') + } + const installationRegistry = new InstallationRegistry({ + storagePath: resolve(registryRoot, 'registry.json') + }) + const releaseKeyRegistry = { + formatVersion: 1 as const, + keys: [], + revocations: [] + } + const verified = verifiedInstallation( + installationPaths, + installationId + ) + installationRegistry.stageCandidate(verified) + const status = vi.fn(async () => ({ state: 'absent' as const })) + const bootstrap = vi.fn(async () => ({ + state: 'ready' as const, + installationId + })) + const dependencies: AgentCliDependencies = { + installationPaths: () => installationPaths, + releaseKeyRegistry, + installationRegistry, + verifyInstallation: async () => verified, + createLifecycle: (() => ({ + status, + bootstrap + })) as unknown as NonNullable< + AgentCliDependencies['createLifecycle'] + > + } + const lifecycleIo = cliIo() + expect( + await runAgentCli( + buildFixedAgentCliArgv(installationId, { kind: 'doctor' }), + { ...dependencies, io: cliIo() } + ) + ).toBe(0) + const lifecycleResult = await runAgentCli( + buildFixedAgentCliArgv(installationId, { + kind: 'lifecycle', + action: 'bootstrap' + }), + { + ...dependencies, + io: lifecycleIo + } + ) + expect( + lifecycleResult, + lifecycleIo.error.read()?.toString() + ).toBe(0) + expect(status).toHaveBeenCalledOnce() + expect(bootstrap).toHaveBeenCalledOnce() + }) + + it('dispatches generated attach argv using only derived managed paths', async () => { + const root = privateTemporaryDirectory() + const paths: ManagedInstallationPaths = { + executablePath: resolve(root, 'goodbuddy-agent'), + stateDirectory: resolve(root, 'state'), + socketPath: resolve(root, 'run', 'agent.sock') + } + mkdirSync(paths.stateDirectory, { mode: 0o700 }) + if (process.platform !== 'win32') { + chmodSync(paths.stateDirectory, 0o700) + } + writeFileSync( + resolve(paths.stateDirectory, 'installation-identity'), + Buffer.alloc(32, 7), + { mode: 0o600 } + ) + const attach = vi.fn(async (options) => { + const connectionId = 'connection-diagnostic' + writeFileSync( + resolve( + paths.stateDirectory, + AGENT_PROTOCOL_FAILURE_RECORD_NAME + ), + `${JSON.stringify({ + formatVersion: 1, + connectionId, + category: 'dispatch/process', + createdAt: 1 + })}\n`, + { mode: 0o600 } + ) + options.onWelcome?.({ + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId, + generation: 1, + installationId: 'install-1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + serverNonce: 'nonce-1' + }) + }) + const installationId = verifyAgentInstallationId('install-1') + const releaseKeyRegistry = { + formatVersion: 1 as const, + keys: [], + revocations: [] + } + const verified = verifiedInstallation(paths, installationId) + const installationRegistry = new InstallationRegistry({ + storagePath: resolve(root, 'registry.json') + }) + installationRegistry.stageCandidate(verified) + const io = cliIo() + + const attachResult = await runAgentCli( + buildFixedAgentCliArgv(installationId, { kind: 'attach' }), + { + attach, + installationPaths: () => paths, + releaseKeyRegistry, + installationRegistry, + verifyInstallation: async () => verified, + io + } + ) + expect(attachResult).toBe(0) + expect(io.error.read()?.toString()).toBe( + `${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n` + ) + expect(attach).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: paths.socketPath, + secret: Buffer.alloc(32, 7) + }) + ) + }) + + it('derives fixed Linux paths and rejects untrusted installation IDs', () => { + expect( + deriveManagedInstallationPaths('install-1', { + platform: 'linux', + homeDirectory: '/home/tester', + uid: 1234 + }) + ).toEqual({ + executablePath: + '/home/tester/.goodbuddy/agent/installations/install-1/goodbuddy-agent', + stateDirectory: '/home/tester/.goodbuddy/state/install-1', + socketPath: + '/tmp/goodbuddy-1234-33847abc68e72629c30d/caab28ec6ec9597dfdd5bc3c2398a893.sock' + }) + expect(() => + deriveManagedInstallationPaths('../escape', { + platform: 'linux', + homeDirectory: '/home/tester', + uid: 1234 + }) + ).toThrow('installation ID') + expect(() => + deriveManagedInstallationPaths('install-1', { + platform: 'win32', + homeDirectory: 'C:\\Users\\tester', + uid: 1234 + }) + ).toThrow('current Linux UID') + }) + + it('rejects injected installation IDs before lifecycle dispatch', async () => { + const createLifecycle = vi.fn() + expect( + await runAgentCli( + [ + 'stop', + '--installation-id', + 'install; shutdown' + ], + { createLifecycle, io: cliIo() } + ) + ).toBe(2) + expect(createLifecycle).not.toHaveBeenCalled() + }) + + it('verifies and atomically registers the fixed Runtime activation', async () => { + const root = privateTemporaryDirectory() + const runtimeRoot = resolve(root, 'runtimes') + const bundleDigest = `sha256:${'f'.repeat(64)}` + const paths = { + runtimeRoot, + bundleDirectory: resolve( + runtimeRoot, + 'opencode', + bundleDigest.slice('sha256:'.length) + ), + releaseKeyRegistryPath: resolve( + runtimeRoot, + 'release-keys.json' + ), + runtimeLockPath: resolve( + runtimeRoot, + 'remote-runtime-lock.json' + ), + registryPath: resolve(runtimeRoot, 'registry.json') + } + const releaseKeyRegistry = { + formatVersion: 1 as const, + keys: [], + revocations: [] + } + const registry = new RuntimeBundleRegistry({ + runtimeRoot + }) + const verifyRuntime = vi.fn(async () => + verifiedRuntime(paths.bundleDirectory, bundleDigest) + ) + const io = cliIo() + + expect( + await runAgentCli( + buildFixedAgentCliArgv( + verifyAgentInstallationId('install-1'), + { + kind: 'runtime-activate', + runtimeId: 'opencode', + bundleDigest, + architecture: 'x64' + } + ), + { + runtimePaths: () => paths, + runtimeReleaseKeyRegistry: releaseKeyRegistry, + runtimeLock: {} as RemoteRuntimeLock, + runtimeRegistry: registry, + verifyRuntime, + currentArchitecture: () => 'x64', + io + } + ) + ).toBe(0) + expect(verifyRuntime).toHaveBeenCalledWith( + paths.bundleDirectory, + expect.objectContaining({ + architecture: 'x64', + verificationEnvironment: 'production' + }) + ) + expect(registry.current('x64')).toEqual([ + expect.objectContaining({ + runtimeId: 'opencode', + bundleDigest, + architecture: 'x64' + }) + ]) + expect(JSON.parse(io.output.read()!.toString())).toEqual({ + activated: true, + runtimeId: 'opencode', + bundleDigest, + architecture: 'x64', + runtimeVersion: '1.18.9' + }) + }) + + it('derives Runtime paths and rejects activation argv injection', async () => { + const digest = `sha256:${'a'.repeat(64)}` + expect( + deriveManagedRuntimePaths('install-1', 'opencode', digest, { + homeDirectory: '/home/tester' + }) + ).toEqual({ + runtimeRoot: '/home/tester/.goodbuddy/runtimes', + bundleDirectory: + `/home/tester/.goodbuddy/runtimes/opencode/${'a'.repeat(64)}`, + releaseKeyRegistryPath: + '/home/tester/.goodbuddy/runtimes/release-keys.json', + runtimeLockPath: + '/home/tester/.goodbuddy/runtimes/remote-runtime-lock.json', + registryPath: + '/home/tester/.goodbuddy/runtimes/registry.json' + }) + const verifyRuntime = vi.fn() + expect( + await runAgentCli( + [ + 'runtime', + 'activate', + '--installation-id', + 'install-1', + '--runtime-id', + 'opencode;id', + '--bundle-digest', + digest, + '--architecture', + 'x64' + ], + { verifyRuntime, io: cliIo() } + ) + ).toBe(2) + expect(verifyRuntime).not.toHaveBeenCalled() + }) +}) + +function verifiedInstallation( + paths: ManagedInstallationPaths, + installationId: string +): VerifiedInstalledAgentBundle { + const manifestSha256 = 'a'.repeat(64) + return { + installationId, + installationDirectory: resolve(paths.executablePath, '..'), + executablePath: paths.executablePath, + manifestSha256, + binaryDigest: `sha256:${manifestSha256}`, + manifest: { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion: '0.11.0', + platform: 'linux', + arch: process.arch === 'arm64' ? 'arm64' : 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files: [ + { + path: 'goodbuddy-agent', + size: 1, + sha256: 'b'.repeat(64), + mode: '0755' + }, + { + path: 'node', + size: 1, + sha256: 'c'.repeat(64), + mode: '0755' + }, + { + path: 'lib/agent.cjs', + size: 1, + sha256: 'd'.repeat(64), + mode: '0644' + }, + { + path: 'licenses/GoodBuddy-0BSD.txt', + size: 1, + sha256: 'e'.repeat(64), + mode: '0644' + } + ], + licenses: [ + { + package: 'GoodBuddy', + version: '0.11.0', + spdx: '0BSD', + path: 'licenses/GoodBuddy-0BSD.txt' + } + ] + } + } +} + +function cliIo(): { + input: PassThrough + output: PassThrough + error: PassThrough +} { + return { + input: new PassThrough(), + output: new PassThrough(), + error: new PassThrough() + } +} + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-cli-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return resolve(path) +} + +function verifiedRuntime( + bundleDirectory: string, + bundleDigest: string +): VerifiedRuntimeBundle { + return { + bundleDirectory, + executablePath: resolve(bundleDirectory, 'bin', 'opencode'), + manifestDigest: `sha256:${'e'.repeat(64)}`, + manifest: { + runtimeId: 'opencode', + provider: 'opencode', + runtimeVersion: '1.18.9', + architecture: 'x64', + signingKeyId: 'test-key', + bundleDigest, + acpCapabilitiesDigest: `sha256:${'d'.repeat(64)}` + } + } as VerifiedRuntimeBundle +} diff --git a/src/agent-daemon/cli.ts b/src/agent-daemon/cli.ts new file mode 100644 index 00000000..030bc777 --- /dev/null +++ b/src/agent-daemon/cli.ts @@ -0,0 +1,861 @@ +import { createHash } from 'node:crypto' +import { homedir } from 'node:os' +import { dirname, posix, resolve } from 'node:path' +import type { Readable, Writable } from 'node:stream' +import { + AGENT_PROTOCOL_FAILURE_RECORD_NAME, + AGENT_PROTOCOL_FAILURE_STDERR_PREFIX, + agentProtocolFailureRecordSchema +} from '../shared/agent-protocol' +import type { + AgentArchitecture, + AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts' +import { AgentDaemon } from './daemon' +import { AgentUnsupportedError } from './errors' +import { + derivePrivateTemporaryRoot, + ensurePrivateTemporaryRoot, + readPrivateFile, + readPrivateSecret, + ensurePrivateDirectory +} from './managed-paths' +import { + attachRelay, + probeAuthenticatedEndpoint, + type UnixPeerIdentityProvider +} from './private-endpoint' +import { createLinuxPeerIdentityProvider } from './peer-identity-provider' +import { + readManagedAgentReleaseKeyRegistry, + verifyInstalledAgentBundle, + type VerifiedInstalledAgentBundle +} from './installed-bundle-verifier' +import { InstallationRegistry } from './installation-registry' +import { + RuntimeBundleRegistry +} from './runtime-bundle-registry' +import { + readRemoteRuntimeLock, + verifyRuntimeBundle, + type VerifiedRuntimeBundle +} from './runtime-bundle-verifier' +import { createProductionRuntimeProtocol } from './runtime-composition' +import { + runOpenCodeModelBridgeHelper, + type ModelBridgeProtocol +} from './model-bridge-helper' +import { + DetachedAgentLifecycle, + type DetachedAgentLifecycleOptions +} from './detached-agent-lifecycle' + +type CliIo = { + input: Readable + output: Writable + error: Writable +} + +export type AgentCliDependencies = { + io?: CliIo + createLifecycle?: ( + options: DetachedAgentLifecycleOptions + ) => DetachedAgentLifecycle + peerIdentityProvider?: UnixPeerIdentityProvider + createDaemon?: (options: ConstructorParameters[0]) => AgentDaemon + waitForShutdown?: () => Promise + installationPaths?: ( + installationId: string + ) => ManagedInstallationPaths + attach?: typeof attachRelay + releaseKeyRegistry?: AgentReleaseKeyRegistry + installationRegistry?: InstallationRegistry + verifyInstallation?: ( + installationDirectory: string, + options: { + installationId: string + architecture: AgentArchitecture + releaseKeyRegistry: AgentReleaseKeyRegistry + } + ) => Promise + runtimePaths?: ( + installationId: string, + runtimeId: 'opencode', + bundleDigest: string + ) => ManagedRuntimePaths + runtimeReleaseKeyRegistry?: AgentReleaseKeyRegistry + runtimeLock?: RemoteRuntimeLock + runtimeRegistry?: RuntimeBundleRegistry + verifyRuntime?: typeof verifyRuntimeBundle + runtimeVerificationEnvironment?: 'production' | 'test' + currentArchitecture?: () => AgentArchitecture + runModelBridgeHelper?: typeof runOpenCodeModelBridgeHelper +} + +export type ManagedInstallationPaths = { + executablePath: string + stateDirectory: string + socketPath: string +} + +export type ManagedRuntimePaths = { + runtimeRoot: string + bundleDirectory: string + releaseKeyRegistryPath: string + runtimeLockPath: string + registryPath: string +} + +const LIFECYCLE_ACTIONS = new Set([ + 'bootstrap', + 'status', + 'health', + 'stop', + 'retire' +]) + +export async function runAgentCli( + argv: readonly string[], + dependencies: AgentCliDependencies = {} +): Promise { + const io = dependencies.io ?? { + input: process.stdin, + output: process.stdout, + error: process.stderr + } + try { + const [command, ...rest] = argv + switch (command) { + case 'daemon': + await runDaemon(rest, dependencies) + return 0 + case 'attach-or-bootstrap': + await runAttach(rest, dependencies, io) + return 0 + case 'doctor': + await runDoctor(rest, dependencies, io) + return 0 + case 'runtime': + await runRuntime(rest, dependencies, io) + return 0 + case 'model-bridge-helper': + return await runModelBridgeHelper(rest, dependencies) + default: + if (command !== undefined && LIFECYCLE_ACTIONS.has(command)) { + await runLifecycle([command, ...rest], dependencies, io) + return 0 + } + throw new Error( + 'Expected daemon, attach-or-bootstrap, doctor, a lifecycle action, runtime, or model-bridge-helper' + ) + } + } catch (error) { + io.error.write( + `${error instanceof Error ? error.message : String(error)}\n` + ) + return error instanceof AgentUnsupportedError ? 3 : 2 + } +} + +async function runModelBridgeHelper( + argv: readonly string[], + dependencies: AgentCliDependencies +): Promise { + const options = parseOptions(argv, [ + 'socket-path', + 'protocol', + 'model', + 'supports-image-input', + 'opencode-entrypoint' + ]) + requireOptions(options, [ + 'socket-path', + 'protocol', + 'model', + 'supports-image-input', + 'opencode-entrypoint' + ]) + const protocol = options.protocol + if ( + protocol !== 'anthropic-messages' && + protocol !== 'openai-chat-completions' && + protocol !== 'openai-responses' + ) { + throw new Error('Invalid model bridge protocol') + } + const imageInput = options['supports-image-input'] + if (imageInput !== 'true' && imageInput !== 'false') { + throw new Error('Invalid model bridge image-input option') + } + return await ( + dependencies.runModelBridgeHelper ?? + runOpenCodeModelBridgeHelper + )({ + socketPath: options['socket-path']!, + protocol: protocol as ModelBridgeProtocol, + model: options.model!, + supportsImageInput: imageInput === 'true', + opencodeEntrypoint: options['opencode-entrypoint']! + }) +} + +async function runRuntime( + argv: readonly string[], + dependencies: AgentCliDependencies, + io: CliIo +): Promise { + const [action, ...optionArguments] = argv + if (action !== 'activate') { + throw new Error('Invalid fixed Runtime action') + } + const options = parseOptions(optionArguments, [ + 'installation-id', + 'runtime-id', + 'bundle-digest', + 'architecture' + ]) + requireOptions(options, [ + 'installation-id', + 'runtime-id', + 'bundle-digest', + 'architecture' + ]) + const installationId = validateInstallationId( + options['installation-id']! + ) + const runtimeId = validateRuntimeId(options['runtime-id']!) + const bundleDigest = validateBundleDigest( + options['bundle-digest']! + ) + const architecture = validateRuntimeArchitecture( + options.architecture! + ) + const hostArchitecture = + dependencies.currentArchitecture?.() ?? + currentAgentArchitecture() + if (architecture !== hostArchitecture) { + throw new Error( + 'Runtime activation architecture does not match the current Agent' + ) + } + const paths = resolveRuntimePaths( + installationId, + runtimeId, + bundleDigest, + dependencies + ) + const releaseKeyRegistry = + dependencies.runtimeReleaseKeyRegistry ?? + await readManagedAgentReleaseKeyRegistry( + paths.releaseKeyRegistryPath + ) + const runtimeLock = + dependencies.runtimeLock ?? + await readRemoteRuntimeLock(paths.runtimeLockPath) + const registry = + dependencies.runtimeRegistry ?? + new RuntimeBundleRegistry({ + runtimeRoot: paths.runtimeRoot, + storagePath: paths.registryPath + }) + const verified = await ( + dependencies.verifyRuntime ?? verifyRuntimeBundle + )(paths.bundleDirectory, { + architecture, + releaseKeyRegistry, + runtimeLock, + verificationEnvironment: + dependencies.runtimeVerificationEnvironment ?? 'production' + }) + assertActivatedRuntimeIdentity( + verified, + runtimeId, + bundleDigest, + architecture + ) + const entry = registry.register(verified) + io.output.write(`${JSON.stringify({ + activated: true, + runtimeId: entry.runtimeId, + bundleDigest: entry.bundleDigest, + architecture: entry.architecture, + runtimeVersion: entry.runtimeVersion + })}\n`) +} + +async function runDaemon( + argv: readonly string[], + dependencies: AgentCliDependencies +): Promise { + const options = parseOptions(argv, ['installation-id']) + requireOptions(options, ['installation-id']) + const installationId = validateInstallationId( + options['installation-id']! + ) + const paths = resolveInstallationPaths(installationId, dependencies) + const { verified, registry } = await verifyManagedInstallation( + installationId, + paths, + dependencies + ) + registry.assertVerifiedRole(verified, ['current', 'candidate']) + prepareManagedSocketDirectory(paths) + const daemonOptions: ConstructorParameters[0] = { + installationId, + binaryDigest: verified.binaryDigest, + agentVersion: verified.manifest.agentVersion, + protocol: verified.manifest.protocol, + stateDirectory: paths.stateDirectory, + socketPath: paths.socketPath, + peerIdentityProvider: + dependencies.peerIdentityProvider ?? + await createLinuxPeerIdentityProvider(), + runtimeFactory: async ({ + events, + workspaces, + outputSink, + blobSink + }) => + await createProductionRuntimeProtocol({ + runtimeRoot: resolve( + dirname(dirname(dirname(dirname(paths.executablePath)))), + 'runtimes' + ), + stateDirectory: paths.stateDirectory, + architecture: currentAgentArchitecture(), + events, + workspaces, + outputSink, + blobSink, + agentExecutablePath: paths.executablePath, + installationId + }) + } + const daemon = + dependencies.createDaemon?.(daemonOptions) ?? + new AgentDaemon(daemonOptions) + await daemon.start() + try { + await createDetachedLifecycle( + installationId, + paths, + dependencies + ).recordCurrentDaemonReady(daemon.status().daemonBootId) + await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal()) + } finally { + await daemon.stop() + } +} + +async function runAttach( + argv: readonly string[], + dependencies: AgentCliDependencies, + io: CliIo +): Promise { + const options = parseOptions(argv, ['installation-id']) + requireOptions(options, ['installation-id']) + const installationId = validateInstallationId( + options['installation-id']! + ) + const paths = resolveInstallationPaths( + installationId, + dependencies + ) + const { verified, registry } = await verifyManagedInstallation( + installationId, + paths, + dependencies + ) + registry.assertVerifiedRole(verified, ['current', 'candidate']) + prepareManagedSocketDirectory(paths) + const stateDirectory = paths.stateDirectory + ensurePrivateDirectory(stateDirectory, { create: false }) + const secret = readPrivateSecret( + resolve(stateDirectory, 'installation-identity') + ) + let connectionId: string | undefined + await (dependencies.attach ?? attachRelay)({ + socketPath: paths.socketPath, + secret, + input: io.input, + output: io.output, + onWelcome: (welcome) => { + connectionId = welcome.connectionId + }, + ensureEndpoint: async () => { + await createDetachedLifecycle( + installationId, + paths, + dependencies + ).bootstrap() + } + }) + if (connectionId !== undefined) { + const category = readProtocolFailureCategory( + stateDirectory, + connectionId + ) + if (category !== undefined) { + io.error.write( + `${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}${category}\n` + ) + } + } +} + +function readProtocolFailureCategory( + stateDirectory: string, + connectionId: string +): string | undefined { + try { + const record = agentProtocolFailureRecordSchema.parse( + JSON.parse( + readPrivateFile( + resolve( + stateDirectory, + AGENT_PROTOCOL_FAILURE_RECORD_NAME + ), + 4096 + ).toString('utf8') + ) + ) + return record.connectionId === connectionId + ? record.category + : undefined + } catch { + return undefined + } +} + +async function runDoctor( + argv: readonly string[], + dependencies: AgentCliDependencies, + io: CliIo +): Promise { + const options = parseOptions(argv, ['installation-id']) + requireOptions(options, ['installation-id']) + const installationId = validateInstallationId( + options['installation-id']! + ) + const paths = resolveInstallationPaths(installationId, dependencies) + const status = await createDetachedLifecycle( + installationId, + paths, + dependencies + ).status() + io.output.write(`${JSON.stringify({ + platform: process.platform, + architecture: process.arch, + supervisor: 'detached-on-demand', + lifecycle: status + })}\n`) +} + +async function runLifecycle( + argv: readonly string[], + dependencies: AgentCliDependencies, + io: CliIo +): Promise { + const [action, ...optionArguments] = argv + if (action === undefined || !LIFECYCLE_ACTIONS.has(action)) { + throw new Error('Invalid fixed lifecycle action') + } + const options = parseOptions(optionArguments, ['installation-id']) + requireOptions(options, ['installation-id']) + const installationId = validateInstallationId( + 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) + prepareManagedSocketDirectory(paths) + const status = await lifecycle.bootstrap() + 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 === + installationId + ) { + registry.promoteCandidate(installationId) + } + io.output.write(`${JSON.stringify(status)}\n`) + break + } + case 'stop': { + registry.assertVerifiedRole(verified, ['current', 'candidate']) + io.output.write(`${JSON.stringify(await lifecycle.stop())}\n`) + break + } + case 'retire': { + registry.assertVerifiedRole(verified, ['current', 'candidate']) + io.output.write(`${JSON.stringify(await lifecycle.retire())}\n`) + break + } + } +} + +function createDetachedLifecycle( + installationId: string, + paths: ManagedInstallationPaths, + dependencies: AgentCliDependencies +): DetachedAgentLifecycle { + const options: DetachedAgentLifecycleOptions = { + installationId, + executablePath: paths.executablePath, + stateDirectory: paths.stateDirectory, + socketPath: paths.socketPath, + verifyInstallation: async () => { + const { verified, registry } = await verifyManagedInstallation( + installationId, + paths, + dependencies + ) + registry.assertVerifiedRole(verified, ['current', 'candidate']) + return verified + }, + probeEndpoint: async (verified) => + await probeAuthenticatedEndpoint({ + socketPath: paths.socketPath, + secret: readPrivateSecret( + resolve(paths.stateDirectory, 'installation-identity') + ), + installationId, + binaryDigest: verified.binaryDigest, + protocol: verified.manifest.protocol + }) + } + return ( + dependencies.createLifecycle?.(options) ?? + new DetachedAgentLifecycle(options) + ) +} + +function prepareManagedSocketDirectory( + paths: ManagedInstallationPaths +): void { + if (process.platform !== 'linux') { + ensurePrivateDirectory(dirname(paths.socketPath)) + return + } + const expected = derivePrivateTemporaryRoot() + if (dirname(paths.socketPath) === expected.rootPath) { + ensurePrivateTemporaryRoot(expected) + return + } + ensurePrivateDirectory(dirname(paths.socketPath)) +} + +async function verifyManagedInstallation( + installationId: string, + paths: ManagedInstallationPaths, + dependencies: AgentCliDependencies +): Promise<{ + verified: VerifiedInstalledAgentBundle + registry: InstallationRegistry + releaseKeyRegistry: AgentReleaseKeyRegistry +}> { + const architecture = currentAgentArchitecture() + const agentRoot = dirname(dirname(dirname(paths.executablePath))) + const releaseKeyRegistry = + dependencies.releaseKeyRegistry ?? + await readManagedAgentReleaseKeyRegistry( + resolve(agentRoot, 'release-keys.json') + ) + const registry = + dependencies.installationRegistry ?? + new InstallationRegistry({ + storagePath: resolve(agentRoot, 'registry.json') + }) + const verified = await ( + dependencies.verifyInstallation ?? verifyInstalledAgentBundle + )(dirname(paths.executablePath), { + installationId, + architecture, + releaseKeyRegistry + }) + return { verified, registry, releaseKeyRegistry } +} + +export function deriveManagedInstallationPaths( + installationIdInput: string, + options: { + platform?: NodeJS.Platform + homeDirectory?: string + uid?: number + } = {} +): ManagedInstallationPaths { + const installationId = validateInstallationId(installationIdInput) + const platform = options.platform ?? process.platform + const uid = options.uid ?? process.getuid?.() + if ( + platform !== 'linux' || + uid === undefined || + !Number.isSafeInteger(uid) || + uid < 0 + ) { + throw new AgentUnsupportedError( + 'Managed Agent paths require a current Linux UID', + 'platform-incompatible' + ) + } + const homeDirectory = posix.resolve( + options.homeDirectory ?? homedir() + ) + const homeHash = createHash('sha256') + .update(`${uid}\0${homeDirectory}`, 'utf8') + .digest('hex') + .slice(0, 20) + const socketRoot = posix.join( + '/tmp', + `goodbuddy-${uid}-${homeHash}` + ) + const socketName = createHash('sha256') + .update(`goodbuddy-agent-socket-v1\0${installationId}`, 'utf8') + .digest('hex') + .slice(0, 32) + return { + executablePath: posix.join( + homeDirectory, + '.goodbuddy', + 'agent', + 'installations', + installationId, + 'goodbuddy-agent' + ), + stateDirectory: posix.join( + homeDirectory, + '.goodbuddy', + 'state', + installationId + ), + socketPath: posix.join(socketRoot, `${socketName}.sock`) + } +} + +export function deriveManagedRuntimePaths( + installationIdInput: string, + runtimeIdInput: string, + bundleDigestInput: string, + options: { + homeDirectory?: string + } = {} +): ManagedRuntimePaths { + validateInstallationId(installationIdInput) + const runtimeId = validateRuntimeId(runtimeIdInput) + const bundleDigest = validateBundleDigest(bundleDigestInput) + const runtimeRoot = posix.join( + posix.resolve(options.homeDirectory ?? homedir()), + '.goodbuddy', + 'runtimes' + ) + return { + runtimeRoot, + bundleDirectory: posix.join( + runtimeRoot, + runtimeId, + bundleDigest.slice('sha256:'.length) + ), + releaseKeyRegistryPath: posix.join( + runtimeRoot, + 'release-keys.json' + ), + runtimeLockPath: posix.join( + runtimeRoot, + 'remote-runtime-lock.json' + ), + registryPath: posix.join(runtimeRoot, 'registry.json') + } +} + +function resolveInstallationPaths( + installationId: string, + dependencies: AgentCliDependencies +): ManagedInstallationPaths { + validateInstallationId(installationId) + const paths = + dependencies.installationPaths?.(installationId) ?? + deriveManagedInstallationPaths(installationId) + return { + executablePath: resolve(paths.executablePath), + stateDirectory: resolve(paths.stateDirectory), + socketPath: resolve(paths.socketPath) + } +} + +function resolveRuntimePaths( + installationId: string, + runtimeId: 'opencode', + bundleDigest: string, + dependencies: AgentCliDependencies +): ManagedRuntimePaths { + const paths = + dependencies.runtimePaths?.( + installationId, + runtimeId, + bundleDigest + ) ?? + deriveManagedRuntimePaths( + installationId, + runtimeId, + bundleDigest + ) + const runtimeRoot = resolve(paths.runtimeRoot) + const expected = { + runtimeRoot, + bundleDirectory: resolve( + runtimeRoot, + runtimeId, + bundleDigest.slice('sha256:'.length) + ), + releaseKeyRegistryPath: resolve( + runtimeRoot, + 'release-keys.json' + ), + runtimeLockPath: resolve( + runtimeRoot, + 'remote-runtime-lock.json' + ), + registryPath: resolve(runtimeRoot, 'registry.json') + } + if ( + Object.entries(expected).some( + ([key, value]) => + resolve(paths[key as keyof ManagedRuntimePaths]) !== value + ) + ) { + throw new Error('Runtime activation paths are not GoodBuddy-managed') + } + return expected +} + +function validateInstallationId(value: string): string { + if ( + value.length > 128 || + !/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value) + ) { + throw new Error('Invalid Agent installation ID') + } + return value +} + +function validateRuntimeId(value: string): 'opencode' { + if (value !== 'opencode') { + throw new Error('Invalid Runtime ID') + } + return value +} + +function validateBundleDigest(value: string): string { + if (!/^sha256:[a-f0-9]{64}$/u.test(value)) { + throw new Error('Invalid Runtime bundle digest') + } + return value +} + +function validateRuntimeArchitecture( + value: string +): AgentArchitecture { + if (value !== 'x64' && value !== 'arm64') { + throw new Error('Invalid Runtime architecture') + } + return value +} + +function assertActivatedRuntimeIdentity( + verified: VerifiedRuntimeBundle, + runtimeId: 'opencode', + bundleDigest: string, + architecture: AgentArchitecture +): void { + if ( + verified.manifest.runtimeId !== runtimeId || + verified.manifest.provider !== 'opencode' || + verified.manifest.bundleDigest !== bundleDigest || + verified.manifest.architecture !== architecture + ) { + throw new Error( + 'Verified Runtime does not match the activation request' + ) + } +} + +function currentAgentArchitecture(): AgentArchitecture { + if (process.arch === 'x64' || process.arch === 'arm64') { + return process.arch + } + throw new AgentUnsupportedError( + `Managed Agent architecture is unsupported: ${process.arch}`, + 'platform-incompatible' + ) +} + +function parseOptions( + argv: readonly string[], + allowed: readonly string[] +): Record { + const allowedSet = new Set(allowed) + const output: Record = {} + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index] + const value = argv[index + 1] + if ( + flag === undefined || + !flag.startsWith('--') || + !allowedSet.has(flag.slice(2)) || + value === undefined || + value.startsWith('--') + ) { + throw new Error('Invalid or unsupported command option') + } + const key = flag.slice(2) + if (output[key] !== undefined) { + throw new Error('Duplicate command option') + } + output[key] = value + } + return output +} + +function requireOptions( + options: Readonly>, + names: readonly string[] +): void { + for (const name of names) { + if (options[name] === undefined) { + throw new Error(`Missing required --${name}`) + } + } +} + +async function waitForShutdownSignal(): Promise { + await new Promise((resolveSignal) => { + const done = (): void => { + process.off('SIGINT', done) + process.off('SIGTERM', done) + resolveSignal() + } + process.once('SIGINT', done) + process.once('SIGTERM', done) + }) +} diff --git a/src/agent-daemon/controller-registry.ts b/src/agent-daemon/controller-registry.ts new file mode 100644 index 00000000..e37b054c --- /dev/null +++ b/src/agent-daemon/controller-registry.ts @@ -0,0 +1,371 @@ +import { randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { agentIdentifierSchema } from '../shared/agent-protocol/contracts' +import { + assertPrivateRegularFile, + writePrivateFileAtomic +} from './managed-paths' + +export type ControllerLease = { + controllerId: string + connectionId: string + generation: number + leaseExpiresAt: number + capabilityGeneration: number + ownedObjects: Readonly> +} + +type MutableController = { + generation: number + leaseExpiresAt: number + capabilityGeneration: number + connectionId?: string + attached: boolean + previousGeneration?: number + previousConnectionId?: string + ownedObjects: Map> +} + +export class ControllerRegistryError extends Error { + constructor( + message: string, + readonly code: + | 'stale-generation' + | 'not-owner' + | 'lease-expired' + | 'not-attached' + | 'takeover-mismatch' + ) { + super(message) + this.name = 'ControllerRegistryError' + } +} + +export class ControllerRegistry { + readonly #controllers = new Map() + readonly #now: () => number + readonly #leaseMs: number + readonly #storagePath?: string + + constructor(options: { + now?: () => number + leaseMs?: number + storagePath?: string + } = {}) { + this.#now = options.now ?? Date.now + this.#leaseMs = options.leaseMs ?? 24 * 60 * 60 * 1000 + this.#storagePath = options.storagePath + if (!Number.isSafeInteger(this.#leaseMs) || this.#leaseMs < 1) { + throw new RangeError('Invalid controller lease duration') + } + this.#load() + } + + attach(controllerIdInput: string): ControllerLease { + const controllerId = agentIdentifierSchema.parse(controllerIdInput) + let record = this.#controllers.get(controllerId) + if (record === undefined) { + record = { + generation: 0, + leaseExpiresAt: 0, + capabilityGeneration: 1, + attached: false, + ownedObjects: new Map() + } + this.#controllers.set(controllerId, record) + } + const previousGeneration = + record.generation === 0 ? undefined : record.generation + const previousConnectionId = record.connectionId + record.generation += 1 + if (record.generation > 0xffff_ffff) { + throw new RangeError('Controller generation exhausted') + } + record.connectionId = `conn-${randomBytes(18).toString('base64url')}` + record.attached = true + record.previousGeneration = previousGeneration + record.previousConnectionId = previousConnectionId + record.leaseExpiresAt = this.#now() + this.#leaseMs + this.#persist() + return snapshot(controllerId, record) + } + + resume( + controllerId: string, + generation: number, + capabilityGeneration?: number + ): ControllerLease { + const record = this.#assertCurrent(controllerId, generation) + if ( + capabilityGeneration !== undefined && + capabilityGeneration !== record.capabilityGeneration + ) { + throw new ControllerRegistryError( + 'Controller capability generation is stale', + 'stale-generation' + ) + } + record.leaseExpiresAt = this.#now() + this.#leaseMs + this.#persist() + return snapshot(controllerId, record) + } + + takeover( + controllerId: string, + generation: number, + input: { + previousGeneration: number + previousConnectionId: string + capabilityGeneration: number + } + ): ControllerLease { + const record = this.#assertCurrent(controllerId, generation) + if ( + generation <= input.previousGeneration || + record.previousGeneration !== input.previousGeneration || + record.previousConnectionId !== input.previousConnectionId || + record.capabilityGeneration !== input.capabilityGeneration + ) { + throw new ControllerRegistryError( + 'Controller takeover does not exactly match the recorded predecessor', + 'takeover-mismatch' + ) + } + record.leaseExpiresAt = this.#now() + this.#leaseMs + this.#persist() + return snapshot(controllerId, record) + } + + assertCurrent(controllerId: string, generation: number): ControllerLease { + return snapshot( + controllerId, + this.#assertCurrent(controllerId, generation) + ) + } + + claim( + controllerId: string, + generation: number, + kind: string, + objectIdInput: string + ): void { + const objectId = agentIdentifierSchema.parse(objectIdInput) + const record = this.#assertCurrent(controllerId, generation) + const objects = record.ownedObjects.get(kind) ?? new Set() + objects.add(objectId) + record.ownedObjects.set(kind, objects) + this.#persist() + } + + assertOwner( + controllerId: string, + generation: number, + kind: string, + objectId: string + ): void { + const record = this.#assertCurrent(controllerId, generation) + if (!record.ownedObjects.get(kind)?.has(objectId)) { + throw new ControllerRegistryError( + 'Object belongs to another controller', + 'not-owner' + ) + } + } + + release( + controllerId: string, + generation: number, + kind: string, + objectId: string + ): void { + this.assertOwner(controllerId, generation, kind, objectId) + this.#controllers.get(controllerId)?.ownedObjects.get(kind)?.delete(objectId) + this.#persist() + } + + revokeCapabilities(controllerId: string): number { + const record = this.#controllers.get(controllerId) + if (record === undefined) { + throw new ControllerRegistryError( + 'Controller has not attached', + 'not-attached' + ) + } + record.capabilityGeneration += 1 + this.#persist() + return record.capabilityGeneration + } + + disconnect(controllerId: string, generation: number): void { + const record = this.#assertCurrent(controllerId, generation) + record.attached = false + this.#persist() + } + + #assertCurrent(controllerIdInput: string, generation: number): MutableController { + const controllerId = agentIdentifierSchema.parse(controllerIdInput) + const record = this.#controllers.get(controllerId) + if (record === undefined) { + throw new ControllerRegistryError( + 'Controller has not attached', + 'not-attached' + ) + } + if (record.generation !== generation) { + throw new ControllerRegistryError( + 'Connection generation is stale', + 'stale-generation' + ) + } + if (!record.attached) { + throw new ControllerRegistryError( + 'Controller is not currently attached', + 'not-attached' + ) + } + if (record.leaseExpiresAt < this.#now()) { + throw new ControllerRegistryError( + 'Controller lease has expired', + 'lease-expired' + ) + } + return record + } + + #load(): void { + if (this.#storagePath === undefined) { + return + } + let parsed: unknown + try { + assertPrivateRegularFile(this.#storagePath) + parsed = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + this.#persist() + return + } + throw error + } + if (!Array.isArray(parsed)) { + throw new Error('Controller registry is corrupt') + } + for (const item of parsed) { + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + throw new Error('Controller registry is corrupt') + } + const value = item as Record + const controllerId = agentIdentifierSchema.parse(value.controllerId) + if ( + !Number.isInteger(value.generation) || + (value.generation as number) < 0 || + (value.generation as number) > 0xffff_ffff || + !Number.isSafeInteger(value.leaseExpiresAt) || + !Number.isInteger(value.capabilityGeneration) || + (value.capabilityGeneration as number) < 1 || + value.ownedObjects === null || + typeof value.ownedObjects !== 'object' || + Array.isArray(value.ownedObjects) + ) { + throw new Error('Controller registry is corrupt') + } + const ownedObjects = new Map>() + for (const [kind, objectIds] of Object.entries( + value.ownedObjects as Record + )) { + if (!Array.isArray(objectIds)) { + throw new Error('Controller registry is corrupt') + } + ownedObjects.set( + kind, + new Set(objectIds.map((objectId) => agentIdentifierSchema.parse(objectId))) + ) + } + this.#controllers.set(controllerId, { + generation: value.generation as number, + leaseExpiresAt: value.leaseExpiresAt as number, + capabilityGeneration: value.capabilityGeneration as number, + connectionId: + value.connectionId === undefined + ? undefined + : agentIdentifierSchema.parse(value.connectionId), + attached: false, + previousGeneration: + value.previousGeneration === undefined + ? undefined + : generation(value.previousGeneration), + previousConnectionId: + value.previousConnectionId === undefined + ? undefined + : agentIdentifierSchema.parse(value.previousConnectionId), + ownedObjects + }) + } + } + + #persist(): void { + if (this.#storagePath === undefined) { + return + } + writePrivateFileAtomic( + this.#storagePath, + JSON.stringify( + [...this.#controllers].map(([controllerId, record]) => ({ + controllerId, + generation: record.generation, + leaseExpiresAt: record.leaseExpiresAt, + capabilityGeneration: record.capabilityGeneration, + connectionId: record.connectionId, + previousGeneration: record.previousGeneration, + previousConnectionId: record.previousConnectionId, + ownedObjects: Object.fromEntries( + [...record.ownedObjects].map(([kind, objects]) => [ + kind, + [...objects] + ]) + ) + })) + ) + ) + } +} + +function snapshot( + controllerId: string, + record: MutableController +): ControllerLease { + if (record.connectionId === undefined || !record.attached) { + throw new ControllerRegistryError( + 'Controller is not currently attached', + 'not-attached' + ) + } + return { + controllerId, + connectionId: record.connectionId, + generation: record.generation, + leaseExpiresAt: record.leaseExpiresAt, + capabilityGeneration: record.capabilityGeneration, + ownedObjects: Object.fromEntries( + [...record.ownedObjects].map(([kind, objects]) => [ + kind, + [...objects] + ]) + ) + } +} + +function generation(value: unknown): number { + if ( + !Number.isInteger(value) || + (value as number) < 1 || + (value as number) > 0xffff_ffff + ) { + throw new Error('Controller registry is corrupt') + } + return value as number +} diff --git a/src/agent-daemon/daemon.test.ts b/src/agent-daemon/daemon.test.ts new file mode 100644 index 00000000..d6045d91 --- /dev/null +++ b/src/agent-daemon/daemon.test.ts @@ -0,0 +1,217 @@ +import { chmodSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_VERSION, + type AgentFrame, + daemonStatusSchema +} from '../shared/agent-protocol' +import { AgentProtocolServer } from './protocol-server' +import { AgentDaemon } from './daemon' +import { EventJournal } from './event-journal' +import { PrivateEndpoint } from './private-endpoint' +import { WorkspaceRegistry } from './workspace-registry' + +const digest = `sha256:${'a'.repeat(64)}` +const temporaryPaths: string[] = [] +const nativePlatform = process.platform +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + let firstRead = true + Object.defineProperty(process, 'platform', { + configurable: true, + enumerable: true, + get() { + if (nativePlatform === 'linux') { + return nativePlatform + } + if (firstRead) { + firstRead = false + return 'linux' + } + return nativePlatform + } + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + if (originalPlatform !== undefined) { + Object.defineProperty(process, 'platform', originalPlatform) + } + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('Agent daemon lifecycle', () => { + it('returns the strict manifest-bound Linux daemon status', () => { + const daemon = daemonForTest() + const status = daemonStatusSchema.parse(daemon.status()) + + expect(status).toEqual({ + state: 'offline', + installationId: 'installation-a', + binaryDigest: digest, + daemonBootId: expect.stringMatching(/^boot-/u), + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: process.arch === 'arm64' ? 'arm64' : 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: + process.getuid === undefined + ? 'uid:unavailable' + : `uid:${process.getuid()}`, + draining: false + }) + expect(Object.keys(status).sort()).toEqual([ + 'agentVersion', + 'architecture', + 'binaryDigest', + 'daemonBootId', + 'draining', + 'installationId', + 'platform', + 'protocol', + 'remoteUserIdentity', + 'state', + 'supervisor' + ]) + }) + + it('does not schedule unused generic journal maintenance', async () => { + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue() + vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue() + const daemon = daemonForTest() + + await daemon.start() + expect(setIntervalSpy).not.toHaveBeenCalled() + await daemon.stop() + }) + + it('retires workspace handles before closing journals', async () => { + vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue() + vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue() + const closeWorkspaces = vi.spyOn( + WorkspaceRegistry.prototype, + 'closeAll' + ) + const closeEvents = vi.spyOn(EventJournal.prototype, 'close') + const daemon = daemonForTest() + + await daemon.start() + await daemon.stop() + + expect(closeWorkspaces).toHaveBeenCalledOnce() + expect(closeEvents).toHaveBeenCalledOnce() + expect(closeWorkspaces.mock.invocationCallOrder[0]).toBeLessThan( + closeEvents.mock.invocationCallOrder[0]! + ) + }) + + it('keeps injected Runtime methods in their protocol namespace', async () => { + vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue() + vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue() + const daemon = daemonForTest({ + runtimeProtocol: { + runtimes: () => [], + methods: { + 'agent/status': () => null + }, + onAcpFrame: async () => {} + } + }) + + await expect(daemon.start()).rejects.toThrow( + /runtime\/ namespace/iu + ) + expect(daemon.status().state).toBe('offline') + }) + + it('routes factory Runtime output and disposes Runtime before journals', async () => { + vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue() + vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue() + const sendAcpFrame = vi + .spyOn(AgentProtocolServer.prototype, 'sendAcpFrame') + .mockResolvedValue() + const dispose = vi.fn(async () => undefined) + let outputSink: + | Parameters[0]['runtimeFactory'] + >>[0]['outputSink'] + | undefined + const runtimeFactory = vi.fn(async (context) => { + outputSink = context.outputSink + return { + runtimes: () => [], + methods: Object.fromEntries( + [ + 'openAcpChannel', + 'closeAcpChannel', + 'preparePrompt', + 'getAcpCursors', + 'escalateCancellation', + 'reconcilePrompt' + ].map((method) => [`runtime/${method}`, () => null]) + ), + onAcpFrame: async () => {}, + dispose + } + }) + const daemon = daemonForTest({ runtimeFactory }) + + await daemon.start() + const frame = { + header: { + protocolMajor: 1, + protocolMinor: 0, + connectionId: 'connection-1', + generation: 1, + channelId: 'binding-1', + channelEpoch: '1', + direction: 'agent-to-main', + sequence: '1', + kind: 'acp', + payloadLength: 2 + }, + payload: Buffer.from('ok') + } satisfies AgentFrame + await outputSink!(frame, { + bindingId: 'binding-1', + controllerId: 'controller-1', + controllerGeneration: 1 + }) + expect(sendAcpFrame).toHaveBeenCalledWith(frame) + + await daemon.stop() + expect(runtimeFactory).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + }) +}) + +function daemonForTest( + overrides: Partial[0]> = {} +): AgentDaemon { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-daemon-')) + chmodSync(root, 0o700) + temporaryPaths.push(root) + return new AgentDaemon({ + installationId: 'installation-a', + binaryDigest: digest, + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + stateDirectory: resolve(root, 'state'), + socketPath: resolve(root, 'runtime', 'agent.sock'), + peerIdentityProvider: { + async getPeerIdentity() { + return { uid: 1 } + } + }, + ...overrides + }) +} diff --git a/src/agent-daemon/daemon.ts b/src/agent-daemon/daemon.ts new file mode 100644 index 00000000..dd871c96 --- /dev/null +++ b/src/agent-daemon/daemon.ts @@ -0,0 +1,405 @@ +import { randomBytes } from 'node:crypto' +import { dirname, resolve } from 'node:path' +import { arch } from 'node:os' +import { + daemonStatusSchema, + agentIdentifierSchema, + AGENT_PROTOCOL_FAILURE_RECORD_NAME, + agentProtocolFailureRecordSchema, + protocolVersionSchema, + sha256DigestSchema +} from '../shared/agent-protocol' +import type { + DaemonStatus, + AttachPreface, + DaemonCapabilities, + AgentFrame +} from '../shared/agent-protocol' +import { ControllerRegistry } from './controller-registry' +import { EventJournal } from './event-journal' +import { InstallationChallengeVerifier } from './installation-challenge' +import { + ensurePrivateDirectory, + ensurePrivateDirectoryTree, + readOrCreatePrivateSecret, + writePrivateFileAtomic +} from './managed-paths' +import { + PrivateEndpoint, + type UnixPeerIdentityProvider +} from './private-endpoint' +import { + AgentProtocolServer, + type ProtocolMethodHandler +} from './protocol-server' +import { AgentUnsupportedError } from './errors' +import { WorkspaceGitService } from './workspace-git-service' +import { createWorkspaceProtocolMethods } from './workspace-protocol-methods' +import { WorkspaceRegistry } from './workspace-registry' + +export type AgentDaemonOptions = { + installationId: string + binaryDigest: string + agentVersion: string + protocol: AttachPreface['protocol'] + stateDirectory: string + socketPath: string + peerIdentityProvider: UnixPeerIdentityProvider + now?: () => number + gitExecutable?: string + workspaceRequestTimeoutMs?: number + runtimeProtocol?: { + runtimes: () => + | DaemonCapabilities['runtimes'] + | Promise + methods: Readonly> + onAcpFrame: NonNullable[0]['onAcpFrame']> + onBlobFrame?: NonNullable[0]['onBlobFrame']> + authorizeBlobFrame?: NonNullable[0]['authorizeBlobFrame']> + dispose?: () => void | Promise + } + runtimeFactory?: (context: { + events: EventJournal + workspaces: WorkspaceRegistry + outputSink: ( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ) => void | Promise + blobSink: ( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ) => void | Promise + }) => + | AgentDaemonOptions['runtimeProtocol'] + | undefined + | Promise + reportError?: (message: string, error: unknown) => void +} + +export type AgentDaemonStatus = DaemonStatus + +export class AgentDaemon { + readonly #installationId: string + readonly #binaryDigest: string + readonly #agentVersion: string + readonly #protocol: AttachPreface['protocol'] + readonly #architecture: 'x64' | 'arm64' + readonly #remoteUserIdentity: string + readonly #stateDirectory: string + readonly #socketPath: string + readonly #bootId = `boot-${randomBytes(18).toString('base64url')}` + readonly #peerIdentityProvider: UnixPeerIdentityProvider + readonly #now: () => number + readonly #gitExecutable?: string + readonly #workspaceRequestTimeoutMs?: number + readonly #runtimeProtocol?: AgentDaemonOptions['runtimeProtocol'] + readonly #runtimeFactory?: AgentDaemonOptions['runtimeFactory'] + readonly #reportError: (message: string, error: unknown) => void + #state: 'starting' | 'ready' | 'stopped' = 'stopped' + #endpoint?: PrivateEndpoint + #events?: EventJournal + #workspaces?: WorkspaceRegistry + #activeRuntimeProtocol?: AgentDaemonOptions['runtimeProtocol'] + + constructor(options: AgentDaemonOptions) { + this.#installationId = agentIdentifierSchema.parse(options.installationId) + this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest) + this.#agentVersion = + daemonStatusSchema.shape.agentVersion.parse(options.agentVersion) + this.#protocol = protocolVersionSchema.parse(options.protocol) + const hostArchitecture = arch() + if (hostArchitecture !== 'x64' && hostArchitecture !== 'arm64') { + throw new AgentUnsupportedError( + `GoodBuddy Agent architecture is unsupported: ${hostArchitecture}`, + 'platform-incompatible' + ) + } + this.#architecture = hostArchitecture + this.#remoteUserIdentity = agentIdentifierSchema.parse( + process.getuid === undefined + ? 'uid:unavailable' + : `uid:${process.getuid()}` + ) + this.#stateDirectory = resolve(options.stateDirectory) + this.#socketPath = resolve(options.socketPath) + this.#peerIdentityProvider = options.peerIdentityProvider + this.#now = options.now ?? Date.now + this.#gitExecutable = options.gitExecutable + this.#workspaceRequestTimeoutMs = options.workspaceRequestTimeoutMs + this.#runtimeProtocol = options.runtimeProtocol + this.#runtimeFactory = options.runtimeFactory + if ( + this.#runtimeProtocol !== undefined && + this.#runtimeFactory !== undefined + ) { + throw new Error( + 'Agent daemon accepts either Runtime protocol or Runtime factory' + ) + } + this.#reportError = + options.reportError ?? + ((message, error) => { + console.error(message, error) + }) + } + + status(): AgentDaemonStatus { + return daemonStatusSchema.parse({ + state: this.#state === 'stopped' ? 'offline' : this.#state, + installationId: this.#installationId, + binaryDigest: this.#binaryDigest, + daemonBootId: this.#bootId, + agentVersion: this.#agentVersion, + protocol: this.#protocol, + platform: 'linux', + architecture: this.#architecture, + supervisor: 'detached-on-demand', + remoteUserIdentity: this.#remoteUserIdentity, + draining: false + }) + } + + async start(): Promise { + if (process.platform !== 'linux') { + throw new AgentUnsupportedError( + 'GoodBuddy Agent daemon is certified only on Linux', + 'platform-incompatible' + ) + } + if (this.#state !== 'stopped') { + throw new Error('GoodBuddy Agent daemon is already started') + } + this.#state = 'starting' + try { + ensurePrivateDirectory(this.#stateDirectory) + ensurePrivateDirectoryTree( + resolve(this.#stateDirectory, 'journal'), + this.#stateDirectory + ) + ensurePrivateDirectory(dirname(this.#socketPath)) + const secret = readOrCreatePrivateSecret( + resolve(this.#stateDirectory, 'installation-identity') + ) + const controllers = new ControllerRegistry({ + now: this.#now, + storagePath: resolve(this.#stateDirectory, 'controllers.json') + }) + this.#events = new EventJournal( + resolve(this.#stateDirectory, 'journal', 'events.sqlite'), + { now: this.#now } + ) + const git = new WorkspaceGitService({ + ...(this.#gitExecutable === undefined + ? {} + : { gitExecutable: this.#gitExecutable }) + }) + this.#workspaces = new WorkspaceRegistry({ + controllers, + now: this.#now, + storagePath: resolve( + this.#stateDirectory, + 'workspaces.json' + ), + inspectGit: async (access, io) => await git.inspect(access, io) + }) + const workspaceMethods = createWorkspaceProtocolMethods({ + workspaces: this.#workspaces, + git, + ...(this.#workspaceRequestTimeoutMs === undefined + ? {} + : { + requestTimeoutMs: + this.#workspaceRequestTimeoutMs + }) + }) + const protocolBox: { + current?: AgentProtocolServer + } = {} + this.#activeRuntimeProtocol = + this.#runtimeProtocol ?? + await this.#runtimeFactory?.({ + events: this.#events, + workspaces: this.#workspaces, + outputSink: async (frame) => { + if (protocolBox.current === undefined) { + throw new Error( + 'Agent Runtime output sink is not ready' + ) + } + await protocolBox.current.sendAcpFrame(frame) + }, + blobSink: async (frame) => { + if (protocolBox.current === undefined) { + throw new Error( + 'Agent Runtime blob sink is not ready' + ) + } + await protocolBox.current.sendBlobFrame(frame) + } + }) + const protocol = new AgentProtocolServer({ + controllers, + events: this.#events, + status: () => this.status(), + ...(this.#activeRuntimeProtocol === undefined + ? {} + : { + runtimes: this.#activeRuntimeProtocol.runtimes, + onAcpFrame: this.#activeRuntimeProtocol.onAcpFrame, + ...(this.#activeRuntimeProtocol.onBlobFrame === undefined + ? {} + : { + onBlobFrame: + this.#activeRuntimeProtocol.onBlobFrame + }), + ...(this.#activeRuntimeProtocol.authorizeBlobFrame === undefined + ? {} + : { + authorizeBlobFrame: + this.#activeRuntimeProtocol.authorizeBlobFrame + }) + }), + onProtocolFailure: ({ + connectionId, + category + }) => { + try { + writePrivateFileAtomic( + resolve( + this.#stateDirectory, + AGENT_PROTOCOL_FAILURE_RECORD_NAME + ), + `${JSON.stringify( + agentProtocolFailureRecordSchema.parse({ + formatVersion: 1, + connectionId, + category, + createdAt: this.#now() + }) + )}\n` + ) + } catch (error) { + this.#reportError( + 'Agent protocol failure category could not be persisted', + error + ) + } + }, + methods: mergeProtocolMethods( + workspaceMethods, + this.#activeRuntimeProtocol?.methods + ) + }) + protocolBox.current = protocol + this.#endpoint = new PrivateEndpoint({ + socketPath: this.#socketPath, + peerIdentity: this.#peerIdentityProvider, + challenge: new InstallationChallengeVerifier(secret, { + now: this.#now + }), + controllers, + installationId: this.#installationId, + binaryDigest: this.#binaryDigest, + daemonBootId: this.#bootId, + protocol: this.#protocol, + onAttach: ({ socket, controller }) => { + protocol.accept(socket, controller) + } + }) + await this.#endpoint.listen() + this.#state = 'ready' + } catch (error) { + await this.stop() + throw error + } + } + + async stop(): Promise { + const endpoint = this.#endpoint + const events = this.#events + const workspaces = this.#workspaces + const runtimeProtocol = this.#activeRuntimeProtocol + this.#endpoint = undefined + this.#events = undefined + this.#workspaces = undefined + this.#activeRuntimeProtocol = undefined + try { + if (endpoint !== undefined) { + await endpoint.close() + } + } finally { + if (runtimeProtocol?.dispose !== undefined) { + await Promise.resolve(runtimeProtocol.dispose()).catch( + (error: unknown) => { + this.#reportRuntimeShutdownError(error) + } + ) + } + workspaces?.closeAll() + events?.close() + this.#state = 'stopped' + } + } + + #reportRuntimeShutdownError(error: unknown): void { + try { + this.#reportError('Agent Runtime shutdown failed', error) + } catch (reportError) { + console.error( + 'Agent Runtime shutdown failure reporting failed', + reportError, + error + ) + } + } + +} + +function mergeProtocolMethods( + workspaceMethods: Readonly>, + runtimeMethods: + | Readonly> + | undefined +): Readonly> { + if (runtimeMethods === undefined) { + return workspaceMethods + } + for (const method of Object.keys(runtimeMethods)) { + if (!method.startsWith('runtime/')) { + throw new Error( + `Runtime protocol method must use the runtime/ namespace: ${method}` + ) + } + if (workspaceMethods[method] !== undefined) { + throw new Error( + `Runtime protocol method conflicts with a Workspace method: ${method}` + ) + } + } + return { ...workspaceMethods, ...runtimeMethods } +} + +export class UnsupportedPeerIdentityProvider + implements UnixPeerIdentityProvider +{ + async getPeerIdentity(): Promise { + throw new AgentUnsupportedError( + 'A certified SO_PEERCRED helper is unavailable', + 'peer-identity-unavailable' + ) + } +} diff --git a/src/agent-daemon/detached-agent-lifecycle.test.ts b/src/agent-daemon/detached-agent-lifecycle.test.ts new file mode 100644 index 00000000..bb6348f2 --- /dev/null +++ b/src/agent-daemon/detached-agent-lifecycle.test.ts @@ -0,0 +1,447 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DetachedAgentLifecycle, + detachedAgentLifecycleRecordSchema, + type DetachedAgentLifecycleOptions, + type DetachedAgentLifecycleRecord, + type DetachedSocketInspector, + type DetachedSpawn +} from './detached-agent-lifecycle' +import { + LinuxProcessInspector, + type LinuxProcessIdentity +} from './linux-process-identity' +import { writePrivateFileAtomic } from './managed-paths' +import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' + +const parentPid = 1101 +const childPid = 2202 +const temporaryPaths: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('detached Agent lifecycle', () => { + it('serializes concurrent bootstrap and records endpoint readiness', async () => { + const harness = lifecycleHarness() + harness.onSleep(async () => { + const starting = harness.readRecord() + if (starting?.daemonBootId === null) { + await harness.daemon.recordCurrentDaemonReady('daemon-boot-1') + } + }) + + const [first, second] = await Promise.all([ + harness.lifecycle.bootstrap(), + harness.lifecycle.bootstrap() + ]) + + expect(first.daemonBootId).toBe('daemon-boot-1') + expect(second).toEqual(first) + expect(harness.spawn).toHaveBeenCalledOnce() + expect(harness.spawn).toHaveBeenCalledWith( + harness.verified.executablePath, + ['daemon', '--installation-id', 'install-1'], + { + detached: true, + shell: false, + stdio: 'ignore', + cwd: harness.verified.installationDirectory, + env: { + HOME: '/home/tester', + LANG: 'zh_CN.UTF-8', + PATH: '/usr/bin', + TMPDIR: '/tmp' + } + } + ) + expect(harness.unref).toHaveBeenCalledOnce() + expect(harness.probe).toHaveBeenCalled() + }) + + it('creates the managed state parent before bootstrapping', async () => { + const harness = lifecycleHarness({ + nestedStateDirectory: true + }) + const stateRoot = resolve(harness.stateDirectory, '..') + expect(existsSync(stateRoot)).toBe(false) + + await expect(harness.becomeReady()).resolves.toMatchObject({ + daemonBootId: 'daemon-boot-1' + }) + + expect(existsSync(stateRoot)).toBe(true) + expect(existsSync(harness.stateDirectory)).toBe(true) + if (process.platform !== 'win32') { + expect(lstatSync(stateRoot).mode & 0o777).toBe(0o700) + expect( + lstatSync(harness.stateDirectory).mode & 0o777 + ).toBe(0o700) + } + }) + + it('recovers stale lifecycle state without signalling a reused PID', async () => { + const harness = lifecycleHarness() + await harness.becomeReady() + harness.signals.length = 0 + harness.identities.set( + childPid, + harness.childIdentity({ starttime: '99999' }) + ) + harness.onSleep(async () => { + const starting = harness.readRecord() + if (starting?.daemonBootId === null) { + await harness.daemon.recordCurrentDaemonReady('daemon-after-reuse') + } + }) + + await expect(harness.lifecycle.status()).resolves.toMatchObject({ + state: 'stale', + reason: 'process-identity-mismatch' + }) + const recovered = await harness.lifecycle.bootstrap() + expect(recovered.daemonBootId).toBe('daemon-after-reuse') + expect(harness.spawn).toHaveBeenCalledTimes(2) + expect(harness.signals).toEqual([]) + expect(harness.socket.removed).toBe(1) + }) + + it.each([ + ['start time', { starttime: '99999' }], + ['executable', { executablePath: resolve('foreign', 'runtime') }] + ])('never signals a PID with mismatched %s', async (_label, mismatch) => { + const harness = lifecycleHarness() + await harness.becomeReady() + harness.identities.set(childPid, harness.childIdentity(mismatch)) + harness.signals.length = 0 + + await expect(harness.lifecycle.stop()).resolves.toEqual({ + state: 'absent' + }) + expect(harness.signals).toEqual([]) + expect(harness.socket.removed).toBe(1) + }) + + it('reclaims a stale exclusive lock and preserves a live lock', async () => { + const harness = lifecycleHarness() + harness.writeLock(harness.childIdentity({ starttime: '1' })) + harness.onSleep(async () => { + if (harness.readRecord()?.daemonBootId === null) { + await harness.daemon.recordCurrentDaemonReady('after-stale-lock') + } + }) + await expect(harness.lifecycle.bootstrap()).resolves.toMatchObject({ + daemonBootId: 'after-stale-lock' + }) + + const live = lifecycleHarness({ readinessTimeoutMs: 100 }) + live.writeLock(live.parentIdentity) + await expect(live.lifecycle.bootstrap()).rejects.toThrow( + 'Timed out waiting' + ) + expect(existsSync(live.lockPath)).toBe(true) + expect(live.spawn).not.toHaveBeenCalled() + }) + + it('rejects corrupt and permissive lifecycle state', async () => { + const corrupt = lifecycleHarness() + mkdirSync(corrupt.stateDirectory, { mode: 0o700 }) + writeFileSync(corrupt.lifecyclePath, '{not-json\n', { mode: 0o600 }) + await expect(corrupt.lifecycle.status()).rejects.toThrow('corrupt') + + if (process.platform !== 'win32') { + const permissive = lifecycleHarness() + mkdirSync(permissive.stateDirectory, { mode: 0o700 }) + writeFileSync(permissive.lifecyclePath, '{}\n', { mode: 0o644 }) + await expect(permissive.lifecycle.status()).rejects.toThrow('corrupt') + } + }) + + it('uses TERM then KILL and cleans lifecycle/socket state', async () => { + const harness = lifecycleHarness({ + readinessTimeoutMs: 100, + stopTimeoutMs: 100, + termExits: false, + killExits: true + }) + + await expect(harness.lifecycle.bootstrap()).rejects.toThrow( + 'readiness timed out' + ) + expect(harness.signals.map(({ signal }) => signal)).toEqual([ + 'SIGTERM', + 'SIGKILL' + ]) + expect(existsSync(harness.lifecyclePath)).toBe(false) + expect(harness.socket.removed).toBe(1) + }) + + it('keeps lifecycle state when TERM and KILL cannot stop the process', async () => { + const harness = lifecycleHarness({ + readinessTimeoutMs: 100, + stopTimeoutMs: 100, + termExits: false, + killExits: false + }) + + await expect(harness.lifecycle.bootstrap()).rejects.toThrow( + 'did not stop' + ) + expect(harness.signals.map(({ signal }) => signal)).toEqual([ + 'SIGTERM', + 'SIGKILL' + ]) + expect(existsSync(harness.lifecyclePath)).toBe(true) + }) +}) + +type HarnessOptions = { + readinessTimeoutMs?: number + stopTimeoutMs?: number + termExits?: boolean + killExits?: boolean + nestedStateDirectory?: boolean +} + +function lifecycleHarness(options: HarnessOptions = {}) { + const root = privateTemporaryDirectory() + const installationDirectory = resolve(root, 'installation') + const stateDirectory = options.nestedStateDirectory + ? resolve(root, 'state', 'install-1') + : resolve(root, 'state') + const socketPath = resolve(root, 'endpoint', 'agent.sock') + const lifecyclePath = resolve(stateDirectory, 'detached-lifecycle.json') + const lockPath = resolve(stateDirectory, 'detached-bootstrap.lock') + const verified = verifiedInstallation(installationDirectory) + const parentIdentity: LinuxProcessIdentity = { + pid: parentPid, + starttime: '101', + executablePath: resolve(root, 'host', 'node') + } + const expectedChildIdentity = ( + changes: Partial = {} + ): LinuxProcessIdentity => ({ + pid: childPid, + starttime: '202', + executablePath: resolve(installationDirectory, 'node'), + ...changes + }) + const identities = new Map([ + [parentPid, parentIdentity], + [childPid, expectedChildIdentity()] + ]) + const inspector = new LinuxProcessInspector() + vi.spyOn(inspector, 'inspect').mockImplementation((pid) => + identities.get(pid) + ) + vi.spyOn(inspector, 'matches').mockImplementation((expected) => { + const actual = identities.get(expected.pid) + return actual !== undefined && sameIdentity(actual, expected) + }) + const signals: Array<{ + identity: LinuxProcessIdentity + signal: NodeJS.Signals + }> = [] + vi.spyOn(inspector, 'signal').mockImplementation((identity, signal) => { + const actual = identities.get(identity.pid) + if (actual === undefined || !sameIdentity(actual, identity)) { + return false + } + signals.push({ identity, signal }) + if ( + (signal === 'SIGTERM' && options.termExits !== false) || + (signal === 'SIGKILL' && options.killExits !== false) + ) { + identities.delete(identity.pid) + } + return true + }) + const socket = new FakeSocketInspector() + const unref = vi.fn() + const spawn = vi.fn(() => { + identities.set(childPid, expectedChildIdentity()) + return { pid: childPid, unref } + }) + let time = 1_000 + let sleepHook: (() => void | Promise) | undefined + let hookActive = false + const sleep = async (milliseconds: number): Promise => { + time += milliseconds + await Promise.resolve() + if (sleepHook !== undefined && !hookActive) { + hookActive = true + try { + await sleepHook() + } finally { + hookActive = false + } + } + } + const probe = vi.fn(async () => ({ + daemonBootId: + detachedAgentLifecycleRecordSchema.parse( + JSON.parse(readFileSync(lifecyclePath, 'utf8')) + ).daemonBootId ?? 'not-ready' + })) + const common: Omit< + DetachedAgentLifecycleOptions, + 'currentPid' | 'spawnDetached' + > = { + installationId: 'install-1', + executablePath: verified.executablePath, + stateDirectory, + socketPath, + verifyInstallation: async () => verified, + processInspector: inspector, + socketInspector: socket, + now: () => time, + sleep, + readinessTimeoutMs: options.readinessTimeoutMs ?? 500, + stopTimeoutMs: options.stopTimeoutMs ?? 100, + environment: { + HOME: '/home/tester', + LANG: 'zh_CN.UTF-8', + PATH: '/usr/bin', + TMPDIR: '/tmp', + NODE_OPTIONS: '--inspect', + GOODBUDDY_SECRET: 'must-not-leak' + } + } + const lifecycle = new DetachedAgentLifecycle({ + ...common, + currentPid: parentPid, + spawnDetached: spawn, + probeEndpoint: probe + }) + const daemon = new DetachedAgentLifecycle({ + ...common, + currentPid: childPid, + spawnDetached: () => { + throw new Error('Daemon must not spawn another daemon') + } + }) + + const readRecord = (): DetachedAgentLifecycleRecord | undefined => { + if (!existsSync(lifecyclePath)) { + return undefined + } + return detachedAgentLifecycleRecordSchema.parse( + JSON.parse(readFileSync(lifecyclePath, 'utf8')) + ) + } + return { + lifecycle, + daemon, + verified, + stateDirectory, + lifecyclePath, + lockPath, + identities, + parentIdentity, + childIdentity: expectedChildIdentity, + socket, + spawn, + unref, + probe, + signals, + readRecord, + onSleep(hook: () => void | Promise) { + sleepHook = hook + }, + writeLock(processIdentity: LinuxProcessIdentity) { + mkdirSync(stateDirectory, { mode: 0o700, recursive: true }) + writePrivateFileAtomic( + lockPath, + `${JSON.stringify({ + formatVersion: 1, + installationId: 'install-1', + process: processIdentity + })}\n` + ) + }, + async becomeReady(): Promise { + let completed = false + sleepHook = async () => { + if (readRecord()?.daemonBootId === null && !completed) { + completed = true + await daemon.recordCurrentDaemonReady('daemon-boot-1') + } + } + return await lifecycle.bootstrap() + } + } +} + +class FakeSocketInspector implements DetachedSocketInspector { + removed = 0 + + removeStale(): boolean { + this.removed += 1 + return true + } +} + +function sameIdentity( + left: LinuxProcessIdentity, + right: LinuxProcessIdentity +): boolean { + return ( + left.pid === right.pid && + left.starttime === right.starttime && + left.executablePath === right.executablePath + ) +} + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-lifecycle-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return resolve(path) +} + +function verifiedInstallation( + installationDirectory: string +): VerifiedInstalledAgentBundle { + const digest = 'a'.repeat(64) + return { + installationId: 'install-1', + installationDirectory, + executablePath: resolve(installationDirectory, 'goodbuddy-agent'), + manifestSha256: digest, + binaryDigest: `sha256:${digest}`, + manifest: { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion: '0.11.0', + platform: 'linux', + arch: 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files: [], + licenses: [] + } + } +} diff --git a/src/agent-daemon/detached-agent-lifecycle.ts b/src/agent-daemon/detached-agent-lifecycle.ts new file mode 100644 index 00000000..6f35ed0d --- /dev/null +++ b/src/agent-daemon/detached-agent-lifecycle.ts @@ -0,0 +1,706 @@ +import { spawn } from 'node:child_process' +import { + closeSync, + constants, + fchmodSync, + lstatSync, + openSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, resolve } from 'node:path' +import { z } from 'zod' +import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' +import { + LinuxProcessInspector, + type LinuxProcessIdentity +} from './linux-process-identity' +import { + assertAbsoluteManagedPath, + assertPrivateRegularFile, + ensurePrivateDirectory, + readPrivateFile, + writePrivateFileAtomic +} from './managed-paths' +import type { AttachWelcome } from '../shared/agent-protocol' + +const DEFAULT_READINESS_TIMEOUT_MS = 15_000 +const DEFAULT_STOP_TIMEOUT_MS = 5_000 +const POLL_INTERVAL_MS = 50 + +const processIdentitySchema = z + .object({ + pid: z.number().int().positive().safe(), + starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u), + executablePath: z.string().min(1) + }) + .strict() + +const bootstrapLockRecordSchema = z + .object({ + formatVersion: z.literal(1), + installationId: z.string().min(1).max(128), + process: processIdentitySchema + }) + .strict() + +export const detachedAgentLifecycleRecordSchema = z + .object({ + formatVersion: z.literal(1), + installationId: z.string().min(1).max(128), + binaryDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + protocol: z + .object({ + major: z.number().int().min(0).max(65_535), + minor: z.number().int().min(0).max(65_535) + }) + .strict(), + process: processIdentitySchema, + daemonBootId: z.string().min(1).max(128).nullable(), + createdAt: z.number().int().nonnegative().safe() + }) + .strict() + +export type DetachedAgentLifecycleRecord = z.infer< + typeof detachedAgentLifecycleRecordSchema +> + +export type DetachedLifecycleStatus = + | { state: 'absent' } + | { + state: 'starting' | 'ready' + record: DetachedAgentLifecycleRecord + } + | { + state: 'stale' + reason: string + record: DetachedAgentLifecycleRecord + } + +export type DetachedSpawn = ( + executable: string, + argv: readonly string[], + options: { + env: Readonly + detached: true + shell: false + stdio: 'ignore' + cwd: string + } +) => { + pid?: number + unref(): void +} + +export interface DetachedSocketInspector { + removeStale(path: string): boolean +} + +export type DetachedAgentLifecycleOptions = { + installationId: string + executablePath: string + stateDirectory: string + socketPath: string + verifyInstallation: () => Promise + processInspector?: LinuxProcessInspector + socketInspector?: DetachedSocketInspector + spawnDetached?: DetachedSpawn + currentPid?: number + now?: () => number + sleep?: (milliseconds: number) => Promise + readinessTimeoutMs?: number + stopTimeoutMs?: number + environment?: Readonly + probeEndpoint?: ( + verified: VerifiedInstalledAgentBundle + ) => Promise> + retireLegacyInstallation?: ( + verified: VerifiedInstalledAgentBundle + ) => void | Promise +} + +export class DetachedAgentLifecycle { + readonly #installationId: string + readonly #executablePath: string + readonly #stateDirectory: string + readonly #socketPath: string + readonly #lifecyclePath: string + readonly #lockPath: string + readonly #verifyInstallation: () => Promise + readonly #processInspector: LinuxProcessInspector + readonly #socketInspector: DetachedSocketInspector + readonly #spawnDetached: DetachedSpawn + readonly #currentPid: number + readonly #now: () => number + readonly #sleep: (milliseconds: number) => Promise + readonly #readinessTimeoutMs: number + readonly #stopTimeoutMs: number + readonly #environment: Readonly + readonly #probeEndpoint?: DetachedAgentLifecycleOptions['probeEndpoint'] + readonly #retireLegacyInstallation?: ( + verified: VerifiedInstalledAgentBundle + ) => void | Promise + + constructor(options: DetachedAgentLifecycleOptions) { + this.#installationId = validateInstallationId(options.installationId) + this.#executablePath = assertAbsoluteManagedPath(resolve(options.executablePath)) + this.#stateDirectory = assertAbsoluteManagedPath(resolve(options.stateDirectory)) + this.#socketPath = assertAbsoluteManagedPath(resolve(options.socketPath)) + this.#lifecyclePath = resolve(this.#stateDirectory, 'detached-lifecycle.json') + this.#lockPath = resolve(this.#stateDirectory, 'detached-bootstrap.lock') + this.#verifyInstallation = options.verifyInstallation + this.#processInspector = + options.processInspector ?? new LinuxProcessInspector() + this.#socketInspector = + options.socketInspector ?? new FileSystemSocketInspector() + this.#spawnDetached = options.spawnDetached ?? spawnDetachedProcess + this.#currentPid = positivePid(options.currentPid ?? process.pid) + this.#now = options.now ?? Date.now + this.#sleep = + options.sleep ?? + (async (milliseconds) => + await new Promise((resolveSleep) => + setTimeout(resolveSleep, milliseconds) + )) + this.#readinessTimeoutMs = boundedTimeout( + options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS + ) + this.#stopTimeoutMs = boundedTimeout( + options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS + ) + this.#environment = options.environment ?? process.env + this.#probeEndpoint = options.probeEndpoint + this.#retireLegacyInstallation = options.retireLegacyInstallation + } + + async status(): Promise { + return this.#statusAfterVerification(await this.#verifyAndBind()) + } + + async health(): Promise { + const verified = await this.#verifyAndBind() + const status = this.#statusAfterVerification(verified) + if (status.state !== 'ready') { + throw new Error(`Detached Agent is not healthy: ${status.state}`) + } + await this.#assertEndpointReady(status.record, verified) + return status.record + } + + async bootstrap(): Promise { + const verified = await this.#verifyAndBind() + ensurePrivateDirectory(dirname(this.#stateDirectory)) + ensurePrivateDirectory(this.#stateDirectory) + ensurePrivateDirectory(dirname(this.#socketPath)) + const releaseLock = await this.#acquireBootstrapLock() + try { + await this.#retireLegacyInstallation?.(verified) + const existing = this.#statusAfterVerification(verified) + if (existing.state === 'ready') { + try { + await this.#assertEndpointReady(existing.record, verified) + return existing.record + } catch { + await this.#retireLiveRecord(existing.record) + } + } else if (existing.state === 'starting') { + const ready = await this.#waitForReady(verified) + if (ready !== undefined) { + return ready + } + await this.#retireLiveRecord(existing.record) + } else if (existing.state === 'stale') { + this.#cleanupRecord(existing.record) + } + + const launchArgv = ['daemon', '--installation-id', this.#installationId] + const runtimePath = expectedRuntimePath(verified) + const child = this.#spawnDetached(this.#executablePath, launchArgv, { + env: detachedEnvironment(this.#environment), + detached: true, + shell: false, + stdio: 'ignore', + cwd: dirname(this.#executablePath) + }) + if (child.pid === undefined) { + throw new Error('Detached Agent spawn did not return a process ID') + } + child.unref() + const processIdentity = await this.#waitForProcessIdentity( + child.pid, + runtimePath + ) + const starting = detachedAgentLifecycleRecordSchema.parse({ + formatVersion: 1, + installationId: this.#installationId, + binaryDigest: verified.binaryDigest, + protocol: verified.manifest.protocol, + process: processIdentity, + daemonBootId: null, + createdAt: this.#now() + }) + this.#writeRecord(starting) + const ready = await this.#waitForReady(verified) + if (ready === undefined) { + await this.#retireLiveRecord(starting) + throw new Error('Detached Agent readiness timed out') + } + return ready + } finally { + releaseLock() + } + } + + async recordCurrentDaemonReady( + daemonBootId: string + ): Promise { + const verified = await this.#verifyAndBind() + const current = this.#processInspector.inspect(this.#currentPid) + if (current === undefined) { + throw new Error('Current detached Agent process identity is unavailable') + } + const deadline = this.#now() + this.#readinessTimeoutMs + while (this.#now() <= deadline) { + const record = this.#readRecord() + if ( + record !== undefined && + record.daemonBootId === null && + sameProcessIdentity(record.process, current) + ) { + this.#assertRecordBinding(record, verified) + const ready = detachedAgentLifecycleRecordSchema.parse({ + ...record, + daemonBootId + }) + this.#writeRecord(ready) + return ready + } + await this.#sleep(POLL_INTERVAL_MS) + } + throw new Error('Detached Agent bootstrap record is unavailable') + } + + async stop(): Promise { + const verified = await this.#verifyAndBind() + ensurePrivateDirectory(dirname(this.#stateDirectory)) + ensurePrivateDirectory(this.#stateDirectory) + const releaseLock = await this.#acquireBootstrapLock() + try { + const status = this.#statusAfterVerification(verified) + if (status.state === 'absent') { + return status + } + if (status.state === 'stale') { + this.#cleanupRecord(status.record) + } else { + await this.#retireLiveRecord(status.record) + } + return { state: 'absent' } + } finally { + releaseLock() + } + } + + async retire(): Promise { + return await this.stop() + } + + #statusAfterVerification( + verified: VerifiedInstalledAgentBundle + ): DetachedLifecycleStatus { + const record = this.#readRecord() + if (record === undefined) { + return { state: 'absent' } + } + this.#assertRecordBinding(record, verified) + if (!this.#processInspector.matches(record.process)) { + return { + state: 'stale', + reason: 'process-identity-mismatch', + record + } + } + return { + state: record.daemonBootId === null ? 'starting' : 'ready', + record + } + } + + async #verifyAndBind(): Promise { + const verified = await this.#verifyInstallation() + if ( + verified.installationId !== this.#installationId || + resolve(verified.executablePath) !== this.#executablePath + ) { + throw new Error( + 'Signed Agent verification does not match the lifecycle installation' + ) + } + return verified + } + + #assertRecordBinding( + record: DetachedAgentLifecycleRecord, + verified: VerifiedInstalledAgentBundle + ): void { + if ( + record.installationId !== this.#installationId || + record.binaryDigest !== verified.binaryDigest || + record.protocol.major !== verified.manifest.protocol.major || + record.protocol.minor !== verified.manifest.protocol.minor || + record.process.executablePath !== expectedRuntimePath(verified) + ) { + throw new Error( + 'Detached Agent lifecycle record does not match the signed installation' + ) + } + } + + #readRecord(): DetachedAgentLifecycleRecord | undefined { + try { + assertPrivateRegularFile(this.#lifecyclePath) + return detachedAgentLifecycleRecordSchema.parse( + JSON.parse(readBoundedPrivateFile(this.#lifecyclePath)) + ) + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return undefined + } + throw new Error('Detached Agent lifecycle record is corrupt', { + cause: error + }) + } + } + + #writeRecord(record: DetachedAgentLifecycleRecord): void { + writePrivateFileAtomic( + this.#lifecyclePath, + `${JSON.stringify(detachedAgentLifecycleRecordSchema.parse(record), null, 2)}\n` + ) + } + + async #waitForReady( + verified: VerifiedInstalledAgentBundle + ): Promise { + const deadline = this.#now() + this.#readinessTimeoutMs + while (this.#now() <= deadline) { + const status = this.#statusAfterVerification(verified) + if (status.state === 'ready') { + try { + await this.#assertEndpointReady(status.record, verified) + return status.record + } catch { + // The process may have written readiness just before listen completed. + } + } else if (status.state === 'absent' || status.state === 'stale') { + return undefined + } + await this.#sleep(POLL_INTERVAL_MS) + } + return undefined + } + + async #assertEndpointReady( + record: DetachedAgentLifecycleRecord, + verified: VerifiedInstalledAgentBundle + ): Promise { + if (this.#probeEndpoint === undefined) { + return + } + const welcome = await this.#probeEndpoint(verified) + if (welcome.daemonBootId !== record.daemonBootId) { + throw new Error( + 'Detached Agent endpoint boot identity does not match lifecycle state' + ) + } + } + + async #waitForProcessIdentity( + pid: number, + executablePath: string + ): Promise { + const deadline = this.#now() + this.#readinessTimeoutMs + while (this.#now() <= deadline) { + const identity = this.#processInspector.inspect(pid) + if ( + identity !== undefined && + identity.executablePath === executablePath + ) { + return identity + } + await this.#sleep(POLL_INTERVAL_MS) + } + throw new Error('Detached Agent process identity could not be established') + } + + async #waitForExit( + identity: LinuxProcessIdentity, + timeoutMs: number + ): Promise { + const deadline = this.#now() + timeoutMs + while (this.#now() <= deadline) { + if (!this.#processInspector.matches(identity)) { + return true + } + await this.#sleep(POLL_INTERVAL_MS) + } + return !this.#processInspector.matches(identity) + } + + async #retireLiveRecord( + record: DetachedAgentLifecycleRecord + ): Promise { + if (this.#processInspector.matches(record.process)) { + this.#processInspector.signal(record.process, 'SIGTERM') + if (!(await this.#waitForExit(record.process, this.#stopTimeoutMs))) { + this.#processInspector.signal(record.process, 'SIGKILL') + await this.#waitForExit(record.process, this.#stopTimeoutMs) + } + } + if (this.#processInspector.matches(record.process)) { + throw new Error('Detached Agent did not stop') + } + this.#cleanupRecord(record) + } + + #cleanupRecord(record: DetachedAgentLifecycleRecord): void { + if (this.#processInspector.matches(record.process)) { + throw new Error('Refusing to clean a live detached Agent lifecycle') + } + assertPrivateRegularFile(this.#lifecyclePath) + const current = detachedAgentLifecycleRecordSchema.parse( + JSON.parse(readBoundedPrivateFile(this.#lifecyclePath)) + ) + if (!sameLifecycleRecord(current, record)) { + throw new Error('Detached Agent lifecycle changed during cleanup') + } + this.#socketInspector.removeStale(this.#socketPath) + unlinkSync(this.#lifecyclePath) + } + + async #acquireBootstrapLock(): Promise<() => void> { + const deadline = this.#now() + this.#readinessTimeoutMs + while (this.#now() <= deadline) { + const descriptor = tryCreateLock(this.#lockPath) + if (descriptor !== undefined) { + const owner = this.#processInspector.inspect(this.#currentPid) + if (owner === undefined) { + closeSync(descriptor) + unlinkSync(this.#lockPath) + throw new Error( + 'Current bootstrap lock process identity is unavailable' + ) + } + try { + writeFileSync( + descriptor, + `${JSON.stringify({ + formatVersion: 1, + installationId: this.#installationId, + process: owner + })}\n`, + 'utf8' + ) + if (process.platform !== 'win32') { + fchmodSync(descriptor, 0o600) + } + } finally { + closeSync(descriptor) + } + return () => this.#releaseBootstrapLock(owner) + } + const lock = this.#readLock() + if (lock === undefined) { + continue + } + if (!this.#processInspector.matches(lock.process)) { + const current = this.#readLock() + if ( + current !== undefined && + sameProcessIdentity(current.process, lock.process) + ) { + unlinkSync(this.#lockPath) + } + continue + } + await this.#sleep(POLL_INTERVAL_MS) + } + throw new Error('Timed out waiting for detached Agent bootstrap lock') + } + + #readLock(): z.infer | undefined { + try { + assertPrivateRegularFile(this.#lockPath) + const lock = bootstrapLockRecordSchema.parse( + JSON.parse(readBoundedPrivateFile(this.#lockPath)) + ) + if (lock.installationId !== this.#installationId) { + throw new Error( + 'Detached Agent bootstrap lock belongs to another installation' + ) + } + return lock + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return undefined + } + throw new Error('Detached Agent bootstrap lock is corrupt', { + cause: error + }) + } + } + + #releaseBootstrapLock(owner: LinuxProcessIdentity): void { + const lock = this.#readLock() + if ( + lock !== undefined && + sameProcessIdentity(lock.process, owner) + ) { + unlinkSync(this.#lockPath) + } + } +} + +function spawnDetachedProcess( + executable: string, + argv: readonly string[], + options: { + env: Readonly + detached: true + shell: false + stdio: 'ignore' + cwd: string + } +): ReturnType { + return spawn(executable, [...argv], options) +} + +function detachedEnvironment( + source: Readonly +): Readonly { + const allowed = [ + 'HOME', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'LOGNAME', + 'PATH', + 'TMPDIR', + 'TZ', + 'USER' + ] as const + const env: NodeJS.ProcessEnv = {} + for (const key of allowed) { + const value = source[key] + if (value !== undefined && !value.includes('\0')) { + env[key] = value + } + } + return env +} + +function expectedRuntimePath( + verified: VerifiedInstalledAgentBundle +): string { + return assertAbsoluteManagedPath( + resolve( + verified.installationDirectory, + verified.manifest.entrypoint.runtimePath + ) + ) +} + +class FileSystemSocketInspector implements DetachedSocketInspector { + removeStale(path: string): boolean { + try { + const stat = lstatSync(path) + const uid = process.getuid?.() + if ( + !stat.isSocket() || + stat.isSymbolicLink() || + (uid !== undefined && stat.uid !== uid) + ) { + throw new Error('Refusing to remove an unrelated endpoint path') + } + unlinkSync(path) + return true + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return false + } + throw error + } + } +} + +function tryCreateLock(path: string): number | undefined { + try { + return openSync( + path, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + (constants.O_NOFOLLOW ?? 0), + 0o600 + ) + } catch (error) { + if (isNodeError(error) && error.code === 'EEXIST') { + return undefined + } + throw error + } +} + +function readBoundedPrivateFile(path: string): string { + return readPrivateFile(path, 64 * 1024).toString('utf8') +} + +function sameProcessIdentity( + left: LinuxProcessIdentity, + right: LinuxProcessIdentity +): boolean { + return ( + left.pid === right.pid && + left.starttime === right.starttime && + left.executablePath === right.executablePath + ) +} + +function sameLifecycleRecord( + left: DetachedAgentLifecycleRecord, + right: DetachedAgentLifecycleRecord +): boolean { + return ( + sameProcessIdentity(left.process, right.process) && + left.daemonBootId === right.daemonBootId && + left.createdAt === right.createdAt + ) +} + +function validateInstallationId(value: string): string { + if ( + value.length > 128 || + !/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value) + ) { + throw new Error('Invalid Agent installation ID') + } + return value +} + +function boundedTimeout(value: number): number { + if (!Number.isSafeInteger(value) || value < 100 || value > 120_000) { + throw new Error('Detached Agent timeout is invalid') + } + return value +} + +function positivePid(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('Detached Agent process ID is invalid') + } + return value +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts new file mode 100644 index 00000000..1f74d8d0 --- /dev/null +++ b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts @@ -0,0 +1,345 @@ +import { EventEmitter } from 'node:events' +import { chmodSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' +import { + launchDirectLinuxStdioProcessOwner, + reconcileOrphanedDirectLinuxStdioProcesses, + type DirectLinuxStdioChild, + type DirectLinuxStdioSpawn +} from './direct-linux-stdio-process-owner' +import { RuntimeOwnerRegistry } from './runtime-owner-registry' +import type { LinuxRuntimeProcessIdentity } from './runtime-process-identity' + +const paths: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true }) +}) + +describe('direct Linux stdio Runtime ownership', () => { + it('spawns exact bubblewrap argv without systemd using detached fixed stdio', async () => { + const registry = createRegistry() + const child = fakeChild() + let launchedIdentity!: LinuxRuntimeProcessIdentity + const spawn = vi.fn((executable, args, options) => { + expect(executable).toBe('/usr/bin/bwrap') + expect(args).not.toContain('--die-with-parent') + expect(args).toContain('--unshare-all') + expect(args).toContain('--new-session') + expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN') + expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu) + expect(options).toEqual({ + shell: false, + detached: true, + windowsHide: true, + cwd: '/state/scratch', + stdio: ['pipe', 'pipe', 'pipe'], + env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' } + }) + launchedIdentity = identity() + queueMicrotask(() => child.emit('spawn')) + return child + }) + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: profile(), + identity: { launchId: 'launch-1', processId: 'process-1' }, + installationId: 'installation-1', + registry, + deadlineAt: '2030-01-01T00:00:00.000Z', + maximumInputBytes: 1024, + platform: 'linux', + spawn, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => launchedIdentity + }) + expect(spawn).toHaveBeenCalledOnce() + expect(owner.processIdentity).toEqual(launchedIdentity) + expect(registry.get(owner.ownerId)).toMatchObject({ + state: 'running', + installationId: 'installation-1', + ownerToken: 'a'.repeat(32) + }) + child.exitCode = 0 + child.emit('close', 0, null) + expect(registry.get(owner.ownerId)).toBeUndefined() + registry.close() + }) + + it('directly launches and verifies the packaged Node bridge helper', async () => { + const registry = createRegistry() + const child = fakeChild() + const processIdentity = { + ...identity(), + executablePath: '/agent/node' + } + const spawn = vi.fn( + (executable, args) => { + expect(executable).toBe('/agent/node') + expect(args).toEqual([ + '/agent/lib/agent.cjs', + 'model-bridge-helper' + ]) + queueMicrotask(() => child.emit('spawn')) + return child + } + ) + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: { + executable: '/agent/node', + processExecutable: '/agent/node', + args: ['/agent/lib/agent.cjs', 'model-bridge-helper'], + cwd: '/workspace', + env: { PATH: '/usr/bin:/bin' }, + workMode: 'execute' + }, + identity: { launchId: 'launch-1', processId: 'process-1' }, + installationId: 'installation-1', + registry, + deadlineAt: '2030-01-01T00:00:00.000Z', + maximumInputBytes: 1024, + platform: 'linux', + spawn, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => processIdentity + }) + + expect(owner.processIdentity.executablePath).toBe('/agent/node') + expect(registry.get(owner.ownerId)).toMatchObject({ + state: 'running', + processIdentity + }) + child.exitCode = 0 + child.emit('close', 0, null) + registry.close() + }) + + it('rejects PID reuse before sending any signal', async () => { + const registry = createRegistry() + const child = fakeChild() + const original = identity() + const spawn: DirectLinuxStdioSpawn = () => { + queueMicrotask(() => child.emit('spawn')) + return child + } + let reads = 0 + const sendSignal = vi.fn(() => true) + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: profile(), + identity: { launchId: 'launch-1', processId: 'process-1' }, + installationId: 'installation-1', + registry, + deadlineAt: '2030-01-01T00:00:00.000Z', + maximumInputBytes: 1024, + platform: 'linux', + spawn, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => { + reads += 1 + return reads === 1 + ? original + : { ...original, startTimeTicks: 999n } + }, + sendSignal + }) + await expect( + owner.stop({ + reason: 'user-cancelled', + deadlineAt: '2030-01-01T00:00:00.000Z' + }) + ).rejects.toThrow(/identity changed/iu) + expect(sendSignal).not.toHaveBeenCalled() + expect(registry.get(owner.ownerId)).toBeUndefined() + registry.close() + }) + + it('models verified process-group TERM then KILL and requires an empty tree', async () => { + const registry = createRegistry() + const child = fakeChild() + let launched!: LinuxRuntimeProcessIdentity + const spawn: DirectLinuxStdioSpawn = () => { + launched = identity() + queueMicrotask(() => child.emit('spawn')) + return child + } + const signals: Array<[number, string | number | undefined]> = [] + let scans = 0 + let now = Date.now() + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), + profile: profile(), + identity: { launchId: 'launch-1', processId: 'process-1' }, + installationId: 'installation-1', + registry, + deadlineAt: new Date(now + 30_000).toISOString(), + maximumInputBytes: 1024, + platform: 'linux', + spawn, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => launched, + now: () => { + now += 1_000 + return now + }, + listPidNamespaceMembers: async () => { + scans += 1 + return scans < 4 ? [launched] : [] + }, + sendSignal: ((pid, signal) => { + signals.push([pid, signal]) + return true + }) as typeof process.kill + }) + await owner.stop({ + reason: 'user-cancelled', + deadlineAt: new Date(now + 20_000).toISOString() + }) + expect(signals).toEqual([ + [-launched.processGroupId, 'SIGTERM'], + [-launched.processGroupId, 'SIGKILL'] + ]) + expect(registry.get(owner.ownerId)).toBeUndefined() + registry.close() + }) + + it('startup reconciles only owners from the current installation', async () => { + const registry = createRegistry() + const reservation = { + ownerId: 'owner-current', + launchId: 'launch-current', + processId: 'process-current', + installationId: 'installation-current', + ownerToken: 'a'.repeat(32) + } + registry.reserve(reservation) + registry.reserve({ + ...reservation, + ownerId: 'owner-other', + launchId: 'launch-other', + processId: 'process-other', + installationId: 'installation-other' + }) + + await expect( + reconcileOrphanedDirectLinuxStdioProcesses({ + installationId: 'installation-current', + registry + }) + ).resolves.toEqual({ + inspected: 1, + stopped: 1, + conflicts: 0, + unknown: 0 + }) + expect(registry.get('owner-current')).toBeUndefined() + expect(registry.get('owner-other')?.state).toBe('reserved') + registry.close() + }) +}) + +function createRegistry(): RuntimeOwnerRegistry { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-direct-owner-')) + paths.push(root) + if (process.platform !== 'win32') chmodSync(root, 0o700) + return new RuntimeOwnerRegistry(join(root, 'owners.sqlite')) +} + +function fakeChild(): DirectLinuxStdioChild & EventEmitter { + const child = new EventEmitter() as DirectLinuxStdioChild & EventEmitter + child.pid = 42 + child.stdin = new PassThrough() + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.exitCode = null + child.signalCode = null + child.kill = vi.fn(() => true) + return child +} + +function profile() { + return { + executable: '/usr/bin/bwrap' as const, + processExecutable: '/usr/bin/bwrap' as const, + cwd: '/state/scratch', + env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }, + workMode: 'ask' as const, + args: [ + '--new-session', + '--unshare-all', + '--clearenv', + '--proc', + '/proc', + '--ro-bind', + '/workspace', + '/workspace', + '--', + '/bundle/bin/opencode', + 'acp' + ] + } +} + +function identity(): LinuxRuntimeProcessIdentity { + return { + bootId: '11111111-1111-1111-1111-111111111111', + pid: 42, + startTimeTicks: 100n, + processGroupId: 42, + executablePath: '/usr/bin/bwrap' + } +} + +function manifest(): RemoteRuntimeBundleManifest { + return { + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + provider: 'opencode', + platform: 'linux', + architecture: 'x64', + signingKeyId: 'runtime-test', + bundleDigest: `sha256:${'a'.repeat(64)}`, + adapterDigest: `sha256:${'b'.repeat(64)}`, + sourcePackage: { + name: 'opencode-linux-x64-baseline', + integrity: + 'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ==' + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: 'd'.repeat(64), + argvPrefix: ['acp'] + }, + files: [ + { path: 'bin/opencode', size: 64, sha256: 'd'.repeat(64), mode: '0755' }, + { path: 'licenses/opencode.txt', size: 4, sha256: 'e'.repeat(64), mode: '0644' } + ], + licenses: [ + { + package: 'opencode-ai', + version: '1.18.9', + spdx: 'MIT', + path: 'licenses/opencode.txt' + } + ], + 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:${'f'.repeat(64)}`, + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 4096, + maximumPromptOutputBytes: 1024 * 1024 + } + } +} diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.ts b/src/agent-daemon/direct-linux-stdio-process-owner.ts new file mode 100644 index 00000000..9acc2e20 --- /dev/null +++ b/src/agent-daemon/direct-linux-stdio-process-owner.ts @@ -0,0 +1,1063 @@ +import { spawn as nodeSpawn } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { readdir } from 'node:fs/promises' +import { posix } from 'node:path' +import type { Readable, Writable } from 'node:stream' +import { + agentIdentifierSchema, + sha256DigestSchema +} from '../shared/agent-protocol/contracts' +import { canonicalJson } from '../shared/agent-protocol/canonical' +import { + remoteRuntimeBundleManifestSchema, + type RemoteRuntimeBundleManifest +} from '../shared/remote-runtime-launch-contracts' +import type { ModelBridgePolicy } from '../shared/model-bridge-contracts' +import { + createOpenCodeLaunchProfile, + OPENCODE_BWRAP_EXECUTABLE, + type OpenCodeLaunchProfile +} from './opencode-runtime-profile' +import { + RuntimeOwnerRegistry +} from './runtime-owner-registry' +import { + readLinuxRuntimeProcessIdentity, + sameLinuxRuntimeProcessIdentity, + type LinuxRuntimeProcessIdentity +} from './runtime-process-identity' + +const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024 +const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128 +const DEFAULT_MAXIMUM_LISTENERS = 8 +const DEFAULT_STOP_TIMEOUT_MS = 10_000 +const TERM_GRACE_MS = 2_000 +const POLL_INTERVAL_MS = 25 +const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN' + +export type DirectLinuxStdioProcessIdentity = { + launchId: string + processId: string + supervisorIdentityDigest: string +} + +export type DirectLinuxStdioProcessOutput = { + stream: 'stdout' | 'stderr' + data: Uint8Array +} + +export type DirectLinuxStdioProcessReconciliation = { + identity: DirectLinuxStdioProcessIdentity + state: + | 'starting' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted' + | 'outcome-unknown' + processTree: 'running' | 'empty' | 'unknown' +} + +export type DirectLinuxStdioChild = { + pid?: number + stdin: Writable + stdout: Readable + stderr: Readable + exitCode: number | null + signalCode: NodeJS.Signals | null + kill(signal?: NodeJS.Signals | number): boolean + once(event: 'spawn', listener: () => void): unknown + once(event: 'error', listener: (error: Error) => void): unknown + once( + event: 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void + ): unknown + on( + event: 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void + ): unknown +} + +export type DirectLinuxStdioSpawn = ( + executable: string, + args: readonly string[], + options: { + shell: false + detached: true + windowsHide: true + cwd: string + stdio: readonly ['pipe', 'pipe', 'pipe'] + env: Readonly + } +) => DirectLinuxStdioChild + +export type DirectLinuxStdioProcessOwnerOptions = { + manifest: RemoteRuntimeBundleManifest + profile?: OpenCodeLaunchProfile + profileInput?: { + bundleDirectory: string + workspaceDirectory: string + scratchDirectory: string + workMode: 'ask' | 'execute' + modelBridge?: { + agentExecutablePath: string + bridgeDirectory: string + socketPath: string + policy: ModelBridgePolicy + } + } + identity: { launchId: string; processId: string } + installationId: string + registry: RuntimeOwnerRegistry + deadlineAt: string + maximumInputBytes: number + signal?: AbortSignal + platform?: NodeJS.Platform + spawn?: DirectLinuxStdioSpawn + now?: () => number + randomOwnerToken?: () => string + readProcessIdentity?: typeof readLinuxRuntimeProcessIdentity + listPidNamespaceMembers?: typeof listLinuxPidNamespaceMembers + sendSignal?: typeof process.kill + maximumStdinWriteBytes?: number + maximumPendingStdinBytes?: number + maximumOutputQueueChunks?: number + maximumListeners?: number +} + +export class DirectLinuxStdioProcessOwnerError extends Error { + constructor( + message: string, + readonly code: + | 'capacity' + | 'closed' + | 'deadline' + | 'identity' + | 'platform' + | 'process' + ) { + super(message) + this.name = 'DirectLinuxStdioProcessOwnerError' + } +} + +export class DirectLinuxStdioProcessOwner { + readonly identity: DirectLinuxStdioProcessIdentity + readonly ownerId: string + readonly processIdentity: LinuxRuntimeProcessIdentity + + readonly #child: DirectLinuxStdioChild + readonly #registry: RuntimeOwnerRegistry + readonly #now: () => number + readonly #readIdentity: typeof readLinuxRuntimeProcessIdentity + readonly #listMembers: typeof listLinuxPidNamespaceMembers + readonly #sendSignal: typeof process.kill + readonly #maximumOutputBytes: number + readonly #maximumPromptInputBytes: number + readonly #maximumPromptRuntimeMilliseconds: number + readonly #maximumStdinWriteBytes: number + readonly #maximumOutputQueueChunks: number + readonly #maximumListeners: number + readonly #outputListeners = new Set< + (output: DirectLinuxStdioProcessOutput) => void | Promise + >() + readonly #exitListeners = new Set<() => void | Promise>() + readonly #outputQueue: Uint8Array[] = [] + #maximumInputBytes: number + #maximumPendingStdinBytes: number + #inputBytes = 0 + #pendingInputBytes = 0 + #outputBytes = 0 + #queuedOutputBytes = 0 + #writeTail: Promise = Promise.resolve() + #deadlineTimer?: NodeJS.Timeout + #promptGeneration = 1 + #promptActive = true + #draining = false + #closed = false + #stdinClosed = false + #stopPromise?: Promise + #terminalState?: 'completed' | 'failed' | 'cancelled' + + constructor(input: { + identity: DirectLinuxStdioProcessIdentity + ownerId: string + processIdentity: LinuxRuntimeProcessIdentity + child: DirectLinuxStdioChild + registry: RuntimeOwnerRegistry + now: () => number + readProcessIdentity: typeof readLinuxRuntimeProcessIdentity + listPidNamespaceMembers: typeof listLinuxPidNamespaceMembers + sendSignal: typeof process.kill + maximumInputBytes: number + maximumPendingStdinBytes: number + maximumStdinWriteBytes: number + maximumOutputBytes: number + maximumPromptInputBytes: number + maximumPromptRuntimeMilliseconds: number + maximumOutputQueueChunks: number + maximumListeners: number + deadlineAt: string + signal?: AbortSignal + }) { + this.identity = input.identity + this.ownerId = input.ownerId + this.processIdentity = Object.freeze({ ...input.processIdentity }) + this.#child = input.child + this.#registry = input.registry + this.#now = input.now + this.#readIdentity = input.readProcessIdentity + this.#listMembers = input.listPidNamespaceMembers + this.#sendSignal = input.sendSignal + this.#maximumInputBytes = input.maximumInputBytes + this.#maximumPendingStdinBytes = input.maximumPendingStdinBytes + this.#maximumStdinWriteBytes = input.maximumStdinWriteBytes + this.#maximumOutputBytes = input.maximumOutputBytes + this.#maximumPromptInputBytes = input.maximumPromptInputBytes + this.#maximumPromptRuntimeMilliseconds = + input.maximumPromptRuntimeMilliseconds + this.#maximumOutputQueueChunks = input.maximumOutputQueueChunks + this.#maximumListeners = input.maximumListeners + this.#scheduleDeadline(input.deadlineAt) + input.child.stdout.on('data', (chunk: Buffer | Uint8Array) => { + this.#acceptOutput(chunk) + }) + // Stderr is deliberately drained but never forwarded into ACP. + input.child.stderr.on('data', () => undefined) + input.child.on('close', (code) => this.#handleClose(code)) + } + + async writeStdin(payload: Uint8Array): Promise { + if (!(payload instanceof Uint8Array) || payload.byteLength === 0) { + throw capacityError('ACP stdin payload must be non-empty bytes') + } + if ( + this.#closed || + this.#stdinClosed || + this.#stopPromise !== undefined || + !this.#promptActive + ) { + throw new DirectLinuxStdioProcessOwnerError('Runtime stdin is closed', 'closed') + } + if ( + payload.byteLength > this.#maximumStdinWriteBytes || + this.#inputBytes + payload.byteLength > this.#maximumInputBytes || + this.#pendingInputBytes + payload.byteLength > this.#maximumPendingStdinBytes + ) { + throw capacityError('Runtime stdin quota reached') + } + const copy = Buffer.from(payload) + this.#inputBytes += copy.byteLength + this.#pendingInputBytes += copy.byteLength + const write = this.#writeTail.then(async () => { + if (this.#closed || this.#stdinClosed || !this.#promptActive) { + throw new DirectLinuxStdioProcessOwnerError('Runtime stdin is closed', 'closed') + } + await writeWithBackpressure(this.#child.stdin, copy) + }) + this.#writeTail = write.catch(() => undefined) + try { + await write + } finally { + this.#pendingInputBytes -= copy.byteLength + } + } + + beginPrompt(input: { deadlineAt: string; maximumInputBytes: number }): void { + if (this.#closed || this.#stdinClosed || this.#promptActive) { + throw new DirectLinuxStdioProcessOwnerError( + this.#promptActive ? 'A Runtime prompt is already active' : 'Runtime process is closed', + this.#promptActive ? 'identity' : 'closed' + ) + } + this.#maximumInputBytes = positiveInteger( + Math.min(input.maximumInputBytes, this.#maximumPromptInputBytes), + 'Maximum Runtime input bytes' + ) + this.#maximumPendingStdinBytes = this.#maximumInputBytes + this.#inputBytes = 0 + this.#pendingInputBytes = 0 + this.#outputBytes = 0 + this.#promptActive = true + this.#promptGeneration += 1 + this.#scheduleDeadline( + boundedPromptDeadline( + input.deadlineAt, + this.#now(), + this.#maximumPromptRuntimeMilliseconds + ) + ) + } + + async completePrompt(): Promise { + if (!this.#promptActive) return + await this.#writeTail + this.#promptActive = false + this.#inputBytes = 0 + this.#pendingInputBytes = 0 + this.#promptGeneration += 1 + this.#clearDeadline() + } + + subscribeOutput( + listener: (output: DirectLinuxStdioProcessOutput) => void | Promise + ): () => void { + if (this.#outputListeners.size >= this.#maximumListeners) { + throw capacityError('Runtime output listener capacity reached') + } + this.#outputListeners.add(listener) + this.#scheduleDrain() + return () => this.#outputListeners.delete(listener) + } + + subscribeExit(listener: () => void | Promise): () => void { + if (this.#closed) { + void Promise.resolve(listener()).catch(() => undefined) + return () => undefined + } + if (this.#exitListeners.size >= this.#maximumListeners) { + throw capacityError('Runtime exit listener capacity reached') + } + this.#exitListeners.add(listener) + return () => this.#exitListeners.delete(listener) + } + + async stop(input: { + reason: + | 'binding-closed' + | 'controller-disconnected' + | 'deadline-exceeded' + | 'identity-conflict' + | 'output-quota' + | 'user-cancelled' + deadlineAt: string + }): Promise { + if (this.#stopPromise !== undefined) return await this.#stopPromise + this.#stopPromise = this.#stopOnce(input.deadlineAt) + try { + await this.#stopPromise + } catch (error) { + this.#stopPromise = undefined + throw error + } + } + + async dispose(): Promise { + await this.stop({ + reason: 'binding-closed', + deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString() + }) + } + + async reconcile(): Promise { + if (this.#terminalState !== undefined) { + return { + identity: this.identity, + state: this.#terminalState, + processTree: 'empty' + } + } + try { + const actual = await this.#readIdentity(this.processIdentity.pid) + if (!sameLinuxRuntimeProcessIdentity(this.processIdentity, actual)) { + return { + identity: this.identity, + state: 'outcome-unknown', + processTree: 'unknown' + } + } + return { identity: this.identity, state: 'running', processTree: 'running' } + } catch (error) { + if (isMissingProcess(error) && this.#closed) { + return { + identity: this.identity, + state: this.#terminalState ?? 'interrupted', + processTree: 'empty' + } + } + return { + identity: this.identity, + state: 'outcome-unknown', + processTree: 'unknown' + } + } + } + + async #stopOnce(deadlineAt: string): Promise { + remainingMilliseconds(deadlineAt, this.#now()) + if (this.#terminalState !== undefined) return + const record = this.#registry.get(this.ownerId) + if (record?.state !== 'running') { + throw identityError('Runtime owner registry identity conflicts') + } + const actual = await this.#readIdentity(this.processIdentity.pid) + if ( + record.processIdentity === undefined || + !sameLinuxRuntimeProcessIdentity(record.processIdentity, actual) + ) { + this.#registry.remove(this.ownerId, 'running') + throw identityError('Runtime PID identity changed before signal') + } + this.#registry.markStopping(this.ownerId) + this.#stdinClosed = true + this.#child.stdin.end() + this.#sendSignal(-record.processIdentity.processGroupId, 'SIGTERM') + const termDeadline = Math.min(Date.parse(deadlineAt), this.#now() + TERM_GRACE_MS) + if (!(await this.#waitForTreeEmpty(termDeadline))) { + await this.#killVerifiedProcessGroup() + if (!(await this.#waitForTreeEmpty(Date.parse(deadlineAt)))) { + this.#registry.remove(this.ownerId, 'stopping') + throw new DirectLinuxStdioProcessOwnerError( + 'Runtime process group cleanup could not be verified', + 'process' + ) + } + } + this.#registry.remove(this.ownerId, 'stopping') + this.#terminalState = 'cancelled' + this.#closed = true + this.#clearDeadline() + } + + async #killVerifiedProcessGroup(): Promise { + const record = this.#registry.get(this.ownerId) + const actual = await this.#readIdentity(this.processIdentity.pid) + if ( + record?.state !== 'stopping' || + record.processIdentity === undefined || + !sameLinuxRuntimeProcessIdentity(record.processIdentity, actual) + ) { + if (record?.state === 'stopping') { + this.#registry.remove(this.ownerId, 'stopping') + } + throw identityError('Runtime PID identity changed before signal') + } + this.#sendSignal(-record.processIdentity.processGroupId, 'SIGKILL') + } + + async #waitForTreeEmpty(deadline: number): Promise { + while (this.#now() < deadline) { + const members = await this.#listMembers(this.processIdentity) + if (members.length === 0) return true + await delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - this.#now()))) + } + return (await this.#listMembers(this.processIdentity)).length === 0 + } + + #acceptOutput(chunk: Buffer | Uint8Array): void { + if (this.#closed || chunk.byteLength === 0) return + if ( + this.#outputBytes + chunk.byteLength > this.#maximumOutputBytes || + this.#outputQueue.length >= this.#maximumOutputQueueChunks || + this.#queuedOutputBytes + chunk.byteLength > this.#maximumOutputBytes + ) { + this.#child.stdout.pause() + this.#outputQueue.splice(0) + this.#queuedOutputBytes = 0 + void this.stop({ + reason: 'output-quota', + deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString() + }).catch(() => undefined) + return + } + const copy = Uint8Array.from(chunk) + this.#outputBytes += copy.byteLength + this.#queuedOutputBytes += copy.byteLength + this.#outputQueue.push(copy) + this.#scheduleDrain() + } + + #scheduleDrain(): void { + if (this.#draining || this.#outputListeners.size === 0 || this.#outputQueue.length === 0) return + this.#draining = true + queueMicrotask(() => void this.#drain()) + } + + async #drain(): Promise { + try { + while (this.#outputListeners.size > 0 && this.#outputQueue.length > 0) { + const data = this.#outputQueue.shift()! + this.#queuedOutputBytes -= data.byteLength + for (const listener of [...this.#outputListeners]) { + await listener({ stream: 'stdout', data: data.slice() }) + } + } + } catch { + void this.stop({ + reason: 'output-quota', + deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString() + }).catch(() => undefined) + } finally { + this.#draining = false + this.#scheduleDrain() + } + } + + #handleClose(code: number | null): void { + this.#closed = true + this.#clearDeadline() + const record = this.#registry.get(this.ownerId) + if (record?.state === 'running') { + const outcome = code === 0 ? 'completed' : 'failed' + this.#registry.remove(this.ownerId, 'running') + this.#terminalState = outcome + } + for (const listener of this.#exitListeners) { + void Promise.resolve(listener()).catch(() => undefined) + } + this.#exitListeners.clear() + } + + #scheduleDeadline(deadlineAt: string): void { + const remaining = remainingMilliseconds(deadlineAt, this.#now()) + this.#clearDeadline() + const generation = this.#promptGeneration + this.#deadlineTimer = setTimeout(() => { + if (!this.#promptActive || generation !== this.#promptGeneration) return + void this.stop({ + reason: 'deadline-exceeded', + deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString() + }).catch(() => undefined) + }, Math.min(remaining, 0x7fff_ffff)) + this.#deadlineTimer.unref?.() + } + + #clearDeadline(): void { + if (this.#deadlineTimer !== undefined) clearTimeout(this.#deadlineTimer) + this.#deadlineTimer = undefined + } + +} + +export async function launchDirectLinuxStdioProcessOwner( + options: DirectLinuxStdioProcessOwnerOptions +): Promise { + if ((options.platform ?? process.platform) !== 'linux') { + throw new DirectLinuxStdioProcessOwnerError( + 'Direct Runtime ownership is Linux-only', + 'platform' + ) + } + if (options.signal?.aborted === true) { + throw new DirectLinuxStdioProcessOwnerError('Runtime launch was cancelled', 'deadline') + } + const now = options.now ?? Date.now + const manifest = remoteRuntimeBundleManifestSchema.parse(options.manifest) + const limits = enforceablePromptLimits(manifest) + const effectiveDeadlineAt = boundedPromptDeadline( + options.deadlineAt, + now(), + limits.maximumPromptRuntimeMilliseconds + ) + const timeoutMs = remainingMilliseconds(effectiveDeadlineAt, now()) + const baseProfile = resolveProfile(options, manifest) + const ownerToken = (options.randomOwnerToken ?? (() => randomBytes(16).toString('hex')))() + if (!/^[a-f0-9]{32}$/u.test(ownerToken)) { + throw identityError('Runtime owner token marker is malformed') + } + const profile = bindDirectOwnerToken(baseProfile, ownerToken) + const stableIdentity = { + launchId: agentIdentifierSchema.parse(options.identity.launchId), + processId: agentIdentifierSchema.parse(options.identity.processId) + } + const installationId = agentIdentifierSchema.parse(options.installationId) + const ownerId = `owner-${stableIdentity.launchId}` + agentIdentifierSchema.parse(ownerId) + const maximumInputBytes = positiveInteger( + Math.min(options.maximumInputBytes, limits.maximumPromptInputBytes), + 'Maximum Runtime input bytes' + ) + const maximumStdinWriteBytes = positiveInteger( + options.maximumStdinWriteBytes ?? DEFAULT_MAXIMUM_STDIN_WRITE_BYTES, + 'Maximum Runtime stdin write bytes' + ) + const maximumPendingStdinBytes = positiveInteger( + options.maximumPendingStdinBytes ?? maximumInputBytes, + 'Maximum pending Runtime stdin bytes' + ) + const maximumOutputQueueChunks = positiveInteger( + options.maximumOutputQueueChunks ?? DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS, + 'Maximum Runtime output queue chunks' + ) + const maximumListeners = positiveInteger( + options.maximumListeners ?? DEFAULT_MAXIMUM_LISTENERS, + 'Maximum Runtime output listeners' + ) + options.registry.reserve({ + ownerId, + ...stableIdentity, + installationId, + ownerToken + }) + const spawn = options.spawn ?? defaultSpawn + const child = spawn(profile.executable, profile.args, { + shell: false, + detached: true, + windowsHide: true, + cwd: profile.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: profile.env + }) + try { + await waitForSpawn(child, options.signal, timeoutMs) + if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID') + const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity + const processIdentity = await readIdentity(child.pid) + assertLaunchedIdentity(processIdentity, profile) + const supervisorIdentityDigest = supervisorDigest({ + installationId, + ownerId, + ownerToken, + processIdentity, + profile + }) + options.registry.markRunning(ownerId, processIdentity) + return new DirectLinuxStdioProcessOwner({ + identity: { ...stableIdentity, supervisorIdentityDigest }, + ownerId, + processIdentity, + child, + registry: options.registry, + now, + readProcessIdentity: readIdentity, + listPidNamespaceMembers: + options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers, + sendSignal: options.sendSignal ?? process.kill, + maximumInputBytes, + maximumPendingStdinBytes, + maximumStdinWriteBytes, + maximumOutputBytes: manifest.limits.maximumPromptOutputBytes, + maximumPromptInputBytes: limits.maximumPromptInputBytes, + maximumPromptRuntimeMilliseconds: + limits.maximumPromptRuntimeMilliseconds, + maximumOutputQueueChunks, + maximumListeners, + deadlineAt: effectiveDeadlineAt, + signal: options.signal + }) + } catch (error) { + const record = options.registry.get(ownerId) + if (record?.state === 'reserved') { + options.registry.remove(ownerId, 'reserved') + } + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') + throw error + } +} + +export async function reconcileOrphanedDirectLinuxStdioProcesses(options: { + installationId: string + registry: RuntimeOwnerRegistry + deadlineAt?: string + now?: () => number + readProcessIdentity?: typeof readLinuxRuntimeProcessIdentity + listPidNamespaceMembers?: typeof listLinuxPidNamespaceMembers + sendSignal?: typeof process.kill +}): Promise<{ inspected: number; stopped: number; conflicts: number; unknown: number }> { + const installationId = agentIdentifierSchema.parse(options.installationId) + const now = options.now ?? Date.now + const deadlineAt = + options.deadlineAt ?? new Date(now() + 30_000).toISOString() + const records = options.registry + .listForInstallation(installationId) + let stopped = 0 + let conflicts = 0 + let unknown = 0 + const sendSignal = options.sendSignal ?? process.kill + for (const record of records) { + remainingMilliseconds(deadlineAt, now()) + if (record.state === 'reserved' || record.processIdentity === undefined) { + options.registry.remove(record.ownerId, record.state) + stopped += 1 + continue + } + const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity + try { + const actual = await readIdentity(record.processIdentity.pid) + if (!sameLinuxRuntimeProcessIdentity(record.processIdentity, actual)) { + options.registry.remove(record.ownerId, record.state) + conflicts += 1 + continue + } + if (record.state === 'running') options.registry.markStopping(record.ownerId) + sendSignal( + -record.processIdentity.processGroupId, + 'SIGTERM' + ) + const listMembers = options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers + const termDeadline = Math.min(Date.parse(deadlineAt), now() + TERM_GRACE_MS) + if (!(await waitForNamespaceEmpty(record.processIdentity, listMembers, termDeadline, now))) { + const current = options.registry.get(record.ownerId) + const ownerIdentity = await readIdentity(record.processIdentity.pid) + if ( + current?.state !== 'stopping' || + current.processIdentity === undefined || + !sameLinuxRuntimeProcessIdentity(current.processIdentity, ownerIdentity) + ) { + throw identityError('Orphan Runtime identity changed before signal') + } + sendSignal(-record.processIdentity.processGroupId, 'SIGKILL') + if (!(await waitForNamespaceEmpty(record.processIdentity, listMembers, Date.parse(deadlineAt), now))) { + throw new Error('Orphan Runtime process group remains populated') + } + } + options.registry.remove(record.ownerId, 'stopping') + stopped += 1 + } catch (error) { + const current = options.registry.get(record.ownerId) + if (isMissingProcess(error) && current !== undefined) { + try { + const listMembers = + options.listPidNamespaceMembers ?? + listLinuxPidNamespaceMembers + const members = await listMembers(record.processIdentity) + if (members.length === 0) { + options.registry.remove(record.ownerId, current.state) + stopped += 1 + } else { + // Without the recorded leader identity there is no safe process + // to match immediately before a group signal. + options.registry.remove(record.ownerId, current.state) + unknown += 1 + } + } catch { + const latest = options.registry.get(record.ownerId) + if (latest !== undefined) options.registry.remove(record.ownerId, latest.state) + unknown += 1 + } + } else if (error instanceof DirectLinuxStdioProcessOwnerError && error.code === 'identity') { + if (current !== undefined) options.registry.remove(record.ownerId, current.state) + conflicts += 1 + } else { + if (current !== undefined) options.registry.remove(record.ownerId, current.state) + unknown += 1 + } + } + } + return { inspected: records.length, stopped, conflicts, unknown } +} + +export function bindDirectOwnerToken( + profileInput: OpenCodeLaunchProfile, + ownerToken: string +): OpenCodeLaunchProfile { + const profile = validateProfile(profileInput) + if ( + !/^[a-f0-9]{32}$/u.test(ownerToken) || + profile.args.includes(OWNER_TOKEN_ENVIRONMENT_NAME) || + profile.env[OWNER_TOKEN_ENVIRONMENT_NAME] !== undefined + ) { + throw identityError('Runtime owner token marker is invalid or duplicated') + } + if (profile.workMode === 'execute') { + return { + ...profile, + env: { + ...profile.env, + [OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken + } + } + } + const separator = profile.args.indexOf('--') + return { + ...profile, + args: [ + ...profile.args.slice(0, separator), + '--setenv', + OWNER_TOKEN_ENVIRONMENT_NAME, + ownerToken, + ...profile.args.slice(separator) + ], + env: { ...profile.env } + } +} + +export async function listLinuxPidNamespaceMembers( + owner: Pick< + LinuxRuntimeProcessIdentity, + 'bootId' | 'processGroupId' + >, + options: { procRoot?: string } = {} +): Promise { + const procRoot = options.procRoot ?? '/proc' + const entries = await readdir(procRoot, { withFileTypes: true }) + const identities: LinuxRuntimeProcessIdentity[] = [] + await Promise.all( + entries + .filter((entry) => entry.isDirectory() && /^[1-9]\d*$/u.test(entry.name)) + .map(async (entry) => { + try { + const identity = await readLinuxRuntimeProcessIdentity(Number(entry.name), { + procRoot, + bootId: owner.bootId + }) + if (identity.processGroupId === owner.processGroupId) { + identities.push(identity) + } + } catch (error) { + if (!isMissingProcess(error)) throw error + } + }) + ) + return identities.sort((left, right) => left.pid - right.pid) +} + +function resolveProfile( + options: DirectLinuxStdioProcessOwnerOptions, + manifest: RemoteRuntimeBundleManifest +): OpenCodeLaunchProfile { + if ((options.profile === undefined) === (options.profileInput === undefined)) { + throw identityError('Exactly one Runtime launch profile source is required') + } + return validateProfile( + options.profile ?? + createOpenCodeLaunchProfile({ manifest, ...options.profileInput! }) + ) +} + +function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile { + if ( + !posix.isAbsolute(profile.executable) || + posix.normalize(profile.executable) !== profile.executable || + !posix.isAbsolute(profile.processExecutable) || + posix.normalize(profile.processExecutable) !== + profile.processExecutable || + !posix.isAbsolute(profile.cwd) || + posix.normalize(profile.cwd) !== profile.cwd || + profile.args.length === 0 || + profile.args.some((argument) => typeof argument !== 'string' || argument.includes('\0')) || + Object.entries(profile.env).some( + ([name, value]) => + name.includes('\0') || + (value !== undefined && value.includes('\0')) + ) + ) { + throw identityError('Runtime launch profile is malformed') + } + if ( + profile.workMode === 'ask' && + ( + profile.executable !== OPENCODE_BWRAP_EXECUTABLE || + profile.args.includes(['--die', 'with-parent'].join('-')) || + !profile.args.includes('--unshare-all') || + !profile.args.includes('--new-session') || + !profile.args.includes('--clearenv') || + !profile.args.includes('--ro-bind') || + profile.args.filter((argument) => argument === '--').length !== 1 + ) + ) { + throw identityError('Ask Runtime launch profile is not read-only') + } + if ( + profile.workMode === 'execute' && + ( + profile.executable === OPENCODE_BWRAP_EXECUTABLE || + profile.args.includes('--unshare-all') || + profile.args.includes('--clearenv') || + profile.args.includes('--ro-bind') || + profile.args.includes('--bind') + ) + ) { + throw identityError('Execute Runtime launch profile must run directly') + } + return { + executable: profile.executable, + processExecutable: profile.processExecutable, + args: [...profile.args], + cwd: profile.cwd, + env: { ...profile.env }, + workMode: profile.workMode + } +} + +function assertLaunchedIdentity( + identity: LinuxRuntimeProcessIdentity, + profile: OpenCodeLaunchProfile +): void { + if ( + identity.executablePath !== profile.processExecutable || + identity.processGroupId !== identity.pid + ) { + throw identityError('Spawned Runtime does not match the launch identity') + } +} + +function supervisorDigest(input: { + installationId: string + ownerId: string + ownerToken: string + processIdentity: LinuxRuntimeProcessIdentity + profile: OpenCodeLaunchProfile +}): string { + return canonicalDigest({ + installationId: input.installationId, + ownerId: input.ownerId, + ownerToken: input.ownerToken, + processIdentity: { + ...input.processIdentity, + startTimeTicks: input.processIdentity.startTimeTicks.toString() + }, + profile: input.profile + }) +} + +function canonicalDigest(value: unknown): string { + return sha256DigestSchema.parse( + `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}` + ) +} + +function defaultSpawn( + executable: string, + args: readonly string[], + options: Parameters[2] +): DirectLinuxStdioChild { + return nodeSpawn(executable, [...args], options) +} + +async function waitForSpawn( + child: DirectLinuxStdioChild, + signal: AbortSignal | undefined, + timeoutMs: number +): Promise { + await new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void): void => { + if (settled) return + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + callback() + } + const abort = (): void => finish(() => reject( + new DirectLinuxStdioProcessOwnerError('Runtime launch was cancelled', 'deadline') + )) + const timer = setTimeout(abort, timeoutMs) + timer.unref?.() + child.once('spawn', () => finish(resolve)) + child.once('error', (error) => finish(() => reject(error))) + signal?.addEventListener('abort', abort, { once: true }) + }) +} + +async function waitForNamespaceEmpty( + identity: LinuxRuntimeProcessIdentity, + listMembers: typeof listLinuxPidNamespaceMembers, + deadline: number, + now: () => number +): Promise { + while (now() < deadline) { + if ((await listMembers(identity)).length === 0) return true + await delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - now()))) + } + return (await listMembers(identity)).length === 0 +} + +async function writeWithBackpressure(stream: Writable, payload: Buffer): Promise { + await new Promise((resolve, reject) => { + let callbackComplete = false + let drained = true + let settled = false + const cleanup = (): void => { + stream.removeListener('drain', onDrain) + stream.removeListener('error', onError) + stream.removeListener('close', onClose) + } + const finish = (): void => { + if (settled || !callbackComplete || !drained) return + settled = true + cleanup() + resolve() + } + const onError = (error: Error): void => { + if (settled) return + settled = true + cleanup() + reject(error) + } + const onClose = (): void => onError( + new DirectLinuxStdioProcessOwnerError('Runtime stdin closed during write', 'closed') + ) + const onDrain = (): void => { + drained = true + finish() + } + stream.once('error', onError) + stream.once('close', onClose) + if (!stream.write(payload, (error?: Error | null) => { + if (error != null) onError(error) + else { + callbackComplete = true + finish() + } + })) { + drained = false + stream.once('drain', onDrain) + } + }) +} + +function positiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw capacityError(`${label} must be positive`) + return value +} + +function remainingMilliseconds(deadlineAt: string, now: number): number { + const deadline = Date.parse(deadlineAt) + const remaining = deadline - now + if (!Number.isFinite(deadline) || !Number.isSafeInteger(remaining) || remaining <= 0) { + throw new DirectLinuxStdioProcessOwnerError('Runtime operation deadline elapsed', 'deadline') + } + return Math.min(remaining, 120_000) +} + +function enforceablePromptLimits(manifest: RemoteRuntimeBundleManifest): { + maximumPromptRuntimeMilliseconds: number + maximumPromptInputBytes: number +} { + return { + maximumPromptRuntimeMilliseconds: + manifest.limits.maximumPromptRuntimeMilliseconds, + maximumPromptInputBytes: + manifest.limits.maximumPromptInputBytes + } +} + +function boundedPromptDeadline( + requestedDeadlineAt: string, + now: number, + maximumRuntimeMilliseconds: number +): string { + const requested = Date.parse(requestedDeadlineAt) + if (!Number.isFinite(requested) || requested <= now) { + throw new DirectLinuxStdioProcessOwnerError( + 'Runtime operation deadline elapsed', + 'deadline' + ) + } + return new Date( + Math.min(requested, now + maximumRuntimeMilliseconds) + ).toISOString() +} + +function capacityError(message: string): DirectLinuxStdioProcessOwnerError { + return new DirectLinuxStdioProcessOwnerError(message, 'capacity') +} + +function identityError(message: string): DirectLinuxStdioProcessOwnerError { + return new DirectLinuxStdioProcessOwnerError(message, 'identity') +} + +function isMissingProcess(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + ['ENOENT', 'ESRCH'].includes(String(error.code)) + ) +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} diff --git a/src/agent-daemon/entry.ts b/src/agent-daemon/entry.ts new file mode 100644 index 00000000..c6d7d116 --- /dev/null +++ b/src/agent-daemon/entry.ts @@ -0,0 +1,15 @@ +import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' +import { runAgentCli } from './cli' + +export async function main(argv = process.argv.slice(2)): Promise { + return await runAgentCli(argv) +} + +const invokedPath = process.argv[1] +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(resolve(invokedPath)).href +) { + process.exitCode = await main() +} diff --git a/src/agent-daemon/errors.ts b/src/agent-daemon/errors.ts new file mode 100644 index 00000000..85756c72 --- /dev/null +++ b/src/agent-daemon/errors.ts @@ -0,0 +1,12 @@ +export class AgentUnsupportedError extends Error { + constructor( + message: string, + readonly code: + | 'platform-incompatible' + | 'supervisor-unavailable' + | 'peer-identity-unavailable' + ) { + super(message) + this.name = 'AgentUnsupportedError' + } +} diff --git a/src/agent-daemon/event-journal.test.ts b/src/agent-daemon/event-journal.test.ts new file mode 100644 index 00000000..5b2829ec --- /dev/null +++ b/src/agent-daemon/event-journal.test.ts @@ -0,0 +1,842 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { + EventJournal, + EventJournalCapacityError, + EventJournalSequenceError +} from './event-journal' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +async function createJournal( + overrides: ConstructorParameters[1] = {} +): Promise<{ journal: EventJournal; path: string }> { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-event-journal-') + ) + directories.push(directory) + const path = join(directory, 'events.sqlite') + const journal = new EventJournal(path, { + defaultStreamLimits: { + maximumBytes: 100, + maximumEvents: 10, + terminalReserveBytes: 20, + terminalReserveEvents: 2, + maximumDurationMs: 60_000 + }, + maximumControllerBytes: 500, + maximumDaemonBytes: 1_000, + terminalReserveBytes: 50, + ...overrides + }) + return { journal, path } +} + +function event(sequence: string) { + return { + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence + } +} + +describe('EventJournal event streams', () => { + it('persists before replay and survives reopening', async () => { + const { journal, path } = await createJournal() + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array([1, 2, 3]) + }) + journal.close() + + const reopened = new EventJournal(path, { + maximumControllerBytes: 500, + maximumDaemonBytes: 1_000 + }) + expect( + reopened.replayEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + afterSequence: '0' + }) + ).toEqual([ + expect.objectContaining({ + identity: event('1'), + payload: new Uint8Array([1, 2, 3]) + }) + ]) + reopened.close() + }) + + it('requires contiguous identity and idempotently accepts exact duplicates', async () => { + const { journal } = await createJournal() + const input = { + identity: event('1'), + eventClass: 'control' as const, + payload: new Uint8Array([4]) + } + expect(journal.appendEvent(input).created).toBe(true) + expect(journal.appendEvent(input).created).toBe(false) + expect(() => + journal.appendEvent({ + ...input, + payload: new Uint8Array([5]) + }) + ).toThrow(EventJournalSequenceError) + expect(() => + journal.appendEvent({ + ...input, + identity: event('3') + }) + ).toThrow(/contiguous/iu) + journal.close() + }) + + it('reserves bytes for truncation/cancellation terminal events', async () => { + const { journal } = await createJournal({ + defaultStreamLimits: { + maximumBytes: 10, + maximumEvents: 10, + terminalReserveBytes: 4, + terminalReserveEvents: 2, + maximumDurationMs: 60_000 + }, + maximumControllerBytes: 20, + maximumDaemonBytes: 30, + terminalReserveBytes: 5 + }) + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array(6) + }) + try { + journal.appendEvent({ + identity: event('2'), + eventClass: 'data', + payload: new Uint8Array(1) + }) + } catch (error) { + expect(error).toMatchObject({ + level: 'stream', + terminalWriteAllowed: true + }) + } + expect( + journal.appendEvent({ + identity: event('2'), + eventClass: 'terminal', + payload: new Uint8Array(4) + }).created + ).toBe(true) + expect(() => + journal.appendEvent({ + identity: event('3'), + eventClass: 'terminal', + payload: new Uint8Array(1) + }) + ).toThrow(EventJournalCapacityError) + journal.close() + }) + + it('ACK-prunes data immediately but retains terminal records for the floor', async () => { + let now = 1_000 + const { journal } = await createJournal({ + now: () => now, + minimumUnacknowledgedTerminalRetentionMs: 100 + }) + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array([1]) + }) + journal.appendEvent({ + identity: event('2'), + eventClass: 'terminal', + payload: new Uint8Array([2]) + }) + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '2' + }) + expect( + journal + .replayEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + afterSequence: '0' + }) + .map((item) => item.identity.sequence) + ).toEqual(['2']) + now += 101 + expect(journal.pruneAcknowledged()).toBe(1) + expect( + journal.replayEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + afterSequence: '0' + }) + ).toEqual([]) + journal.close() + }) + + it('rejects ACKs beyond durable sequence or moving backwards', async () => { + const { journal } = await createJournal() + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array([1]) + }) + expect(() => + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '2' + }) + ).toThrow(/exceeds/iu) + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '1' + }) + expect(() => + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '0' + }) + ).toThrow() + journal.close() + }) + + it('migrates legacy streams and reconstructs durable usage counters', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-event-journal-legacy-') + ) + directories.push(directory) + const path = join(directory, 'events.sqlite') + const legacy = new DatabaseSync(path) + legacy.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE event_streams ( + controller_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_epoch INTEGER NOT NULL, + last_sequence INTEGER NOT NULL DEFAULT 0, + ack_sequence INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + maximum_bytes INTEGER NOT NULL, + maximum_events INTEGER NOT NULL, + terminal_reserve_bytes INTEGER NOT NULL, + terminal_reserve_events INTEGER NOT NULL, + maximum_duration_ms INTEGER NOT NULL, + PRIMARY KEY (controller_id, stream_id, stream_epoch) + ) STRICT; + CREATE TABLE journal_events ( + controller_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_epoch INTEGER NOT NULL, + sequence INTEGER NOT NULL, + event_class TEXT NOT NULL, + payload BLOB NOT NULL, + byte_length INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY ( + controller_id, stream_id, stream_epoch, sequence + ), + FOREIGN KEY (controller_id, stream_id, stream_epoch) + REFERENCES event_streams( + controller_id, stream_id, stream_epoch + ) ON DELETE CASCADE + ) STRICT; + `) + legacy + .prepare( + `INSERT INTO event_streams VALUES ( + 'controller-1', 'run-1', 1, 1, 0, 1000, + 10, 10, 2, 2, 60000 + )` + ) + .run() + legacy + .prepare( + `INSERT INTO journal_events VALUES ( + 'controller-1', 'run-1', 1, 1, 'data', ?, 6, 1000 + )` + ) + .run(new Uint8Array(6)) + legacy.close() + + const migrated = new EventJournal(path, { + defaultStreamLimits: { + maximumBytes: 10, + maximumEvents: 10, + terminalReserveBytes: 2, + terminalReserveEvents: 2, + maximumDurationMs: 60_000 + }, + maximumControllerBytes: 20, + maximumDaemonBytes: 30, + terminalReserveBytes: 2 + }) + expect(() => + migrated.appendEvent({ + identity: event('2'), + eventClass: 'data', + payload: new Uint8Array(3) + }) + ).toThrow(EventJournalCapacityError) + migrated.close() + }) + + it('repairs corrupted usage counters before enforcing quotas', async () => { + const { journal, path } = await createJournal({ + defaultStreamLimits: { + maximumBytes: 10, + maximumEvents: 10, + terminalReserveBytes: 2, + terminalReserveEvents: 2, + maximumDurationMs: 60_000 + } + }) + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array(6) + }) + journal.close() + const corrupt = new DatabaseSync(path) + corrupt.exec(` + UPDATE event_streams + SET stored_bytes = 0, stored_events = 0; + UPDATE event_journal_usage SET total_bytes = 0; + DELETE FROM event_controller_usage; + `) + corrupt.close() + + const repaired = new EventJournal(path, { + maximumControllerBytes: 500, + maximumDaemonBytes: 1_000 + }) + expect(() => + repaired.appendEvent({ + identity: event('2'), + eventClass: 'data', + payload: new Uint8Array(3) + }) + ).toThrow(EventJournalCapacityError) + repaired.close() + }) + + it('accounts for event and ACP bytes together and releases pruned quota', async () => { + const { journal } = await createJournal({ + maximumControllerBytes: 6, + maximumDaemonBytes: 8, + terminalReserveBytes: 0 + }) + journal.appendEvent({ + identity: event('1'), + eventClass: 'data', + payload: new Uint8Array(4) + }) + expect(() => + journal.appendAcpFrame({ + controllerId: 'controller-1', + bindingId: 'binding-controller-limit', + channelEpoch: '1', + direction: 'runtime-to-main', + sequence: '1', + payload: new Uint8Array(3) + }) + ).toThrow( + expect.objectContaining({ level: 'controller' }) + ) + journal.appendAcpFrame({ + controllerId: 'controller-2', + bindingId: 'binding-daemon-limit', + channelEpoch: '1', + direction: 'runtime-to-main', + sequence: '1', + payload: new Uint8Array(4) + }) + expect(() => + journal.appendAcpFrame({ + controllerId: 'controller-2', + bindingId: 'binding-daemon-limit', + channelEpoch: '1', + direction: 'runtime-to-main', + sequence: '2', + payload: new Uint8Array(1) + }) + ).toThrow(expect.objectContaining({ level: 'daemon' })) + + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '1' + }) + expect( + journal.appendAcpFrame({ + controllerId: 'controller-2', + bindingId: 'binding-daemon-limit', + channelEpoch: '1', + direction: 'runtime-to-main', + sequence: '2', + payload: new Uint8Array(1) + }).created + ).toBe(true) + journal.close() + }) + + it('retires fully ACKed terminal stream metadata without permitting reuse', async () => { + const { journal, path } = await createJournal({ + minimumUnacknowledgedTerminalRetentionMs: 0 + }) + journal.appendEvent({ + identity: event('1'), + eventClass: 'terminal', + payload: new Uint8Array([1]) + }) + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '1' + }) + expect(() => + journal.acknowledgeEvents({ + controllerId: 'controller-1', + streamId: 'run-1', + streamEpoch: '1', + sequence: '1' + }) + ).not.toThrow() + expect(() => + journal.appendEvent({ + identity: event('1'), + eventClass: 'terminal', + payload: new Uint8Array([1]) + }) + ).toThrow(EventJournalSequenceError) + journal.close() + + const check = new DatabaseSync(path) + expect( + ( + check + .prepare('SELECT COUNT(*) AS count FROM event_streams') + .get() as { count: number } + ).count + ).toBe(0) + expect( + ( + check + .prepare( + 'SELECT COUNT(*) AS count FROM retired_event_streams' + ) + .get() as { count: number } + ).count + ).toBe(1) + check.close() + }) +}) + +describe('EventJournal ACP directional journals', () => { + it('enforces ACP frame caps before insert without advancing the journal cursor', async () => { + const { journal } = await createJournal({ + maximumAcpChannelFrames: 1, + maximumAcpControllerFrames: 2, + maximumAcpDaemonFrames: 3 + }) + const append = ( + controllerId: string, + bindingId: string, + sequence = '1' + ) => + journal.appendAcpFrame({ + controllerId, + bindingId, + channelEpoch: '1', + direction: 'runtime-to-main', + sequence, + payload: Uint8Array.of(1) + }) + append('controller-1', 'binding-1') + expect(() => append('controller-1', 'binding-1', '2')).toThrow( + expect.objectContaining({ level: 'stream' }) + ) + expect( + journal.getAcpCursor( + 'binding-1', + '1', + 'runtime-to-main' + )?.journaledSequence + ).toBe('1') + append('controller-1', 'binding-2') + expect(() => append('controller-1', 'binding-3')).toThrow( + expect.objectContaining({ level: 'controller' }) + ) + append('controller-2', 'binding-4') + expect(() => append('controller-2', 'binding-5')).toThrow( + expect.objectContaining({ level: 'daemon' }) + ) + expect( + journal.getAcpCursor( + 'binding-5', + '1', + 'runtime-to-main' + ) + ).toBeUndefined() + journal.close() + }) + + it('transactionally prunes delivered outbound frames without retiring cursor history', async () => { + const { journal } = await createJournal() + journal.appendAcpFrame({ + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '9', + direction: 'main-to-runtime', + sequence: '1', + payload: new TextEncoder().encode('{"method":"session/prompt"}\n') + }) + expect( + journal.getAcpCursor( + 'binding-1', + '9', + 'main-to-runtime' + ) + ).toEqual({ + bindingId: 'binding-1', + channelEpoch: '9', + direction: 'main-to-runtime', + journaledSequence: '1', + deliveredSequence: '0', + mainAckSequence: '0' + }) + journal.markAcpDelivered({ + bindingId: 'binding-1', + channelEpoch: '9', + sequence: '1' + }) + expect( + journal.getAcpCursor( + 'binding-1', + '9', + 'main-to-runtime' + )?.deliveredSequence + ).toBe('1') + expect( + journal.replayAcpFrames({ + bindingId: 'binding-1', + channelEpoch: '9', + direction: 'main-to-runtime', + afterSequence: '0' + }) + ).toHaveLength(0) + expect( + journal.pruneReconciledAcpOutbound({ + bindingId: 'binding-1', + channelEpoch: '9', + throughSequence: '1' + }) + ).toBe(0) + expect( + journal.appendAcpFrame({ + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '9', + direction: 'main-to-runtime', + sequence: '2', + payload: new TextEncoder().encode('{"method":"session/load"}\n') + }).created + ).toBe(true) + journal.close() + }) + + it('releases outbound frame capacity as each non-replayable input is delivered', async () => { + const { journal } = await createJournal({ + maximumAcpChannelFrames: 1, + maximumAcpControllerFrames: 1, + maximumAcpDaemonFrames: 1 + }) + const identity = { + controllerId: 'controller-1', + bindingId: 'binding-input-capacity', + channelEpoch: '7', + direction: 'main-to-runtime' as const + } + journal.appendAcpFrame({ + ...identity, + sequence: '1', + payload: Uint8Array.of(1) + }) + journal.markAcpDelivered({ + bindingId: identity.bindingId, + channelEpoch: identity.channelEpoch, + sequence: '1' + }) + expect( + journal.appendAcpFrame({ + ...identity, + sequence: '2', + payload: Uint8Array.of(2) + }).created + ).toBe(true) + expect( + journal.getAcpCursor( + identity.bindingId, + identity.channelEpoch, + identity.direction + ) + ).toMatchObject({ + journaledSequence: '2', + deliveredSequence: '1' + }) + journal.close() + }) + + it('tracks inbound journaled and Main ACK cursors then prunes ACKed bytes', async () => { + const { journal } = await createJournal() + for (const sequence of ['1', '2']) { + journal.appendAcpFrame({ + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '1', + direction: 'runtime-to-main', + sequence, + payload: new Uint8Array([Number(sequence)]) + }) + } + journal.acknowledgeAcpFromMain({ + bindingId: 'binding-1', + channelEpoch: '1', + sequence: '1' + }) + expect( + journal.getAcpCursor( + 'binding-1', + '1', + 'runtime-to-main' + )?.mainAckSequence + ).toBe('1') + expect( + journal.replayAcpFrames({ + bindingId: 'binding-1', + channelEpoch: '1', + direction: 'runtime-to-main', + afterSequence: '0' + }) + ).toEqual([ + expect.objectContaining({ + sequence: '2', + payload: new Uint8Array([2]) + }) + ]) + expect(() => + journal.acknowledgeAcpFromMain({ + bindingId: 'binding-1', + channelEpoch: '1', + sequence: '3' + }) + ).toThrow(/exceeds/iu) + journal.close() + }) + + it('keeps an active inbound channel appendable after ACK reaches its current tail', async () => { + const { journal } = await createJournal() + const frame = { + controllerId: 'controller-1', + bindingId: 'binding-streaming', + channelEpoch: '4', + direction: 'runtime-to-main' as const + } + journal.appendAcpFrame({ + ...frame, + sequence: '1', + payload: new Uint8Array([1]) + }) + journal.acknowledgeAcpFromMain({ + bindingId: frame.bindingId, + channelEpoch: frame.channelEpoch, + sequence: '1' + }) + + expect( + journal.getAcpCursor( + frame.bindingId, + frame.channelEpoch, + frame.direction + ) + ).toEqual({ + bindingId: frame.bindingId, + channelEpoch: frame.channelEpoch, + direction: frame.direction, + journaledSequence: '1', + deliveredSequence: '0', + mainAckSequence: '1' + }) + expect( + journal.appendAcpFrame({ + ...frame, + sequence: '2', + payload: new Uint8Array([2]) + }).created + ).toBe(true) + expect( + journal.replayAcpFrames({ + bindingId: frame.bindingId, + channelEpoch: frame.channelEpoch, + direction: frame.direction, + afterSequence: '1' + }) + ).toEqual([ + expect.objectContaining({ + sequence: '2', + payload: new Uint8Array([2]) + }) + ]) + journal.close() + }) + + it('never reuses an ACP epoch/direction sequence with other bytes', async () => { + const { journal } = await createJournal() + const input = { + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '2', + direction: 'runtime-to-main' as const, + sequence: '1', + payload: new Uint8Array([1]) + } + expect(journal.appendAcpFrame(input).created).toBe(true) + expect(journal.appendAcpFrame(input).created).toBe(false) + expect(() => + journal.appendAcpFrame({ + ...input, + payload: new Uint8Array([2]) + }) + ).toThrow(EventJournalSequenceError) + journal.close() + }) + + it('retires a binding atomically, discards terminal frames, and preserves epoch safety', async () => { + const { journal, path } = await createJournal() + const input = { + controllerId: 'controller-1', + bindingId: 'binding-retired', + channelEpoch: '3', + direction: 'runtime-to-main' as const, + sequence: '1', + payload: new Uint8Array([1]) + } + journal.appendAcpFrame(input) + journal.appendAcpFrame({ + ...input, + direction: 'main-to-runtime', + payload: new Uint8Array([2]) + }) + expect(journal.retireAcpBinding({ + controllerId: input.controllerId, + bindingId: input.bindingId, + channelEpoch: input.channelEpoch + })).toBe(2) + expect( + journal.getAcpCursor( + input.bindingId, + input.channelEpoch, + input.direction + ) + ).toBeUndefined() + expect(() => + journal.appendAcpFrame({ + ...input, + sequence: '2', + payload: new Uint8Array([2]) + }) + ).toThrow(EventJournalSequenceError) + expect(journal.retireAcpBinding({ + controllerId: input.controllerId, + bindingId: input.bindingId, + channelEpoch: input.channelEpoch + })).toBe(0) + expect(() => journal.appendAcpFrame(input)).toThrow( + EventJournalSequenceError + ) + expect(() => + journal.appendAcpFrame({ + ...input, + direction: 'main-to-runtime', + sequence: '1' + }) + ).toThrow(EventJournalSequenceError) + journal.close() + + const check = new DatabaseSync(path) + expect( + ( + check + .prepare('SELECT COUNT(*) AS count FROM acp_channels') + .get() as { count: number } + ).count + ).toBe(0) + expect( + ( + check + .prepare( + 'SELECT COUNT(*) AS count FROM retired_acp_channels' + ) + .get() as { count: number } + ).count + ).toBe(2) + expect( + ( + check + .prepare('SELECT COUNT(*) AS count FROM acp_frames') + .get() as { count: number } + ).count + ).toBe(0) + expect( + ( + check + .prepare( + 'SELECT total_bytes FROM event_journal_usage WHERE id = 1' + ) + .get() as { total_bytes: number } + ).total_bytes + ).toBe(0) + expect( + ( + check + .prepare( + `SELECT total_bytes FROM event_controller_usage + WHERE controller_id = 'controller-1'` + ) + .get() as { total_bytes: number } + ).total_bytes + ).toBe(0) + check.close() + }) +}) diff --git a/src/agent-daemon/event-journal.ts b/src/agent-daemon/event-journal.ts new file mode 100644 index 00000000..4d46c1d9 --- /dev/null +++ b/src/agent-daemon/event-journal.ts @@ -0,0 +1,1738 @@ +import type { DatabaseSync, StatementSync } from 'node:sqlite' +import { + AGENT_PROTOCOL_LIMITS, + acpFrameDirectionSchema, + agentIdentifierSchema, + agentSequenceSchema, + eventIdentitySchema, + positiveAgentSequenceSchema, + type AcpFrameDirection, + type EventIdentity +} from '../shared/agent-protocol/contracts' +import type { AcpJournalCursor } from '../shared/remote-agent-contracts' +import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database' + +export type JournalEventClass = 'data' | 'control' | 'terminal' + +export type JournalEvent = { + identity: EventIdentity + eventClass: JournalEventClass + payload: Uint8Array + createdAt: number +} + +export type EventStreamLimits = { + maximumBytes: number + maximumEvents: number + terminalReserveBytes: number + terminalReserveEvents: number + maximumDurationMs: number +} + +export type EventJournalOptions = { + defaultStreamLimits?: Partial + maximumControllerBytes?: number + maximumDaemonBytes?: number + terminalReserveBytes?: number + minimumUnacknowledgedTerminalRetentionMs?: number + maximumPayloadBytes?: number + maximumAcpChannelBytes?: number + maximumAcpChannelFrames?: number + maximumAcpControllerFrames?: number + maximumAcpDaemonFrames?: number + now?: () => number +} + +export class EventJournalCapacityError extends Error { + constructor( + message: string, + readonly level: 'stream' | 'controller' | 'daemon', + readonly terminalWriteAllowed: boolean + ) { + super(message) + this.name = 'EventJournalCapacityError' + } +} + +export class EventJournalSequenceError extends Error { + constructor(message: string) { + super(message) + this.name = 'EventJournalSequenceError' + } +} + +type StreamRow = { + controller_id: string + stream_id: string + stream_epoch: number + last_sequence: number + ack_sequence: number + created_at: number + maximum_bytes: number + maximum_events: number + terminal_reserve_bytes: number + terminal_reserve_events: number + maximum_duration_ms: number + stored_bytes: number + stored_events: number + terminal_sequence: number +} + +type EventRow = { + controller_id: string + stream_id: string + stream_epoch: number + sequence: number + event_class: JournalEventClass + payload: Uint8Array + byte_length: number + created_at: number +} + +type AcpChannelRow = { + binding_id: string + channel_epoch: number + direction: AcpFrameDirection + journaled_sequence: number + delivered_sequence: number + main_ack_sequence: number + controller_id: string + stored_bytes: number + stored_frames: number +} + +type AcpFrameRow = { + sequence: number + payload: Uint8Array + created_at: number +} + +const defaultStreamLimits: EventStreamLimits = { + maximumBytes: AGENT_PROTOCOL_LIMITS.runEventJournalBytes, + maximumEvents: AGENT_PROTOCOL_LIMITS.runPendingEvents, + terminalReserveBytes: + AGENT_PROTOCOL_LIMITS.runTerminalReserveBytes, + terminalReserveEvents: 4, + maximumDurationMs: 24 * 60 * 60 * 1000 +} + +export class EventJournal { + readonly #database: DatabaseSync + readonly #streamLimits: EventStreamLimits + readonly #maximumControllerBytes: number + readonly #maximumDaemonBytes: number + readonly #terminalReserveBytes: number + readonly #minimumTerminalRetentionMs: number + readonly #maximumPayloadBytes: number + readonly #maximumAcpChannelBytes: number + readonly #maximumAcpChannelFrames: number + readonly #maximumAcpControllerFrames: number + readonly #maximumAcpDaemonFrames: number + readonly #now: () => number + readonly #statements: { + getStream: StatementSync + getEvent: StatementSync + insertEvent: StatementSync + updateEventSequence: StatementSync + replayEvents: StatementSync + updateEventAck: StatementSync + streamsToPrune: StatementSync + pruneEventRows: StatementSync + archiveEventStream: StatementSync + retireEventStream: StatementSync + getControllerUsage: StatementSync + getDaemonUsage: StatementSync + getAcpControllerFrames: StatementSync + getAcpDaemonFrames: StatementSync + insertStream: StatementSync + getAcpChannel: StatementSync + getAcpFrame: StatementSync + insertAcpFrame: StatementSync + updateAcpJournaled: StatementSync + replayAcpFrames: StatementSync + updateAcpDelivered: StatementSync + updateAcpMainAck: StatementSync + pruneAcpInbound: StatementSync + pruneAcpOutbound: StatementSync + deleteAcpBindingFrames: StatementSync + archiveAcpInbound: StatementSync + archiveAcpOutbound: StatementSync + retireAcpInbound: StatementSync + retireAcpOutbound: StatementSync + getRetiredStream: StatementSync + getRetiredAcpChannel: StatementSync + getAcpEpochOwner: StatementSync + insertAcpChannel: StatementSync + } + + constructor(path: string, options: EventJournalOptions = {}) { + this.#streamLimits = { + ...defaultStreamLimits, + ...options.defaultStreamLimits + } + this.#maximumControllerBytes = + options.maximumControllerBytes ?? + AGENT_PROTOCOL_LIMITS.controllerJournalBytes + this.#maximumDaemonBytes = + options.maximumDaemonBytes ?? + AGENT_PROTOCOL_LIMITS.daemonJournalBytes + this.#terminalReserveBytes = + options.terminalReserveBytes ?? + Math.min( + AGENT_PROTOCOL_LIMITS.runTerminalReserveBytes, + this.#maximumControllerBytes, + this.#maximumDaemonBytes + ) + this.#minimumTerminalRetentionMs = + options.minimumUnacknowledgedTerminalRetentionMs ?? + 24 * 60 * 60 * 1000 + this.#maximumPayloadBytes = + options.maximumPayloadBytes ?? + AGENT_PROTOCOL_LIMITS.maximumEventPayloadBytes + this.#maximumAcpChannelBytes = + options.maximumAcpChannelBytes ?? + AGENT_PROTOCOL_LIMITS.runEventJournalBytes + this.#maximumAcpChannelFrames = + options.maximumAcpChannelFrames ?? + AGENT_PROTOCOL_LIMITS.maximumAcpFramesPerChannel + this.#maximumAcpControllerFrames = + options.maximumAcpControllerFrames ?? + AGENT_PROTOCOL_LIMITS.maximumAcpFramesPerController + this.#maximumAcpDaemonFrames = + options.maximumAcpDaemonFrames ?? + AGENT_PROTOCOL_LIMITS.maximumAcpFramesPerDaemon + this.#now = options.now ?? Date.now + validateLimits( + this.#streamLimits, + this.#maximumControllerBytes, + this.#maximumDaemonBytes, + this.#terminalReserveBytes + ) + if ( + !Number.isSafeInteger(this.#minimumTerminalRetentionMs) || + this.#minimumTerminalRetentionMs < 0 || + !Number.isSafeInteger(this.#maximumPayloadBytes) || + this.#maximumPayloadBytes < 0 || + !Number.isSafeInteger(this.#maximumAcpChannelBytes) || + this.#maximumAcpChannelBytes < 1 || + !Number.isSafeInteger(this.#maximumAcpChannelFrames) || + this.#maximumAcpChannelFrames < 1 || + !Number.isSafeInteger(this.#maximumAcpControllerFrames) || + this.#maximumAcpControllerFrames < this.#maximumAcpChannelFrames || + !Number.isSafeInteger(this.#maximumAcpDaemonFrames) || + this.#maximumAcpDaemonFrames < this.#maximumAcpControllerFrames + ) { + throw new RangeError('Invalid event journal limits') + } + this.#database = openPrivateSqliteDatabase(path) + this.#database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS event_streams ( + controller_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_epoch INTEGER NOT NULL CHECK (stream_epoch > 0), + last_sequence INTEGER NOT NULL DEFAULT 0, + ack_sequence INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + maximum_bytes INTEGER NOT NULL, + maximum_events INTEGER NOT NULL, + terminal_reserve_bytes INTEGER NOT NULL, + terminal_reserve_events INTEGER NOT NULL, + maximum_duration_ms INTEGER NOT NULL, + stored_bytes INTEGER NOT NULL DEFAULT 0 + CHECK (stored_bytes >= 0), + stored_events INTEGER NOT NULL DEFAULT 0 + CHECK (stored_events >= 0), + terminal_sequence INTEGER NOT NULL DEFAULT 0 + CHECK (terminal_sequence >= 0), + PRIMARY KEY (controller_id, stream_id, stream_epoch) + ) STRICT; + CREATE TABLE IF NOT EXISTS journal_events ( + controller_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_epoch INTEGER NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + event_class TEXT NOT NULL CHECK ( + event_class IN ('data', 'control', 'terminal') + ), + payload BLOB NOT NULL, + byte_length INTEGER NOT NULL CHECK (byte_length >= 0), + created_at INTEGER NOT NULL, + PRIMARY KEY ( + controller_id, stream_id, stream_epoch, sequence + ), + FOREIGN KEY (controller_id, stream_id, stream_epoch) + REFERENCES event_streams( + controller_id, stream_id, stream_epoch + ) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS journal_events_replay + ON journal_events( + controller_id, stream_id, stream_epoch, sequence + ); + CREATE TABLE IF NOT EXISTS acp_channels ( + binding_id TEXT NOT NULL, + channel_epoch INTEGER NOT NULL CHECK (channel_epoch > 0), + direction TEXT NOT NULL CHECK ( + direction IN ('main-to-runtime', 'runtime-to-main') + ), + controller_id TEXT NOT NULL, + journaled_sequence INTEGER NOT NULL DEFAULT 0, + delivered_sequence INTEGER NOT NULL DEFAULT 0, + main_ack_sequence INTEGER NOT NULL DEFAULT 0, + stored_bytes INTEGER NOT NULL DEFAULT 0 + CHECK (stored_bytes >= 0), + stored_frames INTEGER NOT NULL DEFAULT 0 + CHECK (stored_frames >= 0), + PRIMARY KEY (binding_id, channel_epoch, direction) + ) STRICT; + CREATE TABLE IF NOT EXISTS acp_frames ( + binding_id TEXT NOT NULL, + channel_epoch INTEGER NOT NULL, + direction TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + payload BLOB NOT NULL, + byte_length INTEGER NOT NULL CHECK (byte_length >= 0), + created_at INTEGER NOT NULL, + PRIMARY KEY ( + binding_id, channel_epoch, direction, sequence + ), + FOREIGN KEY (binding_id, channel_epoch, direction) + REFERENCES acp_channels(binding_id, channel_epoch, direction) + ON DELETE CASCADE + ) STRICT; + CREATE TABLE IF NOT EXISTS retired_event_streams ( + controller_id TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_epoch INTEGER NOT NULL, + last_sequence INTEGER NOT NULL, + PRIMARY KEY (controller_id, stream_id, stream_epoch) + ) STRICT; + CREATE TABLE IF NOT EXISTS retired_acp_channels ( + binding_id TEXT NOT NULL, + channel_epoch INTEGER NOT NULL, + direction TEXT NOT NULL, + controller_id TEXT NOT NULL, + journaled_sequence INTEGER NOT NULL, + PRIMARY KEY (binding_id, channel_epoch, direction) + ) STRICT; + `) + this.#migrateAndRepairUsage() + this.#statements = { + getStream: this.#database.prepare( + `SELECT * FROM event_streams + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ?` + ), + getEvent: this.#database.prepare( + `SELECT * FROM journal_events + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ? AND sequence = ?` + ), + insertEvent: this.#database.prepare( + `INSERT INTO journal_events ( + controller_id, stream_id, stream_epoch, sequence, + event_class, payload, byte_length, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ), + updateEventSequence: this.#database.prepare( + `UPDATE event_streams + SET last_sequence = ?, + terminal_sequence = CASE + WHEN ? = 'terminal' THEN ? ELSE terminal_sequence + END + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ?` + ), + replayEvents: this.#database.prepare( + `SELECT * FROM journal_events + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ? AND sequence > ? + ORDER BY sequence ASC LIMIT ?` + ), + updateEventAck: this.#database.prepare( + `UPDATE event_streams SET ack_sequence = ? + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ?` + ), + streamsToPrune: this.#database.prepare( + 'SELECT * FROM event_streams WHERE ack_sequence > 0' + ), + pruneEventRows: this.#database.prepare( + `DELETE FROM journal_events + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ? AND sequence <= ? + AND (event_class != 'terminal' OR created_at <= ?)` + ), + archiveEventStream: this.#database.prepare( + `INSERT OR IGNORE INTO retired_event_streams ( + controller_id, stream_id, stream_epoch, last_sequence + ) + SELECT controller_id, stream_id, stream_epoch, last_sequence + FROM event_streams + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ? + AND terminal_sequence > 0 + AND ack_sequence >= last_sequence + AND stored_events = 0` + ), + retireEventStream: this.#database.prepare( + `DELETE FROM event_streams + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ? + AND terminal_sequence > 0 + AND ack_sequence >= last_sequence + AND stored_events = 0` + ), + getControllerUsage: this.#database.prepare( + `SELECT total_bytes FROM event_controller_usage + WHERE controller_id = ?` + ), + getDaemonUsage: this.#database.prepare( + 'SELECT total_bytes FROM event_journal_usage WHERE id = 1' + ), + getAcpControllerFrames: this.#database.prepare( + `SELECT COALESCE(SUM(stored_frames), 0) AS total_frames + FROM acp_channels WHERE controller_id = ?` + ), + getAcpDaemonFrames: this.#database.prepare( + `SELECT COALESCE(SUM(stored_frames), 0) AS total_frames + FROM acp_channels` + ), + insertStream: this.#database.prepare( + `INSERT INTO event_streams ( + controller_id, stream_id, stream_epoch, created_at, + maximum_bytes, maximum_events, terminal_reserve_bytes, + terminal_reserve_events, maximum_duration_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ), + getAcpChannel: this.#database.prepare( + `SELECT * FROM acp_channels + WHERE binding_id = ? AND channel_epoch = ? AND direction = ?` + ), + getAcpFrame: this.#database.prepare( + `SELECT * FROM acp_frames + WHERE binding_id = ? AND channel_epoch = ? + AND direction = ? AND sequence = ?` + ), + insertAcpFrame: this.#database.prepare( + `INSERT INTO acp_frames ( + binding_id, channel_epoch, direction, sequence, + payload, byte_length, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ), + updateAcpJournaled: this.#database.prepare( + `UPDATE acp_channels SET journaled_sequence = ? + WHERE binding_id = ? AND channel_epoch = ? AND direction = ?` + ), + replayAcpFrames: this.#database.prepare( + `SELECT sequence, payload, created_at FROM acp_frames + WHERE binding_id = ? AND channel_epoch = ? + AND direction = ? AND sequence > ? + ORDER BY sequence ASC LIMIT ?` + ), + updateAcpDelivered: this.#database.prepare( + `UPDATE acp_channels SET delivered_sequence = ? + WHERE binding_id = ? AND channel_epoch = ? AND direction = ?` + ), + updateAcpMainAck: this.#database.prepare( + `UPDATE acp_channels SET main_ack_sequence = ? + WHERE binding_id = ? AND channel_epoch = ? AND direction = ?` + ), + pruneAcpInbound: this.#database.prepare( + `DELETE FROM acp_frames + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'runtime-to-main' AND sequence <= ?` + ), + pruneAcpOutbound: this.#database.prepare( + `DELETE FROM acp_frames + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'main-to-runtime' AND sequence <= ?` + ), + deleteAcpBindingFrames: this.#database.prepare( + `DELETE FROM acp_frames + WHERE binding_id = ? AND channel_epoch = ?` + ), + archiveAcpInbound: this.#database.prepare( + `INSERT OR IGNORE INTO retired_acp_channels ( + binding_id, channel_epoch, direction, controller_id, + journaled_sequence + ) + SELECT binding_id, channel_epoch, direction, controller_id, + journaled_sequence + FROM acp_channels + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'runtime-to-main'` + ), + archiveAcpOutbound: this.#database.prepare( + `INSERT OR IGNORE INTO retired_acp_channels ( + binding_id, channel_epoch, direction, controller_id, + journaled_sequence + ) + SELECT binding_id, channel_epoch, direction, controller_id, + journaled_sequence + FROM acp_channels + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'main-to-runtime'` + ), + retireAcpInbound: this.#database.prepare( + `DELETE FROM acp_channels + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'runtime-to-main'` + ), + retireAcpOutbound: this.#database.prepare( + `DELETE FROM acp_channels + WHERE binding_id = ? AND channel_epoch = ? + AND direction = 'main-to-runtime'` + ), + getRetiredStream: this.#database.prepare( + `SELECT last_sequence FROM retired_event_streams + WHERE controller_id = ? AND stream_id = ? + AND stream_epoch = ?` + ), + getRetiredAcpChannel: this.#database.prepare( + `SELECT controller_id, journaled_sequence + FROM retired_acp_channels + WHERE binding_id = ? AND channel_epoch = ? AND direction = ?` + ), + getAcpEpochOwner: this.#database.prepare( + `SELECT controller_id, channel_epoch + FROM ( + SELECT controller_id, channel_epoch, binding_id + FROM acp_channels + UNION ALL + SELECT controller_id, channel_epoch, binding_id + FROM retired_acp_channels + ) + WHERE binding_id = ? + ORDER BY channel_epoch DESC LIMIT 1` + ), + insertAcpChannel: this.#database.prepare( + `INSERT INTO acp_channels ( + binding_id, channel_epoch, direction, controller_id + ) VALUES (?, ?, ?, ?)` + ) + } + } + + appendEvent(input: { + identity: EventIdentity + eventClass: JournalEventClass + payload: Uint8Array + limits?: Partial + }): { created: boolean; event: JournalEvent } { + const identity = eventIdentitySchema.parse(input.identity) + validateEventClass(input.eventClass) + this.#assertPayload(input.payload) + return this.#transaction(() => { + const stream = this.#ensureStream(identity, input.limits) + const sequence = sequenceNumber(identity.sequence) + if (sequence <= stream.last_sequence) { + const existing = this.#getEvent(identity) + if ( + existing === undefined || + existing.event_class !== input.eventClass || + !equalBytes(existing.payload, input.payload) + ) { + throw new EventJournalSequenceError( + 'Event sequence is already bound to different content' + ) + } + return { created: false, event: eventRowToEvent(existing) } + } + if (sequence !== stream.last_sequence + 1) { + throw new EventJournalSequenceError( + 'Event sequence must be contiguous' + ) + } + const terminal = input.eventClass === 'terminal' + this.#assertEventCapacity( + stream, + input.payload.byteLength, + terminal + ) + const now = this.#now() + this.#statements.insertEvent.run( + identity.controllerId, + identity.streamId, + sequenceNumber(identity.streamEpoch), + sequence, + input.eventClass, + input.payload, + input.payload.byteLength, + now + ) + this.#statements.updateEventSequence.run( + sequence, + input.eventClass, + sequence, + identity.controllerId, + identity.streamId, + sequenceNumber(identity.streamEpoch) + ) + return { + created: true, + event: { + identity, + eventClass: input.eventClass, + payload: input.payload.slice(), + createdAt: now + } + } + }) + } + + replayEvents(input: { + controllerId: string + streamId: string + streamEpoch: string + afterSequence: string + limit?: number + }): JournalEvent[] { + agentIdentifierSchema.parse(input.controllerId) + agentIdentifierSchema.parse(input.streamId) + positiveAgentSequenceSchema.parse(input.streamEpoch) + agentSequenceSchema.parse(input.afterSequence) + const limit = input.limit ?? AGENT_PROTOCOL_LIMITS.runPendingEvents + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) { + throw new RangeError('Invalid replay limit') + } + return ( + this.#statements.replayEvents.all( + input.controllerId, + input.streamId, + sequenceNumber(input.streamEpoch), + sequenceNumber(input.afterSequence), + limit + ) as EventRow[] + ).map(eventRowToEvent) + } + + acknowledgeEvents(input: { + controllerId: string + streamId: string + streamEpoch: string + sequence: string + }): void { + eventIdentitySchema.parse({ + ...input, + sequence: input.sequence + }) + const changes = this.#transaction(() => { + const stream = this.#getStream( + input.controllerId, + input.streamId, + sequenceNumber(input.streamEpoch) + ) + if (stream === undefined) { + const retired = this.#statements.getRetiredStream.get( + input.controllerId, + input.streamId, + sequenceNumber(input.streamEpoch) + ) as { last_sequence: number } | undefined + if ( + retired !== undefined && + sequenceNumber(input.sequence) === retired.last_sequence + ) { + return 0 + } + throw new EventJournalSequenceError('Event stream does not exist') + } + const sequence = sequenceNumber(input.sequence) + if (sequence < stream.ack_sequence) { + throw new EventJournalSequenceError('Event ACK moved backwards') + } + if (sequence > stream.last_sequence) { + throw new EventJournalSequenceError( + 'Event ACK exceeds journaled sequence' + ) + } + this.#statements.updateEventAck.run( + sequence, + input.controllerId, + input.streamId, + sequenceNumber(input.streamEpoch) + ) + return this.#pruneAckedForStream( + input.controllerId, + input.streamId, + sequenceNumber(input.streamEpoch), + sequence + ) + }) + this.#checkpointAfterSubstantialPrune(changes) + } + + pruneAcknowledged(): number { + let changes = 0 + this.#transaction(() => { + const streams = this.#statements.streamsToPrune.all() as StreamRow[] + for (const stream of streams) { + changes += this.#pruneAckedForStream( + stream.controller_id, + stream.stream_id, + stream.stream_epoch, + stream.ack_sequence + ) + } + }) + this.#checkpointAfterSubstantialPrune(changes) + return changes + } + + appendAcpFrame(input: { + controllerId: string + bindingId: string + channelEpoch: string + direction: AcpFrameDirection + sequence: string + payload: Uint8Array + }): { created: boolean } { + agentIdentifierSchema.parse(input.controllerId) + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + acpFrameDirectionSchema.parse(input.direction) + agentSequenceSchema.parse(input.sequence) + this.#assertPayload(input.payload) + return this.#transaction(() => { + const channel = this.#ensureAcpChannel(input) + const sequence = sequenceNumber(input.sequence) + if (sequence <= channel.journaled_sequence) { + const existing = this.#statements.getAcpFrame.get( + input.bindingId, + sequenceNumber(input.channelEpoch), + input.direction, + sequence + ) as AcpFrameRow | undefined + if ( + existing === undefined || + !equalBytes(existing.payload, input.payload) + ) { + throw new EventJournalSequenceError( + 'ACP sequence is already bound to different content' + ) + } + return { created: false } + } + if (sequence !== channel.journaled_sequence + 1) { + throw new EventJournalSequenceError( + 'ACP frame sequence must be contiguous' + ) + } + this.#assertGlobalCapacity( + input.controllerId, + input.payload.byteLength, + false + ) + if ( + channel.stored_bytes + input.payload.byteLength > + this.#maximumAcpChannelBytes + ) { + throw new EventJournalCapacityError( + 'ACP channel journal quota reached', + 'stream', + false + ) + } + this.#assertAcpFrameCapacity(channel) + this.#statements.insertAcpFrame.run( + input.bindingId, + sequenceNumber(input.channelEpoch), + input.direction, + sequence, + input.payload, + input.payload.byteLength, + this.#now() + ) + this.#statements.updateAcpJournaled.run( + sequence, + input.bindingId, + sequenceNumber(input.channelEpoch), + input.direction + ) + return { created: true } + }) + } + + markAcpDelivered(input: { + bindingId: string + channelEpoch: string + sequence: string + }): void { + const changes = this.#advanceAcpCursor({ + ...input, + direction: 'main-to-runtime', + column: 'delivered_sequence' + }) + this.#checkpointAfterSubstantialPrune(changes) + } + + acknowledgeAcpFromMain(input: { + bindingId: string + channelEpoch: string + sequence: string + }): void { + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + agentSequenceSchema.parse(input.sequence) + const changes = this.#transaction(() => { + const epoch = sequenceNumber(input.channelEpoch) + const channel = this.#getAcpChannel( + input.bindingId, + epoch, + 'runtime-to-main' + ) + if (channel === undefined) { + const retired = this.#statements.getRetiredAcpChannel.get( + input.bindingId, + epoch, + 'runtime-to-main' + ) as + | { controller_id: string; journaled_sequence: number } + | undefined + if ( + retired !== undefined && + sequenceNumber(input.sequence) === + retired.journaled_sequence + ) { + return 0 + } + throw new EventJournalSequenceError('ACP channel does not exist') + } + const next = sequenceNumber(input.sequence) + this.#assertAcpCursor(channel, next, 'main_ack_sequence') + if (next === channel.main_ack_sequence) { + return 0 + } + this.#statements.updateAcpMainAck.run( + next, + input.bindingId, + epoch, + 'runtime-to-main' + ) + const pruned = Number( + this.#statements.pruneAcpInbound.run( + input.bindingId, + epoch, + next + ).changes + ) + return pruned + }) + this.#checkpointAfterSubstantialPrune(changes) + } + + retireAcpBinding(input: { + controllerId: string + bindingId: string + channelEpoch: string + }): number { + agentIdentifierSchema.parse(input.controllerId) + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + const changes = this.#transaction(() => { + const epoch = sequenceNumber(input.channelEpoch) + const inbound = this.#ensureAcpRetirementChannel({ + ...input, + channelEpoch: epoch, + direction: 'runtime-to-main' + }) + const outbound = this.#ensureAcpRetirementChannel({ + ...input, + channelEpoch: epoch, + direction: 'main-to-runtime' + }) + const storedFrames = + (inbound?.stored_frames ?? 0) + + (outbound?.stored_frames ?? 0) + this.#statements.archiveAcpInbound.run(input.bindingId, epoch) + this.#statements.archiveAcpOutbound.run(input.bindingId, epoch) + this.#statements.deleteAcpBindingFrames.run( + input.bindingId, + epoch + ) + this.#statements.retireAcpInbound.run(input.bindingId, epoch) + this.#statements.retireAcpOutbound.run(input.bindingId, epoch) + return storedFrames + }) + this.#checkpointAfterSubstantialPrune(changes) + return changes + } + + pruneReconciledAcpOutbound(input: { + bindingId: string + channelEpoch: string + throughSequence: string + }): number { + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + agentSequenceSchema.parse(input.throughSequence) + const changes = this.#transaction(() => { + const epoch = sequenceNumber(input.channelEpoch) + const through = sequenceNumber(input.throughSequence) + const channel = this.#getAcpChannel( + input.bindingId, + epoch, + 'main-to-runtime' + ) + if ( + channel === undefined + ) { + const retired = this.#statements.getRetiredAcpChannel.get( + input.bindingId, + epoch, + 'main-to-runtime' + ) as + | { controller_id: string; journaled_sequence: number } + | undefined + if ( + retired !== undefined && + through <= retired.journaled_sequence + ) { + return 0 + } + throw new EventJournalSequenceError( + 'Cannot prune outbound ACP before delivery reconciliation' + ) + } + if (through > channel.delivered_sequence) { + throw new EventJournalSequenceError( + 'Cannot prune outbound ACP before delivery reconciliation' + ) + } + const pruned = Number( + this.#statements.pruneAcpOutbound.run( + input.bindingId, + epoch, + through + ).changes + ) + return pruned + }) + this.#checkpointAfterSubstantialPrune(changes) + return changes + } + + replayAcpFrames(input: { + bindingId: string + channelEpoch: string + direction: AcpFrameDirection + afterSequence: string + limit?: number + }): Array<{ sequence: string; payload: Uint8Array; createdAt: number }> { + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + acpFrameDirectionSchema.parse(input.direction) + agentSequenceSchema.parse(input.afterSequence) + const limit = input.limit ?? AGENT_PROTOCOL_LIMITS.runPendingEvents + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) { + throw new RangeError('Invalid replay limit') + } + return ( + this.#statements.replayAcpFrames.all( + input.bindingId, + sequenceNumber(input.channelEpoch), + input.direction, + sequenceNumber(input.afterSequence), + limit + ) as AcpFrameRow[] + ).map((row) => ({ + sequence: String(row.sequence), + payload: new Uint8Array(row.payload), + createdAt: row.created_at + })) + } + + getAcpCursor( + bindingId: string, + channelEpoch: string, + direction: AcpFrameDirection + ): AcpJournalCursor | undefined { + agentIdentifierSchema.parse(bindingId) + positiveAgentSequenceSchema.parse(channelEpoch) + acpFrameDirectionSchema.parse(direction) + const row = this.#getAcpChannel( + bindingId, + sequenceNumber(channelEpoch), + direction + ) + if (row === undefined) { + return undefined + } + return { + bindingId, + channelEpoch, + direction, + journaledSequence: String(row.journaled_sequence), + deliveredSequence: String(row.delivered_sequence), + mainAckSequence: String(row.main_ack_sequence) + } + } + + close(): void { + this.#database.close() + } + + #ensureStream( + identity: EventIdentity, + overrides: Partial | undefined + ): StreamRow { + const epoch = sequenceNumber(identity.streamEpoch) + const existing = this.#getStream( + identity.controllerId, + identity.streamId, + epoch + ) + if (existing !== undefined) { + return existing + } + if ( + this.#statements.getRetiredStream.get( + identity.controllerId, + identity.streamId, + epoch + ) !== undefined + ) { + throw new EventJournalSequenceError( + 'Event stream has already been terminally acknowledged' + ) + } + const limits = { ...this.#streamLimits, ...overrides } + validateStreamLimits(limits) + const now = this.#now() + this.#statements.insertStream.run( + identity.controllerId, + identity.streamId, + epoch, + now, + limits.maximumBytes, + limits.maximumEvents, + limits.terminalReserveBytes, + limits.terminalReserveEvents, + limits.maximumDurationMs + ) + return this.#getStream( + identity.controllerId, + identity.streamId, + epoch + )! + } + + #getStream( + controllerId: string, + streamId: string, + streamEpoch: number + ): StreamRow | undefined { + return this.#statements.getStream.get( + controllerId, + streamId, + streamEpoch + ) as + | StreamRow + | undefined + } + + #getEvent(identity: EventIdentity): EventRow | undefined { + return this.#statements.getEvent.get( + identity.controllerId, + identity.streamId, + sequenceNumber(identity.streamEpoch), + sequenceNumber(identity.sequence) + ) as EventRow | undefined + } + + #assertEventCapacity( + stream: StreamRow, + addedBytes: number, + terminal: boolean + ): void { + const streamByteLimit = terminal + ? stream.maximum_bytes + : stream.maximum_bytes - stream.terminal_reserve_bytes + const streamEventLimit = terminal + ? stream.maximum_events + : stream.maximum_events - stream.terminal_reserve_events + if ( + stream.stored_events + 1 > streamEventLimit || + stream.stored_bytes + addedBytes > streamByteLimit || + (!terminal && + this.#now() - stream.created_at > stream.maximum_duration_ms) + ) { + throw new EventJournalCapacityError( + 'Event stream quota reached', + 'stream', + stream.stored_bytes + addedBytes <= stream.maximum_bytes && + stream.stored_events + 1 <= stream.maximum_events + ) + } + this.#assertGlobalCapacity( + stream.controller_id, + addedBytes, + terminal + ) + } + + #assertGlobalCapacity( + controllerId: string, + addedBytes: number, + terminal: boolean + ): void { + const controllerBytes = this.#controllerBytes(controllerId) + const daemonBytes = this.#daemonBytes() + const controllerLimit = terminal + ? this.#maximumControllerBytes + : this.#maximumControllerBytes - this.#terminalReserveBytes + const daemonLimit = terminal + ? this.#maximumDaemonBytes + : this.#maximumDaemonBytes - this.#terminalReserveBytes + if (controllerBytes + addedBytes > controllerLimit) { + throw new EventJournalCapacityError( + 'Controller journal quota reached', + 'controller', + controllerBytes + addedBytes <= this.#maximumControllerBytes + ) + } + if (daemonBytes + addedBytes > daemonLimit) { + throw new EventJournalCapacityError( + 'Daemon journal quota reached', + 'daemon', + daemonBytes + addedBytes <= this.#maximumDaemonBytes + ) + } + } + + #assertAcpFrameCapacity(channel: AcpChannelRow): void { + if (channel.stored_frames + 1 > this.#maximumAcpChannelFrames) { + throw new EventJournalCapacityError( + 'ACP channel frame quota reached', + 'stream', + false + ) + } + const controller = this.#statements.getAcpControllerFrames.get( + channel.controller_id + ) as { total_frames: number } | undefined + const daemon = this.#statements.getAcpDaemonFrames.get() as + | { total_frames: number } + | undefined + if ( + controller === undefined || + !Number.isSafeInteger(controller.total_frames) || + controller.total_frames < 0 || + controller.total_frames + 1 > this.#maximumAcpControllerFrames + ) { + throw new EventJournalCapacityError( + 'ACP controller frame quota reached', + 'controller', + false + ) + } + if ( + daemon === undefined || + !Number.isSafeInteger(daemon.total_frames) || + daemon.total_frames < 0 || + daemon.total_frames + 1 > this.#maximumAcpDaemonFrames + ) { + throw new EventJournalCapacityError( + 'ACP daemon frame quota reached', + 'daemon', + false + ) + } + } + + #controllerBytes(controllerId: string): number { + const usage = this.#statements.getControllerUsage.get( + controllerId + ) as { total_bytes: number } | undefined + if ( + usage === undefined || + !Number.isSafeInteger(usage.total_bytes) || + usage.total_bytes < 0 + ) { + throw new EventJournalCapacityError( + 'Controller journal usage is unavailable', + 'controller', + false + ) + } + return usage.total_bytes + } + + #daemonBytes(): number { + const usage = this.#statements.getDaemonUsage.get() as + | { total_bytes: number } + | undefined + if ( + usage === undefined || + !Number.isSafeInteger(usage.total_bytes) || + usage.total_bytes < 0 + ) { + throw new EventJournalCapacityError( + 'Daemon journal usage is unavailable', + 'daemon', + false + ) + } + return usage.total_bytes + } + + #ensureAcpChannel(input: { + controllerId: string + bindingId: string + channelEpoch: string + direction: AcpFrameDirection + }): AcpChannelRow { + const epoch = sequenceNumber(input.channelEpoch) + const existing = this.#getAcpChannel( + input.bindingId, + epoch, + input.direction + ) + if (existing !== undefined) { + if (existing.controller_id !== input.controllerId) { + throw new EventJournalSequenceError( + 'ACP channel belongs to another controller' + ) + } + return existing + } + if ( + this.#statements.getRetiredAcpChannel.get( + input.bindingId, + epoch, + input.direction + ) !== undefined + ) { + throw new EventJournalSequenceError( + 'ACP channel has already been reconciled' + ) + } + const epochOwner = this.#statements.getAcpEpochOwner.get( + input.bindingId + ) as + | { controller_id: string; channel_epoch: number } + | undefined + if ( + epochOwner !== undefined && + (epoch < epochOwner.channel_epoch || + epochOwner.controller_id !== input.controllerId) + ) { + throw new EventJournalSequenceError( + 'ACP channel epoch is obsolete or belongs to another controller' + ) + } + this.#statements.insertAcpChannel.run( + input.bindingId, + epoch, + input.direction, + input.controllerId + ) + return this.#getAcpChannel( + input.bindingId, + epoch, + input.direction + )! + } + + #getAcpChannel( + bindingId: string, + channelEpoch: number, + direction: AcpFrameDirection + ): AcpChannelRow | undefined { + return this.#statements.getAcpChannel.get( + bindingId, + channelEpoch, + direction + ) as + | AcpChannelRow + | undefined + } + + #ensureAcpRetirementChannel(input: { + controllerId: string + bindingId: string + channelEpoch: number + direction: AcpFrameDirection + }): AcpChannelRow | undefined { + let channel = this.#getAcpChannel( + input.bindingId, + input.channelEpoch, + input.direction + ) + if (channel === undefined) { + const retired = this.#statements.getRetiredAcpChannel.get( + input.bindingId, + input.channelEpoch, + input.direction + ) as + | { controller_id: string; journaled_sequence: number } + | undefined + if (retired !== undefined) { + if (retired.controller_id !== input.controllerId) { + throw new EventJournalSequenceError( + 'ACP channel belongs to another controller' + ) + } + return undefined + } + channel = this.#ensureAcpChannel({ + controllerId: input.controllerId, + bindingId: input.bindingId, + channelEpoch: input.channelEpoch.toString(), + direction: input.direction + }) + } else if (channel.controller_id !== input.controllerId) { + throw new EventJournalSequenceError( + 'ACP channel belongs to another controller' + ) + } + return channel + } + + #advanceAcpCursor(input: { + bindingId: string + channelEpoch: string + sequence: string + direction: AcpFrameDirection + column: 'delivered_sequence' | 'main_ack_sequence' + }): number { + agentIdentifierSchema.parse(input.bindingId) + positiveAgentSequenceSchema.parse(input.channelEpoch) + agentSequenceSchema.parse(input.sequence) + return this.#transaction(() => { + const epoch = sequenceNumber(input.channelEpoch) + const channel = this.#getAcpChannel( + input.bindingId, + epoch, + input.direction + ) + if (channel === undefined) { + const retired = this.#statements.getRetiredAcpChannel.get( + input.bindingId, + epoch, + input.direction + ) as + | { controller_id: string; journaled_sequence: number } + | undefined + if ( + retired !== undefined && + sequenceNumber(input.sequence) === + retired.journaled_sequence + ) { + return 0 + } + throw new EventJournalSequenceError('ACP channel does not exist') + } + const next = sequenceNumber(input.sequence) + this.#assertAcpCursor(channel, next, input.column) + if (next !== channel[input.column]) { + const statement = + input.column === 'delivered_sequence' + ? this.#statements.updateAcpDelivered + : this.#statements.updateAcpMainAck + statement.run( + next, + input.bindingId, + epoch, + input.direction + ) + } + return Number( + this.#statements.pruneAcpOutbound.run( + input.bindingId, + epoch, + next + ).changes + ) + }) + } + + #assertAcpCursor( + channel: AcpChannelRow, + next: number, + column: 'delivered_sequence' | 'main_ack_sequence' + ): void { + const current = channel[column] + if (next < current) { + throw new EventJournalSequenceError('ACP cursor moved backwards') + } + if (next > channel.journaled_sequence) { + throw new EventJournalSequenceError( + 'ACP cursor exceeds journaled sequence' + ) + } + if (column === 'delivered_sequence' && next > current + 1) { + throw new EventJournalSequenceError( + 'ACP delivery cursor must be contiguous' + ) + } + } + + #pruneAckedForStream( + controllerId: string, + streamId: string, + streamEpoch: number, + sequence: number + ): number { + const cutoff = this.#now() - this.#minimumTerminalRetentionMs + const result = this.#statements.pruneEventRows.run( + controllerId, + streamId, + streamEpoch, + sequence, + cutoff + ) + this.#statements.archiveEventStream.run( + controllerId, + streamId, + streamEpoch + ) + this.#statements.retireEventStream.run( + controllerId, + streamId, + streamEpoch + ) + return Number(result.changes) + } + + #migrateAndRepairUsage(): void { + this.#database.exec('BEGIN IMMEDIATE') + try { + const streamColumns = new Set( + ( + this.#database + .prepare('PRAGMA table_info(event_streams)') + .all() as Array<{ name: string }> + ).map((column) => column.name) + ) + if (!streamColumns.has('stored_bytes')) { + this.#database.exec( + `ALTER TABLE event_streams + ADD COLUMN stored_bytes INTEGER NOT NULL DEFAULT 0 + CHECK (stored_bytes >= 0)` + ) + } + if (!streamColumns.has('stored_events')) { + this.#database.exec( + `ALTER TABLE event_streams + ADD COLUMN stored_events INTEGER NOT NULL DEFAULT 0 + CHECK (stored_events >= 0)` + ) + } + if (!streamColumns.has('terminal_sequence')) { + this.#database.exec( + `ALTER TABLE event_streams + ADD COLUMN terminal_sequence INTEGER NOT NULL DEFAULT 0 + CHECK (terminal_sequence >= 0)` + ) + } + const channelColumns = new Set( + ( + this.#database + .prepare('PRAGMA table_info(acp_channels)') + .all() as Array<{ name: string }> + ).map((column) => column.name) + ) + if (!channelColumns.has('stored_bytes')) { + this.#database.exec( + `ALTER TABLE acp_channels + ADD COLUMN stored_bytes INTEGER NOT NULL DEFAULT 0 + CHECK (stored_bytes >= 0)` + ) + } + if (!channelColumns.has('stored_frames')) { + this.#database.exec( + `ALTER TABLE acp_channels + ADD COLUMN stored_frames INTEGER NOT NULL DEFAULT 0 + CHECK (stored_frames >= 0)` + ) + } + this.#database.exec(` + CREATE TABLE IF NOT EXISTS event_journal_usage ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_bytes INTEGER NOT NULL CHECK (total_bytes >= 0) + ) STRICT; + CREATE TABLE IF NOT EXISTS event_controller_usage ( + controller_id TEXT PRIMARY KEY, + total_bytes INTEGER NOT NULL CHECK (total_bytes >= 0) + ) STRICT; + INSERT OR IGNORE INTO event_journal_usage (id, total_bytes) + VALUES (1, 0); + + DROP TRIGGER IF EXISTS journal_events_usage_insert; + DROP TRIGGER IF EXISTS journal_events_usage_delete; + DROP TRIGGER IF EXISTS acp_frames_usage_insert; + DROP TRIGGER IF EXISTS acp_frames_usage_delete; + DROP TRIGGER IF EXISTS event_stream_controller_usage_insert; + DROP TRIGGER IF EXISTS acp_channel_controller_usage_insert; + + CREATE TRIGGER event_stream_controller_usage_insert + AFTER INSERT ON event_streams + BEGIN + INSERT OR IGNORE INTO event_controller_usage ( + controller_id, total_bytes + ) VALUES (NEW.controller_id, 0); + END; + CREATE TRIGGER acp_channel_controller_usage_insert + AFTER INSERT ON acp_channels + BEGIN + INSERT OR IGNORE INTO event_controller_usage ( + controller_id, total_bytes + ) VALUES (NEW.controller_id, 0); + END; + CREATE TRIGGER journal_events_usage_insert + AFTER INSERT ON journal_events + BEGIN + UPDATE event_streams + SET stored_bytes = stored_bytes + NEW.byte_length, + stored_events = stored_events + 1 + WHERE controller_id = NEW.controller_id + AND stream_id = NEW.stream_id + AND stream_epoch = NEW.stream_epoch; + UPDATE event_journal_usage + SET total_bytes = total_bytes + NEW.byte_length + WHERE id = 1; + INSERT INTO event_controller_usage ( + controller_id, total_bytes + ) VALUES (NEW.controller_id, NEW.byte_length) + ON CONFLICT(controller_id) DO UPDATE + SET total_bytes = total_bytes + NEW.byte_length; + END; + CREATE TRIGGER journal_events_usage_delete + AFTER DELETE ON journal_events + BEGIN + UPDATE event_streams + SET stored_bytes = stored_bytes - OLD.byte_length, + stored_events = stored_events - 1 + WHERE controller_id = OLD.controller_id + AND stream_id = OLD.stream_id + AND stream_epoch = OLD.stream_epoch; + UPDATE event_journal_usage + SET total_bytes = total_bytes - OLD.byte_length + WHERE id = 1; + UPDATE event_controller_usage + SET total_bytes = total_bytes - OLD.byte_length + WHERE controller_id = OLD.controller_id; + END; + CREATE TRIGGER acp_frames_usage_insert + AFTER INSERT ON acp_frames + BEGIN + UPDATE acp_channels + SET stored_bytes = stored_bytes + NEW.byte_length, + stored_frames = stored_frames + 1 + WHERE binding_id = NEW.binding_id + AND channel_epoch = NEW.channel_epoch + AND direction = NEW.direction; + UPDATE event_journal_usage + SET total_bytes = total_bytes + NEW.byte_length + WHERE id = 1; + INSERT INTO event_controller_usage ( + controller_id, total_bytes + ) + SELECT controller_id, NEW.byte_length + FROM acp_channels + WHERE binding_id = NEW.binding_id + AND channel_epoch = NEW.channel_epoch + AND direction = NEW.direction + ON CONFLICT(controller_id) DO UPDATE + SET total_bytes = total_bytes + NEW.byte_length; + END; + CREATE TRIGGER acp_frames_usage_delete + AFTER DELETE ON acp_frames + BEGIN + UPDATE event_journal_usage + SET total_bytes = total_bytes - OLD.byte_length + WHERE id = 1; + UPDATE event_controller_usage + SET total_bytes = total_bytes - OLD.byte_length + WHERE controller_id = ( + SELECT controller_id FROM acp_channels + WHERE binding_id = OLD.binding_id + AND channel_epoch = OLD.channel_epoch + AND direction = OLD.direction + ); + UPDATE acp_channels + SET stored_bytes = stored_bytes - OLD.byte_length, + stored_frames = stored_frames - 1 + WHERE binding_id = OLD.binding_id + AND channel_epoch = OLD.channel_epoch + AND direction = OLD.direction; + END; + + UPDATE event_streams + SET stored_bytes = ( + SELECT COALESCE(SUM(byte_length), 0) + FROM journal_events + WHERE controller_id = event_streams.controller_id + AND stream_id = event_streams.stream_id + AND stream_epoch = event_streams.stream_epoch + ), + stored_events = ( + SELECT COUNT(*) + FROM journal_events + WHERE controller_id = event_streams.controller_id + AND stream_id = event_streams.stream_id + AND stream_epoch = event_streams.stream_epoch + ), + terminal_sequence = COALESCE(( + SELECT MAX(sequence) + FROM journal_events + WHERE controller_id = event_streams.controller_id + AND stream_id = event_streams.stream_id + AND stream_epoch = event_streams.stream_epoch + AND event_class = 'terminal' + ), terminal_sequence); + UPDATE acp_channels + SET stored_bytes = ( + SELECT COALESCE(SUM(byte_length), 0) + FROM acp_frames + WHERE binding_id = acp_channels.binding_id + AND channel_epoch = acp_channels.channel_epoch + AND direction = acp_channels.direction + ), + stored_frames = ( + SELECT COUNT(*) + FROM acp_frames + WHERE binding_id = acp_channels.binding_id + AND channel_epoch = acp_channels.channel_epoch + AND direction = acp_channels.direction + ); + DELETE FROM event_controller_usage; + INSERT INTO event_controller_usage (controller_id, total_bytes) + SELECT controller_id, SUM(bytes) + FROM ( + SELECT controller_id, SUM(byte_length) AS bytes + FROM journal_events GROUP BY controller_id + UNION ALL + SELECT channel.controller_id, SUM(frame.byte_length) AS bytes + FROM acp_frames frame + JOIN acp_channels channel + ON channel.binding_id = frame.binding_id + AND channel.channel_epoch = frame.channel_epoch + AND channel.direction = frame.direction + GROUP BY channel.controller_id + ) + GROUP BY controller_id; + INSERT OR IGNORE INTO event_controller_usage ( + controller_id, total_bytes + ) + SELECT controller_id, 0 FROM event_streams + UNION + SELECT controller_id, 0 FROM acp_channels; + UPDATE event_journal_usage + SET total_bytes = + (SELECT COALESCE(SUM(total_bytes), 0) + FROM event_controller_usage) + WHERE id = 1; + `) + this.#database.exec('COMMIT') + } catch (error) { + this.#database.exec('ROLLBACK') + throw error + } + } + + #checkpointAfterSubstantialPrune(changes: number): void { + if (changes >= 100) { + this.#database.exec('PRAGMA wal_checkpoint(PASSIVE)') + } + } + + #assertPayload(payload: Uint8Array): void { + if (payload.byteLength > this.#maximumPayloadBytes) { + throw new EventJournalCapacityError( + 'Journal payload is oversized', + 'stream', + false + ) + } + } + + #transaction(action: () => T): T { + this.#database.exec('BEGIN IMMEDIATE') + try { + const result = action() + this.#database.exec('COMMIT') + return result + } catch (error) { + this.#database.exec('ROLLBACK') + throw error + } + } +} + +function eventRowToEvent(row: EventRow): JournalEvent { + return { + identity: { + controllerId: row.controller_id, + streamId: row.stream_id, + streamEpoch: String(row.stream_epoch), + sequence: String(row.sequence) + }, + eventClass: row.event_class, + payload: new Uint8Array(row.payload), + createdAt: row.created_at + } +} + +function sequenceNumber(sequence: string): number { + return Number(agentSequenceSchema.parse(sequence)) +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) { + return false + } + return left.every((byte, index) => byte === right[index]) +} + +function validateEventClass( + value: string +): asserts value is JournalEventClass { + if (value !== 'data' && value !== 'control' && value !== 'terminal') { + throw new TypeError('Invalid journal event class') + } +} + +function validateLimits( + stream: EventStreamLimits, + controllerBytes: number, + daemonBytes: number, + terminalReserveBytes: number +): void { + validateStreamLimits(stream) + if ( + !Number.isSafeInteger(controllerBytes) || + !Number.isSafeInteger(daemonBytes) || + controllerBytes < 1 || + daemonBytes < controllerBytes || + !Number.isSafeInteger(terminalReserveBytes) || + terminalReserveBytes < 0 || + terminalReserveBytes > controllerBytes + ) { + throw new RangeError('Invalid event journal limits') + } +} + +function validateStreamLimits(limits: EventStreamLimits): void { + if ( + !Number.isSafeInteger(limits.maximumBytes) || + limits.maximumBytes < 1 || + !Number.isSafeInteger(limits.maximumEvents) || + limits.maximumEvents < 1 || + !Number.isSafeInteger(limits.terminalReserveBytes) || + limits.terminalReserveBytes < 0 || + limits.terminalReserveBytes > limits.maximumBytes || + !Number.isSafeInteger(limits.terminalReserveEvents) || + limits.terminalReserveEvents < 1 || + limits.terminalReserveEvents > limits.maximumEvents || + !Number.isSafeInteger(limits.maximumDurationMs) || + limits.maximumDurationMs < 1 + ) { + throw new RangeError('Invalid event stream limits') + } +} diff --git a/src/agent-daemon/index.ts b/src/agent-daemon/index.ts new file mode 100644 index 00000000..148a3dab --- /dev/null +++ b/src/agent-daemon/index.ts @@ -0,0 +1,32 @@ +export * from './bundle-file-verification' +export * from './channel-state' +export * from './cli' +export * from './controller-registry' +export * from './daemon' +export * from './detached-agent-lifecycle' +export * from './direct-linux-stdio-process-owner' +export * from './errors' +export * from './entry' +export * from './event-journal' +export * from './installation-challenge' +export * from './installation-registry' +export * from './installed-bundle-verifier' +export * from './managed-paths' +export * from './linux-process-identity' +export * from './model-bridge-broker' +export * from './model-bridge-client' +export * from './model-bridge-helper' +export * from './opencode-runtime-profile' +export * from './private-endpoint' +export * from './protocol-server' +export * from './runtime-bundle-registry' +export * from './runtime-bundle-verifier' +export * from './runtime-composition' +export * from './runtime-acp-backend' +export * from './runtime-owner-registry' +export * from './runtime-process-identity' +export * from './structured-command-runner' +export * from './workspace-git-service' +export * from './workspace-path-access' +export * from './workspace-protocol-methods' +export * from './workspace-registry' diff --git a/src/agent-daemon/installation-challenge.ts b/src/agent-daemon/installation-challenge.ts new file mode 100644 index 00000000..3a27f450 --- /dev/null +++ b/src/agent-daemon/installation-challenge.ts @@ -0,0 +1,113 @@ +import { + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto' + +const CHALLENGE_DOMAIN = 'goodbuddy-agent-installation-challenge-v1\0' + +export type InstallationChallenge = { + serverNonce: string + expiresAt: number +} + +export class InstallationChallengeVerifier { + readonly #secret: Buffer + readonly #now: () => number + readonly #lifetimeMs: number + readonly #pending = new Map() + + constructor( + secret: Uint8Array, + options: { now?: () => number; lifetimeMs?: number } = {} + ) { + if (secret.byteLength < 32) { + throw new RangeError('Installation challenge secret is too short') + } + this.#secret = Buffer.from(secret) + this.#now = options.now ?? Date.now + this.#lifetimeMs = options.lifetimeMs ?? 30_000 + if (!Number.isSafeInteger(this.#lifetimeMs) || this.#lifetimeMs < 1) { + throw new RangeError('Invalid installation challenge lifetime') + } + } + + issue(): InstallationChallenge { + this.#purgeExpired() + const serverNonce = randomBytes(32).toString('base64url') + const expiresAt = this.#now() + this.#lifetimeMs + this.#pending.set(serverNonce, expiresAt) + return { serverNonce, expiresAt } + } + + createResponse(input: { + serverNonce: string + clientNonce: string + controllerId: string + }): string { + return calculateResponse(this.#secret, input) + } + + verify(input: { + serverNonce: string + clientNonce: string + controllerId: string + response: string + }): boolean { + const expiresAt = this.#pending.get(input.serverNonce) + this.#pending.delete(input.serverNonce) + if (expiresAt === undefined || expiresAt < this.#now()) { + return false + } + const expected = Buffer.from( + calculateResponse(this.#secret, input), + 'base64url' + ) + let received: Buffer + try { + received = Buffer.from(input.response, 'base64url') + } catch { + return false + } + return ( + expected.byteLength === received.byteLength && + timingSafeEqual(expected, received) + ) + } + + #purgeExpired(): void { + const now = this.#now() + for (const [nonce, expiresAt] of this.#pending) { + if (expiresAt < now) { + this.#pending.delete(nonce) + } + } + } +} + +function calculateResponse( + secret: Uint8Array, + input: { + serverNonce: string + clientNonce: string + controllerId: string + } +): string { + for (const value of [ + input.serverNonce, + input.clientNonce, + input.controllerId + ]) { + if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) { + throw new Error('Invalid installation challenge field') + } + } + return createHmac('sha256', secret) + .update(CHALLENGE_DOMAIN) + .update(input.serverNonce) + .update('\0') + .update(input.clientNonce) + .update('\0') + .update(input.controllerId) + .digest('base64url') +} diff --git a/src/agent-daemon/installation-registry.test.ts b/src/agent-daemon/installation-registry.test.ts new file mode 100644 index 00000000..7485b34a --- /dev/null +++ b/src/agent-daemon/installation-registry.test.ts @@ -0,0 +1,231 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { InstallationRegistry } from './installation-registry' +import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('Agent installation registry', () => { + it('stages and atomically promotes an exact signed installation', () => { + const { registry, storagePath } = createRegistry() + const first = verified('install-a', '0.11.0', 'a') + const second = verified('install-b', '0.11.1', 'b') + + registry.stageCandidate(first) + expect(registry.snapshot()).toMatchObject({ + candidate: { installationId: 'install-a' } + }) + registry.promoteCandidate('install-a') + expect(registry.snapshot()).toMatchObject({ + current: { installationId: 'install-a' } + }) + expect(registry.snapshot().candidate).toBeUndefined() + + registry.stageCandidate(second) + registry.promoteCandidate('install-b') + expect(registry.snapshot()).toEqual({ + formatVersion: 1, + current: expect.objectContaining({ + installationId: 'install-b', + agentVersion: '0.11.1' + }) + }) + expect( + new InstallationRegistry({ storagePath }).snapshot() + ).toEqual(registry.snapshot()) + }) + + it('reuses the exact current identity without staging it again', () => { + const { registry } = createRegistry() + const current = verified('install-a', '0.11.1', 'a') + registry.stageCandidate(current) + registry.promoteCandidate('install-a') + + expect(registry.stageCandidate(current)).toEqual( + registry.snapshot().current + ) + expect(registry.snapshot().candidate).toBeUndefined() + expect(() => + registry.assertVerifiedRole(current, ['current']) + ).not.toThrow() + }) + + it('replaces a stale candidate with the requested signed identity', () => { + const { registry } = createRegistry() + registry.stageCandidate(verified('install-a', '0.11.0', 'a')) + registry.stageCandidate(verified('install-b', '0.11.1', 'b')) + + expect(registry.snapshot().candidate?.installationId).toBe( + 'install-b' + ) + }) + + it('preserves legacy bytes until the registry is mutated', () => { + const root = privateTemporaryDirectory() + const storagePath = resolve(root, 'registry.json') + const current = { + installationId: 'install-a', + productVersion: '0.11.0', + agentVersion: '0.11.0', + releaseSequence: 4, + manifestSha256: 'a'.repeat(64), + binaryDigest: `sha256:${'a'.repeat(64)}`, + arch: 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + previouslyVerified: true + } + const legacyBytes = `${JSON.stringify({ + formatVersion: 1, + minimumTrustedReleaseSequence: 1, + current, + draining: [] + }, null, 2)}\n` + writeFileSync(storagePath, legacyBytes, { mode: 0o600 }) + + const registry = new InstallationRegistry({ storagePath }) + expect(registry.snapshot()).toEqual({ + formatVersion: 1, + current: { + installationId: 'install-a', + agentVersion: '0.11.0', + manifestSha256: 'a'.repeat(64), + arch: 'x64' + } + }) + expect(readFileSync(storagePath, 'utf8')).toBe(legacyBytes) + + registry.stageCandidate( + verified('install-b', '0.11.1', 'b') + ) + expect(readFileSync(storagePath, 'utf8')).not.toContain( + 'releaseSequence' + ) + }) + + it('fails closed on corrupt and concurrent state', () => { + const root = privateTemporaryDirectory() + const storagePath = resolve(root, 'registry.json') + writeFileSync( + storagePath, + '{"formatVersion":1,"unknown":true}', + { mode: 0o600 } + ) + expect(() => new InstallationRegistry({ storagePath })).toThrow( + 'corrupt' + ) + writeFileSync(storagePath, '{}', { mode: 0o644 }) + expect(() => new InstallationRegistry({ storagePath })).toThrow( + 'corrupt' + ) + + rmSync(storagePath) + const first = new InstallationRegistry({ storagePath }) + const concurrent = new InstallationRegistry({ storagePath }) + first.stageCandidate(verified('install-a', '0.11.0', 'a')) + expect(() => + concurrent.stageCandidate( + verified('install-b', '0.11.1', 'b') + ) + ).toThrow('changed concurrently') + expect(readFileSync(storagePath, 'utf8')).toContain('install-a') + }) +}) + +function createRegistry(): { + registry: InstallationRegistry + storagePath: string +} { + const root = privateTemporaryDirectory() + const storagePath = resolve(root, 'registry.json') + return { + registry: new InstallationRegistry({ storagePath }), + storagePath + } +} + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-registry-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return resolve(path) +} + +function verified( + installationId: string, + agentVersion: string, + digestCharacter: string +): VerifiedInstalledAgentBundle { + const manifestSha256 = digestCharacter.repeat(64) + return { + installationId, + installationDirectory: `/agent/${installationId}`, + executablePath: `/agent/${installationId}/goodbuddy-agent`, + manifestSha256, + binaryDigest: `sha256:${manifestSha256}`, + manifest: { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion, + platform: 'linux', + arch: 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files: [ + { + path: 'goodbuddy-agent', + size: 1, + sha256: 'a'.repeat(64), + mode: '0755' + }, + { + path: 'node', + size: 1, + sha256: 'b'.repeat(64), + mode: '0755' + }, + { + path: 'lib/agent.cjs', + size: 1, + sha256: 'c'.repeat(64), + mode: '0644' + }, + { + path: 'licenses/license.txt', + size: 1, + sha256: 'd'.repeat(64), + mode: '0644' + } + ], + licenses: [ + { + package: 'GoodBuddy', + version: agentVersion, + spdx: '0BSD', + path: 'licenses/license.txt' + } + ] + } + } +} diff --git a/src/agent-daemon/installation-registry.ts b/src/agent-daemon/installation-registry.ts new file mode 100644 index 00000000..32d42e8a --- /dev/null +++ b/src/agent-daemon/installation-registry.ts @@ -0,0 +1,234 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { + installationRegistryEntrySchema, + installationRegistryIdSchema, + installationRegistryStateSchema, + parseInstallationRegistryState, + type InstallationRegistryEntry, + type InstallationRegistryState +} from '../shared/remote-environment-registry-contracts' +import { + ensurePrivateDirectory, + assertPrivateRegularFile, + writePrivateFileAtomic +} from './managed-paths' +import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' + +export { + installationRegistryEntrySchema, + installationRegistryIdSchema, + installationRegistryStateSchema +} +export type { + InstallationRegistryEntry, + InstallationRegistryState +} + +export class InstallationRegistry { + readonly #storagePath: string + #state: InstallationRegistryState + #needsCanonicalization = false + + constructor(options: { storagePath: string }) { + this.#storagePath = resolve(options.storagePath) + ensurePrivateDirectory(dirname(this.#storagePath)) + this.#state = this.#load() + } + + snapshot(): InstallationRegistryState { + return structuredClone(this.#state) + } + + stageCandidate( + verified: VerifiedInstalledAgentBundle + ): InstallationRegistryEntry { + const entry = entryFromVerified(verified) + if ( + this.#state.current !== undefined && + entryEquals(this.#state.current, entry) + ) { + this.#canonicalizeIfNeeded() + return this.#state.current + } + if ( + this.#state.candidate !== undefined && + entryEquals(this.#state.candidate, entry) + ) { + this.#canonicalizeIfNeeded() + return this.#state.candidate + } + try { + this.#commit({ ...this.#state, candidate: entry }) + } catch (error) { + if (!isConcurrentRegistryError(error)) { + throw error + } + this.#state = this.#load() + const converged = [ + this.#state.current, + this.#state.candidate + ].find( + (candidate) => + candidate !== undefined && entryEquals(candidate, entry) + ) + if (converged === undefined) { + throw error + } + return converged + } + return entry + } + + promoteCandidate( + installationIdInput: string + ): InstallationRegistryState { + const installationId = installationRegistryIdSchema.parse( + installationIdInput + ) + const candidate = this.#state.candidate + if ( + candidate === undefined || + candidate.installationId !== installationId + ) { + throw new Error('Agent candidate is not staged for promotion') + } + this.#commit({ + formatVersion: 1, + current: candidate + }) + return this.snapshot() + } + + assertVerifiedRole( + verified: VerifiedInstalledAgentBundle, + roles: readonly ('current' | 'candidate')[] + ): InstallationRegistryEntry { + const entry = entryFromVerified(verified) + const registered = roles + .map((role) => this.#state[role]) + .find( + (candidate) => + candidate !== undefined && + candidate.installationId === entry.installationId + ) + if ( + registered === undefined || + !entryEquals(registered, entry) + ) { + throw new Error( + 'Verified Agent installation does not match its authorized registry role' + ) + } + return registered + } + + #load(): InstallationRegistryState { + let value: unknown + try { + assertPrivateRegularFile(this.#storagePath) + value = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + const initial: InstallationRegistryState = { + formatVersion: 1 + } + writePrivateFileAtomic( + this.#storagePath, + `${JSON.stringify(initial, null, 2)}\n` + ) + return initial + } + throw new Error('Agent installation registry is corrupt', { + cause: error + }) + } + let parsed: InstallationRegistryState + try { + parsed = parseInstallationRegistryState(value) + } catch (error) { + throw new Error( + 'Agent installation registry is corrupt', + { cause: error } + ) + } + this.#needsCanonicalization = + JSON.stringify(parsed) !== JSON.stringify(value) + return parsed + } + + #commit(stateInput: InstallationRegistryState): void { + const state = installationRegistryStateSchema.parse(stateInput) + let persisted: unknown + try { + assertPrivateRegularFile(this.#storagePath) + persisted = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + throw new Error( + 'Agent installation registry changed or became corrupt before commit', + { cause: error } + ) + } + let parsedPersisted: InstallationRegistryState + try { + parsedPersisted = parseInstallationRegistryState(persisted) + } catch { + throw new Error( + 'Agent installation registry changed or became corrupt before commit' + ) + } + if ( + JSON.stringify(parsedPersisted) !== JSON.stringify(this.#state) + ) { + throw new Error( + 'Agent installation registry changed concurrently; refusing to overwrite it' + ) + } + writePrivateFileAtomic( + this.#storagePath, + `${JSON.stringify(state, null, 2)}\n` + ) + this.#state = state + this.#needsCanonicalization = false + } + + #canonicalizeIfNeeded(): void { + if (this.#needsCanonicalization) { + this.#commit(this.#state) + } + } +} + +function entryFromVerified( + verified: VerifiedInstalledAgentBundle +): InstallationRegistryEntry { + return installationRegistryEntrySchema.parse({ + installationId: verified.installationId, + agentVersion: verified.manifest.agentVersion, + manifestSha256: verified.manifestSha256, + arch: verified.manifest.arch + }) +} + +function entryEquals( + left: InstallationRegistryEntry, + right: InstallationRegistryEntry +): boolean { + return ( + left.installationId === right.installationId && + left.agentVersion === right.agentVersion && + left.manifestSha256 === right.manifestSha256 && + left.arch === right.arch + ) +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} + +function isConcurrentRegistryError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes('changed concurrently') + ) +} diff --git a/src/agent-daemon/installed-bundle-verifier.test.ts b/src/agent-daemon/installed-bundle-verifier.test.ts new file mode 100644 index 00000000..cb7f2009 --- /dev/null +++ b/src/agent-daemon/installed-bundle-verifier.test.ts @@ -0,0 +1,223 @@ +import { + createHash, + generateKeyPairSync, + sign, + type KeyObject +} from 'node:crypto' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + type AgentBundleManifest, + type AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import { + canonicalInstalledAgentManifestBytes, + installedAgentManifestSignaturePayload, + verifyInstalledAgentBundle +} from './installed-bundle-verifier' + +let root: string +let installationDirectory: string +let privateKey: KeyObject +let releaseKeyRegistry: AgentReleaseKeyRegistry + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'goodbuddy-installed-agent-')) + installationDirectory = join(root, 'install-10') + mkdirSync(installationDirectory, { mode: 0o700 }) + const generated = generateKeyPairSync('ed25519') + privateKey = generated.privateKey + releaseKeyRegistry = { + formatVersion: 1, + keys: [ + { + keyId: 'installed-test-key', + publicKeySpkiBase64: generated.publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + environment: 'test' + } + ], + revocations: [] + } +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('installed Agent bundle verifier', () => { + it('binds the installation identity to a signed complete payload', async () => { + writeBundle() + + await expect(verifyBundle()).resolves.toMatchObject({ + installationId: 'install-10', + binaryDigest: `sha256:${createHash('sha256') + .update(readFileSync(join(installationDirectory, 'manifest.json'))) + .digest('hex')}`, + manifest: { + agentVersion: '0.11.0', + protocol: { major: 1, minor: 0 } + } + }) + await expect( + verifyInstalledAgentBundle(installationDirectory, { + installationId: 'different-id', + architecture: 'x64', + releaseKeyRegistry, + verificationEnvironment: 'test' + }) + ).rejects.toThrow('does not match') + }) + + it('rejects tampering, undeclared files, and symlinks', async () => { + writeBundle() + writeFileSync( + join(installationDirectory, 'lib', 'agent.cjs'), + 'tampered' + ) + await expect(verifyBundle()).rejects.toThrow( + /size mismatch|hash mismatch/u + ) + + resetInstallation() + writeBundle() + writeFileSync(join(installationDirectory, 'undeclared'), 'no') + await expect(verifyBundle()).rejects.toThrow('undeclared') + + if (process.platform !== 'win32') { + resetInstallation() + writeBundle() + rmSync(join(installationDirectory, 'lib', 'agent.cjs')) + symlinkSync( + join(installationDirectory, 'goodbuddy-agent'), + join(installationDirectory, 'lib', 'agent.cjs') + ) + await expect(verifyBundle()).rejects.toThrow(/regular file|symlink/u) + } + }) + + it('rejects mode, architecture, and signature violations', async () => { + if (process.platform !== 'win32') { + writeBundle() + chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777) + await expect(verifyBundle()).rejects.toThrow('mode mismatch') + resetInstallation() + } + + writeBundle('arm64') + await expect(verifyBundle()).rejects.toThrow( + 'current host' + ) + + resetInstallation() + writeBundle() + writeFileSync( + join(installationDirectory, 'manifest.sig'), + `${Buffer.alloc(64).toString('base64')}\n`, + { mode: 0o644 } + ) + await expect(verifyBundle()).rejects.toThrow( + 'signature verification failed' + ) + + }) +}) + +function resetInstallation(): void { + rmSync(installationDirectory, { recursive: true, force: true }) + mkdirSync(installationDirectory, { mode: 0o700 }) +} + +function writeBundle(arch: 'x64' | 'arm64' = 'x64'): void { + for (const directory of ['lib', 'licenses']) { + mkdirSync(join(installationDirectory, directory), { mode: 0o700 }) + } + const contents = new Map([ + ['node', elf(arch)], + ['goodbuddy-agent', Buffer.from('#!/bin/sh\n')], + ['lib/agent.cjs', Buffer.from('module.exports = {}\n')], + ['licenses/GoodBuddy-0BSD.txt', Buffer.from('0BSD')] + ]) + for (const [path, bytes] of contents) { + writeFileSync( + join(installationDirectory, ...path.split('/')), + bytes, + { mode: path === 'node' || path === 'goodbuddy-agent' ? 0o755 : 0o644 } + ) + } + const manifest: AgentBundleManifest = { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion: '0.11.0', + platform: 'linux', + arch, + protocol: { major: 1, minor: 0 }, + signingKeyId: 'installed-test-key', + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files: [...contents].map(([path, bytes]) => ({ + path, + size: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + mode: + path === 'node' || path === 'goodbuddy-agent' + ? '0755' + : '0644' + })), + licenses: [ + { + package: 'GoodBuddy', + version: '0.11.0', + spdx: '0BSD', + path: 'licenses/GoodBuddy-0BSD.txt' + } + ] + } + const bytes = canonicalInstalledAgentManifestBytes(manifest) + writeFileSync( + join(installationDirectory, 'manifest.json'), + bytes, + { mode: 0o644 } + ) + writeFileSync( + join(installationDirectory, 'manifest.sig'), + `${sign( + null, + installedAgentManifestSignaturePayload(bytes), + privateKey + ).toString('base64')}\n`, + { mode: 0o644 } + ) +} + +function verifyBundle() { + return verifyInstalledAgentBundle(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]) + header[5] = 1 + header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18) + return header +} diff --git a/src/agent-daemon/installed-bundle-verifier.ts b/src/agent-daemon/installed-bundle-verifier.ts new file mode 100644 index 00000000..cd137dc3 --- /dev/null +++ b/src/agent-daemon/installed-bundle-verifier.ts @@ -0,0 +1,488 @@ +import { + createHash, + createPublicKey, + verify +} from 'node:crypto' +import { + lstat, + readFile, + readdir +} from 'node:fs/promises' +import { basename, join, relative, resolve, sep } from 'node:path' +import { + agentBundleManifestSchema, + agentReleaseKeyRegistrySchema, + type AgentArchitecture, + type AgentBundleManifest, + type AgentReleaseKey, + type AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import { assertAbsoluteManagedPath } from './managed-paths' +import { + assertElfArchitecture, + sha256File +} from './bundle-file-verification' + +const SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Agent Bundle Manifest Signature v1\0', + 'utf8' +) +const MAXIMUM_METADATA_BYTES = 1024 * 1024 +const MANIFEST_FILE_NAME = 'manifest.json' +const SIGNATURE_FILE_NAME = 'manifest.sig' +const installationIdPattern = + /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u + +export type InstalledBundleVerificationEnvironment = + | 'production' + | 'test' + +export type VerifiedInstalledAgentBundle = { + installationId: string + installationDirectory: string + executablePath: string + manifest: AgentBundleManifest + manifestSha256: string + binaryDigest: string +} + +export type VerifyInstalledAgentBundleOptions = { + installationId: string + architecture: AgentArchitecture + releaseKeyRegistry: AgentReleaseKeyRegistry + verificationEnvironment?: InstalledBundleVerificationEnvironment + enforceFilesystemMode?: boolean + filesystemPlatform?: NodeJS.Platform + uid?: number +} + +export async function readManagedAgentReleaseKeyRegistry( + filePath: string, + options: { uid?: number; filesystemPlatform?: NodeJS.Platform } = {} +): Promise { + const normalizedPath = assertAbsoluteManagedPath(resolve(filePath)) + const stat = await lstat(normalizedPath) + assertOwnedRegularFile(stat, normalizedPath, options.uid) + if ( + shouldEnforceMode(options) && + modeString(stat.mode) !== '0600' + ) { + throw new Error( + 'Managed Agent release-key registry permissions must be 0600' + ) + } + if (stat.size > MAXIMUM_METADATA_BYTES) { + throw new Error('Managed Agent release-key registry exceeds its size limit') + } + let value: unknown + try { + value = JSON.parse( + (await readFile(normalizedPath)).toString('utf8') + ) + } catch (error) { + throw new Error('Managed Agent release-key registry is invalid JSON', { + cause: error + }) + } + return parseReleaseKeyRegistry(value) +} + +export async function verifyInstalledAgentBundle( + installationDirectoryInput: string, + options: VerifyInstalledAgentBundleOptions +): Promise { + const installationId = validateInstallationId(options.installationId) + const installationDirectory = resolve(installationDirectoryInput) + assertAbsoluteManagedPath(installationDirectory) + if (basename(installationDirectory) !== installationId) { + throw new Error( + 'Agent installation ID does not match its managed directory' + ) + } + const releaseKeyRegistry = parseReleaseKeyRegistry( + options.releaseKeyRegistry + ) + const verificationEnvironment = + options.verificationEnvironment ?? 'production' + assertRegistryAvailable(releaseKeyRegistry, verificationEnvironment) + + 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 + ) + const signatureBytes = await readSignature( + join(installationDirectory, SIGNATURE_FILE_NAME), + options + ) + const manifest = verifyInstalledAgentManifestSignature( + manifestBytes, + signatureBytes, + releaseKeyRegistry, + verificationEnvironment + ) + if (manifest.arch !== options.architecture) { + throw new Error( + 'Agent manifest architecture does not match the current host' + ) + } + + 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' + ) + } + + await assertElfArchitecture( + join(installationDirectory, manifest.entrypoint.runtimePath), + manifest.arch, + 'Agent Node runtime' + ) + const manifestSha256 = sha256Bytes(manifestBytes) + return { + installationId, + installationDirectory, + executablePath: join( + installationDirectory, + manifest.entrypoint.path + ), + manifest, + manifestSha256, + binaryDigest: `sha256:${manifestSha256}` + } +} + +export function canonicalInstalledAgentManifestBytes( + manifest: AgentBundleManifest +): Buffer { + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +} + +export function installedAgentManifestSignaturePayload( + manifestBytes: Uint8Array +): Buffer { + return Buffer.concat([ + SIGNATURE_DOMAIN, + Buffer.from(manifestBytes) + ]) +} + +export function verifyInstalledAgentManifestSignature( + manifestBytes: Buffer, + signatureBytes: Buffer, + registryInput: AgentReleaseKeyRegistry, + verificationEnvironment: InstalledBundleVerificationEnvironment = + 'production' +): AgentBundleManifest { + const registry = parseReleaseKeyRegistry(registryInput) + assertRegistryAvailable(registry, verificationEnvironment) + let untrusted: unknown + try { + untrusted = JSON.parse(manifestBytes.toString('utf8')) + } catch (error) { + throw new Error('Agent manifest JSON is invalid', { cause: error }) + } + const parsed = agentBundleManifestSchema.safeParse(untrusted) + if (!parsed.success) { + throw new Error( + `Agent manifest contract is invalid: ${parsed.error.message}` + ) + } + const manifest = parsed.data + if ( + !canonicalInstalledAgentManifestBytes(manifest).equals(manifestBytes) + ) { + throw new Error( + 'Agent manifest is not in canonical deterministic form' + ) + } + const key = trustedKeyForManifest( + manifest, + registry, + verificationEnvironment + ) + const publicKey = importPublicKey(key) + if ( + signatureBytes.length !== 64 || + !verify( + null, + installedAgentManifestSignaturePayload(manifestBytes), + publicKey, + signatureBytes + ) + ) { + throw new Error('Agent manifest signature verification failed') + } + return manifest +} + +function validateInstallationId(value: string): string { + if (value.length > 128 || !installationIdPattern.test(value)) { + throw new Error('Invalid Agent installation ID') + } + return value +} + +function parseReleaseKeyRegistry( + value: unknown +): AgentReleaseKeyRegistry { + const parsed = agentReleaseKeyRegistrySchema.safeParse(value) + if (!parsed.success) { + throw new Error( + `Agent trusted key registry contract is invalid: ${parsed.error.message}` + ) + } + for (const key of parsed.data.keys) { + importPublicKey(key) + } + return parsed.data +} + +function assertRegistryAvailable( + registry: AgentReleaseKeyRegistry, + environment: InstalledBundleVerificationEnvironment +): void { + if ( + environment === 'production' && + !registry.keys.some((key) => key.environment === 'production') + ) { + throw new Error( + 'Agent production key registry is empty; installed Agent verification is unavailable' + ) + } +} + +function trustedKeyForManifest( + manifest: AgentBundleManifest, + registry: AgentReleaseKeyRegistry, + environment: InstalledBundleVerificationEnvironment +): AgentReleaseKey { + const key = registry.keys.find( + (candidate) => candidate.keyId === manifest.signingKeyId + ) + if (key === undefined) { + throw new Error( + `Agent manifest uses an unknown signing key: ${manifest.signingKeyId}` + ) + } + if (environment === 'production' && key.environment !== 'production') { + throw new Error( + `Production Agent verification rejects non-production key: ${key.keyId}` + ) + } + if ( + registry.revocations.some( + (revocation) => revocation.keyId === key.keyId + ) + ) { + throw new Error(`Agent signing key is revoked: ${key.keyId}`) + } + return key +} + +function importPublicKey(key: AgentReleaseKey) { + let publicKey + try { + publicKey = createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + } catch (error) { + throw new Error(`Agent trusted public key is invalid: ${key.keyId}`, { + cause: error + }) + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error( + `Agent trusted public key is not Ed25519: ${key.keyId}` + ) + } + return publicKey +} + +async function readMetadataFile( + filePath: string, + label: string, + options: Pick< + VerifyInstalledAgentBundleOptions, + 'uid' | 'filesystemPlatform' | 'enforceFilesystemMode' + > +): Promise { + const stat = await lstat(filePath) + assertOwnedRegularFile(stat, label, options.uid) + if ( + shouldEnforceMode(options) && + modeString(stat.mode) !== '0644' + ) { + throw new Error(`Agent payload mode mismatch: ${basename(filePath)}`) + } + if (stat.size > MAXIMUM_METADATA_BYTES) { + throw new Error(`${label} exceeds its size limit`) + } + return readFile(filePath) +} + +async function readSignature( + filePath: string, + options: Pick< + VerifyInstalledAgentBundleOptions, + 'uid' | 'filesystemPlatform' | 'enforceFilesystemMode' + > +): Promise { + const text = (await readMetadataFile( + filePath, + 'Agent detached signature', + options + )).toString('utf8') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) { + throw new Error('Agent detached signature encoding is invalid') + } + return Buffer.from(text.trim(), 'base64') +} + +async function listInstallationFiles( + installationDirectory: string, + options: Pick< + VerifyInstalledAgentBundleOptions, + 'uid' | 'filesystemPlatform' | 'enforceFilesystemMode' + > +): Promise { + const files: string[] = [] + const pending = [installationDirectory] + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined) { + continue + } + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + const absolutePath = join(directory, entry.name) + if (entry.isSymbolicLink()) { + throw new Error( + `Agent installation cannot contain a symlink: ${absolutePath}` + ) + } + if (entry.isDirectory()) { + const stat = await lstat(absolutePath) + assertOwnedDirectory(stat, absolutePath, options.uid) + if ( + shouldEnforceMode(options) && + modeString(stat.mode) !== '0700' + ) { + throw new Error( + `Agent installation directory permissions must be 0700: ${absolutePath}` + ) + } + pending.push(absolutePath) + } else if (entry.isFile()) { + files.push( + relative(installationDirectory, absolutePath) + .split(sep) + .join('/') + ) + } else { + throw new Error( + `Agent installation contains an unsupported file: ${absolutePath}` + ) + } + } + } + return files +} + +function assertOwnedDirectory( + stat: Awaited>, + label: string, + uid = process.getuid?.() +): void { + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} is not a real directory`) + } + if (uid !== undefined && stat.uid !== uid) { + throw new Error(`${label} is not owned by the current user`) + } +} + +function assertOwnedRegularFile( + stat: Awaited>, + label: string, + uid = process.getuid?.() +): void { + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} is not a regular file`) + } + if (uid !== undefined && stat.uid !== uid) { + throw new Error(`${label} is not owned by the current user`) + } +} + +function shouldEnforceMode( + options: { + enforceFilesystemMode?: boolean + filesystemPlatform?: NodeJS.Platform + } +): boolean { + return ( + options.enforceFilesystemMode !== false && + (options.filesystemPlatform ?? process.platform) !== 'win32' + ) +} + +function modeString(mode: number): string { + return (mode & 0o777).toString(8).padStart(4, '0') +} + +function sha256Bytes(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} diff --git a/src/agent-daemon/lifecycle.test.ts b/src/agent-daemon/lifecycle.test.ts new file mode 100644 index 00000000..6ba041b0 --- /dev/null +++ b/src/agent-daemon/lifecycle.test.ts @@ -0,0 +1,300 @@ +import { + chmodSync, + closeSync, + fstatSync, + mkdirSync, + mkdtempSync, + openSync, + rmSync, + symlinkSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createConnection, createServer, type Socket } from 'node:net' +import { afterEach, describe, expect, it } from 'vitest' +import { AGENT_PROTOCOL_VERSION } from '../shared/agent-protocol/contracts' +import { + decodeAgentFrame, + encodeAgentFrame +} from '../shared/agent-protocol/frame' +import { + ControllerRegistry, + ControllerRegistryError +} from './controller-registry' +import { EventJournal } from './event-journal' +import { InstallationChallengeVerifier } from './installation-challenge' +import { + ensurePrivateDirectory, + ManagedPathError +} from './managed-paths' +import { AgentProtocolServer } from './protocol-server' + +const temporaryPaths: string[] = [] +const digest = `sha256:${'a'.repeat(64)}` + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('managed daemon lifecycle', () => { + it('creates private directories and rejects symlink-managed paths', () => { + const root = temporaryDirectory() + const privateDirectory = join(root, 'private') + ensurePrivateDirectory(privateDirectory) + if (process.platform !== 'win32') { + expect(chmodMode(privateDirectory)).toBe(0o700) + } + const target = join(root, 'target') + mkdirSync(target) + const link = join(root, 'link') + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + expect(() => ensurePrivateDirectory(resolve(link))).toThrow(ManagedPathError) + }) + + it('uses one-shot installation HMAC challenges', () => { + let now = 10 + const verifier = new InstallationChallengeVerifier(Buffer.alloc(32, 7), { + now: () => now, + lifetimeMs: 100 + }) + const challenge = verifier.issue() + const response = verifier.createResponse({ + serverNonce: challenge.serverNonce, + clientNonce: 'client-1', + controllerId: 'controller-1' + }) + expect( + verifier.verify({ + serverNonce: challenge.serverNonce, + clientNonce: 'client-1', + controllerId: 'controller-1', + response + }) + ).toBe(true) + expect( + verifier.verify({ + serverNonce: challenge.serverNonce, + clientNonce: 'client-1', + controllerId: 'controller-1', + response + }) + ).toBe(false) + const expired = verifier.issue() + now = 111 + expect( + verifier.verify({ + serverNonce: expired.serverNonce, + clientNonce: 'client-1', + controllerId: 'controller-1', + response: verifier.createResponse({ + serverNonce: expired.serverNonce, + clientNonce: 'client-1', + controllerId: 'controller-1' + }) + }) + ).toBe(false) + }) + + it('persists generations and enforces controller ownership', () => { + const root = privateTemporaryDirectory() + const registryPath = resolve(root, 'controllers.json') + const first = new ControllerRegistry({ storagePath: registryPath }) + const attached = first.attach('controller-a') + first.claim( + attached.controllerId, + attached.generation, + 'process', + 'process-a' + ) + const second = new ControllerRegistry({ storagePath: registryPath }) + const resumed = second.attach('controller-a') + expect(resumed.generation).toBe(attached.generation + 1) + expect(() => + second.assertCurrent('controller-a', attached.generation) + ).toThrow(ControllerRegistryError) + expect(() => + second.assertOwner( + 'controller-b', + resumed.generation, + 'process', + 'process-a' + ) + ).toThrow(ControllerRegistryError) + expect(() => + second.assertOwner( + 'controller-a', + resumed.generation, + 'process', + 'process-a' + ) + ).not.toThrow() + }) + + it('frames attach control requests with stale-generation rejection', async () => { + const root = privateTemporaryDirectory() + const events = new EventJournal(resolve(root, 'events.sqlite')) + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-a') + const protocol = new AgentProtocolServer({ + controllers, + events, + status: () => ({ + state: 'ready', + installationId: 'install-1', + binaryDigest: digest, + daemonBootId: 'boot-1', + agentVersion: '1.0.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'uid:1000', + draining: false + }) + }) + const server = createServer((socket) => protocol.accept(socket, controller)) + await listen(server) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Test server did not open a TCP endpoint') + } + const client = createConnection(address.port, '127.0.0.1') + try { + await onceConnected(client) + const payload = Buffer.from( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'agent/status', + params: {} + }) + ) + client.write( + encodeAgentFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: controller.connectionId, + generation: controller.generation, + channelId: 'control', + channelEpoch: '1', + direction: 'main-to-agent', + sequence: '1', + kind: 'control', + payloadLength: payload.byteLength + }, + payload + }) + ) + const replies = await readFrames(client, 2) + expect( + JSON.parse(Buffer.from(replies[0]!.payload).toString('utf8')) + ).toMatchObject({ + id: 1, + result: { + state: 'ready', + installationId: 'install-1', + binaryDigest: digest + } + }) + + controllers.attach('controller-a') + client.write( + encodeAgentFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: controller.connectionId, + generation: controller.generation, + channelId: 'control', + channelEpoch: '1', + direction: 'main-to-agent', + sequence: '2', + kind: 'control', + payloadLength: payload.byteLength + }, + payload + }) + ) + await onceClosed(client) + } finally { + client.destroy() + await new Promise((resolveClose) => + server.close(() => resolveClose()) + ) + events.close() + } + }) +}) + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-')) + temporaryPaths.push(path) + return path +} + +function privateTemporaryDirectory(): string { + const path = temporaryDirectory() + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return path +} + +function chmodMode(path: string): number { + const descriptor = openSync(path, 'r') + try { + return fstatSync(descriptor).mode & 0o777 + } finally { + closeSync(descriptor) + } +} + +async function listen(server: ReturnType): Promise { + await new Promise((resolveListen, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolveListen) + }) +} + +async function onceConnected(socket: Socket): Promise { + await new Promise((resolveConnect, reject) => { + socket.once('connect', resolveConnect) + socket.once('error', reject) + }) +} + +async function onceClosed(socket: Socket): Promise { + await new Promise((resolveClose) => socket.once('close', resolveClose)) +} + +async function readFrames( + socket: Socket, + count: number +): Promise[]> { + return await new Promise((resolveFrames, reject) => { + let buffer = Buffer.alloc(0) + const frames: ReturnType[] = [] + socket.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + while (buffer.byteLength >= 40) { + const length = + 40 + + buffer.readUInt16BE(32) + + buffer.readUInt16BE(34) + + buffer.readUInt32BE(36) + if (buffer.byteLength < length) { + break + } + frames.push(decodeAgentFrame(buffer.subarray(0, length))) + buffer = buffer.subarray(length) + } + if (frames.length >= count) { + resolveFrames(frames) + } + }) + socket.once('error', reject) + }) +} diff --git a/src/agent-daemon/linux-process-identity.test.ts b/src/agent-daemon/linux-process-identity.test.ts new file mode 100644 index 00000000..2a7cd0ed --- /dev/null +++ b/src/agent-daemon/linux-process-identity.test.ts @@ -0,0 +1,141 @@ +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + LinuxProcessInspector, + type LinuxProcFileSystem, + type LinuxProcessIdentity +} from './linux-process-identity' + +describe('Linux process identity', () => { + it('reads only PID, start time, and canonical executable', () => { + const executablePath = resolve('fixtures', 'runtime', 'node') + const inspector = new LinuxProcessInspector({ + procRoot: resolve('fixtures', 'proc'), + fileSystem: procFileSystem({ + executablePath, + stat: procStat('98765', 'worker ) name') + }) + }) + + expect(inspector.inspect(4242)).toEqual({ + pid: 4242, + starttime: '98765', + executablePath + }) + }) + + it('returns absent only for a missing process and rejects malformed proc data', () => { + expect( + new LinuxProcessInspector({ + procRoot: resolve('fixtures', 'proc'), + fileSystem: procFileSystem({ + executablePath: resolve('fixtures', 'node'), + missingPid: true + }) + }).inspect(4242) + ).toBeUndefined() + + expect(() => + new LinuxProcessInspector({ + procRoot: resolve('fixtures', 'proc'), + fileSystem: procFileSystem({ + executablePath: resolve('fixtures', 'node'), + stat: '42 malformed' + }) + }).inspect(4242) + ).toThrow() + }) + + it.each([ + ['start time', { starttime: '98766' }], + ['executable', { executablePath: resolve('fixtures', 'other-node') }] + ])('refuses an identity with mismatched %s', (_label, change) => { + const { inspector, identity } = fixtureInspector() + expect(inspector.matches({ ...identity, ...change })).toBe(false) + }) + + it('signals only an exact process identity and handles ESRCH', () => { + const { inspector, identity, kill } = fixtureInspector() + expect(inspector.signal(identity, 'SIGTERM')).toBe(true) + expect(kill).toHaveBeenCalledWith(identity.pid, 'SIGTERM') + kill.mockClear() + + expect( + inspector.signal({ ...identity, starttime: '98766' }, 'SIGKILL') + ).toBe(false) + expect(kill).not.toHaveBeenCalled() + + const gone = new LinuxProcessInspector({ + procRoot: resolve('fixtures', 'proc'), + fileSystem: procFileSystem({ + executablePath: identity.executablePath + }), + kill: () => { + throw nodeError('ESRCH') + } + }) + expect(gone.signal(identity, 'SIGTERM')).toBe(false) + }) +}) + +function fixtureInspector(): { + inspector: LinuxProcessInspector + identity: LinuxProcessIdentity + kill: ReturnType void>> +} { + const executablePath = resolve('fixtures', 'runtime', 'node') + const kill = vi.fn<(pid: number, signal: NodeJS.Signals | 0) => void>() + return { + inspector: new LinuxProcessInspector({ + procRoot: resolve('fixtures', 'proc'), + fileSystem: procFileSystem({ executablePath }), + kill + }), + identity: { + pid: 4242, + starttime: '98765', + executablePath + }, + kill + } +} + +function procFileSystem(options: { + executablePath: string + stat?: string + missingPid?: boolean +}): LinuxProcFileSystem { + return { + readText(path) { + if (!path.endsWith('4242\\stat') && !path.endsWith('4242/stat')) { + throw new Error(`Unexpected proc text read: ${path}`) + } + if (options.missingPid) { + throw nodeError('ENOENT') + } + return options.stat ?? procStat('98765') + }, + readLink(path) { + if (!path.endsWith('4242\\exe') && !path.endsWith('4242/exe')) { + throw new Error(`Unexpected proc link read: ${path}`) + } + return options.executablePath + }, + realpath(path) { + return path + } + } +} + +function procStat(starttime: string, name = 'agent'): string { + const fields = ['S'] + while (fields.length < 19) { + fields.push('0') + } + fields.push(starttime, '0') + return `4242 (${name}) ${fields.join(' ')}\n` +} + +function nodeError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }) +} diff --git a/src/agent-daemon/linux-process-identity.ts b/src/agent-daemon/linux-process-identity.ts new file mode 100644 index 00000000..08371c89 --- /dev/null +++ b/src/agent-daemon/linux-process-identity.ts @@ -0,0 +1,168 @@ +import { + lstatSync, + readFileSync, + readlinkSync, + realpathSync +} from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' +import { z } from 'zod' +import { assertAbsoluteManagedPath } from './managed-paths' + +const linuxProcessIdentitySchema = z + .object({ + pid: z.number().int().positive().safe(), + starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u), + executablePath: z.string().min(1) + }) + .strict() + +export type LinuxProcessIdentity = z.infer< + typeof linuxProcessIdentitySchema +> + +export type LinuxProcessInspectorOptions = { + procRoot?: string + kill?: (pid: number, signal: NodeJS.Signals | 0) => void + fileSystem?: LinuxProcFileSystem +} + +export interface LinuxProcFileSystem { + readText(path: string): string + readLink(path: string): string + realpath(path: string): string +} + +export class LinuxProcessInspector { + readonly #procRoot: string + readonly #kill: (pid: number, signal: NodeJS.Signals | 0) => void + readonly #fileSystem: LinuxProcFileSystem + + constructor(options: LinuxProcessInspectorOptions = {}) { + this.#procRoot = resolve(options.procRoot ?? '/proc') + this.#kill = options.kill ?? process.kill + this.#fileSystem = options.fileSystem ?? nodeProcFileSystem + } + + inspect(pidInput: number): LinuxProcessIdentity | undefined { + const pid = positivePid(pidInput) + try { + const starttime = parseStarttime( + this.#fileSystem.readText( + join(this.#procRoot, String(pid), 'stat') + ) + ) + const executableLink = this.#fileSystem.readLink( + join(this.#procRoot, String(pid), 'exe') + ) + if ( + executableLink.includes('\0') || + !isAbsolute(executableLink) || + resolve(executableLink) !== executableLink + ) { + throw new Error('Linux process executable link is malformed') + } + return linuxProcessIdentitySchema.parse({ + pid, + starttime, + executablePath: assertAbsoluteManagedPath( + this.#fileSystem.realpath(executableLink) + ) + }) + } catch (error) { + if (isMissingProcessError(error)) { + return undefined + } + throw error + } + } + + matches(expectedInput: LinuxProcessIdentity): boolean { + const expected = linuxProcessIdentitySchema.parse(expectedInput) + const actual = this.inspect(expected.pid) + return ( + actual !== undefined && + actual.starttime === expected.starttime && + actual.executablePath === expected.executablePath + ) + } + + signal( + expectedInput: LinuxProcessIdentity, + signal: NodeJS.Signals + ): boolean { + const expected = linuxProcessIdentitySchema.parse(expectedInput) + if (!this.matches(expected)) { + return false + } + try { + this.#kill(expected.pid, signal) + return true + } catch (error) { + if (isNodeError(error) && error.code === 'ESRCH') { + return false + } + throw error + } + } +} + +export function currentLinuxProcessIdentity( + options: LinuxProcessInspectorOptions = {} +): LinuxProcessIdentity { + const identity = new LinuxProcessInspector(options).inspect(process.pid) + if (identity === undefined) { + throw new Error('Current Linux process identity is unavailable') + } + return identity +} + +export function canonicalExecutablePath(path: string): string { + const normalized = assertAbsoluteManagedPath(resolve(path)) + const stat = lstatSync(normalized) + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error('Agent executable must be a regular file') + } + return assertAbsoluteManagedPath(realpathSync(normalized)) +} + +function parseStarttime(contents: string): string { + const close = contents.lastIndexOf(')') + if (close < 2 || contents[close + 1] !== ' ') { + throw new Error('Linux process stat is malformed') + } + const starttime = contents + .slice(close + 2) + .trim() + .split(/\s+/u)[19] + if ( + starttime === undefined || + !/^(?:0|[1-9][0-9]*)$/u.test(starttime) + ) { + throw new Error('Linux process stat identity is malformed') + } + return starttime +} + +const nodeProcFileSystem: LinuxProcFileSystem = { + readText: (path) => readFileSync(path, 'utf8'), + readLink: (path) => readlinkSync(path), + realpath: (path) => realpathSync(path) +} + +function positivePid(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('Linux process ID is invalid') + } + return value +} + +function isMissingProcessError(error: unknown): boolean { + return ( + isNodeError(error) && + (error.code === 'ENOENT' || error.code === 'ESRCH') + ) +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/managed-paths.test.ts b/src/agent-daemon/managed-paths.test.ts new file mode 100644 index 00000000..ab5c2ade --- /dev/null +++ b/src/agent-daemon/managed-paths.test.ts @@ -0,0 +1,189 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertAbsoluteManagedPath, + derivePrivateTemporaryRoot, + ensurePrivateDirectory, + ensurePrivateDirectoryTree, + ensurePrivateTemporaryRoot, + readPrivateFile, + writePrivateFileAtomic +} from './managed-paths' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('deterministic private temporary root', () => { + it('binds a 0700 root marker to the UID and canonical home', () => { + const temporaryDirectory = privateTemporaryDirectory() + const identity = derivePrivateTemporaryRoot({ + platform: 'linux', + uid: 1234, + homeDirectory: resolve(temporaryDirectory, 'home'), + temporaryDirectory + }) + expect(identity.rootPath).toContain('goodbuddy-1234-') + expect(ensurePrivateTemporaryRoot(identity)).toBe(identity.rootPath) + const marker = readFileSync( + resolve(identity.rootPath, '.goodbuddy-private-root-v1'), + 'utf8' + ) + expect(JSON.parse(marker)).toEqual({ + formatVersion: 1, + uid: 1234, + canonicalHomeDirectory: resolve(temporaryDirectory, 'home') + }) + }) + + it('rejects an existing marker for another home', () => { + const temporaryDirectory = privateTemporaryDirectory() + const identity = derivePrivateTemporaryRoot({ + platform: 'linux', + uid: 1234, + homeDirectory: resolve(temporaryDirectory, 'home'), + temporaryDirectory + }) + ensurePrivateTemporaryRoot(identity) + writeFileSync( + resolve(identity.rootPath, '.goodbuddy-private-root-v1'), + '{"formatVersion":1,"uid":1234,"canonicalHomeDirectory":"/foreign"}\n', + { mode: 0o600 } + ) + expect(() => ensurePrivateTemporaryRoot(identity)).toThrow( + 'does not match this user' + ) + }) + + it('rejects a corrupt or permissive marker without replacing it', () => { + const temporaryDirectory = privateTemporaryDirectory() + const identity = derivePrivateTemporaryRoot({ + platform: 'linux', + uid: 1234, + homeDirectory: resolve(temporaryDirectory, 'home'), + temporaryDirectory + }) + ensurePrivateTemporaryRoot(identity) + const markerPath = resolve( + identity.rootPath, + '.goodbuddy-private-root-v1' + ) + writeFileSync(markerPath, '{broken-json\n') + expect(() => ensurePrivateTemporaryRoot(identity)).toThrow( + 'does not match this user' + ) + expect(readFileSync(markerPath, 'utf8')).toBe('{broken-json\n') + + if (process.platform !== 'win32') { + writeFileSync( + markerPath, + `${JSON.stringify({ + formatVersion: 1, + uid: identity.uid, + canonicalHomeDirectory: identity.canonicalHomeDirectory + }, null, 2)}\n` + ) + chmodSync(markerPath, 0o644) + expect(() => ensurePrivateTemporaryRoot(identity)).toThrow( + 'permissions must be 0600' + ) + } + }) +}) + +describe('managed path containment', () => { + it('requires normalized absolute paths and rejects anchor escapes', () => { + const root = privateTemporaryDirectory() + expect(() => assertAbsoluteManagedPath('relative')).toThrow( + 'normalized absolute' + ) + expect(() => + assertAbsoluteManagedPath(`${resolve(root, 'entry')}\0tail`) + ).toThrow('cannot contain NUL') + expect(() => + ensurePrivateDirectoryTree(resolve(root, '..', 'escape'), root) + ).toThrow('escapes its configured anchor') + }) + + it('rejects files, permissive directories, and symlink traversal', () => { + const root = privateTemporaryDirectory() + const filePath = resolve(root, 'file') + writeFileSync(filePath, 'not a directory') + expect(() => ensurePrivateDirectory(filePath)).toThrow( + 'not a real directory' + ) + + if (process.platform !== 'win32') { + const permissive = resolve(root, 'permissive') + mkdirSync(permissive, { mode: 0o755 }) + chmodSync(permissive, 0o755) + expect(() => ensurePrivateDirectory(permissive)).toThrow( + 'permissions must be 0700' + ) + } + + const outside = privateTemporaryDirectory() + const junction = resolve(root, 'junction') + symlinkSync(outside, junction, 'junction') + expect(() => + assertAbsoluteManagedPath(resolve(junction, 'managed.json')) + ).toThrow('cannot traverse a symlink') + }) + + it('atomically writes only private regular targets and reads bounded bytes', () => { + const root = privateTemporaryDirectory() + const target = resolve(root, 'state.json') + writePrivateFileAtomic(target, '{"state":"ready"}\n') + expect(readPrivateFile(target, 64).toString('utf8')).toBe( + '{"state":"ready"}\n' + ) + expect(() => readPrivateFile(target, 4)).toThrow('size limit') + + if (process.platform !== 'win32') { + chmodSync(target, 0o644) + expect(() => writePrivateFileAtomic(target, 'replacement')).toThrow( + 'permissions must be 0600' + ) + expect(readFileSync(target, 'utf8')).toBe('{"state":"ready"}\n') + } + }) + + it('never writes through a symlinked component outside its root', () => { + const root = privateTemporaryDirectory() + const outside = privateTemporaryDirectory() + const sentinel = resolve(outside, 'sentinel.txt') + writeFileSync(sentinel, 'user-owned') + const junction = resolve(root, 'junction') + symlinkSync(outside, junction, 'junction') + + expect(() => + writePrivateFileAtomic(resolve(junction, 'sentinel.txt'), 'changed') + ).toThrow('cannot traverse a symlink') + expect(readFileSync(sentinel, 'utf8')).toBe('user-owned') + expect(existsSync(resolve(root, 'sentinel.txt'))).toBe(false) + }) +}) + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-paths-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return resolve(path) +} diff --git a/src/agent-daemon/managed-paths.ts b/src/agent-daemon/managed-paths.ts new file mode 100644 index 00000000..dff40643 --- /dev/null +++ b/src/agent-daemon/managed-paths.ts @@ -0,0 +1,373 @@ +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import type { Stats } from 'node:fs' +import { + dirname, + isAbsolute, + parse, + relative, + resolve, + sep +} from 'node:path' +import { createHash, randomBytes } from 'node:crypto' +import { homedir } from 'node:os' + +export class ManagedPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'ManagedPathError' + } +} + +function assertOwner(stat: Stats, label: string): void { + const uid = process.getuid?.() + if (uid !== undefined && stat.uid !== uid) { + throw new ManagedPathError(`${label} is not owned by the current user`) + } +} + +export function assertAbsoluteManagedPath(path: string): string { + if (path.includes('\0')) { + throw new ManagedPathError('Managed paths cannot contain NUL') + } + if (!isAbsolute(path) || resolve(path) !== path) { + throw new ManagedPathError('Managed paths must be normalized absolute paths') + } + assertNoSymlinkComponents(path) + return path +} + +export function ensurePrivateDirectory( + path: string, + options: { create?: boolean } = {} +): void { + assertAbsoluteManagedPath(path) + let created = false + try { + const stat = lstatSync(path) + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new ManagedPathError('Managed directory is not a real directory') + } + assertOwner(stat, 'Managed directory') + if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) { + throw new ManagedPathError('Managed directory permissions must be 0700') + } + } catch (error) { + if ( + error instanceof ManagedPathError || + !isNodeError(error) || + error.code !== 'ENOENT' || + options.create === false + ) { + throw error + } + mkdirSync(path, { mode: 0o700 }) + created = true + } + if (created && process.platform !== 'win32') { + chmodSync(path, 0o700) + } +} + +function assertNoSymlinkComponents(path: string): void { + const root = parse(path).root + const components = relative(root, path).split(sep).filter(Boolean) + let current = root + for (const component of components) { + current = resolve(current, component) + try { + if (lstatSync(current).isSymbolicLink()) { + throw new ManagedPathError('Managed paths cannot traverse a symlink') + } + } catch (error) { + if ( + error instanceof ManagedPathError || + !isNodeError(error) || + error.code !== 'ENOENT' + ) { + throw error + } + return + } + } +} + +export function ensurePrivateDirectoryTree(path: string, anchor: string): void { + const normalizedPath = assertAbsoluteManagedPath(path) + const normalizedAnchor = assertAbsoluteManagedPath(anchor) + if ( + normalizedPath !== normalizedAnchor && + !normalizedPath.startsWith(`${normalizedAnchor}${process.platform === 'win32' ? '\\' : '/'}`) + ) { + throw new ManagedPathError('Managed path escapes its configured anchor') + } + const relative = normalizedPath.slice(normalizedAnchor.length) + ensurePrivateDirectory(normalizedAnchor) + let current = normalizedAnchor + for (const segment of relative.split(/[\\/]/u).filter(Boolean)) { + current = resolve(current, segment) + ensurePrivateDirectory(current) + } +} + +export function assertPrivateRegularFile(path: string): void { + assertAbsoluteManagedPath(path) + const stat = lstatSync(path) + assertPrivateRegularFileStat(stat) +} + +function assertPrivateRegularFileStat(stat: Stats): void { + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new ManagedPathError('Managed file is not a regular file') + } + assertOwner(stat, 'Managed file') + if (process.platform !== 'win32' && (stat.mode & 0o177) !== 0) { + throw new ManagedPathError('Managed file permissions must be 0600') + } +} + +export function readOrCreatePrivateSecret(path: string): Buffer { + assertAbsoluteManagedPath(path) + ensurePrivateDirectory(dirname(path), { create: false }) + const noFollow = constants.O_NOFOLLOW ?? 0 + let descriptor: number + try { + descriptor = openSync( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow, + 0o600 + ) + const secret = randomBytes(32) + try { + writeFileSync(descriptor, secret) + if (process.platform !== 'win32') { + fchmodSync(descriptor, 0o600) + } + } finally { + closeSync(descriptor) + } + return secret + } catch (error) { + if (!isNodeError(error) || error.code !== 'EEXIST') { + throw error + } + } + assertPrivateRegularFile(path) + descriptor = openSync(path, constants.O_RDONLY | noFollow) + try { + const stat = fstatSync(descriptor) + assertPrivateRegularFileStat(stat) + if (stat.size !== 32) { + throw new ManagedPathError('Installation identity must contain 32 bytes') + } + const output = Buffer.alloc(32) + if (readSync(descriptor, output, 0, output.length, 0) !== output.length) { + throw new ManagedPathError('Installation identity could not be read') + } + return output + } finally { + closeSync(descriptor) + } +} + +export function readPrivateSecret(path: string): Buffer { + const output = readPrivateFile(path, 32) + if (output.byteLength !== 32) { + throw new ManagedPathError('Installation identity must contain 32 bytes') + } + return output +} + +export function readPrivateFile(path: string, maximumBytes: number): Buffer { + assertAbsoluteManagedPath(path) + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + throw new ManagedPathError('Managed file size limit is invalid') + } + const noFollow = constants.O_NOFOLLOW ?? 0 + const descriptor = openSync(path, constants.O_RDONLY | noFollow) + try { + const stat = fstatSync(descriptor) + assertPrivateRegularFileStat(stat) + if (stat.size > maximumBytes) { + throw new ManagedPathError('Managed file exceeds its size limit') + } + const output = Buffer.alloc(stat.size) + if ( + stat.size > 0 && + readSync(descriptor, output, 0, output.length, 0) !== output.length + ) { + throw new ManagedPathError('Managed file could not be read') + } + return output + } finally { + closeSync(descriptor) + } +} + +export function writePrivateFileAtomic(path: string, contents: string): void { + assertAbsoluteManagedPath(path) + ensurePrivateDirectory(dirname(path), { create: false }) + assertPrivateTargetIfPresent(path) + const temporary = `${path}.tmp-${randomBytes(12).toString('hex')}` + const noFollow = constants.O_NOFOLLOW ?? 0 + const descriptor = openSync( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, + 0o600 + ) + let descriptorOpen = true + let renamed = false + let operationError: unknown + try { + writeFileSync(descriptor, contents, { encoding: 'utf8' }) + if (process.platform !== 'win32') { + fchmodSync(descriptor, 0o600) + } + closeSync(descriptor) + descriptorOpen = false + assertPrivateTargetIfPresent(path) + renameSync(temporary, path) + renamed = true + assertPrivateRegularFile(path) + } catch (error) { + operationError = error + } + if (descriptorOpen) { + try { + closeSync(descriptor) + } catch (error) { + operationError ??= error + } + } + if (!renamed) { + try { + unlinkSync(temporary) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + operationError ??= error + } + } + } + if (operationError !== undefined) { + throw operationError + } +} + +export function unlinkOwnedPrivateFile(path: string): void { + assertPrivateRegularFile(path) + unlinkSync(path) +} + +const TEMP_ROOT_MARKER = '.goodbuddy-private-root-v1' + +export type PrivateTemporaryRootIdentity = { + rootPath: string + uid: number + canonicalHomeDirectory: string +} + +export function derivePrivateTemporaryRoot(options: { + uid?: number + homeDirectory?: string + temporaryDirectory?: string + platform?: NodeJS.Platform +} = {}): PrivateTemporaryRootIdentity { + const platform = options.platform ?? process.platform + const uid = options.uid ?? process.getuid?.() + if ( + platform !== 'linux' || + uid === undefined || + !Number.isSafeInteger(uid) || + uid < 0 + ) { + throw new ManagedPathError( + 'Private Agent endpoint root requires a current Linux UID' + ) + } + const canonicalHomeDirectory = resolve( + options.homeDirectory ?? homedir() + ) + const temporaryDirectory = resolve( + options.temporaryDirectory ?? '/tmp' + ) + assertAbsoluteManagedPath(canonicalHomeDirectory) + assertAbsoluteManagedPath(temporaryDirectory) + const homeHash = createHash('sha256') + .update(`${uid}\0${canonicalHomeDirectory}`, 'utf8') + .digest('hex') + .slice(0, 20) + return { + rootPath: resolve( + temporaryDirectory, + `goodbuddy-${uid}-${homeHash}` + ), + uid, + canonicalHomeDirectory + } +} + +export function ensurePrivateTemporaryRoot( + identityInput: PrivateTemporaryRootIdentity +): string { + const identity = { + rootPath: assertAbsoluteManagedPath(identityInput.rootPath), + uid: identityInput.uid, + canonicalHomeDirectory: assertAbsoluteManagedPath( + identityInput.canonicalHomeDirectory + ) + } + if (!Number.isSafeInteger(identity.uid) || identity.uid < 0) { + throw new ManagedPathError('Private Agent endpoint root UID is invalid') + } + ensurePrivateDirectory(identity.rootPath) + const markerPath = resolve(identity.rootPath, TEMP_ROOT_MARKER) + const expected = `${JSON.stringify({ + formatVersion: 1, + uid: identity.uid, + canonicalHomeDirectory: identity.canonicalHomeDirectory + }, null, 2)}\n` + try { + assertPrivateRegularFile(markerPath) + if (readPrivateText(markerPath) !== expected) { + throw new ManagedPathError( + 'Private Agent endpoint root marker does not match this user' + ) + } + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error + } + writePrivateFileAtomic(markerPath, expected) + } + return identity.rootPath +} + +function readPrivateText(path: string): string { + return readPrivateFile(path, 16 * 1024).toString('utf8') +} + +function assertPrivateTargetIfPresent(path: string): void { + try { + assertPrivateRegularFile(path) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error + } + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/model-bridge-broker.ts b/src/agent-daemon/model-bridge-broker.ts new file mode 100644 index 00000000..0a4814a8 --- /dev/null +++ b/src/agent-daemon/model-bridge-broker.ts @@ -0,0 +1,805 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + lstatSync, + type Stats, + unlinkSync +} from 'node:fs' +import { + createConnection, + createServer, + type Server, + type Socket +} from 'node:net' +import { dirname, join } from 'node:path' +import { TextDecoder } from 'node:util' +import { z } from 'zod' +import { + REMOTE_MODEL_GATEWAY_LIMITS, + remoteModelGatewayRequestSchema, + remoteModelGatewayResponseSchema, + type RemoteModelGatewayRequest, + type RemoteModelGatewayResponse +} from '../shared/remote-model-gateway-contracts' +import { + assertAbsoluteManagedPath, + ensurePrivateDirectory +} from './managed-paths' +import { AgentUnsupportedError } from './errors' +import { + boundedInteger, + closeServer, + isNodeError, + raceWithAbort, + settleWithin +} from './async-utils' + +export const MODEL_BRIDGE_BROKER_SOCKET_NAME = + 'model-bridge.sock' + +const BROKER_PROTOCOL_VERSION = 1 +const DEFAULT_MAXIMUM_CONNECTIONS = 8 +const DEFAULT_REQUEST_TIMEOUT_MS = 150_000 +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 +const CLOSE_TIMEOUT_MS = 2_000 +const MAXIMUM_UNIX_SOCKET_PATH_BYTES = 107 +const MAXIMUM_FRAME_BYTES = + Math.ceil( + REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes / 3 + ) * + 4 + + 64 * 1_024 + +const requestIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u) + +const brokerRequestPacketSchema = z + .object({ + version: z.literal(BROKER_PROTOCOL_VERSION), + type: z.literal('model-request'), + requestId: requestIdSchema, + request: remoteModelGatewayRequestSchema + }) + .strict() + +const brokerSuccessPacketSchema = z + .object({ + version: z.literal(BROKER_PROTOCOL_VERSION), + type: z.literal('model-response'), + requestId: requestIdSchema, + ok: z.literal(true), + response: remoteModelGatewayResponseSchema + }) + .strict() + +const brokerFailureCodeSchema = z.enum([ + 'request-invalid', + 'request-timeout', + 'request-cancelled', + 'dispatch-failed' +]) + +const brokerFailurePacketSchema = z + .object({ + version: z.literal(BROKER_PROTOCOL_VERSION), + type: z.literal('model-response'), + requestId: requestIdSchema, + ok: z.literal(false), + error: z + .object({ + code: brokerFailureCodeSchema + }) + .strict() + }) + .strict() + +const brokerResponsePacketSchema = z.discriminatedUnion('ok', [ + brokerSuccessPacketSchema, + brokerFailurePacketSchema +]) + +const brokerDeliveryPacketSchema = z + .object({ + version: z.literal(BROKER_PROTOCOL_VERSION), + type: z.literal('response-delivered'), + requestId: requestIdSchema + }) + .strict() + +const brokerDeliveryAcceptedPacketSchema = z + .object({ + version: z.literal(BROKER_PROTOCOL_VERSION), + type: z.literal('delivery-accepted'), + requestId: requestIdSchema + }) + .strict() + +export type ModelBridgeDispatchContext = { + requestId: string + signal: AbortSignal +} + +export type ModelBridgeExchangeResult = { + response: RemoteModelGatewayResponse + acknowledgeDelivery(): Promise + failDelivery(reason?: unknown): void | Promise +} + +export type ModelBridgeBrokerDispatch = ( + request: RemoteModelGatewayRequest, + context: ModelBridgeDispatchContext +) => Promise + +export type ModelBridgeExchange = ( + request: RemoteModelGatewayRequest, + context: ModelBridgeDispatchContext +) => Promise + +export class ModelBridgeBrokerError extends Error { + readonly code: + | 'request-invalid' + | 'request-timeout' + | 'request-cancelled' + | 'dispatch-failed' + | 'transport-failed' + + constructor(code: ModelBridgeBrokerError['code']) { + super(modelBridgeBrokerErrorMessage(code)) + this.name = 'ModelBridgeBrokerError' + this.code = code + } +} + +export class ModelBridgeBrokerServer { + readonly socketPath: string + readonly #dispatch: ModelBridgeBrokerDispatch + readonly #maximumConnections: number + readonly #requestTimeoutMs: number + readonly #connections = new Set() + #server?: Server + #socketIdentity?: FileIdentity + #closing = false + #closePromise?: Promise + + constructor(options: { + scratchDirectory: string + dispatch: ModelBridgeBrokerDispatch + maximumConnections?: number + requestTimeoutMs?: number + }) { + const scratchDirectory = assertAbsoluteManagedPath( + options.scratchDirectory + ) + this.socketPath = assertAbsoluteManagedPath( + join(scratchDirectory, MODEL_BRIDGE_BROKER_SOCKET_NAME) + ) + if ( + process.platform !== 'win32' && + Buffer.byteLength(this.socketPath, 'utf8') > + MAXIMUM_UNIX_SOCKET_PATH_BYTES + ) { + throw new Error('Model bridge endpoint path is too long') + } + this.#dispatch = options.dispatch + this.#maximumConnections = boundedInteger( + options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS, + 1, + 64, + 'Model bridge connection limit' + ) + this.#requestTimeoutMs = boundedInteger( + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + 1, + 300_000, + 'Model bridge request timeout' + ) + } + + async listen(): Promise { + if (process.platform === 'win32') { + throw new AgentUnsupportedError( + 'Unix model bridge brokers are unavailable on Windows', + 'platform-incompatible' + ) + } + if (this.#server !== undefined || this.#closing) { + throw new Error('Model bridge broker is already active') + } + ensurePrivateDirectory(dirname(this.socketPath), { + create: false + }) + removeOwnedStaleSocket(this.socketPath) + + const server = createServer( + { pauseOnConnect: true }, + (socket) => { + if ( + this.#closing || + this.#connections.size >= this.#maximumConnections + ) { + socket.destroy() + return + } + this.#connections.add(socket) + socket.once('close', () => { + this.#connections.delete(socket) + }) + void this.#serveOne(socket) + } + ) + this.#server = server + try { + await listenOnUnixSocket(server, this.socketPath) + const initialStat = lstatSync(this.socketPath) + this.#socketIdentity = fileIdentity(initialStat) + assertOwnedSocket(initialStat, 'Model bridge endpoint') + chmodSync(this.socketPath, 0o600) + const securedStat = lstatSync(this.socketPath) + assertSameFile(securedStat, this.#socketIdentity) + assertOwnedSocket(securedStat, 'Model bridge endpoint') + if ((securedStat.mode & 0o777) !== 0o600) { + throw new Error('Model bridge endpoint mode verification failed') + } + } catch (error) { + await this.close() + throw error + } + } + + async close(): Promise { + if (this.#closePromise !== undefined) { + await this.#closePromise + return + } + const closePromise = this.#close() + this.#closePromise = closePromise + try { + await closePromise + } finally { + if (this.#closePromise === closePromise) { + this.#closePromise = undefined + } + } + } + + async #close(): Promise { + const server = this.#server + this.#server = undefined + this.#closing = true + try { + for (const connection of this.#connections) { + connection.destroy() + } + await settleWithin( + server === undefined + ? Promise.resolve() + : closeServer(server), + CLOSE_TIMEOUT_MS + ) + } finally { + this.#connections.clear() + removeSocketIfIdentityMatches( + this.socketPath, + this.#socketIdentity + ) + this.#socketIdentity = undefined + this.#closing = false + } + } + + async #serveOne(socket: Socket): Promise { + const cancellation = new AbortController() + const onClose = (): void => { + cancellation.abort( + new ModelBridgeBrokerError('request-cancelled') + ) + } + socket.once('close', onClose) + const timeout = setTimeout(() => { + cancellation.abort( + new ModelBridgeBrokerError('request-timeout') + ) + }, this.#requestTimeoutMs) + timeout.unref?.() + + let requestId = `invalid-${randomUUID()}` + let dispatched: ModelBridgeExchangeResult | undefined + let delivered = false + try { + const packet = brokerRequestPacketSchema.parse( + await readPacket(socket, cancellation.signal) + ) + requestId = packet.requestId + dispatched = normalizeExchangeResult(await raceWithAbort( + this.#dispatch(packet.request, { + requestId, + signal: cancellation.signal + }), + cancellation.signal + )) + const response = remoteModelGatewayResponseSchema.parse( + dispatched.response + ) + cancellation.signal.throwIfAborted() + await writePacket(socket, { + version: BROKER_PROTOCOL_VERSION, + type: 'model-response', + requestId, + ok: true, + response + }) + const delivery = brokerDeliveryPacketSchema.parse( + await readPacket(socket, cancellation.signal) + ) + if (delivery.requestId !== requestId) { + throw new ModelBridgeBrokerError('transport-failed') + } + await raceWithAbort( + Promise.resolve(dispatched.acknowledgeDelivery()), + cancellation.signal + ) + delivered = true + await writePacket(socket, { + version: BROKER_PROTOCOL_VERSION, + type: 'delivery-accepted', + requestId + }) + } catch (error) { + if (dispatched !== undefined && !delivered) { + await Promise.resolve( + dispatched.failDelivery(error) + ).catch(() => undefined) + } else if (!socket.destroyed) { + await writeFailure( + socket, + requestId, + failureCode(error, cancellation.signal) + ).catch(() => undefined) + } + } finally { + clearTimeout(timeout) + socket.off('close', onClose) + if (!socket.destroyed) { + socket.end() + } + } + } +} + +export function createUnixModelBridgeExchange(options: { + socketPath: string + connectTimeoutMs?: number + requestTimeoutMs?: number +}): ModelBridgeExchange { + const socketPath = assertAbsoluteManagedPath(options.socketPath) + const connectTimeoutMs = boundedInteger( + options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, + 1, + 60_000, + 'Model bridge connect timeout' + ) + const requestTimeoutMs = boundedInteger( + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + 1, + 300_000, + 'Model bridge request timeout' + ) + + return async (request, context) => { + const parsedRequest = remoteModelGatewayRequestSchema.parse(request) + const requestId = requestIdSchema.parse(context.requestId) + context.signal.throwIfAborted() + const socket = createConnection(socketPath) + const timeoutController = new AbortController() + const timeout = setTimeout(() => { + timeoutController.abort( + new ModelBridgeBrokerError('request-timeout') + ) + socket.destroy() + }, requestTimeoutMs) + timeout.unref?.() + const onAbort = (): void => { + socket.destroy() + } + context.signal.addEventListener('abort', onAbort, { once: true }) + let responseHandedOff = false + const cleanup = (): void => { + clearTimeout(timeout) + context.signal.removeEventListener('abort', onAbort) + socket.destroy() + } + try { + await waitForConnection( + socket, + Math.min(connectTimeoutMs, requestTimeoutMs), + context.signal + ) + await writePacket(socket, { + version: BROKER_PROTOCOL_VERSION, + type: 'model-request', + requestId, + request: parsedRequest + }) + const packet = brokerResponsePacketSchema.parse( + await readPacket( + socket, + AbortSignal.any([ + context.signal, + timeoutController.signal + ]) + ) + ) + if (packet.requestId !== requestId) { + throw new ModelBridgeBrokerError('transport-failed') + } + if (!packet.ok) { + throw new ModelBridgeBrokerError(packet.error.code) + } + responseHandedOff = true + let deliveryFinalized = false + return { + response: packet.response, + acknowledgeDelivery: async () => { + if (deliveryFinalized) { + throw new ModelBridgeBrokerError('transport-failed') + } + deliveryFinalized = true + await writePacket(socket, { + version: BROKER_PROTOCOL_VERSION, + type: 'response-delivered', + requestId + }) + const accepted = brokerDeliveryAcceptedPacketSchema.parse( + await readPacket( + socket, + AbortSignal.any([ + context.signal, + timeoutController.signal + ]) + ) + ) + if (accepted.requestId !== requestId) { + throw new ModelBridgeBrokerError('transport-failed') + } + cleanup() + }, + failDelivery: () => { + deliveryFinalized = true + cleanup() + } + } + } catch (error) { + if (context.signal.aborted) { + throw new ModelBridgeBrokerError('request-cancelled') + } + if (timeoutController.signal.aborted) { + throw new ModelBridgeBrokerError('request-timeout') + } + if (error instanceof ModelBridgeBrokerError) { + throw error + } + throw new ModelBridgeBrokerError('transport-failed') + } finally { + if (!responseHandedOff) { + cleanup() + } + } + } +} + +type BrokerFailureCode = z.infer< + typeof brokerFailureCodeSchema +> + +type FileIdentity = { + dev: bigint + ino: bigint +} + +function failureCode( + error: unknown, + signal: AbortSignal +): BrokerFailureCode { + const reason = signal.reason + if ( + signal.aborted && + reason instanceof ModelBridgeBrokerError && + reason.code === 'request-timeout' + ) { + return 'request-timeout' + } + if (signal.aborted) { + return 'request-cancelled' + } + if (error instanceof z.ZodError || error instanceof SyntaxError) { + return 'request-invalid' + } + return 'dispatch-failed' +} + +async function writeFailure( + socket: Socket, + requestId: string, + code: BrokerFailureCode +): Promise { + await writePacket(socket, { + version: BROKER_PROTOCOL_VERSION, + type: 'model-response', + requestId, + ok: false, + error: { code } + }) +} + +async function readPacket( + socket: Socket, + signal: AbortSignal +): Promise { + const lengthBytes = await readExactly(socket, 4, signal) + const length = lengthBytes.readUInt32BE(0) + if (length < 2 || length > MAXIMUM_FRAME_BYTES) { + throw new ModelBridgeBrokerError('request-invalid') + } + const contents = await readExactly(socket, length, signal) + const text = new TextDecoder('utf-8', { fatal: true }).decode( + contents + ) + return JSON.parse(text) as unknown +} + +async function writePacket( + socket: Socket, + value: unknown +): Promise { + const contents = Buffer.from(JSON.stringify(value), 'utf8') + if ( + contents.byteLength < 2 || + contents.byteLength > MAXIMUM_FRAME_BYTES + ) { + throw new ModelBridgeBrokerError('request-invalid') + } + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + await new Promise((resolve, reject) => { + socket.write(packet, (error) => { + if (error === undefined || error === null) { + resolve() + } else { + reject(error) + } + }) + }) +} + +async function readExactly( + socket: Socket, + byteLength: number, + signal: AbortSignal +): Promise { + const chunks: Buffer[] = [] + let remaining = byteLength + while (remaining > 0) { + signal.throwIfAborted() + const chunk = socket.read(remaining) as Buffer | null + if (chunk !== null) { + chunks.push(chunk) + remaining -= chunk.byteLength + continue + } + await new Promise((resolve, reject) => { + const onReadable = (): void => { + cleanup() + resolve() + } + const onClose = (): void => { + cleanup() + reject(new ModelBridgeBrokerError('transport-failed')) + } + const onError = (): void => { + cleanup() + reject(new ModelBridgeBrokerError('transport-failed')) + } + const onAbort = (): void => { + cleanup() + reject(signal.reason) + } + const cleanup = (): void => { + socket.off('readable', onReadable) + socket.off('close', onClose) + socket.off('end', onClose) + socket.off('error', onError) + signal.removeEventListener('abort', onAbort) + } + socket.once('readable', onReadable) + socket.once('close', onClose) + socket.once('end', onClose) + socket.once('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + return Buffer.concat(chunks, byteLength) +} + +async function waitForConnection( + socket: Socket, + timeoutMs: number, + signal: AbortSignal +): Promise { + let timeout: ReturnType | undefined + await new Promise((resolve, reject) => { + const onConnect = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new ModelBridgeBrokerError('transport-failed')) + } + const onAbort = (): void => { + cleanup() + reject(new ModelBridgeBrokerError('request-cancelled')) + } + const cleanup = (): void => { + if (timeout !== undefined) { + clearTimeout(timeout) + } + socket.off('connect', onConnect) + socket.off('error', onError) + signal.removeEventListener('abort', onAbort) + } + timeout = setTimeout(() => { + cleanup() + reject(new ModelBridgeBrokerError('request-timeout')) + }, timeoutMs) + timeout.unref?.() + socket.once('connect', onConnect) + socket.once('error', onError) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +function listenOnUnixSocket( + server: Server, + socketPath: string +): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onListening = (): void => { + cleanup() + resolve() + } + const cleanup = (): void => { + server.off('error', onError) + server.off('listening', onListening) + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(socketPath) + }) +} + +function removeOwnedStaleSocket(path: string): void { + let stat: Stats + try { + stat = lstatSync(path) + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return + } + throw error + } + assertOwnedSocket(stat, 'Existing model bridge endpoint') + if ((stat.mode & 0o777) !== 0o600) { + throw new Error( + 'Refusing to replace an insecure model bridge endpoint' + ) + } + unlinkSync(path) +} + +function removeSocketIfIdentityMatches( + path: string, + identity: FileIdentity | undefined +): void { + if (identity === undefined) { + return + } + let stat: Stats + try { + stat = lstatSync(path) + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return + } + throw error + } + if ( + stat.isSocket() && + isOwnedByCurrentUser(stat) && + sameFile(stat, identity) + ) { + unlinkSync(path) + } +} + +function assertOwnedSocket(stat: Stats, label: string): void { + if ( + stat.isSymbolicLink() || + !stat.isSocket() || + !isOwnedByCurrentUser(stat) + ) { + throw new Error(`${label} is not an owned Unix socket`) + } +} + +function assertSameFile( + stat: Stats, + identity: FileIdentity +): void { + if (!sameFile(stat, identity)) { + throw new Error('Model bridge endpoint changed during setup') + } +} + +function sameFile(stat: Stats, identity: FileIdentity): boolean { + return ( + BigInt(stat.dev) === identity.dev && + BigInt(stat.ino) === identity.ino + ) +} + +function fileIdentity(stat: Stats): FileIdentity { + return { + dev: BigInt(stat.dev), + ino: BigInt(stat.ino) + } +} + +function isOwnedByCurrentUser(stat: Stats): boolean { + const uid = process.getuid?.() + return uid === undefined || stat.uid === uid +} + + +function modelBridgeBrokerErrorMessage( + code: ModelBridgeBrokerError['code'] +): string { + switch (code) { + case 'request-invalid': + return 'Model bridge request was rejected' + case 'request-timeout': + return 'Model bridge request timed out' + case 'request-cancelled': + return 'Model bridge request was cancelled' + case 'dispatch-failed': + return 'Model bridge dispatch failed' + case 'transport-failed': + return 'Model bridge transport failed' + } +} + +function normalizeExchangeResult( + value: ModelBridgeExchangeResult | RemoteModelGatewayResponse +): ModelBridgeExchangeResult { + if ( + value !== null && + typeof value === 'object' && + 'response' in value && + typeof value.acknowledgeDelivery === 'function' && + typeof value.failDelivery === 'function' + ) { + return value + } + return { + response: remoteModelGatewayResponseSchema.parse(value), + acknowledgeDelivery: async () => undefined, + failDelivery: () => undefined + } +} diff --git a/src/agent-daemon/model-bridge-client.test.ts b/src/agent-daemon/model-bridge-client.test.ts new file mode 100644 index 00000000..07227e44 --- /dev/null +++ b/src/agent-daemon/model-bridge-client.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from 'vitest' +import { + decodeModelBridgeMessage, + encodeModelBridgeMessage, + type ModelBridgePolicy, + type ModelBridgeRequestMessage +} from '../shared/model-bridge-contracts' +import { + AGENT_PROTOCOL_VERSION, + type AgentFrame +} from '../shared/agent-protocol' +import { + ModelBridgeBlobClient, + ModelBridgeClientError +} from './model-bridge-client' + +const digest = (character: string): string => + `sha256:${character.repeat(64)}` + +const policy: ModelBridgePolicy = { + protocol: 'openai-responses', + model: 'private-model', + modelProfileDigest: digest('a'), + supportsImageInput: false +} + +function createHarness(policyOverride: Partial = {}) { + const sent: AgentFrame[] = [] + const poisoned = vi.fn() + let nextId = 0 + const client = new ModelBridgeBlobClient({ + binding: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + controllerId: 'controller-1', + controllerGeneration: 2, + connectionId: 'connection-1', + acpChannelEpoch: '4', + channelId: 'model-channel-1', + channelEpoch: '9', + runtimeId: 'opencode', + workspaceIdentity: 'workspace-1', + policy: { ...policy, ...policyOverride }, + deadlineAt: new Date(Date.now() + 60_000).toISOString() + }, + sendBlobFrame: async (frame) => { + sent.push(frame) + }, + onPoison: poisoned, + randomMessageId: () => `message-${nextId++}` + }) + return { client, sent, poisoned } +} + +const request = { + method: 'POST' as const, + path: '/v1/responses' as const, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'private-model', max_output_tokens: 100 }) + ).toString('base64') +} + +async function outboundRequest( + sent: AgentFrame[], + index = 0 +): Promise { + await waitFor(() => sent.length > index) + const decoded = await decodeModelBridgeMessage(sent[index]!.payload) + if (decoded.kind !== 'request') { + throw new Error('Expected request') + } + return decoded +} + +async function deliverResponse( + client: ModelBridgeBlobClient, + outbound: ModelBridgeRequestMessage, + bodyBytes = 12 +): Promise { + await client.onBlobFrame( + incomingFrame( + await encodeModelBridgeMessage({ + protocol: 'goodbuddy-model-bridge-v1', + kind: 'response', + identity: outbound.identity, + requestDigest: outbound.requestDigest, + response: { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.alloc(bodyBytes, 2).toString('base64') + } + }), + 1 + ) + ) +} + +describe('Agent model bridge blob client', () => { + it('uses exact sequential identities and two-phase delivery', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange(request, { + requestId: 'request-local-1', + signal: new AbortController().signal + }) + const outbound = await outboundRequest(fixture.sent) + expect(outbound).toMatchObject({ + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'request-local-1', + roundIndex: 0, + modelProfileDigest: digest('a'), + messageId: 'message-0' + }, + policy + }) + + await deliverResponse(fixture.client, outbound) + const exchange = await pending + expect(exchange.response.status).toBe(200) + expect(fixture.sent).toHaveLength(1) + + await exchange.acknowledgeDelivery() + expect(fixture.sent).toHaveLength(2) + await expect( + decodeModelBridgeMessage(fixture.sent[1]!.payload) + ).resolves.toMatchObject({ + kind: 'response-delivered', + identity: outbound.identity, + requestDigest: outbound.requestDigest + }) + expect(fixture.poisoned).not.toHaveBeenCalled() + }) + + it('receives a maximum legal response in one message', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange(request, { + requestId: 'request-large-response', + signal: new AbortController().signal + }) + const outbound = await outboundRequest(fixture.sent) + await deliverResponse(fixture.client, outbound, 768 * 1024) + + const exchange = await pending + expect( + Buffer.from(exchange.response.bodyBase64, 'base64').byteLength + ).toBe(768 * 1024) + await exchange.acknowledgeDelivery() + expect(fixture.sent).toHaveLength(2) + expect(fixture.poisoned).not.toHaveBeenCalled() + }) + + it('sends a large request as one bounded message', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange( + { + ...request, + bodyBase64: Buffer.alloc(700_000, 1).toString('base64') + }, + { + requestId: 'request-large', + signal: new AbortController().signal + } + ) + const outbound = await outboundRequest(fixture.sent) + expect(fixture.sent).toHaveLength(1) + expect(outbound.identity.roundIndex).toBe(0) + await fixture.client.close({ poisonIfActive: false }) + await expect(pending).rejects.toMatchObject({ code: 'cancelled' }) + }) + + it('does not send after cancellation during request preparation', async () => { + const fixture = createHarness() + const controller = new AbortController() + const pending = fixture.client.exchange( + { + ...request, + bodyBase64: Buffer.alloc(700_000, 1).toString('base64') + }, + { + requestId: 'request-cancelled-during-prepare', + signal: controller.signal + } + ) + controller.abort() + + await expect(pending).rejects.toThrow() + expect(fixture.sent).toHaveLength(0) + expect(fixture.poisoned).not.toHaveBeenCalled() + }) + + it('does not dispatch when detach closes the bridge during request preparation', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange( + { + ...request, + bodyBase64: Buffer.alloc(700_000, 1).toString('base64') + }, + { + requestId: 'request-closed-during-prepare', + signal: new AbortController().signal + } + ) + await fixture.client.close({ poisonIfActive: false }) + + await expect(pending).rejects.toMatchObject({ + code: 'cancelled', + poisoned: false, + outcomeUnknown: false + }) + expect(fixture.sent).toHaveLength(0) + expect(fixture.poisoned).not.toHaveBeenCalled() + }) + + it('poisons cancellation after the complete request write starts', async () => { + const sent: AgentFrame[] = [] + const poisoned = vi.fn() + const controller = new AbortController() + const client = new ModelBridgeBlobClient({ + binding: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + controllerId: 'controller-1', + controllerGeneration: 2, + connectionId: 'connection-1', + acpChannelEpoch: '4', + channelId: 'model-channel-1', + channelEpoch: '9', + runtimeId: 'opencode', + workspaceIdentity: 'workspace-1', + policy, + deadlineAt: new Date(Date.now() + 60_000).toISOString() + }, + sendBlobFrame: async (frame) => { + sent.push(frame) + controller.abort() + }, + onPoison: poisoned, + randomMessageId: () => 'message-cancelled-after-write' + }) + + await expect( + client.exchange(request, { + requestId: 'request-cancelled-after-write', + signal: controller.signal + }) + ).rejects.toThrow() + expect(sent).toHaveLength(1) + await waitFor(() => poisoned.mock.calls.length === 1) + expect(client.poisoned).toBe(true) + }) + + it('does not impose a model output-token quota', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange( + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'private-model', + max_output_tokens: 1_000_000 + }) + ).toString('base64') + }, + { + requestId: 'request-with-provider-limit', + signal: new AbortController().signal + } + ) + await outboundRequest(fixture.sent) + await fixture.client.close({ poisonIfActive: false }) + await expect(pending).rejects.toMatchObject({ code: 'cancelled' }) + }) + + it('does not consume a round for a definitive rejection', async () => { + const fixture = createHarness() + const firstPending = fixture.client.exchange(request, { + requestId: 'request-first', + signal: new AbortController().signal + }) + const first = await outboundRequest(fixture.sent) + await fixture.client.onBlobFrame( + incomingFrame( + await encodeModelBridgeMessage({ + protocol: 'goodbuddy-model-bridge-v1', + kind: 'error', + identity: first.identity, + requestDigest: first.requestDigest, + error: { + code: 'policy-rejected', + message: 'Remote model request was rejected', + retryable: false, + poisoned: false, + outcomeUnknown: false + } + }), + 1 + ) + ) + await expect(firstPending).rejects.toMatchObject({ code: 'remote' }) + + const secondPending = fixture.client.exchange(request, { + requestId: 'request-second', + signal: new AbortController().signal + }) + const second = await outboundRequest(fixture.sent, 1) + expect(second.identity.roundIndex).toBe(0) + await fixture.client.close({ poisonIfActive: false }) + await expect(secondPending).rejects.toMatchObject({ code: 'cancelled' }) + }) + + it('rejects a malformed response and poisons the binding', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange(request, { + requestId: 'request-malformed', + signal: new AbortController().signal + }) + await outboundRequest(fixture.sent) + await expect( + fixture.client.onBlobFrame( + incomingFrame(Buffer.from('{ "not": "canonical" }'), 1) + ) + ).rejects.toBeInstanceOf(Error) + await expect(pending).rejects.toBeInstanceOf(Error) + expect(fixture.client.poisoned).toBe(true) + }) + + it('permanently poisons delivery that ends before the helper ACK', async () => { + const fixture = createHarness() + const pending = fixture.client.exchange(request, { + requestId: 'request-uncertain', + signal: new AbortController().signal + }) + const outbound = await outboundRequest(fixture.sent) + await deliverResponse(fixture.client, outbound, 0) + const exchange = await pending + await exchange.failDelivery() + expect(fixture.client.poisoned).toBe(true) + expect(fixture.poisoned).toHaveBeenCalledOnce() + await expect( + fixture.client.exchange(request, { + requestId: 'request-after-poison', + signal: new AbortController().signal + }) + ).rejects.toBeInstanceOf(ModelBridgeClientError) + }) +}) + +function incomingFrame(payload: Uint8Array, sequence: number): AgentFrame { + return { + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 2, + channelId: 'model-channel-1', + channelEpoch: '9', + direction: 'main-to-agent', + sequence: String(sequence), + kind: 'blob', + payloadLength: payload.byteLength + }, + payload + } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for model bridge') + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} \ No newline at end of file diff --git a/src/agent-daemon/model-bridge-client.ts b/src/agent-daemon/model-bridge-client.ts new file mode 100644 index 00000000..171c7aca --- /dev/null +++ b/src/agent-daemon/model-bridge-client.ts @@ -0,0 +1,519 @@ +import { randomUUID } from 'node:crypto' +import { + AGENT_PROTOCOL_VERSION, + agentIdentifierSchema, + type AgentFrame +} from '../shared/agent-protocol' +import { + createModelBridgeRequestMessage, + decodeModelBridgeMessage, + encodeModelBridgeMessage, + modelBridgeDeliveryAckMessageSchema, + type ModelBridgeError, + type ModelBridgeIdentity, + type ModelBridgePolicy, + type ModelBridgeResponseMessage +} from '../shared/model-bridge-contracts' +import type { + RemoteModelGatewayRequest, + RemoteModelGatewayResponse +} from '../shared/remote-model-gateway-contracts' + +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 + +export type ModelBridgeClientBinding = { + bindingId: string + promptOperationId: string + controllerId: string + controllerGeneration: number + connectionId: string + acpChannelEpoch: string + channelId: string + channelEpoch: string + runtimeId: string + workspaceIdentity: string + policy: ModelBridgePolicy + deadlineAt: string +} + +export type ModelBridgeClientExchange = { + response: RemoteModelGatewayResponse + acknowledgeDelivery(): Promise + failDelivery(reason?: unknown): Promise +} + +export class ModelBridgeClientError extends Error { + constructor( + message: string, + readonly code: + | 'cancelled' + | 'capacity' + | 'identity' + | 'poisoned' + | 'remote' + | 'timeout' + | 'transport', + readonly poisoned = false, + readonly outcomeUnknown = false + ) { + super(message) + this.name = 'ModelBridgeClientError' + } +} + +type ActiveRequest = { + identity: ModelBridgeIdentity + requestDigest: string + resolve: (message: ModelBridgeResponseMessage) => void + reject: (error: unknown) => void + timeout: NodeJS.Timeout + responseReceived: boolean + providerDispatchPossible: boolean + settled: boolean +} + +export class ModelBridgeBlobClient { + readonly binding: ModelBridgeClientBinding + readonly #sendBlobFrame: (frame: AgentFrame) => Promise + readonly #onPoison: (error: ModelBridgeClientError) => void | Promise + readonly #now: () => number + readonly #requestTimeoutMs: number + readonly #randomMessageId: () => string + #nextRoundIndex = 0 + #nextOutboundSequence = 1n + #active?: ActiveRequest + #closed = false + #poisoned = false + + constructor(options: { + binding: ModelBridgeClientBinding + sendBlobFrame: (frame: AgentFrame) => Promise + onPoison: (error: ModelBridgeClientError) => void | Promise + now?: () => number + requestTimeoutMs?: number + randomMessageId?: () => string + }) { + this.binding = options.binding + this.#sendBlobFrame = options.sendBlobFrame + this.#onPoison = options.onPoison + this.#now = options.now ?? Date.now + this.#requestTimeoutMs = boundedTimeout( + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + ) + this.#randomMessageId = + options.randomMessageId ?? + (() => `bridge-${randomUUID()}`) + } + + get poisoned(): boolean { + return this.#poisoned + } + + get active(): boolean { + return this.#active !== undefined + } + + async exchange( + request: RemoteModelGatewayRequest, + context: { requestId: string; signal: AbortSignal } + ): Promise { + this.#assertAvailable() + if (this.#active !== undefined) { + throw new ModelBridgeClientError( + 'Only one model request may be active', + 'capacity' + ) + } + context.signal.throwIfAborted() + this.#assertRequest(request) + const roundIndex = this.#nextRoundIndex + const messageId = agentIdentifierSchema.parse( + this.#randomMessageId() + ) + const identity: ModelBridgeIdentity = { + bindingId: this.binding.bindingId, + promptOperationId: this.binding.promptOperationId, + requestId: agentIdentifierSchema.parse(context.requestId), + roundIndex, + modelProfileDigest: + this.binding.policy.modelProfileDigest, + messageId + } + const message = await createModelBridgeRequestMessage({ + identity, + policy: this.binding.policy, + request + }) + context.signal.throwIfAborted() + const payload = await encodeModelBridgeMessage(message) + context.signal.throwIfAborted() + this.#assertAvailable() + + const response = new Promise( + (resolve, reject) => { + const remaining = Math.max( + 1, + Date.parse(this.binding.deadlineAt) - this.#now() + ) + const timeout = setTimeout(() => { + const active = this.#active + if (active?.identity.messageId !== messageId) { + return + } + const error = new ModelBridgeClientError( + 'Model bridge response timed out', + 'timeout', + true, + true + ) + active.settled = true + active.reject(error) + void this.#poison(error) + }, Math.min(this.#requestTimeoutMs, remaining)) + timeout.unref?.() + this.#active = { + identity, + requestDigest: message.requestDigest, + resolve, + reject, + timeout, + responseReceived: false, + providerDispatchPossible: false, + settled: false + } + } + ) + // Cancellation can end the send loop before it reaches `await response`. + // Keep the original rejection for that await while preventing an orphaned + // internal promise from becoming an unhandled rejection. + void response.catch(() => undefined) + const onAbort = (): void => { + const active = this.#active + if (active?.identity.messageId !== messageId) { + return + } + const error = new ModelBridgeClientError( + 'Model bridge request was cancelled', + 'cancelled', + active.responseReceived || active.providerDispatchPossible, + active.responseReceived || active.providerDispatchPossible + ) + active.settled = true + active.reject(error) + if (active.responseReceived || active.providerDispatchPossible) { + void this.#poison(error) + } else { + this.#clearActive(active) + } + } + context.signal.addEventListener('abort', onAbort, { once: true }) + if (context.signal.aborted) { + onAbort() + } + try { + context.signal.throwIfAborted() + const dispatchActive = this.#currentActive() + if ( + dispatchActive === undefined || + dispatchActive.identity.messageId !== messageId + ) { + throw new ModelBridgeClientError( + 'Model request lost its active identity before dispatch', + 'cancelled' + ) + } + dispatchActive.providerDispatchPossible = true + try { + await this.#send(payload) + } catch { + const error = new ModelBridgeClientError( + 'Model request transmission was uncertain', + 'transport', + true, + true + ) + await this.#poison(error) + throw error + } + const deliveredResponse = await response + const active = this.#currentActive() + if ( + active === undefined || + active.identity.messageId !== messageId + ) { + throw new ModelBridgeClientError( + 'Model bridge response lost its active identity', + 'identity', + true, + true + ) + } + active.responseReceived = true + let deliveryFinished = false + return { + response: deliveredResponse.response, + acknowledgeDelivery: async () => { + if (deliveryFinished) { + throw new ModelBridgeClientError( + 'Model bridge delivery was already finalized', + 'identity', + true, + true + ) + } + deliveryFinished = true + try { + const acknowledgment = + modelBridgeDeliveryAckMessageSchema.parse({ + protocol: 'goodbuddy-model-bridge-v1', + kind: 'response-delivered', + identity, + requestDigest: active.requestDigest + }) + await this.#send( + await encodeModelBridgeMessage(acknowledgment) + ) + this.#nextRoundIndex += 1 + this.#clearActive(active) + } catch { + const error = new ModelBridgeClientError( + 'Model bridge delivery acknowledgment failed', + 'transport', + true, + true + ) + await this.#poison(error) + throw error + } + }, + failDelivery: async () => { + if (deliveryFinished) { + return + } + deliveryFinished = true + const error = new ModelBridgeClientError( + 'Provider response delivery was not acknowledged', + 'poisoned', + true, + true + ) + await this.#poison(error) + } + } + } catch (error) { + const active = this.#currentActive() + if ( + active !== undefined && + active.identity.messageId === messageId && + !active.responseReceived + ) { + this.#clearActive(active) + } + throw error + } finally { + context.signal.removeEventListener('abort', onAbort) + } + } + + async onBlobFrame(frame: AgentFrame): Promise { + if ( + this.#closed || + frame.header.kind !== 'blob' || + frame.header.direction !== 'main-to-agent' || + frame.header.connectionId !== this.binding.connectionId || + frame.header.generation !== + this.binding.controllerGeneration || + frame.header.channelId !== this.binding.channelId || + frame.header.channelEpoch !== this.binding.channelEpoch + ) { + throw new ModelBridgeClientError( + 'Blob frame does not match the active model bridge', + 'identity' + ) + } + const active = this.#active + if (active === undefined) { + throw new ModelBridgeClientError( + 'No model request is awaiting blob data', + 'identity' + ) + } + try { + const message = await decodeModelBridgeMessage(frame.payload, { + expectedIdentity: active.identity, + expectedRequestDigest: active.requestDigest + }) + if (message.kind === 'response') { + active.responseReceived = true + active.settled = true + clearTimeout(active.timeout) + active.resolve(message) + return + } + if (message.kind === 'error') { + const error = remoteError(message.error) + active.settled = true + clearTimeout(active.timeout) + active.reject(error) + if (message.error.poisoned || message.error.outcomeUnknown) { + await this.#poison(error) + } else { + this.#clearActive(active) + } + return + } + throw new ModelBridgeClientError( + 'Main sent an invalid model bridge message kind', + 'identity', + true, + true + ) + } catch (error) { + if (!active.settled) { + active.settled = true + active.reject(error) + } + const poisoned = + error instanceof ModelBridgeClientError + ? error + : new ModelBridgeClientError( + 'Model bridge response framing failed', + 'identity', + true, + true + ) + await this.#poison(poisoned) + throw error + } + } + + async close(options: { poisonIfActive: boolean }): Promise { + if (this.#closed) { + return + } + this.#closed = true + const active = this.#active + if (active !== undefined) { + const error = new ModelBridgeClientError( + 'Model bridge closed with an active request', + options.poisonIfActive ? 'poisoned' : 'cancelled', + options.poisonIfActive, + options.poisonIfActive + ) + active.reject(error) + if (options.poisonIfActive) { + await this.#poison(error) + } else { + this.#clearActive(active) + } + } + } + + #assertRequest(request: RemoteModelGatewayRequest): void { + const policy = this.binding.policy + const body = parseJsonBody(request.bodyBase64) + if (typeof body?.model === 'string' && body.model !== policy.model) { + throw new ModelBridgeClientError( + 'Model request does not match the prepared profile', + 'identity' + ) + } + } + + #assertAvailable(): void { + if (this.#poisoned) { + throw new ModelBridgeClientError( + 'Model bridge is permanently poisoned', + 'poisoned', + true, + true + ) + } + if (this.#closed) { + throw new ModelBridgeClientError( + 'Model bridge is closed', + 'cancelled' + ) + } + } + + async #send(payload: Uint8Array): Promise { + const sequence = this.#nextOutboundSequence.toString() + await this.#sendBlobFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.binding.connectionId, + generation: this.binding.controllerGeneration, + channelId: this.binding.channelId, + channelEpoch: this.binding.channelEpoch, + direction: 'agent-to-main', + sequence, + kind: 'blob', + payloadLength: payload.byteLength + }, + payload + }) + this.#nextOutboundSequence += 1n + } + + #clearActive(active: ActiveRequest): void { + clearTimeout(active.timeout) + if (this.#active === active) { + this.#active = undefined + } + } + + #currentActive(): ActiveRequest | undefined { + return this.#active + } + + async #poison(error: ModelBridgeClientError): Promise { + if (this.#poisoned) { + return + } + this.#poisoned = true + const active = this.#active + if (active !== undefined) { + this.#clearActive(active) + } + await this.#onPoison(error) + } +} + +function remoteError(error: ModelBridgeError): ModelBridgeClientError { + return new ModelBridgeClientError( + error.message, + error.poisoned || error.outcomeUnknown ? 'poisoned' : 'remote', + error.poisoned, + error.outcomeUnknown + ) +} + +function parseJsonBody( + bodyBase64: string +): Record | undefined { + try { + const parsed: unknown = JSON.parse( + Buffer.from(bodyBase64, 'base64').toString('utf8') + ) + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : undefined + } catch { + return undefined + } +} + +function boundedTimeout(value: number): number { + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > 300_000 + ) { + throw new RangeError('Invalid model bridge timeout') + } + return value +} diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts new file mode 100644 index 00000000..6a43ac5d --- /dev/null +++ b/src/agent-daemon/model-bridge-helper.ts @@ -0,0 +1,900 @@ +import { randomUUID } from 'node:crypto' +import { spawn as nodeSpawn } from 'node:child_process' +import { + createServer, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse +} from 'node:http' +import type { Socket } from 'node:net' +import { isAbsolute, resolve } from 'node:path' +import type { + ModelBridgeModelProtocol +} from '../shared/model-bridge-contracts' +import { + REMOTE_MODEL_GATEWAY_LIMITS, + remoteModelApiPathSchema, + remoteModelGatewayRequestSchema, + remoteModelGatewayResponseSchema, + type RemoteModelGatewayRequest +} from '../shared/remote-model-gateway-contracts' +import { + createUnixModelBridgeExchange, + ModelBridgeBrokerError, + type ModelBridgeExchange, + type ModelBridgeExchangeResult +} from './model-bridge-broker' +import { + boundedInteger, + closeServer, + raceWithAbort, + settleWithin +} from './async-utils' + +export type ModelBridgeProtocol = ModelBridgeModelProtocol + +// The Anthropic and OpenAI SDK adapters refuse to issue a request without an +// apiKey option. This fixed value is only a local compatibility marker. The +// loopback proxy verifies and removes its header before creating the wire +// request, so it is neither a credential nor visible outside the sandbox. +export const MODEL_BRIDGE_SDK_AUTH_SENTINEL = + 'goodbuddy-local-model-bridge-v1' + +export type OpenCodeModelBridgeProviderConfig = { + model: string + provider: Record< + string, + { + name: string + npm: + | '@ai-sdk/anthropic' + | '@ai-sdk/openai-compatible' + | '@ai-sdk/openai' + options: { + baseURL: string + apiKey?: typeof MODEL_BRIDGE_SDK_AUTH_SENTINEL + } + models: Record< + string, + { + name: string + attachment: boolean + modalities: { + input: Array<'text' | 'image'> + output: ['text'] + } + provider: { + npm: + | '@ai-sdk/anthropic' + | '@ai-sdk/openai-compatible' + | '@ai-sdk/openai' + } + } + > + } + > +} + +export type ModelBridgeHelperChild = { + once(event: 'error', listener: (error: Error) => void): unknown + once( + event: 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void + ): unknown +} + +export type ModelBridgeHelperSpawn = ( + executable: string, + args: readonly string[], + options: { + shell: false + stdio: 'inherit' + env: Readonly + } +) => ModelBridgeHelperChild + +const LOOPBACK_HOST = '127.0.0.1' +const DEFAULT_MAXIMUM_CONNECTIONS = 8 +const DEFAULT_REQUEST_TIMEOUT_MS = 180_000 +const RESPONSE_CLOSE_TIMEOUT_MS = 2_000 +const MAXIMUM_HTTP_HEADER_BYTES = 16 * 1024 +const MAXIMUM_HTTP_HEADER_COUNT = 32 +const CREDENTIAL_INBOUND_HEADER_NAMES = new Set([ + 'api-key', + 'authorization', + 'cookie', + 'proxy-authorization', + 'set-cookie', + 'x-api-key', + 'x-goog-api-key' +]) +const FORWARDED_HEADER_NAMES = [ + 'accept', + 'content-type' +] as const + +export class ModelBridgeLoopbackProxy { + readonly #exchange: ModelBridgeExchange + readonly #maximumConnections: number + readonly #requestTimeoutMs: number + readonly #sockets = new Set() + readonly #dispatchWaiters: DispatchWaiter[] = [] + #server?: Server + #origin?: string + #dispatchBusy = false + #closing = false + #closePromise?: Promise + + constructor(options: { + exchange: ModelBridgeExchange + maximumConnections?: number + requestTimeoutMs?: number + }) { + this.#exchange = options.exchange + this.#maximumConnections = boundedInteger( + options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS, + 1, + 64, + 'Model bridge loopback connection limit' + ) + this.#requestTimeoutMs = boundedInteger( + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + 1, + 300_000, + 'Model bridge loopback request timeout' + ) + } + + get origin(): string { + if (this.#origin === undefined) { + throw new Error('Model bridge loopback proxy is not listening') + } + return this.#origin + } + + async listen(): Promise { + if (this.#server !== undefined || this.#closing) { + throw new Error('Model bridge loopback proxy is already active') + } + const server = createServer( + { + maxHeaderSize: MAXIMUM_HTTP_HEADER_BYTES, + requireHostHeader: true + }, + (request, response) => { + void this.#handleRequest(request, response) + } + ) + server.maxConnections = this.#maximumConnections + server.maxHeadersCount = MAXIMUM_HTTP_HEADER_COUNT + server.maxRequestsPerSocket = 1 + server.requestTimeout = this.#requestTimeoutMs + server.headersTimeout = Math.min( + this.#requestTimeoutMs, + 30_000 + ) + server.keepAliveTimeout = 1_000 + server.on('connection', (socket) => { + if ( + this.#closing || + this.#sockets.size >= this.#maximumConnections + ) { + socket.destroy() + return + } + this.#sockets.add(socket) + socket.once('close', () => this.#sockets.delete(socket)) + }) + this.#server = server + try { + await listenOnLoopback(server) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error( + 'Model bridge loopback address is unavailable' + ) + } + this.#origin = `http://${LOOPBACK_HOST}:${address.port}` + return this.#origin + } catch (error) { + await this.close() + throw error + } + } + + async close(): Promise { + if (this.#closePromise !== undefined) { + await this.#closePromise + return + } + const closePromise = this.#close() + this.#closePromise = closePromise + try { + await closePromise + } finally { + if (this.#closePromise === closePromise) { + this.#closePromise = undefined + } + } + } + + async #close(): Promise { + const server = this.#server + this.#server = undefined + this.#origin = undefined + this.#closing = true + try { + for (const socket of this.#sockets) { + socket.destroy() + } + await settleWithin( + server === undefined + ? Promise.resolve() + : closeServer(server), + RESPONSE_CLOSE_TIMEOUT_MS + ) + } finally { + this.#sockets.clear() + this.#closing = false + } + } + + async #handleRequest( + incoming: IncomingMessage, + outgoing: ServerResponse + ): Promise { + outgoing.setHeader('connection', 'close') + outgoing.setHeader('cache-control', 'no-store') + outgoing.setHeader('x-content-type-options', 'nosniff') + if (this.#closing) { + incoming.resume() + sendError(outgoing, 503, 'bridge-closing') + return + } + const cancellation = new AbortController() + let dispatched: ModelBridgeExchangeResult | undefined + let responseFlushed = false + let deliveryAcknowledged = false + let dispatchHeld = false + const onAborted = (): void => { + cancellation.abort( + new ModelBridgeBrokerError('request-cancelled') + ) + } + const onResponseClose = (): void => { + if (!responseFlushed && !outgoing.writableFinished) { + onAborted() + void dispatched?.failDelivery( + new ModelBridgeBrokerError('transport-failed') + ) + } + } + incoming.once('aborted', onAborted) + outgoing.once('close', onResponseClose) + const timeout = setTimeout(() => { + cancellation.abort( + new ModelBridgeBrokerError('request-timeout') + ) + }, this.#requestTimeoutMs) + timeout.unref?.() + + try { + const request = await parseHttpRequest(incoming) + await this.#acquireDispatch(cancellation.signal) + dispatchHeld = true + dispatched = normalizeExchangeResult( + await raceWithAbort(this.#exchange(request, { + requestId: `model-${randomUUID()}`, + signal: cancellation.signal + }), cancellation.signal) + ) + const parsedResponse = + remoteModelGatewayResponseSchema.parse(dispatched.response) + if (!outgoing.headersSent && !outgoing.destroyed) { + outgoing.statusCode = parsedResponse.status + for (const [name, value] of Object.entries( + parsedResponse.headers + )) { + outgoing.setHeader(name, value) + } + const body = Buffer.from( + parsedResponse.bodyBase64, + 'base64' + ) + outgoing.setHeader('content-length', body.byteLength) + await endResponseFlushed(outgoing, body, () => { + responseFlushed = true + }) + cancellation.signal.throwIfAborted() + await dispatched.acknowledgeDelivery() + deliveryAcknowledged = true + } + } catch (error) { + if (dispatched !== undefined && !deliveryAcknowledged) { + await Promise.resolve( + dispatched.failDelivery(error) + ).catch(() => undefined) + } + if (!outgoing.headersSent && !outgoing.destroyed) { + const failure = httpFailure(error, cancellation.signal) + sendError(outgoing, failure.status, failure.code) + } + } finally { + clearTimeout(timeout) + incoming.off('aborted', onAborted) + outgoing.off('close', onResponseClose) + if (dispatchHeld) { + this.#releaseDispatch() + } + } + } + + async #acquireDispatch(signal: AbortSignal): Promise { + signal.throwIfAborted() + if (!this.#dispatchBusy) { + this.#dispatchBusy = true + return + } + await new Promise((resolve, reject) => { + const waiter: DispatchWaiter = { resolve, reject, signal } + waiter.abort = (): void => { + const index = this.#dispatchWaiters.indexOf(waiter) + if (index >= 0) { + this.#dispatchWaiters.splice(index, 1) + } + reject(signal.reason) + } + signal.addEventListener('abort', waiter.abort, { once: true }) + this.#dispatchWaiters.push(waiter) + }) + } + + #releaseDispatch(): void { + while (this.#dispatchWaiters.length > 0) { + const waiter = this.#dispatchWaiters.shift()! + if (waiter.abort !== undefined) { + waiter.signal.removeEventListener('abort', waiter.abort) + } + if (waiter.signal.aborted) { + waiter.reject(waiter.signal.reason) + continue + } + waiter.resolve() + return + } + this.#dispatchBusy = false + } +} + +export async function runOpenCodeModelBridgeHelper(options: { + socketPath: string + protocol: ModelBridgeProtocol + model: string + supportsImageInput: boolean + opencodeEntrypoint: string + environment?: Readonly + spawn?: ModelBridgeHelperSpawn +}): Promise { + const socketPath = normalizedAbsolutePath( + options.socketPath, + 'Model bridge socket' + ) + const opencodeEntrypoint = normalizedAbsolutePath( + options.opencodeEntrypoint, + 'OpenCode entrypoint' + ) + const exchange = createUnixModelBridgeExchange({ socketPath }) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const config = createOpenCodeModelBridgeProviderConfig({ + protocol: options.protocol, + model: options.model, + loopbackOrigin: origin, + supportsImageInput: options.supportsImageInput + }) + const environment = credentialFreeHelperEnvironment( + options.environment ?? process.env, + JSON.stringify(config) + ) + const child = (options.spawn ?? defaultHelperSpawn)( + opencodeEntrypoint, + ['acp'], + { + shell: false, + stdio: 'inherit', + env: environment + } + ) + return await new Promise((resolveExit, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { + if (signal !== null) { + resolveExit(128) + return + } + resolveExit(code ?? 1) + }) + }) + } finally { + await proxy.close() + } +} + +function endResponseFlushed( + response: ServerResponse, + body: Uint8Array, + onFlushed: () => void +): Promise { + return new Promise((resolve, reject) => { + const onFinish = (): void => { + cleanup() + onFlushed() + resolve() + } + const onClose = (): void => { + if (response.writableFinished) { + onFinish() + return + } + cleanup() + reject(new ModelBridgeBrokerError('transport-failed')) + } + const onError = (): void => { + cleanup() + reject(new ModelBridgeBrokerError('transport-failed')) + } + const cleanup = (): void => { + response.off('finish', onFinish) + response.off('close', onClose) + response.off('error', onError) + } + response.once('finish', onFinish) + response.once('close', onClose) + response.once('error', onError) + response.end(body) + }) +} + +type DispatchWaiter = { + resolve: () => void + reject: (error: unknown) => void + signal: AbortSignal + abort?: () => void +} + +export function createOpenCodeModelBridgeProviderConfig(input: { + protocol: ModelBridgeProtocol + model: string + loopbackOrigin: string + name?: string + supportsImageInput?: boolean +}): OpenCodeModelBridgeProviderConfig { + const model = boundedMetadataText(input.model, 'Model name') + const name = boundedMetadataText( + input.name ?? 'GoodBuddy Model Bridge', + 'Provider name' + ) + const origin = parseLoopbackOrigin(input.loopbackOrigin) + const descriptor = providerDescriptor(input.protocol) + const npm = descriptor.npm + const providerId = descriptor.id + const baseURL = `${origin}/v1` + return { + model: `${providerId}/${model}`, + provider: { + [providerId]: { + name, + npm, + options: { + baseURL, + ...(descriptor.requiresSdkAuthSentinel + ? { apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL } + : {}) + }, + models: { + [model]: { + name, + attachment: input.supportsImageInput === true, + modalities: { + input: + input.supportsImageInput === true + ? ['text', 'image'] + : ['text'], + output: ['text'] + }, + provider: { npm } + } + } + } + } + } +} + +async function parseHttpRequest( + incoming: IncomingMessage +): Promise { + if (incoming.method !== 'POST') { + throw new HttpRequestError(405, 'method-not-allowed') + } + const path = remoteModelApiPathSchema.safeParse(incoming.url) + if (!path.success) { + throw new HttpRequestError(404, 'path-not-allowed') + } + validateInboundHeaders( + incoming.headers, + incoming.rawHeaders, + path.data + ) + const declaredLength = parseContentLength( + incoming.headers['content-length'] + ) + if (declaredLength === undefined) { + throw new HttpRequestError(411, 'content-length-required') + } + if ( + declaredLength !== undefined && + declaredLength > + REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + ) { + incoming.resume() + throw new HttpRequestError(413, 'request-too-large') + } + const body = await readBoundedBody(incoming) + if ( + declaredLength !== undefined && + body.byteLength !== declaredLength + ) { + throw new HttpRequestError(400, 'request-invalid') + } + const headers: Record = {} + for (const name of FORWARDED_HEADER_NAMES) { + const value = incoming.headers[name] + if (typeof value === 'string') { + headers[name] = value + } + } + const parsed = remoteModelGatewayRequestSchema.safeParse({ + method: 'POST', + path: path.data, + headers, + bodyBase64: body.toString('base64') + }) + if (!parsed.success) { + throw new HttpRequestError(400, 'request-invalid') + } + return parsed.data +} + +function validateInboundHeaders( + headers: IncomingHttpHeaders, + rawHeaders: readonly string[], + path: RemoteModelGatewayRequest['path'] +): void { + const entries = Object.entries(headers) + if (entries.length > MAXIMUM_HTTP_HEADER_COUNT) { + throw new HttpRequestError(431, 'headers-rejected') + } + const securityCriticalNames = new Set([ + 'accept', + 'connection', + 'content-length', + 'content-type', + 'host', + 'transfer-encoding', + ...CREDENTIAL_INBOUND_HEADER_NAMES + ]) + const seen = new Set() + for (let index = 0; index < rawHeaders.length; index += 2) { + const name = rawHeaders[index]?.toLowerCase() + if ( + name !== undefined && + securityCriticalNames.has(name) && + seen.has(name) + ) { + throw new HttpRequestError(400, 'headers-rejected') + } + if (name !== undefined) { + seen.add(name) + } + } + for (const [name, value] of entries) { + if ( + !/^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name) || + Array.isArray(value) || + (CREDENTIAL_INBOUND_HEADER_NAMES.has(name) && + !isExpectedSdkAuthenticationHeader(path, name, value)) + ) { + throw new HttpRequestError(400, 'headers-rejected') + } + if ( + value !== undefined && + (value.length > + REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes || + /[\r\n]/u.test(value)) + ) { + throw new HttpRequestError(431, 'headers-rejected') + } + if (name === 'transfer-encoding' && value !== undefined) { + throw new HttpRequestError(400, 'headers-rejected') + } + } +} + +function isExpectedSdkAuthenticationHeader( + path: RemoteModelGatewayRequest['path'], + name: string, + value: string | undefined +): boolean { + if (path === '/v1/messages') { + return ( + name === 'x-api-key' && + value === MODEL_BRIDGE_SDK_AUTH_SENTINEL + ) + } + if (path === '/responses' || path === '/v1/responses') { + return ( + name === 'authorization' && + value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}` + ) + } + return false +} + +function parseContentLength( + value: string | undefined +): number | undefined { + if (value === undefined) { + return undefined + } + if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) { + throw new HttpRequestError(400, 'request-invalid') + } + const length = Number(value) + if (!Number.isSafeInteger(length)) { + throw new HttpRequestError(413, 'request-too-large') + } + return length +} + +async function readBoundedBody( + incoming: IncomingMessage +): Promise { + const chunks: Buffer[] = [] + let total = 0 + for await (const untrustedChunk of incoming) { + const chunk = Buffer.isBuffer(untrustedChunk) + ? untrustedChunk + : Buffer.from(untrustedChunk as Uint8Array) + total += chunk.byteLength + if ( + total > + REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + ) { + throw new HttpRequestError(413, 'request-too-large') + } + chunks.push(chunk) + } + return Buffer.concat(chunks, total) +} + +function sendError( + response: ServerResponse, + status: number, + code: string +): void { + const body = Buffer.from(JSON.stringify({ error: { code } }), 'utf8') + response.statusCode = status + response.setHeader('content-type', 'application/json') + response.setHeader('content-length', body.byteLength) + response.end(body) +} + +function httpFailure( + error: unknown, + signal: AbortSignal +): { status: number; code: string } { + if (error instanceof HttpRequestError) { + return { status: error.status, code: error.code } + } + const reason = signal.reason + if ( + (signal.aborted && + reason instanceof ModelBridgeBrokerError && + reason.code === 'request-timeout') || + (error instanceof ModelBridgeBrokerError && + error.code === 'request-timeout') + ) { + return { status: 504, code: 'bridge-timeout' } + } + if ( + signal.aborted || + (error instanceof ModelBridgeBrokerError && + error.code === 'request-cancelled') + ) { + return { status: 499, code: 'request-cancelled' } + } + return { status: 502, code: 'bridge-failed' } +} + +function providerDescriptor(protocol: ModelBridgeProtocol): { + id: string + npm: + | '@ai-sdk/anthropic' + | '@ai-sdk/openai-compatible' + | '@ai-sdk/openai' + requiresSdkAuthSentinel: boolean +} { + switch (protocol) { + case 'anthropic-messages': + return { + id: 'goodbuddy-anthropic', + npm: '@ai-sdk/anthropic', + requiresSdkAuthSentinel: true + } + case 'openai-chat-completions': + return { + id: 'goodbuddy-openai-chat', + npm: '@ai-sdk/openai-compatible', + requiresSdkAuthSentinel: false + } + case 'openai-responses': + return { + id: 'goodbuddy-openai-responses', + npm: '@ai-sdk/openai', + requiresSdkAuthSentinel: true + } + } +} + +function normalizeExchangeResult( + value: Awaited> +): ModelBridgeExchangeResult { + if ( + value !== null && + typeof value === 'object' && + 'response' in value && + typeof value.acknowledgeDelivery === 'function' && + typeof value.failDelivery === 'function' + ) { + return value + } + return { + response: remoteModelGatewayResponseSchema.parse(value), + acknowledgeDelivery: async () => undefined, + failDelivery: () => undefined + } +} + +function defaultHelperSpawn( + executable: string, + args: readonly string[], + options: { + shell: false + stdio: 'inherit' + env: Readonly + } +): ModelBridgeHelperChild { + return nodeSpawn(executable, [...args], { + shell: options.shell, + stdio: options.stdio, + env: options.env + }) +} + +function credentialFreeHelperEnvironment( + source: Readonly, + config: string +): Readonly { + const allowed = [ + 'HOME', + 'LANG', + 'LC_ALL', + 'PATH', + 'TMPDIR', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME' + ] as const + const environment: NodeJS.ProcessEnv = { + OPENCODE_CONFIG_CONTENT: config + } + for (const name of allowed) { + const value = source[name] + if ( + value === undefined || + value.includes('\0') || + /(?:key|secret|token|authorization)/iu.test(name) + ) { + throw new Error(`Fixed helper environment is missing ${name}`) + } + environment[name] = value + } + return environment +} + +function normalizedAbsolutePath(value: string, label: string): string { + if ( + value.includes('\0') || + !isAbsolute(value) || + resolve(value) !== value + ) { + throw new Error(`${label} must be a normalized absolute path`) + } + return value +} + +function parseLoopbackOrigin(input: string): string { + let url: URL + try { + url = new URL(input) + } catch { + throw new Error('Model bridge origin is invalid') + } + if ( + url.protocol !== 'http:' || + url.hostname !== LOOPBACK_HOST || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + url.port === '' + ) { + throw new Error('Model bridge origin must be loopback HTTP') + } + return url.origin +} + +function boundedMetadataText( + value: string, + label: string +): string { + if ( + value.length < 1 || + Buffer.byteLength(value, 'utf8') > 512 || + /[\0\r\n]/u.test(value) + ) { + throw new Error(`${label} is invalid`) + } + return value +} + +function listenOnLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onListening = (): void => { + cleanup() + resolve() + } + const cleanup = (): void => { + server.off('error', onError) + server.off('listening', onListening) + } + server.once('error', onError) + server.once('listening', onListening) + server.listen({ + host: LOOPBACK_HOST, + port: 0, + exclusive: true + }) + }) +} + + +class HttpRequestError extends Error { + readonly status: number + readonly code: string + + constructor(status: number, code: string) { + super('Model bridge HTTP request was rejected') + this.name = 'HttpRequestError' + this.status = status + this.code = code + } +} diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts new file mode 100644 index 00000000..33971cfb --- /dev/null +++ b/src/agent-daemon/model-bridge.test.ts @@ -0,0 +1,658 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs' +import { + request as httpRequest, + type RequestOptions +} from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + REMOTE_MODEL_GATEWAY_LIMITS, + type RemoteModelGatewayRequest, + type RemoteModelGatewayResponse +} from '../shared/remote-model-gateway-contracts' +import { + createOpenCodeModelBridgeProviderConfig, + MODEL_BRIDGE_SDK_AUTH_SENTINEL, + ModelBridgeLoopbackProxy +} from './model-bridge-helper' +import { + createUnixModelBridgeExchange, + MODEL_BRIDGE_BROKER_SOCKET_NAME, + ModelBridgeBrokerServer, + type ModelBridgeExchange +} from './model-bridge-broker' + +const temporaryPaths: string[] = [] +const validResponse: RemoteModelGatewayResponse = { + status: 201, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'provider-request' + }, + bodyBase64: Buffer.from('{"ok":true}', 'utf8').toString( + 'base64' + ) +} + +afterEach(() => { + vi.restoreAllMocks() + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('model bridge loopback helper', () => { + it('forwards only the bounded request contract and returns the response', async () => { + const exchange = vi.fn( + async () => validResponse + ) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-api-key': MODEL_BRIDGE_SDK_AUTH_SENTINEL + }, + body: '{"model":"private-model","messages":[]}' + }) + + expect(response).toEqual({ + status: 201, + headers: expect.objectContaining({ + 'content-type': 'application/json', + 'x-request-id': 'provider-request' + }), + body: '{"ok":true}' + }) + expect(exchange).toHaveBeenCalledOnce() + const [request, context] = exchange.mock.calls[0]! + expect(request).toEqual({ + method: 'POST', + path: '/v1/messages', + headers: { + accept: 'application/json', + 'content-type': 'application/json' + }, + bodyBase64: Buffer.from( + '{"model":"private-model","messages":[]}', + 'utf8' + ).toString('base64') + }) + expect(context.requestId).toMatch(/^model-/u) + expect(context.signal.aborted).toBe(false) + } finally { + await proxy.close() + } + }) + + it('acknowledges delivery only after the HTTP response flushes', async () => { + const acknowledgeDelivery = vi.fn(async () => undefined) + const failDelivery = vi.fn(async () => undefined) + const proxy = new ModelBridgeLoopbackProxy({ + exchange: async () => ({ + response: validResponse, + acknowledgeDelivery, + failDelivery + }) + }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + expect(response.body).toBe('{"ok":true}') + expect(acknowledgeDelivery).toHaveBeenCalledOnce() + expect(failDelivery).not.toHaveBeenCalled() + } finally { + await proxy.close() + } + }) + + it('flushes and acknowledges a multi-frame-sized HTTP response', async () => { + const body = Buffer.alloc(600 * 1024, 0x61) + const acknowledgeDelivery = vi.fn(async () => undefined) + const failDelivery = vi.fn(async () => undefined) + const proxy = new ModelBridgeLoopbackProxy({ + exchange: async () => ({ + response: { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + bodyBase64: body.toString('base64') + }, + acknowledgeDelivery, + failDelivery + }) + }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + expect(Buffer.byteLength(response.body)).toBe(body.byteLength) + expect(acknowledgeDelivery).toHaveBeenCalledOnce() + expect(failDelivery).not.toHaveBeenCalled() + } finally { + await proxy.close() + } + }) + + it.each([ + { + name: 'non-POST method', + request: { method: 'GET', path: '/v1/messages' }, + status: 405 + }, + { + name: 'unknown path', + request: { path: '/images/generations' }, + status: 404 + }, + { + name: 'path query', + request: { path: '/v1/messages?token=secret' }, + status: 404 + }, + { + name: 'credential header', + request: { + path: '/v1/messages', + headers: { authorization: 'Bearer remote-secret' } + }, + status: 400 + } + ])('rejects $name without dispatch', async ({ request, status }) => { + const exchange = vi.fn(async () => validResponse) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, request) + expect(response.status).toBe(status) + expect(response.body).not.toContain('remote-secret') + expect(exchange).not.toHaveBeenCalled() + } finally { + await proxy.close() + } + }) + + it('rejects a declared oversized body before dispatch', async () => { + const exchange = vi.fn(async () => validResponse) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + headers: { + 'content-length': String( + REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + 1 + ) + }, + body: '' + }) + expect(response.status).toBe(413) + expect(exchange).not.toHaveBeenCalled() + } finally { + await proxy.close() + } + }) + + it('drops harmless SDK metadata headers but rejects chunked framing', async () => { + const exchange = vi.fn( + async () => validResponse + ) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const accepted = await sendHttp(origin, { + path: '/v1/messages', + headers: { + 'content-type': 'application/json', + 'x-stainless-runtime': 'node' + }, + body: '{}' + }) + expect(accepted.status).toBe(201) + expect(exchange).toHaveBeenCalledOnce() + expect(exchange.mock.calls[0]![0].headers).toEqual({ + 'content-type': 'application/json' + }) + + const rejected = await sendHttp(origin, { + path: '/v1/messages', + headers: { + 'content-type': 'application/json', + 'transfer-encoding': 'chunked' + }, + body: '{}' + }) + expect(rejected.status).toBe(400) + expect(exchange).toHaveBeenCalledOnce() + } finally { + await proxy.close() + } + }) + + it('accepts only the fixed SDK compatibility marker and strips it', async () => { + const exchange = vi.fn( + async () => validResponse + ) + const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const origin = await proxy.listen() + try { + const accepted = await sendHttp(origin, { + path: '/v1/responses', + headers: { + authorization: `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}` + }, + body: '{}' + }) + expect(accepted.status).toBe(201) + expect(exchange).toHaveBeenCalledOnce() + expect(exchange.mock.calls[0]![0].headers).toEqual({}) + + for (const authorization of [ + 'Bearer remote-secret', + `Basic ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}` + ]) { + const rejected = await sendHttp(origin, { + path: '/v1/responses', + headers: { authorization }, + body: '{}' + }) + expect(rejected.status).toBe(400) + } + expect(exchange).toHaveBeenCalledOnce() + } finally { + await proxy.close() + } + }) + + it('times out once, aborts dispatch, and never returns raw errors', async () => { + let dispatchSignal: AbortSignal | undefined + const exchange = vi.fn( + async ( + _request: RemoteModelGatewayRequest, + context: { signal: AbortSignal } + ) => { + dispatchSignal = context.signal + await new Promise(() => undefined) + return validResponse + } + ) + const proxy = new ModelBridgeLoopbackProxy({ + exchange, + requestTimeoutMs: 20 + }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + expect(response.status).toBe(504) + expect(response.body).toBe( + '{"error":{"code":"bridge-timeout"}}' + ) + expect(dispatchSignal?.aborted).toBe(true) + expect(exchange).toHaveBeenCalledOnce() + } finally { + await proxy.close() + } + }) + + it('maps dispatch errors without leaking credentials', async () => { + const privateValue = 'provider-key-do-not-leak' + const proxy = new ModelBridgeLoopbackProxy({ + exchange: async () => { + throw new Error(`Upstream used ${privateValue}`) + } + }) + const origin = await proxy.listen() + try { + const response = await sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + expect(response.status).toBe(502) + expect(response.body).toBe( + '{"error":{"code":"bridge-failed"}}' + ) + expect(response.body).not.toContain(privateValue) + } finally { + await proxy.close() + } + }) + + it('queues concurrent OpenCode requests instead of returning bridge-busy', async () => { + let markStarted: (() => void) | undefined + let releaseFirst: (() => void) | undefined + const started = new Promise((resolveStarted) => { + markStarted = resolveStarted + }) + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + let callIndex = 0 + const exchange = vi.fn(async () => { + const index = callIndex++ + if (index === 0) { + markStarted?.() + await firstGate + } + return validResponse + }) + const proxy = new ModelBridgeLoopbackProxy({ + exchange + }) + const origin = await proxy.listen() + const first = sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + await started + const second = sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }) + try { + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(exchange).toHaveBeenCalledOnce() + releaseFirst?.() + await expect(first).resolves.toMatchObject({ status: 201 }) + await expect(second).resolves.toMatchObject({ status: 201 }) + expect(exchange).toHaveBeenCalledTimes(2) + } finally { + await proxy.close() + } + }) + + it('aborts an in-flight dispatch when the proxy closes', async () => { + let dispatchSignal: AbortSignal | undefined + let markStarted: (() => void) | undefined + const started = new Promise((resolveStarted) => { + markStarted = resolveStarted + }) + const proxy = new ModelBridgeLoopbackProxy({ + exchange: async (_request, context) => { + dispatchSignal = context.signal + markStarted?.() + await new Promise(() => undefined) + return validResponse + } + }) + const origin = await proxy.listen() + const pending = sendHttp(origin, { + path: '/v1/messages', + body: '{}' + }).catch(() => undefined) + await started + + await proxy.close() + await pending + expect(dispatchSignal?.aborted).toBe(true) + }) + + it.each([ + { + protocol: 'anthropic-messages' as const, + providerId: 'goodbuddy-anthropic', + npm: '@ai-sdk/anthropic', + requiresSdkAuthSentinel: true + }, + { + protocol: 'openai-chat-completions' as const, + providerId: 'goodbuddy-openai-chat', + npm: '@ai-sdk/openai-compatible', + requiresSdkAuthSentinel: false + }, + { + protocol: 'openai-responses' as const, + providerId: 'goodbuddy-openai-responses', + npm: '@ai-sdk/openai', + requiresSdkAuthSentinel: true + } + ])( + 'creates secret-free $protocol OpenCode metadata', + ({ protocol, providerId, npm, requiresSdkAuthSentinel }) => { + const config = createOpenCodeModelBridgeProviderConfig({ + protocol, + model: 'private-model', + name: 'Private model', + loopbackOrigin: 'http://127.0.0.1:12345', + supportsImageInput: true + }) + + expect(config).toMatchObject({ + model: `${providerId}/private-model`, + provider: { + [providerId]: { + npm, + options: { + baseURL: 'http://127.0.0.1:12345/v1' + }, + models: { + 'private-model': { + attachment: true, + provider: { npm } + } + } + } + } + }) + const options = config.provider[providerId]!.options + expect(options.apiKey).toBe( + requiresSdkAuthSentinel + ? MODEL_BRIDGE_SDK_AUTH_SENTINEL + : undefined + ) + expect(JSON.stringify(config)).not.toContain('provider.example') + } + ) +}) + +describe('model bridge Unix broker', () => { + const runOnUnix = process.platform === 'win32' ? it.skip : it + + runOnUnix('rejects a socket path beyond the Unix kernel limit', () => { + const scratch = resolve( + privateTemporaryDirectory(), + 'a'.repeat(150) + ) + expect( + () => + new ModelBridgeBrokerServer({ + scratchDirectory: scratch, + dispatch: async () => validResponse + }) + ).toThrow('endpoint path is too long') + }) + + runOnUnix( + 'uses a private owned socket, exchanges one request, and cleans up', + async () => { + const scratch = privateTemporaryDirectory() + const dispatch = vi.fn(async () => validResponse) + const broker = new ModelBridgeBrokerServer({ + scratchDirectory: scratch, + dispatch + }) + await broker.listen() + const socketPath = resolve( + scratch, + MODEL_BRIDGE_BROKER_SOCKET_NAME + ) + expect(lstatSync(socketPath).mode & 0o777).toBe(0o600) + + const exchange = createUnixModelBridgeExchange({ + socketPath, + requestTimeoutMs: 1_000 + }) + const request: RemoteModelGatewayRequest = { + method: 'POST', + path: '/v1/responses', + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from('{}').toString('base64') + } + const exchanged = await exchange(request, { + requestId: 'request-a', + signal: new AbortController().signal + }) + expect( + 'response' in exchanged ? exchanged.response : exchanged + ).toEqual(validResponse) + if ('response' in exchanged) { + await exchanged.acknowledgeDelivery() + } + expect(dispatch).toHaveBeenCalledWith(request, { + requestId: 'request-a', + signal: expect.any(AbortSignal) + }) + + await broker.close() + expect(existsSync(socketPath)).toBe(false) + } + ) + + runOnUnix( + 'refuses to replace or remove a non-socket endpoint path', + async () => { + const scratch = privateTemporaryDirectory() + const socketPath = join( + scratch, + MODEL_BRIDGE_BROKER_SOCKET_NAME + ) + writeFileSync(socketPath, 'user-owned') + chmodSync(socketPath, 0o600) + const broker = new ModelBridgeBrokerServer({ + scratchDirectory: scratch, + dispatch: async () => validResponse + }) + + await expect(broker.listen()).rejects.toThrow( + 'not an owned Unix socket' + ) + await broker.close() + expect(existsSync(socketPath)).toBe(true) + } + ) + + runOnUnix( + 'bounds dispatch time and forwards only a generic failure code', + async () => { + const scratch = privateTemporaryDirectory() + const privateValue = 'provider-secret-in-error' + let dispatchSignal: AbortSignal | undefined + const broker = new ModelBridgeBrokerServer({ + scratchDirectory: scratch, + requestTimeoutMs: 20, + dispatch: async (_request, context) => { + dispatchSignal = context.signal + await new Promise(() => undefined) + throw new Error(privateValue) + } + }) + await broker.listen() + try { + const exchange = createUnixModelBridgeExchange({ + socketPath: broker.socketPath, + requestTimeoutMs: 1_000 + }) + await expect( + exchange( + { + method: 'POST', + path: '/v1/messages', + headers: {}, + bodyBase64: '' + }, + { + requestId: 'request-timeout', + signal: new AbortController().signal + } + ) + ).rejects.toMatchObject({ + code: 'request-timeout', + message: 'Model bridge request timed out' + }) + expect(dispatchSignal?.aborted).toBe(true) + } finally { + await broker.close() + } + } + ) +}) + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-model-bridge-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return resolve(path) +} + +async function sendHttp( + origin: string, + options: { + method?: string + path?: string + headers?: Record + body?: string + } +): Promise<{ + status: number + headers: Record + body: string +}> { + const url = new URL(origin) + const body = options.body ?? '' + const requestOptions: RequestOptions = { + hostname: url.hostname, + port: url.port, + method: options.method ?? 'POST', + path: options.path ?? '/v1/messages', + headers: { + ...(options.headers ?? {}), + ...(!hasHeader(options.headers, 'content-length') && + !hasHeader(options.headers, 'transfer-encoding') + ? { 'content-length': Buffer.byteLength(body) } + : {}) + } + } + return await new Promise((resolveResponse, reject) => { + const request = httpRequest(requestOptions, (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + response.once('end', () => { + resolveResponse({ + status: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8') + }) + }) + }) + request.once('error', reject) + request.end(body) + }) +} + +function hasHeader( + headers: Record | undefined, + expected: string +): boolean { + return Object.keys(headers ?? {}).some( + (name) => name.toLowerCase() === expected + ) +} diff --git a/src/agent-daemon/opencode-runtime-profile.test.ts b/src/agent-daemon/opencode-runtime-profile.test.ts new file mode 100644 index 00000000..e1fa9716 --- /dev/null +++ b/src/agent-daemon/opencode-runtime-profile.test.ts @@ -0,0 +1,446 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + remoteRuntimeBundleManifestSchema, + type RemoteRuntimeBundleManifest +} from '../shared/remote-runtime-launch-contracts' +import { + OPENCODE_BWRAP_EXECUTABLE, + createOpenCodeLaunchProfile +} from './opencode-runtime-profile' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('OpenCode bubblewrap launch profile', () => { + it('uses a read-only Workspace bind for Ask', () => { + const fixture = createFixture() + const profile = createOpenCodeLaunchProfile({ + ...fixture, + workMode: 'ask' + }) + + expect(profile.executable).toBe(OPENCODE_BWRAP_EXECUTABLE) + expect(profile.cwd).toBe(fixture.scratchDirectory) + expect(profile.args).toContain('--unshare-all') + expect(profile.args).toContain('--clearenv') + expectArgumentSequence(profile.args, [ + '--ro-bind', + fixture.workspaceDirectory, + fixture.workspaceDirectory + ]) + expectArgumentSequence(profile.args, [ + '--', + resolve(fixture.bundleDirectory, 'bin', 'opencode'), + 'acp' + ]) + }) + + it('runs Execute directly with the SSH account environment', () => { + const fixture = createFixture() + const execute = createOpenCodeLaunchProfile({ + ...fixture, + workMode: 'execute' + }) + + expect(execute.executable).toBe( + resolve(fixture.bundleDirectory, 'bin', 'opencode') + ) + expect(execute.processExecutable).toBe(execute.executable) + expect(execute.args).toEqual(['acp']) + expect(execute.cwd).toBe(fixture.workspaceDirectory) + expect(execute.env).toMatchObject({ + HOME: + process.env.HOME ?? fixture.workspaceDirectory, + LANG: process.env.LANG || 'C.UTF-8', + LC_ALL: process.env.LC_ALL || 'C.UTF-8', + PATH: process.env.PATH || '/usr/bin:/bin', + TMPDIR: process.env.TMPDIR || '/tmp', + XDG_CACHE_HOME: + process.env.XDG_CACHE_HOME || + resolve( + process.env.HOME ?? fixture.workspaceDirectory, + '.cache' + ), + XDG_CONFIG_HOME: + process.env.XDG_CONFIG_HOME || + resolve( + process.env.HOME ?? fixture.workspaceDirectory, + '.config' + ), + XDG_DATA_HOME: + process.env.XDG_DATA_HOME || + resolve( + process.env.HOME ?? fixture.workspaceDirectory, + '.local', + 'share' + ), + XDG_STATE_HOME: + process.env.XDG_STATE_HOME || + resolve( + process.env.HOME ?? fixture.workspaceDirectory, + '.local', + 'state' + ) + }) + expect(execute.args).not.toContain('--unshare-all') + }) + + it('sets only the fixed environment and rejects a changed profile', () => { + const fixture = createFixture() + const profile = createOpenCodeLaunchProfile({ + ...fixture, + workMode: 'ask' + }) + const setEnvironmentNames = profile.args.flatMap( + (argument, index) => + argument === '--setenv' + ? [profile.args[index + 1]] + : [] + ) + expect(setEnvironmentNames).toEqual([ + 'HOME', + 'LANG', + 'LC_ALL', + 'PATH', + 'TMPDIR', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME' + ]) + expect(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY') + + expect(() => + createOpenCodeLaunchProfile({ + ...fixture, + manifest: remoteRuntimeBundleManifestSchema.parse({ + ...fixture.manifest, + allowedEnvironmentNames: ['HOME'] + }), + workMode: 'ask' + }) + ).toThrow(/environment allowlist/iu) + expect(() => + createOpenCodeLaunchProfile({ + ...fixture, + workspaceDirectory: 'relative/workspace', + workMode: 'ask' + }) + ).toThrow(/normalized absolute/iu) + }) + + it('rejects overlapping bundle, Workspace, and scratch mounts', () => { + const fixture = createFixture() + for (const conflict of [ + { + scratchDirectory: fixture.workspaceDirectory + }, + { + scratchDirectory: resolve( + fixture.workspaceDirectory, + 'scratch' + ) + }, + { + workspaceDirectory: resolve( + fixture.bundleDirectory, + 'workspace' + ) + }, + { + workspaceDirectory: resolve( + fixture.bundleDirectory, + '..' + ) + } + ]) { + expect(() => + createOpenCodeLaunchProfile({ + ...fixture, + ...conflict, + workMode: 'ask' + }) + ).toThrow(/must not overlap/iu) + } + }) + + it('launches the signed Agent helper with fixed credential-free bridge argv', () => { + const fixture = createFixture() + const bridgeDirectory = resolve( + fixture.workspaceDirectory, + '..', + 'bridge' + ) + mkdirSync(bridgeDirectory, { mode: 0o700 }) + const agentInstallationDirectory = resolve( + fixture.workspaceDirectory, + '..', + 'agent-installation' + ) + mkdirSync(agentInstallationDirectory, { mode: 0o700 }) + const agentExecutablePath = resolve( + agentInstallationDirectory, + 'goodbuddy-agent' + ) + const socketPath = resolve( + bridgeDirectory, + 'model-bridge.sock' + ) + const profile = createOpenCodeLaunchProfile({ + ...fixture, + workMode: 'ask', + modelBridge: { + agentExecutablePath, + bridgeDirectory, + socketPath, + policy: { + protocol: 'anthropic-messages', + model: 'private-model', + modelProfileDigest: `sha256:${'9'.repeat(64)}`, + supportsImageInput: false + } + } + }) + + expectArgumentSequence(profile.args, [ + '--ro-bind', + agentInstallationDirectory, + agentInstallationDirectory + ]) + expectArgumentSequence(profile.args, [ + '--ro-bind', + bridgeDirectory, + bridgeDirectory + ]) + expectArgumentSequence(profile.args, [ + '--', + resolve(agentInstallationDirectory, 'node'), + resolve(agentInstallationDirectory, 'lib', 'agent.cjs'), + 'model-bridge-helper', + '--socket-path', + socketPath, + '--protocol', + 'anthropic-messages', + '--model', + 'private-model', + '--supports-image-input', + 'false', + '--opencode-entrypoint', + resolve(fixture.bundleDirectory, 'bin', 'opencode') + ]) + expect(profile.args.join('\0')).not.toMatch( + /api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu + ) + + const executeProfile = createOpenCodeLaunchProfile({ + ...fixture, + workMode: 'execute', + modelBridge: { + agentExecutablePath, + bridgeDirectory, + socketPath, + policy: { + protocol: 'anthropic-messages', + model: 'private-model', + modelProfileDigest: `sha256:${'9'.repeat(64)}`, + supportsImageInput: false + } + } + }) + expect(executeProfile.executable).toBe( + resolve(agentInstallationDirectory, 'node') + ) + expect(executeProfile.processExecutable).toBe( + resolve(agentInstallationDirectory, 'node') + ) + expect(executeProfile.args[0]).toBe( + resolve(agentInstallationDirectory, 'lib', 'agent.cjs') + ) + }) + + it('allows Execute to use a workspace that contains managed Runtime paths', () => { + const fixture = createFixture() + const workspaceDirectory = resolve( + fixture.workspaceDirectory, + '..' + ) + const managedRoot = resolve(workspaceDirectory, '.goodbuddy') + const bundleDirectory = resolve( + managedRoot, + 'runtimes', + 'opencode' + ) + const scratchDirectory = resolve( + managedRoot, + 'scratch', + 'prompt' + ) + const agentInstallationDirectory = resolve( + managedRoot, + 'agent', + 'installation' + ) + const bridgeDirectory = resolve( + managedRoot, + 'bridges', + 'prompt' + ) + const modelBridge = { + agentExecutablePath: resolve( + agentInstallationDirectory, + 'goodbuddy-agent' + ), + bridgeDirectory, + socketPath: resolve( + bridgeDirectory, + 'model-bridge.sock' + ), + policy: { + protocol: 'anthropic-messages' as const, + model: 'private-model', + modelProfileDigest: `sha256:${'9'.repeat(64)}`, + supportsImageInput: false + } + } + + expect( + createOpenCodeLaunchProfile({ + manifest: fixture.manifest, + bundleDirectory, + workspaceDirectory, + scratchDirectory, + workMode: 'execute', + modelBridge + }) + ).toMatchObject({ + cwd: workspaceDirectory, + workMode: 'execute' + }) + expect(() => + createOpenCodeLaunchProfile({ + manifest: fixture.manifest, + bundleDirectory, + workspaceDirectory, + scratchDirectory, + workMode: 'ask', + modelBridge + }) + ).toThrow(/paths must not overlap/iu) + }) +}) + +function createFixture(): { + manifest: RemoteRuntimeBundleManifest + bundleDirectory: string + workspaceDirectory: string + scratchDirectory: string +} { + const root = resolve( + mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-')) + ) + temporaryPaths.push(root) + const bundleDirectory = resolve(root, 'bundle') + const workspaceDirectory = resolve(root, 'workspace') + const scratchDirectory = resolve(root, 'scratch') + for (const directory of [root, bundleDirectory, workspaceDirectory]) { + mkdirSync(directory, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + chmodSync(directory, 0o700) + } + } + return { + bundleDirectory, + workspaceDirectory, + scratchDirectory, + manifest: remoteRuntimeBundleManifestSchema.parse({ + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + provider: 'opencode', + platform: 'linux', + architecture: 'x64', + signingKeyId: 'runtime-test', + bundleDigest: `sha256:${'a'.repeat(64)}`, + adapterDigest: `sha256:${'b'.repeat(64)}`, + sourcePackage: { + name: 'opencode-linux-x64-baseline', + integrity: + 'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ==' + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: 'd'.repeat(64), + argvPrefix: ['acp'] + }, + files: [ + { + path: 'bin/opencode', + size: 64, + sha256: 'd'.repeat(64), + mode: '0755' + }, + { + path: 'licenses/opencode.txt', + size: 4, + sha256: 'e'.repeat(64), + mode: '0644' + } + ], + licenses: [ + { + package: 'opencode-ai', + version: '1.18.9', + spdx: 'MIT', + path: 'licenses/opencode.txt' + } + ], + 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:${'f'.repeat(64)}`, + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 4096, + maximumPromptOutputBytes: 1024 * 1024 + } + }) + } +} + +function expectArgumentSequence( + args: readonly string[], + sequence: readonly string[] +): void { + const start = args.findIndex( + (value, index) => + value === sequence[0] && + sequence.every( + (candidate, offset) => args[index + offset] === candidate + ) + ) + expect(start).toBeGreaterThanOrEqual(0) +} diff --git a/src/agent-daemon/opencode-runtime-profile.ts b/src/agent-daemon/opencode-runtime-profile.ts new file mode 100644 index 00000000..62704e16 --- /dev/null +++ b/src/agent-daemon/opencode-runtime-profile.ts @@ -0,0 +1,352 @@ +import { + dirname, + isAbsolute, + join, + relative, + resolve, + sep +} from 'node:path' +import { + OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES, + remoteRuntimeBundleManifestSchema, + type RemoteRuntimeBundleManifest +} from '../shared/remote-runtime-launch-contracts' +import { + modelBridgePolicySchema, + type ModelBridgePolicy +} from '../shared/model-bridge-contracts' +import { + assertAbsoluteManagedPath, + ensurePrivateDirectoryTree +} from './managed-paths' + +export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap' + +export type OpenCodeLaunchProfile = { + executable: string + processExecutable: string + args: readonly string[] + cwd: string + env: Readonly + workMode: 'ask' | 'execute' +} + +export function createOpenCodeLaunchProfile(input: { + manifest: RemoteRuntimeBundleManifest + bundleDirectory: string + workspaceDirectory: string + scratchDirectory: string + workMode: 'ask' | 'execute' + modelBridge?: { + agentExecutablePath: string + bridgeDirectory: string + socketPath: string + policy: ModelBridgePolicy + } +}): OpenCodeLaunchProfile { + const manifest = remoteRuntimeBundleManifestSchema.parse( + input.manifest + ) + if ( + manifest.runtimeId !== 'opencode' || + manifest.provider !== 'opencode' || + manifest.entrypoint.identity !== 'opencode-acp' || + manifest.entrypoint.argvPrefix.length !== 1 || + manifest.entrypoint.argvPrefix[0] !== 'acp' + ) { + throw new Error('Runtime manifest is not the fixed OpenCode ACP profile') + } + if ( + [...manifest.allowedEnvironmentNames].sort().join('\0') !== + [...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES] + .sort() + .join('\0') + ) { + throw new Error( + 'OpenCode Runtime environment allowlist does not match the fixed profile' + ) + } + + const bundleDirectory = assertAbsoluteManagedPath( + resolve(input.bundleDirectory) + ) + const workspaceDirectory = normalizedAbsolutePath( + input.workspaceDirectory, + 'Workspace' + ) + const modelBridge = + input.modelBridge === undefined + ? undefined + : { + agentExecutablePath: normalizedAbsolutePath( + input.modelBridge.agentExecutablePath, + 'Agent executable' + ), + agentInstallationDirectory: normalizedAbsolutePath( + dirname(input.modelBridge.agentExecutablePath), + 'Agent installation' + ), + bridgeDirectory: normalizedAbsolutePath( + input.modelBridge.bridgeDirectory, + 'Model bridge' + ), + socketPath: normalizedAbsolutePath( + input.modelBridge.socketPath, + 'Model bridge socket' + ), + policy: modelBridgePolicySchema.parse( + input.modelBridge.policy + ) + } + if ( + modelBridge !== undefined && + (!isStrictDescendant( + modelBridge.bridgeDirectory, + modelBridge.socketPath + ) || + !isStrictDescendant( + modelBridge.agentInstallationDirectory, + modelBridge.agentExecutablePath + )) + ) { + throw new Error( + 'Model bridge socket or Agent executable escapes its managed directory' + ) + } + const managedPaths = [ + ['Runtime bundle', bundleDirectory], + ['Workspace', workspaceDirectory], + ...(modelBridge === undefined + ? [] + : [ + [ + 'Agent installation', + modelBridge.agentInstallationDirectory + ] as const, + [ + 'Model bridge', + modelBridge.bridgeDirectory + ] as const + ]) + ] as const + const executablePath = resolve( + bundleDirectory, + ...manifest.entrypoint.path.split('/') + ) + if (!isStrictDescendant(bundleDirectory, executablePath)) { + throw new Error('OpenCode entrypoint escapes its bundle directory') + } + + const runtimeCommand = + modelBridge === undefined + ? { + executable: executablePath, + args: [...manifest.entrypoint.argvPrefix] + } + : { + executable: join( + modelBridge.agentInstallationDirectory, + 'node' + ), + args: [ + join( + modelBridge.agentInstallationDirectory, + 'lib', + 'agent.cjs' + ), + 'model-bridge-helper', + '--socket-path', + modelBridge.socketPath, + '--protocol', + modelBridge.policy.protocol, + '--model', + modelBridge.policy.model, + '--supports-image-input', + modelBridge.policy.supportsImageInput ? 'true' : 'false', + '--opencode-entrypoint', + executablePath + ] + } + + if (input.workMode === 'execute') { + return { + ...runtimeCommand, + processExecutable: runtimeCommand.executable, + cwd: workspaceDirectory, + env: executeEnvironment(process.env, workspaceDirectory), + workMode: 'execute' + } + } + + const scratchDirectory = assertAbsoluteManagedPath( + resolve(input.scratchDirectory) + ) + assertDisjointPaths([ + ...managedPaths, + ['Runtime scratch', scratchDirectory] + ]) + const scratchPaths = { + home: join(scratchDirectory, 'home'), + temporary: join(scratchDirectory, 'tmp'), + cache: join(scratchDirectory, 'xdg-cache'), + config: join(scratchDirectory, 'xdg-config'), + data: join(scratchDirectory, 'xdg-data'), + state: join(scratchDirectory, 'xdg-state') + } + for (const path of Object.values(scratchPaths)) { + ensurePrivateDirectoryTree(path, scratchDirectory) + } + + const environment: Readonly> = { + HOME: scratchPaths.home, + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + PATH: '/usr/bin:/bin', + TMPDIR: scratchPaths.temporary, + XDG_CACHE_HOME: scratchPaths.cache, + XDG_CONFIG_HOME: scratchPaths.config, + XDG_DATA_HOME: scratchPaths.data, + XDG_STATE_HOME: scratchPaths.state + } + const args = [ + '--new-session', + '--unshare-all', + '--clearenv', + '--proc', + '/proc', + '--dev', + '/dev', + '--tmpfs', + '/run', + '--ro-bind', + '/usr', + '/usr', + '--ro-bind-try', + '/bin', + '/bin', + '--ro-bind-try', + '/lib', + '/lib', + '--ro-bind-try', + '/lib64', + '/lib64', + '--ro-bind', + bundleDirectory, + bundleDirectory, + '--ro-bind', + workspaceDirectory, + workspaceDirectory, + '--bind', + scratchDirectory, + scratchDirectory, + ...Object.entries(environment).flatMap(([name, value]) => [ + '--setenv', + name, + value + ]), + '--chdir', + workspaceDirectory, + ...(modelBridge === undefined + ? [] + : [ + '--ro-bind', + modelBridge.agentInstallationDirectory, + modelBridge.agentInstallationDirectory, + '--ro-bind', + modelBridge.bridgeDirectory, + modelBridge.bridgeDirectory + ]), + '--', + runtimeCommand.executable, + ...runtimeCommand.args + ] as const + + return { + executable: OPENCODE_BWRAP_EXECUTABLE, + processExecutable: OPENCODE_BWRAP_EXECUTABLE, + args, + cwd: scratchDirectory, + env: { + PATH: '/usr/bin:/bin', + LANG: 'C.UTF-8' + }, + workMode: 'ask' + } +} + +function normalizedAbsolutePath(value: string, label: string): string { + if ( + value.includes('\0') || + !isAbsolute(value) || + resolve(value) !== value + ) { + throw new Error(`${label} path must be a normalized absolute path`) + } + return value +} + +function executeEnvironment( + source: Readonly, + workspaceDirectory: string +): Readonly { + const home = usableEnvironmentValue(source.HOME) + ? source.HOME! + : workspaceDirectory + const defaults = { + HOME: home, + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + PATH: '/usr/bin:/bin', + TMPDIR: '/tmp', + XDG_CACHE_HOME: join(home, '.cache'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + XDG_STATE_HOME: join(home, '.local', 'state') + } as const + const environment: NodeJS.ProcessEnv = { ...source } + for (const [name, fallback] of Object.entries(defaults)) { + if (!usableEnvironmentValue(environment[name])) { + environment[name] = fallback + } + } + return environment +} + +function usableEnvironmentValue( + value: string | undefined +): value is string { + return value !== undefined && value.length > 0 && !value.includes('\0') +} + +function assertDisjointPaths( + paths: readonly (readonly [label: string, path: string])[] +): void { + for (let left = 0; left < paths.length; left += 1) { + for (let right = left + 1; right < paths.length; right += 1) { + const leftEntry = paths[left]! + const rightEntry = paths[right]! + if ( + leftEntry[1] === rightEntry[1] || + isStrictDescendant(leftEntry[1], rightEntry[1]) || + isStrictDescendant(rightEntry[1], leftEntry[1]) + ) { + throw new Error( + `${leftEntry[0]} and ${rightEntry[0]} paths must not overlap` + ) + } + } + } +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const child = relative(parent, candidate) + return ( + child.length > 0 && + child !== '..' && + !child.startsWith(`..${sep}`) && + !isAbsolute(child) + ) +} diff --git a/src/agent-daemon/peer-identity-provider.test.ts b/src/agent-daemon/peer-identity-provider.test.ts new file mode 100644 index 00000000..3286327e --- /dev/null +++ b/src/agent-daemon/peer-identity-provider.test.ts @@ -0,0 +1,106 @@ +import type { Socket } from 'node:net' +import { describe, expect, it, vi } from 'vitest' +import { + createNativeBinding, + createLinuxPeerIdentityProvider, + LinuxPeerIdentityProvider, + type LinuxPeerCredentialNativeBinding +} from './peer-identity-provider' + +describe('Linux SO_PEERCRED provider', () => { + it('returns only kernel-provided credentials for the accepted socket', async () => { + const native = nativeBinding((descriptor, credentials, length) => { + expect(descriptor).toBe(42) + expect(length.readUInt32LE()).toBe(12) + credentials.writeInt32LE(1234, 0) + credentials.writeUInt32LE(1000, 4) + credentials.writeUInt32LE(1000, 8) + return 0 + }) + const provider = new LinuxPeerIdentityProvider(native) + + await expect( + provider.getPeerIdentity(socketWithDescriptor(42)) + ).resolves.toEqual({ uid: 1000, pid: 1234 }) + }) + + it('fails closed on missing descriptors, native errors, and ABI mismatch', async () => { + const provider = new LinuxPeerIdentityProvider( + nativeBinding((_descriptor, _credentials, length) => { + length.writeUInt32LE(8) + return 0 + }) + ) + await expect( + provider.getPeerIdentity({} as Socket) + ).rejects.toMatchObject({ + code: 'peer-identity-unavailable' + }) + await expect( + new LinuxPeerIdentityProvider( + nativeBinding(() => -1, 13) + ).getPeerIdentity(socketWithDescriptor(7)) + ).rejects.toThrow('errno 13') + await expect( + provider.getPeerIdentity(socketWithDescriptor(7)) + ).rejects.toThrow('invalid ABI size') + }) + + it('rejects unsupported platforms and ABIs before loading native code', async () => { + const loadKoffi = vi.fn() + await expect( + createLinuxPeerIdentityProvider({ + platform: 'win32', + architecture: 'x64', + byteOrder: 'LE', + loadKoffi + }) + ).rejects.toMatchObject({ + code: 'peer-identity-unavailable' + }) + await expect( + createLinuxPeerIdentityProvider({ + platform: 'linux', + architecture: 'ia32', + byteOrder: 'LE', + loadKoffi + }) + ).rejects.toMatchObject({ + code: 'peer-identity-unavailable' + }) + await expect( + createLinuxPeerIdentityProvider({ + platform: 'linux', + architecture: 'arm64', + byteOrder: 'BE', + loadKoffi + }) + ).rejects.toMatchObject({ + code: 'peer-identity-unavailable' + }) + expect(loadKoffi).not.toHaveBeenCalled() + expect(() => + createNativeBinding({ + sizeof: () => 8 + } as never) + ).toThrow('integer ABI is unsupported') + }) +}) + +function nativeBinding( + getSocketPeerCredentials: LinuxPeerCredentialNativeBinding[ + 'getSocketPeerCredentials' + ], + errorNumber = 0 +): LinuxPeerCredentialNativeBinding { + return { + getSocketPeerCredentials, + errno: () => errorNumber + } +} + +function socketWithDescriptor(descriptor: number): Socket { + return { + _handle: { fd: descriptor } + } as unknown as Socket +} diff --git a/src/agent-daemon/peer-identity-provider.ts b/src/agent-daemon/peer-identity-provider.ts new file mode 100644 index 00000000..a52337fc --- /dev/null +++ b/src/agent-daemon/peer-identity-provider.ts @@ -0,0 +1,158 @@ +import { arch, endianness } from 'node:os' +import type { Socket } from 'node:net' +import type koffiType from 'koffi' +import { AgentUnsupportedError } from './errors' +import type { + PeerIdentity, + UnixPeerIdentityProvider +} from './private-endpoint' + +const SOL_SOCKET = 1 +const SO_PEERCRED = 17 +const LINUX_UCRED_BYTES = 12 +const SUPPORTED_ARCHITECTURES = new Set(['x64', 'arm64']) + +type Koffi = typeof koffiType + +export type LinuxPeerCredentialNativeBinding = { + getSocketPeerCredentials( + descriptor: number, + credentials: Buffer, + length: Buffer + ): number + errno(): number +} + +export class LinuxPeerIdentityProvider + implements UnixPeerIdentityProvider +{ + readonly #native: LinuxPeerCredentialNativeBinding + + constructor(native: LinuxPeerCredentialNativeBinding) { + this.#native = native + } + + async getPeerIdentity(socket: Socket): Promise { + const descriptor = socketDescriptor(socket) + const credentials = Buffer.alloc(LINUX_UCRED_BYTES) + const length = Buffer.alloc(4) + length.writeUInt32LE(credentials.byteLength) + const result = this.#native.getSocketPeerCredentials( + descriptor, + credentials, + length + ) + if (result !== 0) { + const nativeError = this.#native.errno() + throw new Error( + `getsockopt(SO_PEERCRED) failed with errno ${nativeError}` + ) + } + if (length.readUInt32LE() !== LINUX_UCRED_BYTES) { + throw new Error('getsockopt(SO_PEERCRED) returned an invalid ABI size') + } + const pid = credentials.readInt32LE(0) + const uid = credentials.readUInt32LE(4) + if (pid <= 0) { + throw new Error('getsockopt(SO_PEERCRED) returned an invalid PID') + } + return { uid, pid } + } +} + +export async function createLinuxPeerIdentityProvider( + options: { + platform?: NodeJS.Platform + architecture?: string + byteOrder?: 'BE' | 'LE' + loadKoffi?: () => Promise + } = {} +): Promise { + const platform = options.platform ?? process.platform + const architecture = options.architecture ?? arch() + const byteOrder = options.byteOrder ?? endianness() + if ( + platform !== 'linux' || + !SUPPORTED_ARCHITECTURES.has(architecture) || + byteOrder !== 'LE' + ) { + throw unavailable( + 'SO_PEERCRED is certified only on little-endian Linux x64 and arm64' + ) + } + try { + const koffi = + await (options.loadKoffi?.() ?? + import('koffi').then((module) => module.default)) + return new LinuxPeerIdentityProvider(createNativeBinding(koffi)) + } catch (error) { + if (error instanceof AgentUnsupportedError) { + throw error + } + throw unavailable( + `SO_PEERCRED native binding is unavailable: ${boundedError(error)}` + ) + } +} + +export function createNativeBinding( + koffi: Koffi +): LinuxPeerCredentialNativeBinding { + if ( + koffi.sizeof('int32_t') !== 4 || + koffi.sizeof('uint32_t') !== 4 + ) { + throw unavailable('Linux peer credential integer ABI is unsupported') + } + const libc = koffi.load(null) + const getsockopt = libc.func('getsockopt', 'int', [ + 'int', + 'int', + 'int', + 'void *', + 'void *' + ]) + return { + getSocketPeerCredentials: ( + descriptor, + credentials, + length + ): number => + getsockopt( + descriptor, + SOL_SOCKET, + SO_PEERCRED, + credentials, + length + ) as number, + errno: () => koffi.errno() + } +} + +function socketDescriptor(socket: Socket): number { + const descriptor = ( + socket as Socket & { + _handle?: { fd?: unknown } + } + )._handle?.fd + if ( + typeof descriptor !== 'number' || + !Number.isSafeInteger(descriptor) || + descriptor < 0 + ) { + throw unavailable('The accepted Unix socket descriptor is unavailable') + } + return descriptor +} + +function unavailable(message: string): AgentUnsupportedError { + return new AgentUnsupportedError( + message, + 'peer-identity-unavailable' + ) +} + +function boundedError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.replaceAll(/\s+/gu, ' ').trim().slice(0, 500) +} diff --git a/src/agent-daemon/private-endpoint.test.ts b/src/agent-daemon/private-endpoint.test.ts new file mode 100644 index 00000000..ae17d12b --- /dev/null +++ b/src/agent-daemon/private-endpoint.test.ts @@ -0,0 +1,576 @@ +import { + chmodSync, + mkdtempSync, + rmSync, + unlinkSync +} from 'node:fs' +import { createConnection, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_VERSION, + attachWelcomeSchema, + daemonCapabilitiesSchema, + daemonStatusSchema +} from '../shared/agent-protocol' +import { + decodeAgentFrame, + encodeAgentFrame, + type AgentFrame +} from '../shared/agent-protocol/frame' +import { ControllerRegistry } from './controller-registry' +import { EventJournal } from './event-journal' +import { InstallationChallengeVerifier } from './installation-challenge' +import { + attachRelay, + PrivateEndpoint, + type PeerIdentity +} from './private-endpoint' +import { AgentProtocolServer } from './protocol-server' + +const runOnUnix = process.platform !== 'win32' ? it : it.skip +const temporaryPaths: string[] = [] +const binaryDigest = `sha256:${'a'.repeat(64)}` +const endpointIdentity = { + installationId: 'installation-a', + binaryDigest, + daemonBootId: 'boot-a', + protocol: AGENT_PROTOCOL_VERSION +} + +afterEach(() => { + vi.restoreAllMocks() + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('private endpoint lifecycle', () => { + runOnUnix( + 'never pre-unlinks an endpoint that may still be live', + async () => { + const root = privateTemporaryDirectory() + const socketPath = resolve(root, 'agent.sock') + const existing = createServer() + await new Promise((resolveListen, reject) => { + existing.once('error', reject) + existing.listen(socketPath, resolveListen) + }) + const endpoint = new PrivateEndpoint({ + socketPath, + peerIdentity: currentPeerIdentity(), + challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)), + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: vi.fn() + }) + + await expect(endpoint.listen()).rejects.toMatchObject({ + code: 'EADDRINUSE' + }) + const client = createConnection(socketPath) + await expect(onceConnected(client)).resolves.toBeUndefined() + client.destroy() + await new Promise((resolveClose) => + existing.close(() => resolveClose()) + ) + } + ) + + runOnUnix( + 'never unlinks a replacement socket it did not bind', + async () => { + const root = privateTemporaryDirectory() + const socketPath = resolve(root, 'agent.sock') + const endpoint = new PrivateEndpoint({ + socketPath, + peerIdentity: currentPeerIdentity(), + challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)), + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: vi.fn() + }) + await endpoint.listen() + + unlinkSync(socketPath) + const replacement = createServer() + await new Promise((resolveListen, reject) => { + replacement.once('error', reject) + replacement.listen(socketPath, resolveListen) + }) + + await endpoint.close() + const client = createConnection(socketPath) + await expect(onceConnected(client)).resolves.toBeUndefined() + client.destroy() + await new Promise((resolveClose) => + replacement.close(() => resolveClose()) + ) + } + ) + + runOnUnix( + 'destroys an authenticated persistent socket during shutdown', + async () => { + const root = privateTemporaryDirectory() + const secret = Buffer.alloc(32, 7) + const verifier = new InstallationChallengeVerifier(secret) + const attached = vi.fn() + const endpoint = new PrivateEndpoint({ + socketPath: resolve(root, 'agent.sock'), + peerIdentity: currentPeerIdentity(), + challenge: verifier, + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: attached + }) + await endpoint.listen() + const client = createConnection(resolve(root, 'agent.sock')) + await onceConnected(client) + const challenge = await readPacket(client) + const clientNonce = 'client-nonce' + await writePacket(client, { + type: 'response', + version: 1, + controllerId: 'controller-a', + clientNonce, + response: verifier.createResponse({ + serverNonce: String(challenge.serverNonce), + clientNonce, + controllerId: 'controller-a' + }), + protocol: AGENT_PROTOCOL_VERSION + }) + const welcome = await readPacket(client) + expect(attachWelcomeSchema.parse(welcome)).toEqual({ + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId: expect.any(String), + generation: 1, + ...endpointIdentity, + serverNonce: challenge.serverNonce + }) + await vi.waitFor(() => expect(attached).toHaveBeenCalledOnce()) + + const closed = onceClosed(client) + await expect(endpoint.close()).resolves.toBeUndefined() + await closed + } + ) + + runOnUnix( + 'settles a socket that is still resolving peer identity', + async () => { + const root = privateTemporaryDirectory() + let resolvePeer: ((peer: PeerIdentity) => void) | undefined + const peerIdentity = vi.fn( + () => + new Promise((resolveIdentity) => { + resolvePeer = resolveIdentity + }) + ) + const attached = vi.fn() + const endpoint = new PrivateEndpoint({ + socketPath: resolve(root, 'agent.sock'), + peerIdentity: { getPeerIdentity: peerIdentity }, + challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)), + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: attached + }) + await endpoint.listen() + const client = createConnection(resolve(root, 'agent.sock')) + await onceConnected(client) + await vi.waitFor(() => expect(peerIdentity).toHaveBeenCalledOnce()) + + const closed = onceClosed(client) + await expect(endpoint.close()).resolves.toBeUndefined() + await closed + resolvePeer?.({ uid: process.getuid!() }) + await Promise.resolve() + expect(attached).not.toHaveBeenCalled() + } + ) + + runOnUnix('rejects connections beyond the configured cap', async () => { + const root = privateTemporaryDirectory() + const peerIdentity = vi.fn( + () => new Promise(() => undefined) + ) + const endpoint = new PrivateEndpoint({ + socketPath: resolve(root, 'agent.sock'), + peerIdentity: { getPeerIdentity: peerIdentity }, + challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)), + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: vi.fn(), + maximumConnections: 1 + }) + await endpoint.listen() + const first = createConnection(resolve(root, 'agent.sock')) + await onceConnected(first) + await vi.waitFor(() => expect(peerIdentity).toHaveBeenCalledOnce()) + + const second = createConnection(resolve(root, 'agent.sock')) + const rejected = onceClosed(second) + await onceConnected(second) + await rejected + expect(peerIdentity).toHaveBeenCalledOnce() + + await endpoint.close() + first.destroy() + }) + + runOnUnix( + 'rejects malformed or client-extended authentication packets', + async () => { + const root = privateTemporaryDirectory() + const endpoint = new PrivateEndpoint({ + socketPath: resolve(root, 'agent.sock'), + peerIdentity: currentPeerIdentity(), + challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)), + controllers: new ControllerRegistry(), + ...endpointIdentity, + onAttach: vi.fn() + }) + await endpoint.listen() + const client = createConnection(resolve(root, 'agent.sock')) + await onceConnected(client) + await readPacket(client) + await writePacket(client, { + type: 'response', + version: 1, + controllerId: 'controller-a', + clientNonce: 'client-nonce', + protocol: AGENT_PROTOCOL_VERSION, + response: 'not-authenticated', + installationId: 'client-claimed' + }) + await onceClosed(client) + await endpoint.close() + } + ) + + runOnUnix( + 'relays a strict Main attach into real protocol status and capabilities', + async () => { + const root = privateTemporaryDirectory() + const controllers = new ControllerRegistry() + const events = new EventJournal(resolve(root, 'events.sqlite')) + const status = { + state: 'ready' as const, + installationId: endpointIdentity.installationId, + binaryDigest, + daemonBootId: endpointIdentity.daemonBootId, + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux' as const, + architecture: 'x64' as const, + supervisor: 'detached-on-demand' as const, + remoteUserIdentity: `uid:${process.getuid!()}`, + draining: false + } + const readMethods = Object.fromEntries( + [ + 'workspace/validate', + 'workspace/open', + 'workspace/resume', + 'workspace/close', + 'workspace/list', + 'workspace/stat', + 'workspace/readText', + 'workspace/search', + 'git/status', + 'git/diff' + ].map((method) => [method, () => null]) + ) + const protocol = new AgentProtocolServer({ + controllers, + events, + status: () => status, + methods: readMethods + }) + const secret = Buffer.alloc(32, 7) + const endpoint = new PrivateEndpoint({ + socketPath: resolve(root, 'agent.sock'), + peerIdentity: currentPeerIdentity(), + challenge: new InstallationChallengeVerifier(secret), + controllers, + ...endpointIdentity, + onAttach: ({ socket, controller }) => { + protocol.accept(socket, controller) + } + }) + await endpoint.listen() + const input = new PassThrough() + const output = new PassThrough() + const relay = attachRelay({ + socketPath: resolve(root, 'agent.sock'), + secret, + input, + output + }) + writeStreamPacket(input, { + type: 'goodbuddy-agent-attach', + protocol: AGENT_PROTOCOL_VERSION, + goodBuddyVersion: '0.11.0', + controllerId: 'controller-main', + clientNonce: 'main-client-nonce', + hostRevision: 2, + hostKeyGeneration: 3 + }) + const welcome = attachWelcomeSchema.parse( + await readStreamPacket(output) + ) + expect(welcome).toMatchObject({ + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + installationId: endpointIdentity.installationId, + binaryDigest, + daemonBootId: endpointIdentity.daemonBootId, + generation: 1 + }) + + input.write( + controlRequest(welcome, 'status-channel', 1, 'agent/status') + ) + const statusResponse = await readFrame(output) + await readFrame(output) + expect( + daemonStatusSchema.parse(jsonRpcResult(statusResponse)) + ).toEqual(status) + + input.write( + controlRequest( + welcome, + 'capabilities-channel', + 2, + 'agent/capabilities' + ) + ) + const capabilitiesResponse = await readFrame(output) + await readFrame(output) + expect( + daemonCapabilitiesSchema.parse( + jsonRpcResult(capabilitiesResponse) + ) + ).toEqual({ + generation: welcome.generation, + capabilities: [ + { name: 'agent/control', version: 1, critical: true }, + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + }) + + input.end() + await relay + await endpoint.close() + events.close() + } + ) +}) + +describe('attach relay preface validation', () => { + it('rejects unknown Main preface fields before opening an endpoint', async () => { + const input = new PassThrough() + const output = new PassThrough() + writeStreamPacket(input, { + type: 'goodbuddy-agent-attach', + protocol: AGENT_PROTOCOL_VERSION, + goodBuddyVersion: '0.11.0', + controllerId: 'controller-main', + clientNonce: 'main-client-nonce', + hostRevision: 2, + hostKeyGeneration: 3, + installationId: 'client-claimed' + }) + await expect( + attachRelay({ + socketPath: resolve('missing-agent.sock'), + secret: Buffer.alloc(32, 7), + input, + output + }) + ).rejects.toThrow() + }) + + it('rejects malformed and oversized Main prefaces deterministically', async () => { + const malformed = new PassThrough() + writeStreamPacket(malformed, { + type: 'goodbuddy-agent-attach', + protocol: { major: 1, minor: -1 } + }) + await expect( + attachRelay({ + socketPath: resolve('missing-agent.sock'), + secret: Buffer.alloc(32, 7), + input: malformed, + output: new PassThrough() + }) + ).rejects.toThrow() + + const oversized = new PassThrough() + const length = Buffer.alloc(4) + length.writeUInt32BE(4097) + oversized.end(length) + await expect( + attachRelay({ + socketPath: resolve('missing-agent.sock'), + secret: Buffer.alloc(32, 7), + input: oversized, + output: new PassThrough() + }) + ).rejects.toThrow('oversized') + }) +}) + +function currentPeerIdentity(): { + getPeerIdentity(): Promise +} { + return { + async getPeerIdentity() { + return { uid: process.getuid!() } + } + } +} + +function privateTemporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-endpoint-')) + chmodSync(path, 0o700) + temporaryPaths.push(path) + return path +} + +async function onceConnected(socket: Socket): Promise { + await new Promise((resolveConnection, reject) => { + socket.once('connect', resolveConnection) + socket.once('error', reject) + }) +} + +async function onceClosed(socket: Socket): Promise { + await new Promise((resolveClose) => { + socket.once('close', resolveClose) + }) +} + +async function readPacket(socket: Socket): Promise> { + const length = await readExactly(socket, 4) + const contents = await readExactly(socket, length.readUInt32BE(0)) + return JSON.parse(contents.toString('utf8')) as Record +} + +async function writePacket( + socket: Socket, + value: Record +): Promise { + const contents = Buffer.from(JSON.stringify(value), 'utf8') + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + await new Promise((resolveWrite, reject) => { + socket.write(packet, (error) => { + if (error === null || error === undefined) { + resolveWrite() + } else { + reject(error) + } + }) + }) +} + +async function readExactly( + socket: NodeJS.ReadableStream & { + read(size?: number): Buffer | string | null + }, + byteLength: number +): Promise { + const chunks: Buffer[] = [] + let remaining = byteLength + while (remaining > 0) { + const chunk = socket.read(remaining) + if (chunk !== null) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + chunks.push(bytes) + remaining -= bytes.byteLength + continue + } + await new Promise((resolveRead, reject) => { + socket.once('readable', resolveRead) + socket.once('error', reject) + }) + } + return Buffer.concat(chunks, byteLength) +} + +function writeStreamPacket( + output: NodeJS.WritableStream, + value: Record +): void { + const contents = Buffer.from(JSON.stringify(value), 'utf8') + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + output.write(packet) +} + +async function readStreamPacket( + input: PassThrough +): Promise> { + const length = await readExactly(input, 4) + const contents = await readExactly(input, length.readUInt32BE(0)) + return JSON.parse(contents.toString('utf8')) as Record +} + +function controlRequest( + welcome: { + connectionId: string + generation: number + protocol: { major: number; minor: number } + }, + channelId: string, + id: number, + method: string +): Uint8Array { + const payload = Buffer.from( + JSON.stringify({ jsonrpc: '2.0', id, method, params: {} }), + 'utf8' + ) + return encodeAgentFrame({ + header: { + protocolMajor: welcome.protocol.major, + protocolMinor: welcome.protocol.minor, + connectionId: welcome.connectionId, + generation: welcome.generation, + channelId, + channelEpoch: '1', + direction: 'main-to-agent', + sequence: '1', + kind: 'control', + payloadLength: payload.byteLength + }, + payload + }) +} + +async function readFrame(input: PassThrough): Promise { + const header = await readExactly(input, 40) + const connectionLength = header.readUInt16BE(32) + const channelLength = header.readUInt16BE(34) + const payloadLength = header.readUInt32BE(36) + const remainder = await readExactly( + input, + connectionLength + channelLength + payloadLength + ) + return decodeAgentFrame(Buffer.concat([header, remainder])) +} + +function jsonRpcResult(frame: AgentFrame): unknown { + const response = JSON.parse( + Buffer.from(frame.payload).toString('utf8') + ) as { result?: unknown } + return response.result +} diff --git a/src/agent-daemon/private-endpoint.ts b/src/agent-daemon/private-endpoint.ts new file mode 100644 index 00000000..1b7d25f5 --- /dev/null +++ b/src/agent-daemon/private-endpoint.ts @@ -0,0 +1,635 @@ +import { + chmodSync, + lstatSync +} from 'node:fs' +import { randomBytes } from 'node:crypto' +import { + createConnection, + createServer, + type Server, + type Socket +} from 'node:net' +import { dirname } from 'node:path' +import { z } from 'zod' +import { + agentIdentifierSchema, + attachPrefaceSchema, + attachWelcomeSchema, + protocolVersionSchema, + sha256DigestSchema, + type AttachPreface, + type AttachWelcome +} from '../shared/agent-protocol' +import { ControllerRegistry, type ControllerLease } from './controller-registry' +import { + InstallationChallengeVerifier +} from './installation-challenge' +import { + assertAbsoluteManagedPath, + ensurePrivateDirectory +} from './managed-paths' +import { AgentUnsupportedError } from './errors' +import { closeServer, settleWithin } from './async-utils' + +const MAXIMUM_HANDSHAKE_BYTES = 4096 +const DEFAULT_MAXIMUM_CONNECTIONS = 64 +const ENDPOINT_CLOSE_TIMEOUT_MS = 2_000 + +const challengePacketSchema = z + .object({ + type: z.literal('challenge'), + version: z.literal(1), + serverNonce: agentIdentifierSchema, + expiresAt: z.number().int().safe().nonnegative() + }) + .strict() + +const challengeResponsePacketSchema = z + .object({ + type: z.literal('response'), + version: z.literal(1), + controllerId: agentIdentifierSchema, + clientNonce: agentIdentifierSchema, + protocol: protocolVersionSchema, + response: agentIdentifierSchema + }) + .strict() + +export type PeerIdentity = { + uid: number + pid?: number +} + +export interface UnixPeerIdentityProvider { + getPeerIdentity(socket: Socket): Promise +} + +export type AuthenticatedAttach = { + socket: Socket + controller: ControllerLease + peer: PeerIdentity +} + +export class PrivateEndpoint { + readonly #socketPath: string + readonly #peerIdentity: UnixPeerIdentityProvider + readonly #challenge: InstallationChallengeVerifier + readonly #controllers: ControllerRegistry + readonly #onAttach: (attach: AuthenticatedAttach) => void | Promise + readonly #installationId: string + readonly #binaryDigest: string + readonly #daemonBootId: string + readonly #protocol: AttachPreface['protocol'] + readonly #maximumConnections: number + readonly #sockets = new Set() + #server?: Server + #closing = false + #closePromise?: Promise + + constructor(options: { + socketPath: string + peerIdentity: UnixPeerIdentityProvider + challenge: InstallationChallengeVerifier + controllers: ControllerRegistry + onAttach: (attach: AuthenticatedAttach) => void | Promise + installationId: string + binaryDigest: string + daemonBootId: string + protocol: AttachPreface['protocol'] + maximumConnections?: number + }) { + this.#socketPath = assertAbsoluteManagedPath(options.socketPath) + this.#peerIdentity = options.peerIdentity + this.#challenge = options.challenge + this.#controllers = options.controllers + this.#onAttach = options.onAttach + this.#installationId = agentIdentifierSchema.parse(options.installationId) + this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest) + this.#daemonBootId = agentIdentifierSchema.parse(options.daemonBootId) + this.#protocol = protocolVersionSchema.parse(options.protocol) + this.#maximumConnections = + options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS + if ( + !Number.isSafeInteger(this.#maximumConnections) || + this.#maximumConnections < 1 || + this.#maximumConnections > 1024 + ) { + throw new RangeError('Invalid private endpoint connection limit') + } + } + + async listen(): Promise { + if (process.platform === 'win32') { + throw new AgentUnsupportedError( + 'Unix private endpoints are unavailable on Windows', + 'platform-incompatible' + ) + } + ensurePrivateDirectory(dirname(this.#socketPath), { create: false }) + if (this.#server !== undefined || this.#closing) { + throw new Error('Private endpoint is already active') + } + this.#server = createServer((socket) => { + if ( + this.#closing || + this.#sockets.size >= this.#maximumConnections + ) { + socket.destroy() + return + } + this.#sockets.add(socket) + socket.once('close', () => { + this.#sockets.delete(socket) + }) + void this.#authenticate(socket).catch(() => socket.destroy()) + }) + await new Promise((resolve, reject) => { + const server = this.#server + if (server === undefined) { + reject(new Error('Endpoint server was not created')) + return + } + server.once('error', reject) + server.listen(this.#socketPath, () => { + server.off('error', reject) + resolve() + }) + }) + chmodSync(this.#socketPath, 0o600) + const stat = lstatSync(this.#socketPath, { bigint: true }) + if (!stat.isSocket() || (stat.mode & 0o177n) !== 0n) { + await this.close() + throw new Error('Private endpoint mode verification failed') + } + const uid = process.getuid?.() + if (uid !== undefined && stat.uid !== BigInt(uid)) { + await this.close() + throw new Error('Private endpoint owner verification failed') + } + } + + async close(): Promise { + const activeClose = this.#closePromise + if (activeClose !== undefined) { + await activeClose + return + } + const closePromise = this.#closeEndpoint() + this.#closePromise = closePromise + try { + await closePromise + } finally { + if (this.#closePromise === closePromise) { + this.#closePromise = undefined + } + } + } + + async #closeEndpoint(): Promise { + const server = this.#server + this.#server = undefined + this.#closing = true + try { + const socketClosures = [...this.#sockets].map(waitForSocketClose) + for (const socket of this.#sockets) { + socket.destroy() + } + let serverCloseError: unknown + const serverClosure = + server === undefined + ? Promise.resolve() + : closeServer(server).catch((error: unknown) => { + serverCloseError = error + }) + await settleWithin( + Promise.allSettled([...socketClosures, serverClosure]), + ENDPOINT_CLOSE_TIMEOUT_MS + ) + if (serverCloseError !== undefined) { + throw serverCloseError + } + } finally { + this.#sockets.clear() + this.#closing = false + } + } + + async #authenticate(socket: Socket): Promise { + socket.pause() + socket.setTimeout(30_000, () => socket.destroy()) + const peer = await this.#peerIdentity.getPeerIdentity(socket) + this.#assertOpen(socket) + const expectedUid = process.getuid?.() + if (expectedUid === undefined || peer.uid !== expectedUid) { + throw new Error('Attach peer is not the current user') + } + const challenge = this.#challenge.issue() + this.#assertOpen(socket) + await writePacket(socket, { + type: 'challenge', + version: 1, + ...challenge + }) + const response = challengeResponsePacketSchema.parse( + await readPacket(socket) + ) + this.#assertOpen(socket) + const controllerId = response.controllerId + if ( + !this.#challenge.verify({ + serverNonce: challenge.serverNonce, + clientNonce: response.clientNonce, + controllerId, + response: response.response + }) + ) { + throw new Error('Installation challenge failed') + } + if ( + response.protocol.major !== this.#protocol.major || + response.protocol.minor < this.#protocol.minor + ) { + throw new Error('Attach protocol version is incompatible') + } + this.#assertOpen(socket) + const controller = this.#controllers.attach(controllerId) + this.#assertOpen(socket) + await writePacket(socket, attachWelcomeSchema.parse({ + type: 'goodbuddy-agent-welcome', + protocol: this.#protocol, + connectionId: controller.connectionId, + generation: controller.generation, + installationId: this.#installationId, + binaryDigest: this.#binaryDigest, + daemonBootId: this.#daemonBootId, + serverNonce: challenge.serverNonce + })) + this.#assertOpen(socket) + socket.setTimeout(0) + await this.#onAttach({ socket, controller, peer }) + this.#assertOpen(socket) + socket.resume() + } + + #assertOpen(socket: Socket): void { + if ( + this.#closing || + this.#server === undefined || + !this.#sockets.has(socket) || + socket.destroyed + ) { + throw new Error('Private endpoint is closing') + } + } +} + +export async function attachRelay(options: { + socketPath: string + secret: Uint8Array + input: NodeJS.ReadableStream + output: NodeJS.WritableStream + ensureEndpoint?: ( + reason: 'absent' | 'stale' + ) => void | Promise + onWelcome?: (welcome: AttachWelcome) => void +}): Promise { + const preface = await readRelayPreface(options.input) + if (process.platform === 'win32') { + throw new AgentUnsupportedError( + 'Unix attach is unavailable on Windows', + 'platform-incompatible' + ) + } + const socketPath = assertAbsoluteManagedPath(options.socketPath) + let socket = createConnection(socketPath) + let welcome: AttachWelcome + try { + try { + await waitForConnection(socket) + } catch (error) { + socket.destroy() + const reason = endpointAbsenceReason(error) + if (reason === undefined || options.ensureEndpoint === undefined) { + throw error + } + await options.ensureEndpoint(reason) + socket = createConnection(socketPath) + await waitForConnection(socket) + } + socket.pause() + const challengeResult = challengePacketSchema.safeParse( + await readPacket(socket) + ) + if (!challengeResult.success) { + throw new Error('Daemon returned an invalid challenge') + } + const challenge = challengeResult.data + const verifier = new InstallationChallengeVerifier(options.secret) + await writePacket(socket, { + type: 'response', + version: 1, + controllerId: preface.controllerId, + clientNonce: preface.clientNonce, + protocol: preface.protocol, + response: verifier.createResponse({ + serverNonce: challenge.serverNonce, + clientNonce: preface.clientNonce, + controllerId: preface.controllerId + }) + }) + const welcomeResult = attachWelcomeSchema.safeParse( + await readPacket(socket) + ) + if (!welcomeResult.success) { + throw new Error('Daemon rejected attach authentication') + } + welcome = welcomeResult.data + if ( + welcome.protocol.major !== preface.protocol.major || + welcome.protocol.minor > preface.protocol.minor || + welcome.serverNonce !== challenge.serverNonce + ) { + throw new Error('Daemon returned an incompatible attach welcome') + } + } catch (error) { + socket.destroy() + throw error + } + try { + await writeStreamPacket(options.output, welcome) + options.onWelcome?.(welcome) + } catch (error) { + socket.destroy() + throw error + } + socket.resume() + options.input.pipe(socket) + socket.pipe(options.output) + await new Promise((resolve, reject) => { + socket.once('close', resolve) + socket.once('error', reject) + options.input.once('error', reject) + }) +} + +export async function probeAuthenticatedEndpoint(options: { + socketPath: string + secret: Buffer + installationId: string + binaryDigest: string + protocol: AttachPreface['protocol'] +}): Promise { + if (process.platform === 'win32') { + throw new AgentUnsupportedError( + 'Unix attach is unavailable on Windows', + 'platform-incompatible' + ) + } + const socket = createConnection( + assertAbsoluteManagedPath(options.socketPath) + ) + try { + await waitForConnection(socket) + socket.pause() + const challenge = challengePacketSchema.parse(await readPacket(socket)) + const verifier = new InstallationChallengeVerifier(options.secret) + const controllerId = 'lifecycle-health' + const clientNonce = randomBytes(24).toString('base64url') + await writePacket(socket, { + type: 'response', + version: 1, + controllerId, + clientNonce, + protocol: options.protocol, + response: verifier.createResponse({ + serverNonce: challenge.serverNonce, + clientNonce, + controllerId + }) + }) + const welcome = attachWelcomeSchema.parse(await readPacket(socket)) + if ( + welcome.serverNonce !== challenge.serverNonce || + welcome.installationId !== options.installationId || + welcome.binaryDigest !== options.binaryDigest || + welcome.protocol.major !== options.protocol.major || + welcome.protocol.minor > options.protocol.minor + ) { + throw new Error( + 'Detached Agent endpoint identity does not match its installation' + ) + } + return welcome + } finally { + socket.destroy() + } +} + +function waitForConnection(socket: Socket): Promise { + return new Promise((resolve, reject) => { + const connected = (): void => { + socket.off('error', failed) + resolve() + } + const failed = (error: Error): void => { + socket.off('connect', connected) + reject(error) + } + socket.once('connect', connected) + socket.once('error', failed) + }) +} + +function endpointAbsenceReason( + error: unknown +): 'absent' | 'stale' | undefined { + if (!isNodeError(error)) { + return undefined + } + if (error.code === 'ENOENT') { + return 'absent' + } + if (error.code === 'ECONNREFUSED') { + return 'stale' + } + return undefined +} + +async function readRelayPreface( + input: NodeJS.ReadableStream +): Promise> { + const parsed = attachPrefaceSchema.parse(await readStreamPacket(input)) + return { + protocol: parsed.protocol, + controllerId: parsed.controllerId, + clientNonce: parsed.clientNonce + } +} + +async function readStreamPacket( + input: NodeJS.ReadableStream +): Promise> { + const stream = input as NodeJS.ReadableStream & { + read(size?: number): Buffer | null + } + const lengthBytes = await readExactlyFromStream(stream, 4) + const length = lengthBytes.readUInt32BE(0) + if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) { + throw new Error('Attach relay preface is oversized') + } + const contents = await readExactlyFromStream(stream, length) + const value: unknown = JSON.parse(contents.toString('utf8')) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Attach relay preface must be an object') + } + return value as Record +} + +async function writeStreamPacket( + output: NodeJS.WritableStream, + value: Record +): Promise { + const contents = Buffer.from(JSON.stringify(value), 'utf8') + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + await new Promise((resolve, reject) => { + output.write(packet, (error?: Error | null) => { + if (error === null || error === undefined) { + resolve() + } else { + reject(error) + } + }) + }) +} + +async function readPacket(socket: Socket): Promise> { + const lengthBytes = await readExactly(socket, 4) + const length = lengthBytes.readUInt32BE(0) + if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) { + throw new Error('Attach handshake packet is oversized') + } + const contents = await readExactly(socket, length) + const value: unknown = JSON.parse(contents.toString('utf8')) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Attach handshake packet must be an object') + } + return value as Record +} + +async function writePacket( + socket: Socket, + value: Record +): Promise { + const contents = Buffer.from(JSON.stringify(value), 'utf8') + if (contents.byteLength > MAXIMUM_HANDSHAKE_BYTES) { + throw new Error('Attach handshake packet is oversized') + } + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + await new Promise((resolve, reject) => { + socket.write(packet, (error) => { + if (error === null || error === undefined) { + resolve() + } else { + reject(error) + } + }) + }) +} + +async function readExactly(socket: Socket, byteLength: number): Promise { + const chunks: Buffer[] = [] + let remaining = byteLength + while (remaining > 0) { + const chunk = socket.read(remaining) as Buffer | null + if (chunk !== null) { + chunks.push(chunk) + remaining -= chunk.byteLength + continue + } + await new Promise((resolve, reject) => { + const onReadable = (): void => { + cleanup() + resolve() + } + const onEnd = (): void => { + cleanup() + reject(new Error('Attach socket closed during handshake')) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const cleanup = (): void => { + socket.off('readable', onReadable) + socket.off('end', onEnd) + socket.off('error', onError) + } + socket.once('readable', onReadable) + socket.once('end', onEnd) + socket.once('error', onError) + }) + } + return Buffer.concat(chunks, byteLength) +} + +async function readExactlyFromStream( + stream: NodeJS.ReadableStream & { + read(size?: number): Buffer | null + }, + byteLength: number +): Promise { + const chunks: Buffer[] = [] + let remaining = byteLength + while (remaining > 0) { + const chunk = stream.read(remaining) + if (chunk !== null) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + chunks.push(bytes) + remaining -= bytes.byteLength + continue + } + await new Promise((resolve, reject) => { + const onReadable = (): void => { + cleanup() + resolve() + } + const onEnd = (): void => { + cleanup() + reject(new Error('Attach relay input closed during preface')) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const cleanup = (): void => { + stream.removeListener('readable', onReadable) + stream.removeListener('end', onEnd) + stream.removeListener('error', onError) + } + stream.once('readable', onReadable) + stream.once('end', onEnd) + stream.once('error', onError) + }) + } + return Buffer.concat(chunks, byteLength) +} + +function waitForSocketClose(socket: Socket): Promise { + if (socket.closed) { + return Promise.resolve() + } + return new Promise((resolve) => { + socket.once('close', resolve) + }) +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/protocol-server.test.ts b/src/agent-daemon/protocol-server.test.ts new file mode 100644 index 00000000..9d08a57e --- /dev/null +++ b/src/agent-daemon/protocol-server.test.ts @@ -0,0 +1,970 @@ +import { EventEmitter } from 'node:events' +import { chmodSync, mkdtempSync, rmSync } from 'node:fs' +import type { Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + daemonCapabilitiesSchema, + daemonStatusSchema +} from '../shared/agent-protocol' +import { + decodeAgentFrame, + encodeAgentFrame, + type AgentFrame +} from '../shared/agent-protocol/frame' +import { ControllerRegistry, type ControllerLease } from './controller-registry' +import { EventJournal } from './event-journal' +import { AgentProtocolServer } from './protocol-server' + +const temporaryPaths: string[] = [] +const closeMethod = 'channel/close' +const daemonStatus = { + state: 'ready' as const, + installationId: 'installation-test', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-test', + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux' as const, + architecture: 'x64' as const, + supervisor: 'detached-on-demand' as const, + remoteUserIdentity: 'uid:1000', + draining: false +} + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('AgentProtocolServer connection bounds', () => { + it('accepts a frame fragmented into single-byte socket chunks', async () => { + const harness = createHarness() + const frame = controlRequest( + harness.controller, + 'fragmented', + '1', + 1, + 'agent/status' + ) + for (const byte of frame) { + harness.socket.receive(Uint8Array.of(byte)) + } + await waitFor(() => harness.socket.writes.length === 2) + expect( + jsonPayload(decodeAgentFrame(harness.socket.writes[0]!)) + ).toMatchObject({ result: { state: 'ready' } }) + harness.close() + }) + + it('reports a bounded category when binary dispatch fails', async () => { + const harness = createHarness({ + onAcpFrame: async () => { + throw Object.assign(new Error('sensitive detail'), { + code: 'process' + }) + } + }) + harness.socket.receive( + binaryFrame( + harness.controller, + 'binding-failed', + '1', + 1, + 'acp', + 'prompt' + ) + ) + await waitFor(() => harness.socket.destroyCalled) + expect(harness.failures).toEqual([ + { + connectionId: harness.controller.connectionId, + category: 'dispatch/process' + } + ]) + harness.close() + }) + + it('pauses at the item cap and dispatches frames in FIFO order', async () => { + const gate = deferred() + const calls: string[] = [] + const harness = createHarness({ + methods: { + 'test/wait': async (_params, context) => { + calls.push(context.channelId) + await gate.promise + return { done: true } + } + }, + incomingQueueLimits: { + maximumItems: 2, + maximumBytes: 4096, + maximumBufferedBytes: 4096 + } + }) + const frames = ['one', 'two', 'three'].map((channelId) => + controlRequest(harness.controller, channelId, '1', 1, 'test/wait') + ) + harness.socket.receive(Buffer.concat(frames.map(Buffer.from))) + + expect(harness.socket.paused).toBe(true) + expect(calls).toEqual(['one']) + expect(harness.socket.writes).toHaveLength(0) + + gate.resolve() + await waitFor(() => harness.socket.writes.length === 6) + expect(calls).toEqual(['one', 'two', 'three']) + expect(harness.socket.maximumConcurrentWrites).toBe(1) + expect(harness.socket.paused).toBe(false) + harness.close() + }) + + it('writes bounded blob messages directly and honors channel close', async () => { + const harness = createHarness({ authorizeBlobFrame: () => true }) + const maximum = Buffer.alloc( + AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + ) + + await harness.protocol.sendBlobFrame( + outgoingBinaryFrame( + harness.controller, + 'blob-direct', + '1', + 1, + 'blob', + maximum + ) + ) + await harness.protocol.sendBlobFrame( + outgoingBinaryFrame( + harness.controller, + 'blob-direct', + '1', + 2, + 'blob', + Buffer.of(1) + ) + ) + + expect(harness.socket.writes).toHaveLength(2) + expect(decodeAgentFrame(harness.socket.writes[0]!).payload).toHaveLength( + maximum.byteLength + ) + harness.socket.receive( + controlNotification( + harness.controller, + 'blob-direct', + '1', + 1, + closeMethod, + { channelId: 'blob-direct', channelEpoch: '1' } + ) + ) + await waitFor(() => !harness.socket.paused) + await expect( + harness.protocol.sendBlobFrame( + outgoingBinaryFrame( + harness.controller, + 'blob-direct', + '1', + 3, + 'blob', + Buffer.of(2) + ) + ) + ).rejects.toThrow(/cannot be reused/iu) + expect(harness.socket.destroyCalled).toBe(false) + harness.close() + }) + it('fails closed when a peer keeps flooding past the paused byte bound', async () => { + const gate = deferred() + const harness = createHarness({ + methods: { + 'test/wait': async () => { + await gate.promise + return null + } + }, + incomingQueueLimits: { + maximumItems: 1, + maximumBytes: 512, + maximumBufferedBytes: 512 + } + }) + harness.socket.receive( + controlRequest(harness.controller, 'one', '1', 1, 'test/wait') + ) + expect(harness.socket.paused).toBe(true) + harness.socket.receive(Buffer.alloc(1024)) + await waitFor(() => harness.socket.destroyCalled) + expect(harness.socket.writes).toHaveLength(0) + gate.resolve() + harness.close() + }) + + it('retires request channels after a response and rejects ID reuse', async () => { + const harness = createHarness() + harness.socket.receive( + controlRequest( + harness.controller, + 'request-1', + '1', + 1, + 'agent/status' + ) + ) + await waitFor(() => harness.socket.writes.length === 2) + const response = decodeAgentFrame(harness.socket.writes[0]!) + const close = decodeAgentFrame(harness.socket.writes[1]!) + expect(jsonPayload(response)).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: daemonStatus + }) + expect(jsonPayload(close)).toEqual({ + jsonrpc: '2.0', + method: closeMethod, + params: { channelId: 'request-1', channelEpoch: '1' } + }) + + harness.socket.receive( + controlRequest( + harness.controller, + 'request-1', + '2', + 1, + 'agent/status' + ) + ) + await waitFor(() => harness.socket.destroyCalled) + harness.close() + }) + + it('accepts an authenticated explicit close and prevents queued follow-up work', async () => { + const calls: string[] = [] + const harness = createHarness({ + methods: { + 'test/record': () => { + calls.push('called') + return null + } + } + }) + const close = controlNotification( + harness.controller, + 'stream-1', + '7', + 1, + closeMethod, + { channelId: 'stream-1', channelEpoch: '7' } + ) + const followUp = controlNotification( + harness.controller, + 'stream-1', + '7', + 2, + 'test/record', + {} + ) + harness.socket.receive(Buffer.concat([Buffer.from(close), Buffer.from(followUp)])) + await waitFor(() => harness.socket.destroyCalled) + expect(calls).toEqual([]) + harness.close() + }) + + it('fails closed on malformed envelopes and malformed close controls', async () => { + const malformedHarness = createHarness() + const malformed = Buffer.from( + controlRequest( + malformedHarness.controller, + 'bad-frame', + '1', + 1, + 'agent/status' + ) + ) + malformed[0] = 0 + malformedHarness.socket.receive(malformed) + await waitFor(() => malformedHarness.socket.destroyCalled) + expect(malformedHarness.socket.writes).toHaveLength(0) + malformedHarness.close() + + const closeHarness = createHarness() + closeHarness.socket.receive( + controlNotification( + closeHarness.controller, + 'bad-close', + '1', + 1, + closeMethod, + { channelId: 'another-channel', channelEpoch: '1' } + ) + ) + await waitFor(() => closeHarness.socket.destroyCalled) + expect(closeHarness.socket.writes).toHaveLength(0) + closeHarness.close() + }) + + it('preserves stable typed service error codes', async () => { + const harness = createHarness({ + methods: { + 'test/typed-error': () => { + throw Object.assign(new Error('Read-only service'), { + code: 'read-only' + }) + } + } + }) + harness.socket.receive( + controlRequest( + harness.controller, + 'typed-error', + '1', + 1, + 'test/typed-error' + ) + ) + await waitFor(() => harness.socket.writes.length === 2) + expect(jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))).toEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: 'Read-only service', + data: { code: 'read-only' } + } + }) + harness.close() + }) + + it('returns strict status, doctor, and exact implemented capabilities', async () => { + const methods = Object.fromEntries( + [ + 'workspace/validate', + 'workspace/open', + 'workspace/resume', + 'workspace/close', + 'workspace/list', + 'workspace/stat', + 'workspace/readText', + 'workspace/search', + 'git/status', + 'git/diff' + ].map((method) => [method, () => null]) + ) + const harness = createHarness({ methods }) + for (const [index, method] of [ + 'agent/status', + 'agent/doctor', + 'agent/capabilities' + ].entries()) { + harness.socket.receive( + controlRequest( + harness.controller, + `strict-${index}`, + '1', + index + 1, + method + ) + ) + await waitFor(() => harness.socket.writes.length === (index + 1) * 2) + const response = decodeAgentFrame( + harness.socket.writes[index * 2]! + ) + const result = ( + jsonPayload(response) as { result: unknown } + ).result + if (method === 'agent/capabilities') { + expect(daemonCapabilitiesSchema.parse(result)).toEqual({ + generation: harness.controller.capabilityGeneration, + capabilities: [ + { name: 'agent/control', version: 1, critical: true }, + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + }) + } else { + expect(daemonStatusSchema.parse(result)).toEqual(daemonStatus) + } + } + harness.close() + }) + + it('does not advertise workspace writes, runtime, ACP, or partial reads', async () => { + const harness = createHarness({ + methods: { + 'workspace/readText': () => null, + 'workspace/writeTextAtomic': () => null, + 'runtime/openAcpChannel': () => null, + 'runtime/closeAcpChannel': () => null + } + }) + harness.socket.receive( + controlRequest( + harness.controller, + 'capabilities', + '1', + 1, + 'agent/capabilities' + ) + ) + await waitFor(() => harness.socket.writes.length === 2) + const result = ( + jsonPayload( + decodeAgentFrame(harness.socket.writes[0]!) + ) as { result: unknown } + ).result + expect(daemonCapabilitiesSchema.parse(result)).toEqual({ + generation: harness.controller.capabilityGeneration, + capabilities: [ + { name: 'agent/control', version: 1, critical: true } + ], + runtimes: [] + }) + harness.close() + }) + + it('advertises only a verified Runtime with ACP control and data handlers', async () => { + const runtime = { + runtimeId: 'opencode', + version: '1.18.9', + bundleDigest: `sha256:${'b'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`, + sessionLoad: false, + sessionResume: false + } + const harness = createHarness({ + methods: { + 'runtime/openAcpChannel': () => null, + 'runtime/closeAcpChannel': () => null, + 'runtime/resumeAcpChannel': () => null, + 'runtime/replayAcpChannel': () => null, + 'runtime/preparePrompt': () => null, + 'runtime/completePrompt': () => null, + 'runtime/getAcpCursors': () => null, + 'runtime/escalateCancellation': () => null, + 'runtime/reconcilePrompt': () => null + }, + onAcpFrame: async () => {}, + runtimes: async () => [runtime] + }) + harness.socket.receive( + controlRequest( + harness.controller, + 'runtime-capabilities', + '1', + 1, + 'agent/capabilities' + ) + ) + await waitFor(() => harness.socket.writes.length === 2) + const result = ( + jsonPayload( + decodeAgentFrame(harness.socket.writes[0]!) + ) as { result: unknown } + ).result + expect(daemonCapabilitiesSchema.parse(result)).toEqual({ + generation: harness.controller.capabilityGeneration, + capabilities: [ + { name: 'agent/control', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true } + ], + runtimes: [runtime] + }) + harness.close() + }) + + it('advertises the critical model bridge only with blob composition', async () => { + const runtime = { + runtimeId: 'opencode', + version: '1.18.9', + bundleDigest: `sha256:${'b'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`, + sessionLoad: false, + sessionResume: false + } + const methods = Object.fromEntries( + [ + 'runtime/openAcpChannel', + 'runtime/closeAcpChannel', + 'runtime/resumeAcpChannel', + 'runtime/replayAcpChannel', + 'runtime/preparePrompt', + 'runtime/completePrompt', + 'runtime/getAcpCursors', + 'runtime/escalateCancellation', + 'runtime/reconcilePrompt' + ].map((method) => [method, () => null]) + ) + const harness = createHarness({ + methods, + onAcpFrame: async () => {}, + onBlobFrame: async () => {}, + authorizeBlobFrame: () => true, + runtimes: async () => [runtime] + }) + harness.socket.receive( + controlRequest( + harness.controller, + 'bridge-capabilities', + '1', + 1, + 'agent/capabilities' + ) + ) + await waitFor(() => harness.socket.writes.length === 2) + const result = ( + jsonPayload( + decodeAgentFrame(harness.socket.writes[0]!) + ) as { result: unknown } + ).result + expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({ + capabilities: [ + { name: 'agent/control', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true }, + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } + ] + }) + harness.close() + }) + + it('locks channel kinds and rejects ACP/blob cross-injection', async () => { + let handled = false + const harness = createHarness({ + onBlobFrame: async () => {}, + methods: { + 'test/notify': async () => { + handled = true + } + } + }) + harness.socket.receive( + controlNotification( + harness.controller, + 'locked-channel', + '5', + 1, + 'test/notify', + {} + ) + ) + await waitFor(() => handled) + harness.socket.receive( + binaryFrame( + harness.controller, + 'locked-channel', + '5', + 2, + 'blob', + 'injected' + ) + ) + await waitFor(() => harness.socket.destroyCalled) + harness.close() + }) + + it('rejects Agent-first blob output without exact prompt authority', async () => { + const harness = createHarness({ + onBlobFrame: async () => {} + }) + const payload = Buffer.from('request') + await expect( + harness.protocol.sendBlobFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: harness.controller.connectionId, + generation: harness.controller.generation, + channelId: 'unknown-blob-output', + channelEpoch: '3', + direction: 'agent-to-main', + sequence: '1', + kind: 'blob', + payloadLength: payload.byteLength + }, + payload + }) + ).rejects.toThrow(/active prompt authority/iu) + expect(harness.socket.writes).toHaveLength(0) + harness.close() + }) + + it('routes bounded blob frames without adding them to ACP journals', async () => { + const received: AgentFrame[] = [] + const harness = createHarness({ + onBlobFrame: async (frame) => { + received.push(frame) + } + }) + harness.socket.receive( + binaryFrame( + harness.controller, + 'blob-channel', + '11', + 1, + 'blob', + 'chunk' + ) + ) + await waitFor(() => received.length === 1) + expect(received).toHaveLength(1) + expect(harness.socket.writes).toHaveLength(0) + expect(harness.socket.destroyCalled).toBe(false) + harness.close() + }) + + it('rejects non-empty status and capability params', async () => { + const harness = createHarness() + harness.socket.receive( + controlFrame(harness.controller, 'bad-params', '1', 1, { + jsonrpc: '2.0', + id: 1, + method: 'agent/status', + params: { unexpected: true } + }) + ) + await waitFor(() => harness.socket.writes.length === 2) + expect( + jsonPayload(decodeAgentFrame(harness.socket.writes[0]!)) + ).toMatchObject({ + error: { code: -32602, message: 'Method params must be an empty object' } + }) + harness.close() + }) + + it('resumes with the current connection and capability generations', async () => { + const harness = createHarness({ reconnect: true }) + const previous = harness.previousController! + harness.socket.receive( + controlFrame(harness.controller, 'resume', '1', 1, { + jsonrpc: '2.0', + id: 1, + method: 'controller/resume', + params: { + previousGeneration: previous.generation, + previousConnectionId: previous.connectionId, + daemonBootId: daemonStatus.daemonBootId, + capabilityGeneration: previous.capabilityGeneration + } + }) + ) + await waitFor(() => harness.socket.writes.length === 2) + const result = ( + jsonPayload( + decodeAgentFrame(harness.socket.writes[0]!) + ) as { result: Record } + ).result + expect(result).toMatchObject({ + resumed: true, + generation: harness.controller.generation, + daemonBootId: daemonStatus.daemonBootId, + capabilityGeneration: + harness.controller.capabilityGeneration + }) + expect(result.leaseDeadlineAt).toEqual(expect.any(String)) + harness.close() + }) + + it.each([ + ['previous connection', { previousConnectionId: 'connection-wrong' }], + ['capability generation', { capabilityGeneration: 2 }], + ['daemon boot', { daemonBootId: 'boot-wrong' }] + ])('rejects takeover with mismatched %s', async (_label, override) => { + const harness = createHarness({ reconnect: true }) + const previous = harness.previousController! + harness.socket.receive( + controlFrame(harness.controller, 'resume-mismatch', '1', 1, { + jsonrpc: '2.0', + id: 1, + method: 'controller/resume', + params: { + previousGeneration: previous.generation, + previousConnectionId: previous.connectionId, + daemonBootId: daemonStatus.daemonBootId, + capabilityGeneration: previous.capabilityGeneration, + ...override + } + }) + ) + await waitFor(() => harness.socket.writes.length === 2) + expect( + ( + jsonPayload( + decodeAgentFrame(harness.socket.writes[0]!) + ) as { result: { resumed: boolean } } + ).result.resumed + ).toBe(false) + harness.close() + }) +}) + +class FakeSocket extends EventEmitter { + writes: Buffer[] = [] + paused = false + destroyCalled = false + maximumConcurrentWrites = 0 + #concurrentWrites = 0 + + pause(): this { + this.paused = true + return this + } + + resume(): this { + this.paused = false + return this + } + + write( + data: Uint8Array, + callback?: (error?: Error | null) => void + ): boolean { + this.#concurrentWrites += 1 + this.maximumConcurrentWrites = Math.max( + this.maximumConcurrentWrites, + this.#concurrentWrites + ) + this.writes.push(Buffer.from(data)) + queueMicrotask(() => { + this.#concurrentWrites -= 1 + callback?.() + }) + return true + } + + destroy(): this { + if (!this.destroyCalled) { + this.destroyCalled = true + queueMicrotask(() => this.emit('close')) + } + return this + } + + receive(data: Uint8Array): void { + this.emit('data', Buffer.from(data)) + } +} + +type Harness = { + socket: FakeSocket + protocol: AgentProtocolServer + controller: ControllerLease + previousController?: ControllerLease + failures: Array<{ + connectionId: string + category: string + }> + close: () => void +} + +function createHarness( + options: { + methods?: ConstructorParameters[0]['methods'] + incomingQueueLimits?: ConstructorParameters< + typeof AgentProtocolServer + >[0]['incomingQueueLimits'] + runtimes?: ConstructorParameters< + typeof AgentProtocolServer + >[0]['runtimes'] + onAcpFrame?: ConstructorParameters< + typeof AgentProtocolServer + >[0]['onAcpFrame'] + onBlobFrame?: ConstructorParameters< + typeof AgentProtocolServer + >[0]['onBlobFrame'] + authorizeBlobFrame?: ConstructorParameters< + typeof AgentProtocolServer + >[0]['authorizeBlobFrame'] + reconnect?: boolean + } = {} +): Harness { + const root = temporaryDirectory() + const controllers = new ControllerRegistry() + const previousController = options.reconnect + ? controllers.attach('controller-test') + : undefined + if (previousController !== undefined) { + controllers.disconnect( + previousController.controllerId, + previousController.generation + ) + } + const controller = controllers.attach('controller-test') + const events = new EventJournal(resolve(root, 'events.sqlite')) + const failures: Harness['failures'] = [] + const protocol = new AgentProtocolServer({ + controllers, + events, + status: () => daemonStatus, + runtimes: options.runtimes, + methods: options.methods, + onAcpFrame: options.onAcpFrame, + onBlobFrame: options.onBlobFrame, + authorizeBlobFrame: options.authorizeBlobFrame, + onProtocolFailure: (failure) => { + failures.push(failure) + }, + incomingQueueLimits: options.incomingQueueLimits + }) + const socket = new FakeSocket() + protocol.accept(socket as unknown as Socket, controller) + return { + socket, + protocol, + controller, + failures, + ...(previousController === undefined ? {} : { previousController }), + close: () => { + socket.destroy() + events.close() + } + } +} + +function binaryFrame( + controller: ControllerLease, + channelId: string, + channelEpoch: string, + sequence: number, + kind: 'acp' | 'blob', + value: string +): Uint8Array { + const payload = Buffer.from(value) + return encodeAgentFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: controller.connectionId, + generation: controller.generation, + channelId, + channelEpoch, + direction: 'main-to-agent', + sequence: String(sequence), + kind, + payloadLength: payload.byteLength + }, + payload + }) +} + +function outgoingBinaryFrame( + controller: ControllerLease, + channelId: string, + channelEpoch: string, + sequence: number, + kind: 'acp' | 'blob', + payload: Uint8Array +): AgentFrame { + return { + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: controller.connectionId, + generation: controller.generation, + channelId, + channelEpoch, + direction: 'agent-to-main', + sequence: String(sequence), + kind, + payloadLength: payload.byteLength + }, + payload + } +} + +function controlRequest( + controller: ControllerLease, + channelId: string, + channelEpoch: string, + id: number, + method: string +): Uint8Array { + return controlFrame(controller, channelId, channelEpoch, 1, { + jsonrpc: '2.0', + id, + method, + params: {} + }) +} + +function controlNotification( + controller: ControllerLease, + channelId: string, + channelEpoch: string, + sequence: number, + method: string, + params: Record +): Uint8Array { + return controlFrame(controller, channelId, channelEpoch, sequence, { + jsonrpc: '2.0', + method, + params + }) +} + +function controlFrame( + controller: ControllerLease, + channelId: string, + channelEpoch: string, + sequence: number, + value: Record +): Uint8Array { + const payload = Buffer.from(JSON.stringify(value), 'utf8') + return encodeAgentFrame({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: controller.connectionId, + generation: controller.generation, + channelId, + channelEpoch, + direction: 'main-to-agent', + sequence: sequence.toString(), + kind: 'control', + payloadLength: payload.byteLength + }, + payload + }) +} + +function jsonPayload(frame: AgentFrame): unknown { + return JSON.parse(Buffer.from(frame.payload).toString('utf8')) +} + +function deferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void +} { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000 + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for protocol state') + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1)) + } +} + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-protocol-')) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return path +} diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts new file mode 100644 index 00000000..2d6e5e37 --- /dev/null +++ b/src/agent-daemon/protocol-server.ts @@ -0,0 +1,1118 @@ +import type { Socket } from 'node:net' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + agentProtocolFailureCategorySchema, + agentIdentifierSchema, + agentSequenceSchema, + channelCloseRequestSchema, + controllerResumeRequestSchema, + controllerResumeResultSchema, + daemonCapabilitiesSchema, + daemonStatusSchema, + frameAcknowledgmentSchema, + jsonRpcNotificationSchema, + jsonRpcRequestSchema, + positiveAgentSequenceSchema, + type DaemonCapabilities, + type AgentProtocolFailureCategory, + type FrameAcknowledgment, + type JsonRpcNotification, + type JsonRpcRequest +} from '../shared/agent-protocol' +import { + AGENT_FRAME_FIXED_HEADER_BYTES, + MAXIMUM_ENCODED_AGENT_FRAME_BYTES, + decodeAgentFrame, + encodeAgentFrame, + inspectAgentFramePrefix, + type AgentFrame +} from '../shared/agent-protocol/frame' +import { + AgentConnectionChannels, + type AgentChannelState +} from '../shared/agent-protocol/channel-state' +import type { ControllerLease, ControllerRegistry } from './controller-registry' +import type { EventJournal } from './event-journal' + +type IncomingQueueLimits = { + maximumItems: number + maximumBytes: number + maximumBufferedBytes: number +} + +type AdmittedFrame = { + frame: AgentFrame + channel: AgentChannelState + encodedByteLength: number + request?: JsonRpcRequest | JsonRpcNotification + acknowledgment?: FrameAcknowledgment +} + +const DEFAULT_INCOMING_QUEUE_LIMITS: IncomingQueueLimits = { + maximumItems: AGENT_PROTOCOL_LIMITS.maximumChannelsPerConnection * 4, + maximumBytes: AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes, + maximumBufferedBytes: MAXIMUM_ENCODED_AGENT_FRAME_BYTES +} +const CHANNEL_CLOSE_METHOD = 'channel/close' +const WORKSPACE_READ_METHODS = [ + 'workspace/validate', + 'workspace/open', + 'workspace/resume', + 'workspace/close', + 'workspace/list', + 'workspace/stat', + 'workspace/readText', + 'workspace/search', + 'git/status', + 'git/diff' +] as const +const RUNTIME_ACP_METHODS = [ + 'runtime/openAcpChannel', + 'runtime/closeAcpChannel', + 'runtime/resumeAcpChannel', + 'runtime/replayAcpChannel', + 'runtime/preparePrompt', + 'runtime/completePrompt', + 'runtime/getAcpCursors', + 'runtime/escalateCancellation', + 'runtime/reconcilePrompt' +] as const + +export type ProtocolMethodContext = { + controller: ControllerLease + channelId: string + signal?: AbortSignal +} + +export type ProtocolMethodHandler = ( + params: unknown, + context: ProtocolMethodContext +) => unknown | Promise + +export class AgentProtocolServer { + readonly #controllers: ControllerRegistry + readonly #events: EventJournal + readonly #status: () => unknown | Promise + readonly #runtimes: () => + | DaemonCapabilities['runtimes'] + | Promise + readonly #methods: Readonly> + readonly #onAcpFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + readonly #onBlobFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + readonly #authorizeBlobFrame?: ( + frame: AgentFrame, + controller: ControllerLease + ) => boolean + readonly #incomingQueueLimits: IncomingQueueLimits + readonly #onProtocolFailure?: (input: { + connectionId: string + category: AgentProtocolFailureCategory + }) => void + readonly #connections = new Map() + + constructor(options: { + controllers: ControllerRegistry + events: EventJournal + status: () => unknown | Promise + runtimes?: () => + | DaemonCapabilities['runtimes'] + | Promise + methods?: Readonly> + onAcpFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + onBlobFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + authorizeBlobFrame?: ( + frame: AgentFrame, + controller: ControllerLease + ) => boolean + onProtocolFailure?: (input: { + connectionId: string + category: AgentProtocolFailureCategory + }) => void + incomingQueueLimits?: Partial + }) { + this.#controllers = options.controllers + this.#events = options.events + this.#status = options.status + this.#runtimes = options.runtimes ?? (() => []) + this.#methods = options.methods ?? {} + this.#onAcpFrame = options.onAcpFrame + this.#onBlobFrame = options.onBlobFrame + this.#authorizeBlobFrame = options.authorizeBlobFrame + this.#onProtocolFailure = options.onProtocolFailure + this.#incomingQueueLimits = incomingQueueLimits( + options.incomingQueueLimits + ) + } + + accept(socket: Socket, controller: ControllerLease): void { + const connection = new ProtocolConnection({ + socket, + controller, + controllers: this.#controllers, + methods: { + 'agent/status': async (params) => { + emptyParams(params) + return daemonStatusSchema.parse(await this.#status()) + }, + 'agent/doctor': async (params) => { + emptyParams(params) + return daemonStatusSchema.parse(await this.#status()) + }, + 'agent/capabilities': async (params) => { + emptyParams(params) + const current = this.#controllers.assertCurrent( + controller.controllerId, + controller.generation + ) + const runtimeAcpImplemented = + this.#onAcpFrame !== undefined && + RUNTIME_ACP_METHODS.every( + (method) => this.#methods[method] !== undefined + ) + const modelBridgeImplemented = + runtimeAcpImplemented && + this.#onBlobFrame !== undefined && + this.#authorizeBlobFrame !== undefined + const runtimes = runtimeAcpImplemented + ? await this.#runtimes() + : [] + return daemonCapabilitiesSchema.parse({ + generation: current.capabilityGeneration, + capabilities: [ + { name: 'agent/control', version: 1, critical: true }, + ...(WORKSPACE_READ_METHODS.every( + (method) => this.#methods[method] !== undefined + ) + ? [ + { + name: 'workspace/read', + version: 1, + critical: true + } + ] + : []), + ...(runtimes.length > 0 + ? [ + { + name: 'runtime/acp', + version: 3, + critical: true + }, + ...(modelBridgeImplemented + ? [ + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } + ] + : []) + ] + : []) + ], + runtimes + }) + }, + 'controller/resume': async (params) => { + const request = controllerResumeRequestSchema.parse(params) + const status = daemonStatusSchema.parse(await this.#status()) + if ( + request.daemonBootId !== status.daemonBootId || + request.previousGeneration >= controller.generation + ) { + return controllerResumeResultSchema.parse({ + resumed: false, + generation: controller.generation, + daemonBootId: status.daemonBootId, + capabilityGeneration: controller.capabilityGeneration, + leaseDeadlineAt: new Date( + controller.leaseExpiresAt + ).toISOString() + }) + } + let resumed: ControllerLease + try { + resumed = this.#controllers.takeover( + controller.controllerId, + controller.generation, + request + ) + } catch { + return controllerResumeResultSchema.parse({ + resumed: false, + generation: controller.generation, + daemonBootId: status.daemonBootId, + capabilityGeneration: controller.capabilityGeneration, + leaseDeadlineAt: new Date( + controller.leaseExpiresAt + ).toISOString() + }) + } + this.#connections + .get(request.previousConnectionId) + ?.fence() + return controllerResumeResultSchema.parse({ + resumed: true, + generation: resumed.generation, + daemonBootId: status.daemonBootId, + capabilityGeneration: resumed.capabilityGeneration, + leaseDeadlineAt: new Date( + resumed.leaseExpiresAt + ).toISOString() + }) + }, + ...this.#methods + }, + events: this.#events, + onAcpFrame: this.#onAcpFrame, + onBlobFrame: this.#onBlobFrame, + incomingQueueLimits: this.#incomingQueueLimits, + onClose: (category) => { + if (category !== undefined) { + this.#onProtocolFailure?.({ + connectionId: controller.connectionId, + category + }) + } + if ( + this.#connections.get(controller.connectionId) === connection + ) { + this.#connections.delete(controller.connectionId) + } + } + }) + this.#connections.set(controller.connectionId, connection) + connection.start() + } + + async sendAcpFrame(frame: AgentFrame): Promise { + const connection = this.#connections.get( + frame.header.connectionId + ) + if (connection === undefined) { + throw new Error('ACP controller connection is unavailable') + } + this.#controllers.assertCurrent( + connection.controller.controllerId, + connection.controller.generation + ) + await connection.sendAcpFrame(frame) + } + + async sendBlobFrame(frame: AgentFrame): Promise { + const connection = this.#connections.get( + frame.header.connectionId + ) + if (connection === undefined) { + throw new Error('Blob controller connection is unavailable') + } + this.#controllers.assertCurrent( + connection.controller.controllerId, + connection.controller.generation + ) + if ( + this.#authorizeBlobFrame === undefined || + !this.#authorizeBlobFrame(frame, connection.controller) + ) { + throw new Error( + 'Outgoing blob channel has no active prompt authority' + ) + } + await connection.sendBlobFrame(frame) + } +} + +class ProtocolConnection { + readonly #socket: Socket + readonly #controller: ControllerLease + readonly #controllers: ControllerRegistry + readonly #methods: Readonly> + readonly #events: EventJournal + readonly #onAcpFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + readonly #onBlobFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + readonly #channels: AgentConnectionChannels + readonly #incomingQueueLimits: IncomingQueueLimits + readonly #onClose: ( + category?: AgentProtocolFailureCategory + ) => void + readonly #channelKinds = new Map< + string, + 'control' | 'acp' | 'blob' + >() + #inputChunks: Buffer[] = [] + #inputHeadOffset = 0 + #bufferedInputBytes = 0 + #queue: AdmittedFrame[] = [] + #queuedBytes = 0 + #draining = false + #closed = false + #writeTail: Promise = Promise.resolve() + readonly #abortController = new AbortController() + #takeoverProven = false + + get controller(): ControllerLease { + return this.#controller + } + + constructor(options: { + socket: Socket + controller: ControllerLease + controllers: ControllerRegistry + methods: Readonly> + events: EventJournal + onAcpFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + onBlobFrame?: ( + frame: AgentFrame, + context: ProtocolMethodContext + ) => void | Promise + incomingQueueLimits: IncomingQueueLimits + onClose: ( + category?: AgentProtocolFailureCategory + ) => void + }) { + this.#socket = options.socket + this.#controller = options.controller + this.#controllers = options.controllers + this.#methods = options.methods + this.#events = options.events + this.#onAcpFrame = options.onAcpFrame + this.#onBlobFrame = options.onBlobFrame + this.#incomingQueueLimits = options.incomingQueueLimits + this.#onClose = options.onClose + this.#channels = new AgentConnectionChannels({ + connectionId: options.controller.connectionId, + generation: options.controller.generation + }) + } + + start(): void { + this.#socket.on('data', (chunk: Buffer) => { + if (this.#closed) { + return + } + this.#socket.pause() + if ( + this.#queuedBytes + + this.#bufferedInputBytes + + chunk.byteLength > + this.#incomingQueueLimits.maximumBytes + + this.#incomingQueueLimits.maximumBufferedBytes + ) { + this.#close('input-overflow') + return + } + if (chunk.byteLength > 0) { + this.#inputChunks.push(chunk) + this.#bufferedInputBytes += chunk.byteLength + } + try { + this.#pumpInput() + } catch (error) { + this.#close(protocolFailureCategory('input', error)) + } + }) + this.#socket.once('error', () => this.#close('socket-error')) + this.#socket.once('close', () => this.#close()) + } + + fence(): void { + this.#close() + } + + async sendAcpFrame(frame: AgentFrame): Promise { + if ( + frame.header.kind !== 'acp' || + frame.header.direction !== 'agent-to-main' || + frame.header.connectionId !== this.#controller.connectionId || + frame.header.generation !== this.#controller.generation + ) { + throw new Error('Outgoing ACP frame identity is invalid') + } + const channel = this.#channels.get(frame.header.channelId) + if ( + channel === undefined || + channel.channelEpoch !== frame.header.channelEpoch + ) { + throw new Error('Outgoing ACP channel is unavailable') + } + this.#assertOrBindChannelKind(channel.channelId, 'acp') + const header = channel.reserveOutbound({ + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.#controller.connectionId, + generation: this.#controller.generation, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch, + direction: 'agent-to-main', + kind: 'acp', + payloadLength: frame.payload.byteLength + }) + if (header.sequence !== frame.header.sequence) { + throw new Error('Outgoing ACP sequence does not match its journal') + } + await this.#writeEncoded({ header, payload: frame.payload }) + } + + async sendBlobFrame(frame: AgentFrame): Promise { + if ( + frame.header.kind !== 'blob' || + frame.header.direction !== 'agent-to-main' || + frame.header.connectionId !== this.#controller.connectionId || + frame.header.generation !== this.#controller.generation + ) { + throw new Error('Outgoing blob frame identity is invalid') + } + let channel = this.#channels.get(frame.header.channelId) + if (channel === undefined) { + channel = this.#channels.open({ + channelId: frame.header.channelId, + channelEpoch: frame.header.channelEpoch, + inboundDirection: 'main-to-agent' + }) + } + if (channel.channelEpoch !== frame.header.channelEpoch) { + throw new Error('Outgoing blob channel is unavailable') + } + this.#assertOrBindChannelKind(channel.channelId, 'blob') + const header = channel.reserveOutbound({ + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.#controller.connectionId, + generation: this.#controller.generation, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch, + direction: 'agent-to-main', + kind: 'blob', + payloadLength: frame.payload.byteLength + }) + if (header.sequence !== frame.header.sequence) { + throw new Error('Outgoing blob sequence does not match its channel') + } + await this.#writeEncoded({ header, payload: frame.payload }) + } + + #pumpInput(): void { + while ( + !this.#closed && + this.#bufferedInputBytes >= AGENT_FRAME_FIXED_HEADER_BYTES + ) { + const prefix = inspectAgentFramePrefix( + this.#peekInput(AGENT_FRAME_FIXED_HEADER_BYTES) + ) + const frameLength = prefix.encodedByteLength + if (frameLength > MAXIMUM_ENCODED_AGENT_FRAME_BYTES) { + throw new Error('Invalid incoming frame length') + } + if (this.#bufferedInputBytes < frameLength) { + break + } + if (frameLength > this.#incomingQueueLimits.maximumBytes) { + throw new Error('Incoming frame exceeds queue byte limit') + } + if ( + this.#queue.length >= this.#incomingQueueLimits.maximumItems || + this.#queuedBytes + frameLength > + this.#incomingQueueLimits.maximumBytes + ) { + this.#startDrain() + return + } + const encoded = this.#takeInput(frameLength) + const admitted = this.#admitFrame(encoded, frameLength) + this.#queue.push(admitted) + this.#queuedBytes += frameLength + } + this.#startDrain() + if ( + !this.#closed && + !this.#draining && + this.#queue.length < this.#incomingQueueLimits.maximumItems && + this.#queuedBytes < this.#incomingQueueLimits.maximumBytes + ) { + this.#socket.resume() + } + } + + #peekInput(byteLength: number): Buffer { + const first = this.#inputChunks[0] + if (first === undefined || byteLength > this.#bufferedInputBytes) { + throw new Error('Incoming frame is incomplete') + } + const available = first.byteLength - this.#inputHeadOffset + if (available >= byteLength) { + return first.subarray( + this.#inputHeadOffset, + this.#inputHeadOffset + byteLength + ) + } + const result = Buffer.allocUnsafe(byteLength) + let written = 0 + let index = 0 + let offset = this.#inputHeadOffset + while (written < byteLength) { + const chunk = this.#inputChunks[index] + if (chunk === undefined) { + throw new Error('Incoming frame is incomplete') + } + const copied = Math.min( + chunk.byteLength - offset, + byteLength - written + ) + chunk.copy(result, written, offset, offset + copied) + written += copied + index += 1 + offset = 0 + } + return result + } + + #takeInput(byteLength: number): Buffer { + const first = this.#inputChunks[0] + if (first === undefined || byteLength > this.#bufferedInputBytes) { + throw new Error('Incoming frame is incomplete') + } + const available = first.byteLength - this.#inputHeadOffset + let result: Buffer + if (available >= byteLength) { + result = first.subarray( + this.#inputHeadOffset, + this.#inputHeadOffset + byteLength + ) + } else { + result = this.#peekInput(byteLength) + } + let remaining = byteLength + while (remaining > 0) { + const chunk = this.#inputChunks[0]! + const inChunk = chunk.byteLength - this.#inputHeadOffset + if (remaining < inChunk) { + this.#inputHeadOffset += remaining + remaining = 0 + } else { + remaining -= inChunk + this.#inputChunks.shift() + this.#inputHeadOffset = 0 + } + } + this.#bufferedInputBytes -= byteLength + return result + } + + #admitFrame( + encoded: Uint8Array, + encodedByteLength: number + ): AdmittedFrame { + this.#controllers.assertCurrent( + this.#controller.controllerId, + this.#controller.generation + ) + const frame = decodeAgentFrame(encoded, { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + maximumProtocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.#controller.connectionId, + generation: this.#controller.generation + }) + let channel = this.#channels.get(frame.header.channelId) + if (channel === undefined) { + channel = this.#channels.open({ + channelId: frame.header.channelId, + channelEpoch: frame.header.channelEpoch, + inboundDirection: 'main-to-agent' + }) + } + channel.acceptInbound(frame.header) + if (frame.header.kind === 'ack') { + const acknowledgment = frameAcknowledgmentSchema.parse( + JSON.parse(Buffer.from(frame.payload).toString('utf8')) + ) + return { + frame, + channel, + encodedByteLength, + acknowledgment + } + } + if (frame.header.kind === 'control') { + const request = parseJsonRpc(frame) + if (request.method !== CHANNEL_CLOSE_METHOD) { + this.#assertOrBindChannelKind(channel.channelId, 'control') + } else if (!this.#channelKinds.has(channel.channelId)) { + this.#channelKinds.set(channel.channelId, 'control') + } + if ('id' in request || request.method === CHANNEL_CLOSE_METHOD) { + channel.beginClose() + } + return { frame, channel, encodedByteLength, request } + } + if (frame.header.kind === 'acp' && this.#onAcpFrame !== undefined) { + this.#assertOrBindChannelKind(channel.channelId, 'acp') + return { frame, channel, encodedByteLength } + } + if (frame.header.kind === 'blob' && this.#onBlobFrame !== undefined) { + this.#assertOrBindChannelKind(channel.channelId, 'blob') + return { frame, channel, encodedByteLength } + } + throw new Error('No handler is registered for this channel kind') + } + + #startDrain(): void { + if (this.#draining || this.#closed || this.#queue.length === 0) { + return + } + this.#draining = true + void this.#drain() + } + + async #drain(): Promise { + try { + while (!this.#closed) { + const admitted = this.#queue.shift() + if (admitted === undefined) { + break + } + this.#queuedBytes -= admitted.encodedByteLength + await this.#dispatchFrame(admitted) + } + } catch (error) { + this.#close(protocolFailureCategory('dispatch', error)) + } finally { + this.#draining = false + if (!this.#closed) { + try { + this.#pumpInput() + } catch (error) { + this.#close(protocolFailureCategory('input', error)) + } + } + } + } + + #acceptFrameAcknowledgment(admitted: AdmittedFrame): void { + this.#controllers.assertCurrent( + this.#controller.controllerId, + this.#controller.generation + ) + const { channel, acknowledgment } = admitted + if ( + acknowledgment === undefined || + this.#channelKinds.get(channel.channelId) !== 'acp' + ) { + throw new Error('Frame ACK does not match an ACP channel') + } + this.#events.acknowledgeAcpFromMain({ + bindingId: channel.channelId, + channelEpoch: channel.channelEpoch, + sequence: acknowledgment.acknowledgedSequence + }) + } + + async #dispatchFrame(admitted: AdmittedFrame): Promise { + this.#controllers.assertCurrent( + this.#controller.controllerId, + this.#controller.generation + ) + const { frame, channel } = admitted + if (frame.header.kind === 'ack') { + this.#acceptFrameAcknowledgment(admitted) + return + } + if (frame.header.kind === 'blob') { + if (this.#onBlobFrame === undefined) { + throw new Error('No blob Runtime channel is bound') + } + await this.#onBlobFrame(frame, { + controller: this.#controller, + channelId: channel.channelId, + signal: this.#abortController.signal + }) + return + } + if (frame.header.kind === 'acp') { + if (this.#onAcpFrame === undefined) { + throw new Error('No ACP runtime channel is bound') + } + this.#events.appendAcpFrame({ + controllerId: this.#controller.controllerId, + bindingId: frame.header.channelId, + channelEpoch: frame.header.channelEpoch, + direction: 'main-to-runtime', + sequence: frame.header.sequence, + payload: frame.payload + }) + await this.#onAcpFrame(frame, { + controller: this.#controller, + channelId: channel.channelId, + signal: this.#abortController.signal + }) + this.#events.markAcpDelivered({ + bindingId: frame.header.channelId, + channelEpoch: frame.header.channelEpoch, + sequence: frame.header.sequence + }) + return + } + if (frame.header.kind !== 'control') { + throw new Error('No handler is registered for this channel kind') + } + const request = admitted.request + if (request === undefined) { + throw new Error('Control request was not admitted') + } + if (request.method === CHANNEL_CLOSE_METHOD) { + await this.#acceptChannelClose(request, channel) + return + } + if (!('id' in request)) { + await this.#invokeNotification(request, channel) + } else { + await this.#invokeRequest(request, channel) + await this.#sendChannelClose(channel) + this.#channels.close(channel.channelId) + this.#channelKinds.delete(channel.channelId) + return + } + } + + async #acceptChannelClose( + request: JsonRpcRequest | JsonRpcNotification, + channel: AgentChannelState + ): Promise { + const params = channelCloseParams(request.params) + if ( + params.channelId !== channel.channelId || + params.channelEpoch !== channel.channelEpoch + ) { + throw new Error('Channel close target does not match its frame') + } + if ('id' in request) { + await this.#sendJson(channel, { + jsonrpc: '2.0', + id: request.id, + result: { closed: true } + }) + } + this.#channels.close(channel.channelId) + this.#channelKinds.delete(channel.channelId) + } + + async #invokeRequest( + request: JsonRpcRequest, + channel: AgentChannelState + ): Promise { + const handler = this.#methods[request.method] + if (handler === undefined) { + await this.#sendJson(channel, { + jsonrpc: '2.0', + id: request.id, + error: { code: -32601, message: 'Method not found' } + }) + return + } + try { + if ( + (request.method === 'runtime/resumeAcpChannel' || + request.method === 'runtime/replayAcpChannel') && + !this.#takeoverProven + ) { + throw new Error('Controller takeover has not been proven') + } + const result = await handler(request.params, { + controller: this.#controller, + channelId: channel.channelId, + signal: this.#abortController.signal + }) + if (request.method === 'runtime/resumeAcpChannel') { + this.#bindResumedAcpChannel(result) + } + if ( + request.method === 'controller/resume' && + result !== null && + typeof result === 'object' && + !Array.isArray(result) && + (result as { resumed?: unknown }).resumed === true + ) { + this.#takeoverProven = true + } + await this.#sendJson(channel, { + jsonrpc: '2.0', + id: request.id, + result: result ?? null + }) + } catch (error) { + const serviceCode = typedErrorCode(error) + await this.#sendJson(channel, { + jsonrpc: '2.0', + id: request.id, + error: { + code: serviceCode === undefined ? -32602 : -32000, + message: error instanceof Error ? error.message : 'Invalid request', + ...(serviceCode === undefined + ? {} + : { data: { code: serviceCode } }) + } + }) + } + } + + #bindResumedAcpChannel(result: unknown): void { + if ( + result === null || + typeof result !== 'object' || + Array.isArray(result) + ) { + throw new Error('Runtime resume result is invalid') + } + const value = result as Record + const bindingId = agentIdentifierSchema.parse(value.bindingId) + const channelId = agentIdentifierSchema.parse(value.channelId) + const channelEpoch = positiveAgentSequenceSchema.parse( + value.channelEpoch + ) + if (bindingId !== channelId) { + throw new Error('Runtime resume channel identity is invalid') + } + const cursors = + value.cursors === null || + typeof value.cursors !== 'object' || + Array.isArray(value.cursors) + ? undefined + : (value.cursors as Record) + if (cursors === undefined) { + throw new Error('Runtime resume cursors are invalid') + } + const nextInboundSequence = ( + BigInt(agentSequenceSchema.parse(cursors.lastOutboundJournaledSequence)) + + 1n + ).toString() + const nextOutboundSequence = ( + BigInt(agentSequenceSchema.parse(cursors.lastMainAckSequence)) + 1n + ).toString() + const existing = this.#channels.get(channelId) + if (existing !== undefined) { + if (existing.channelEpoch !== channelEpoch) { + throw new Error('Runtime resume channel epoch conflicts') + } + return + } + this.#channels.open({ + channelId, + channelEpoch, + inboundDirection: 'main-to-agent', + nextInboundSequence, + nextOutboundSequence + }) + this.#assertOrBindChannelKind(channelId, 'acp') + } + + async #invokeNotification( + request: JsonRpcNotification, + channel: AgentChannelState + ): Promise { + const handler = this.#methods[request.method] + if (handler !== undefined) { + await handler(request.params, { + controller: this.#controller, + channelId: channel.channelId, + signal: this.#abortController.signal + }) + } + } + + async #sendJson( + channel: AgentChannelState, + value: Record + ): Promise { + await this.#send( + channel, + 'control', + Buffer.from(JSON.stringify(value), 'utf8') + ) + } + + async #sendChannelClose(channel: AgentChannelState): Promise { + await this.#sendJson(channel, { + jsonrpc: '2.0', + method: CHANNEL_CLOSE_METHOD, + params: { + channelId: channel.channelId, + channelEpoch: channel.channelEpoch + } + }) + } + + async #send( + channel: AgentChannelState, + kind: 'control' | 'ack', + payload: Uint8Array + ): Promise { + const header = channel.reserveOutbound({ + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.#controller.connectionId, + generation: this.#controller.generation, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch, + direction: 'agent-to-main', + kind, + payloadLength: payload.byteLength + }) + await this.#writeEncoded({ header, payload }) + } + + async #writeEncoded(frame: AgentFrame): Promise { + const encoded = encodeAgentFrame(frame) + const write = this.#writeTail.then( + async () => + await new Promise((resolve, reject) => { + this.#socket.write(encoded, (error) => { + if (error === null || error === undefined) { + resolve() + } else { + reject(error) + } + }) + }) + ) + this.#writeTail = write + await write + } + + #close(category?: AgentProtocolFailureCategory): void { + if (this.#closed) { + return + } + this.#closed = true + this.#abortController.abort( + new Error('Protocol connection closed') + ) + this.#queue = [] + this.#queuedBytes = 0 + this.#inputChunks = [] + this.#inputHeadOffset = 0 + this.#bufferedInputBytes = 0 + this.#channelKinds.clear() + try { + this.#controllers.disconnect( + this.#controller.controllerId, + this.#controller.generation + ) + } catch { + // A newer generation already superseded this connection. + } + this.#onClose(category) + this.#socket.destroy() + } + + #assertOrBindChannelKind( + channelId: string, + kind: 'control' | 'acp' | 'blob' + ): void { + const existing = this.#channelKinds.get(channelId) + if (existing !== undefined && existing !== kind) { + throw new Error( + `Channel kind cannot change from ${existing} to ${kind}` + ) + } + if (existing === undefined) { + this.#channelKinds.set(channelId, kind) + } + } +} + +function protocolFailureCategory( + stage: string, + error: unknown +): AgentProtocolFailureCategory { + const code = typedErrorCode(error) + return agentProtocolFailureCategorySchema.parse( + code === undefined ? stage : `${stage}/${code}` + ) +} + +function typedErrorCode(error: unknown): string | undefined { + if ( + error === null || + typeof error !== 'object' || + !('code' in error) || + typeof error.code !== 'string' || + !/^[a-z][a-z0-9-]{0,63}$/u.test(error.code) + ) { + return undefined + } + return error.code +} + +function parseJsonRpc( + frame: AgentFrame +): JsonRpcRequest | JsonRpcNotification { + const value: unknown = JSON.parse(Buffer.from(frame.payload).toString('utf8')) + const request = jsonRpcRequestSchema.safeParse(value) + if (request.success) { + return request.data + } + return jsonRpcNotificationSchema.parse(value) +} + +function objectParams(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Method params must be an object') + } + return value as Record +} + +function emptyParams(value: unknown): void { + const params = objectParams(value) + if (Object.keys(params).length !== 0) { + throw new Error('Method params must be an empty object') + } +} + +function channelCloseParams(value: unknown): { + channelId: string + channelEpoch: string +} { + return channelCloseRequestSchema.parse(value) +} + +function incomingQueueLimits( + input: Partial | undefined +): IncomingQueueLimits { + const limits = { + ...DEFAULT_INCOMING_QUEUE_LIMITS, + ...input + } + for (const [name, value] of Object.entries(limits)) { + const maximum = + name === 'maximumItems' + ? DEFAULT_INCOMING_QUEUE_LIMITS.maximumItems + : name === 'maximumBytes' + ? DEFAULT_INCOMING_QUEUE_LIMITS.maximumBytes + : DEFAULT_INCOMING_QUEUE_LIMITS.maximumBufferedBytes + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new RangeError(`Invalid incoming queue ${name}`) + } + } + return limits +} diff --git a/src/agent-daemon/queue-and-channel.test.ts b/src/agent-daemon/queue-and-channel.test.ts new file mode 100644 index 00000000..ac6c14be --- /dev/null +++ b/src/agent-daemon/queue-and-channel.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + AgentChannelState, + AgentConnectionChannels, + ChannelProtocolError +} from '../shared/agent-protocol/channel-state' +import { + AGENT_PROTOCOL_VERSION, + type AgentFrameHeader +} from '../shared/agent-protocol/contracts' + +const base = { + connectionId: 'connection-1', + generation: 1, + channelId: 'channel-1', + channelEpoch: '1', + inboundDirection: 'main-to-agent' as const +} + +function inbound( + overrides: Partial = {} +): AgentFrameHeader { + return { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: base.connectionId, + generation: base.generation, + channelId: base.channelId, + channelEpoch: base.channelEpoch, + direction: 'main-to-agent', + sequence: '1', + kind: 'acp', + payloadLength: 1, + ...overrides + } +} + +describe('AgentChannelState', () => { + it('enforces identity, generation, direction, and contiguous sequence', () => { + const channel = new AgentChannelState(base) + expect(() => channel.acceptInbound(inbound())).not.toThrow() + expect(() => + channel.acceptInbound(inbound({ sequence: '2' })) + ).not.toThrow() + + const stale = new AgentChannelState(base) + expect(() => + stale.acceptInbound(inbound({ generation: 2 })) + ).toThrowError(ChannelProtocolError) + expect(stale.closed).toBe(true) + + const wrongDirection = new AgentChannelState(base) + expect(() => + wrongDirection.acceptInbound( + inbound({ direction: 'agent-to-main' }) + ) + ).toThrowError(ChannelProtocolError) + + const gap = new AgentChannelState(base) + expect(() => + gap.acceptInbound(inbound({ sequence: '2' })) + ).toThrowError(ChannelProtocolError) + }) + + it('sequences durable ACKs independently from data', () => { + const channel = new AgentChannelState(base) + channel.acceptInbound(inbound({ kind: 'acp', sequence: '1' })) + channel.acceptInbound(inbound({ kind: 'ack', sequence: '1' })) + channel.acceptInbound(inbound({ kind: 'acp', sequence: '2' })) + + const reserve = (kind: AgentFrameHeader['kind']) => + channel.reserveOutbound({ + ...inbound({ kind }), + direction: 'agent-to-main' + }) + expect(reserve('acp').sequence).toBe('1') + expect(reserve('ack').sequence).toBe('1') + expect(reserve('acp').sequence).toBe('2') + }) + + it('rejects inbound data after close begins', () => { + const channel = new AgentChannelState(base) + channel.beginClose() + expect(() => channel.acceptInbound(inbound())).toThrowError( + ChannelProtocolError + ) + }) +}) + +describe('AgentConnectionChannels', () => { + it('bounds active and lifetime channels without reusing IDs', () => { + const connection = new AgentConnectionChannels({ + connectionId: 'connection-1', + generation: 1, + maximumChannels: 2, + maximumLifetimeChannels: 3 + }) + connection.open({ + channelId: 'one', + channelEpoch: '1', + inboundDirection: 'main-to-agent' + }) + connection.open({ + channelId: 'two', + channelEpoch: '1', + inboundDirection: 'main-to-agent' + }) + expect(() => + connection.open({ + channelId: 'blocked', + channelEpoch: '1', + inboundDirection: 'main-to-agent' + }) + ).toThrowError(ChannelProtocolError) + + connection.close('one') + expect(() => + connection.open({ + channelId: 'one', + channelEpoch: '2', + inboundDirection: 'main-to-agent' + }) + ).toThrowError(ChannelProtocolError) + connection.open({ + channelId: 'three', + channelEpoch: '1', + inboundDirection: 'main-to-agent' + }) + connection.close('two') + expect(() => + connection.open({ + channelId: 'four', + channelEpoch: '1', + inboundDirection: 'main-to-agent' + }) + ).toThrowError(ChannelProtocolError) + }) +}) \ No newline at end of file diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts new file mode 100644 index 00000000..86910a24 --- /dev/null +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -0,0 +1,1111 @@ +import { describe, expect, it, vi } from 'vitest' +import { + type AcpJournalCursor, + type RemotePromptOperationPreparation +} from '../shared/remote-agent-contracts' +import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' +import type { ControllerLease } from './controller-registry' +import type { ProtocolMethodContext } from './protocol-server' +import type { + ModelBridgeBrokerDispatch, + ModelBridgeBrokerServer +} from './model-bridge-broker' +import { + RuntimeAcpBackend, + type RuntimeAcpJournal, + type RuntimeAcpProcessIdentity, + type RuntimeAcpProcessOutput, + type RuntimeAcpProcessOwner, + type RuntimeAcpProcessReconciliation, + type RuntimeAcpResolvedBundle, + type RuntimeAcpVerifiedBundle +} from './runtime-acp-backend' + +const digest = (character: string): string => + `sha256:${character.repeat(64)}` + +class MemoryAcpJournal implements RuntimeAcpJournal { + readonly frames: Array<{ + controllerId: string + bindingId: string + channelEpoch: string + direction: 'main-to-runtime' | 'runtime-to-main' + sequence: string + payload: Uint8Array + }> = [] + readonly delivered: string[] = [] + readonly retired: Array<{ + controllerId: string + bindingId: string + channelEpoch: string + }> = [] + mainAckSequence = '0' + failAppend = false + + appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } { + if (this.failAppend) { + throw new Error('journal capacity') + } + const existing = this.frames.find( + (frame) => + frame.bindingId === input.bindingId && + frame.channelEpoch === input.channelEpoch && + frame.direction === input.direction && + frame.sequence === input.sequence + ) + if (existing !== undefined) { + if ( + Buffer.compare( + Buffer.from(existing.payload), + Buffer.from(input.payload) + ) !== 0 + ) { + throw new Error('sequence conflict') + } + return { created: false } + } + const latest = this.frames + .filter( + (frame) => + frame.bindingId === input.bindingId && + frame.channelEpoch === input.channelEpoch && + frame.direction === input.direction + ) + .at(-1) + if (BigInt(input.sequence) !== BigInt(latest?.sequence ?? '0') + 1n) { + throw new Error('non-contiguous sequence') + } + this.frames.push({ ...input, payload: input.payload.slice() }) + return { created: true } + } + + markAcpDelivered(input: { + bindingId: string + channelEpoch: string + sequence: string + }): void { + this.delivered.push(input.sequence) + } + + acknowledgeAcpFromMain(input: { sequence: string }): void { + this.mainAckSequence = input.sequence + } + + retireAcpBinding(input: { + controllerId: string + bindingId: string + channelEpoch: string + }): void { + this.retired.push(input) + } + + replayAcpFrames(input: { + bindingId: string + channelEpoch: string + direction: 'runtime-to-main' + afterSequence: string + limit?: number + }) { + return this.frames + .filter( + (frame) => + frame.bindingId === input.bindingId && + frame.channelEpoch === input.channelEpoch && + frame.direction === input.direction && + BigInt(frame.sequence) > BigInt(input.afterSequence) + ) + .slice(0, input.limit) + .map((frame) => ({ + sequence: frame.sequence, + payload: frame.payload.slice(), + createdAt: 1 + })) + } + + getAcpCursor( + bindingId: string, + channelEpoch: string, + direction: 'main-to-runtime' | 'runtime-to-main' + ): AcpJournalCursor | undefined { + const frames = this.frames.filter( + (frame) => + frame.bindingId === bindingId && + frame.channelEpoch === channelEpoch && + frame.direction === direction + ) + if (frames.length === 0) { + return undefined + } + const journaledSequence = frames.at(-1)!.sequence + return { + bindingId, + channelEpoch, + direction, + journaledSequence, + deliveredSequence: + direction === 'main-to-runtime' + ? this.delivered.at(-1) ?? '0' + : '0', + mainAckSequence: + direction === 'runtime-to-main' + ? this.mainAckSequence + : '0' + } + } +} + +class FakeProcess implements RuntimeAcpProcessOwner { + readonly identity: RuntimeAcpProcessIdentity = { + launchId: 'launch-1', + processId: 'process-1', + supervisorIdentityDigest: digest('9') + } + readonly writes: Uint8Array[] = [] + readonly stops: string[] = [] + readonly prompts: Array<{ + deadlineAt: string + maximumInputBytes: number + }> = [] + completions = 0 + reconciliation: RuntimeAcpProcessReconciliation = { + identity: this.identity, + state: 'running', + processTree: 'running' + } + #listener?: ( + output: RuntimeAcpProcessOutput + ) => void | Promise + + beginPrompt(input: { + deadlineAt: string + maximumInputBytes: number + }): void { + this.prompts.push(input) + this.reconciliation = { + identity: this.identity, + state: 'running', + processTree: 'running' + } + } + + completePrompt(): void { + this.completions += 1 + } + + writeStdin(payload: Uint8Array): void { + this.writes.push(payload.slice()) + } + + stop(input: { reason: string }): void { + this.stops.push(input.reason) + this.reconciliation = { + identity: this.identity, + state: 'cancelled', + processTree: 'empty' + } + } + + reconcile(): RuntimeAcpProcessReconciliation { + return this.reconciliation + } + + subscribeOutput( + listener: ( + output: RuntimeAcpProcessOutput + ) => void | Promise + ): () => void { + this.#listener = listener + return () => { + this.#listener = undefined + } + } + + async emit( + stream: 'stdout' | 'stderr', + value: string + ): Promise { + await this.#listener?.({ + stream, + data: Buffer.from(value) + }) + } +} + +type Harness = ReturnType + +function harness(input: { + workMode?: 'ask' | 'execute' + now?: number + maximumOutputBytes?: number + bridgeCloseError?: boolean +} = {}) { + const workMode = input.workMode ?? 'ask' + let now = input.now ?? 1_000 + const journal = new MemoryAcpJournal() + const process = new FakeProcess() + const outputFrames: string[] = [] + const launches: Array<{ + workMode: 'ask' | 'execute' + scratch: string + }> = [] + const lifecycle: string[] = [] + const bridgeCloses = vi.fn(async () => { + if (input.bridgeCloseError) { + throw new Error('broker close failed') + } + }) + const blobSink = vi.fn(async () => undefined) + let bridgeDispatch: ModelBridgeBrokerDispatch | undefined + const launchModelBridges: unknown[] = [] + const resolved = resolvedBundle() + const backend = new RuntimeAcpBackend({ + journal, + resolveRuntimeBundle: vi.fn(async () => resolved), + reverifyRuntimeBundle: vi.fn(async () => verifiedBundle()), + resolveWorkspace: vi.fn(async (preparation) => ({ + workspaceIdentity: preparation.workspaceIdentity, + workspaceDirectory: '/workspace', + scratchDirectory: '/scratch', + bridgeDirectory: '/bridge' + })), + launchProcess: vi.fn(async (launch) => { + lifecycle.push('launch') + launchModelBridges.push(launch.modelBridge) + launches.push({ + workMode: launch.workMode, + scratch: launch.scratch + }) + return process + }), + now: () => now, + outputSink: vi.fn(async (frame) => { + outputFrames.push(Buffer.from(frame.payload).toString()) + }), + blobSink, + createModelBridgeBroker: ({ dispatch }) => { + bridgeDispatch = dispatch + return { + socketPath: '/bridge/model-bridge.sock', + listen: vi.fn(async () => { + lifecycle.push('bridge-listen') + }), + close: bridgeCloses + } as unknown as ModelBridgeBrokerServer + } + }) + const context = protocolContext() + const openRequest = { + bindingId: 'binding-1', + runtimeId: 'opencode', + runtimeBundleDigest: digest('1'), + workspaceIdentity: 'workspace-identity-1' + } + + return { + backend, + context, + journal, + process, + launches, + lifecycle, + bridgeCloses, + blobSink, + get bridgeDispatch() { + return bridgeDispatch + }, + launchModelBridges, + outputFrames, + openRequest, + preparation: ( + overrides: Partial = {} + ): RemotePromptOperationPreparation => ({ + bindingId: openRequest.bindingId, + operationId: 'request-1', + requestId: 'request-1', + workMode, + controllerId: context.controller.controllerId, + controllerGeneration: context.controller.generation, + connectionGeneration: context.controller.generation, + channelEpoch: '1000', + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + workspaceIdentity: openRequest.workspaceIdentity, + agentInstallationId: 'installation-1', + runtimeId: openRequest.runtimeId, + runtimeBundleDigest: openRequest.runtimeBundleDigest, + runtimeAdapterDigest: digest('2'), + deadlineAt: new Date(now + 60_000).toISOString(), + budget: { + maximumInputBytes: 1_024, + maximumOutputBytes: input.maximumOutputBytes ?? 1_024 + }, + ...overrides + }), + setNow(value: number): void { + now = value + } + } +} + +describe('RuntimeAcpBackend', () => { + it('rejects Agent-first unknown blob channels before prompt authority exists', async () => { + const fixture = harness() + await expect( + fixture.backend.onBlobFrame( + { + header: { + protocolMajor: 1, + protocolMinor: 0, + connectionId: fixture.context.controller.connectionId, + generation: fixture.context.controller.generation, + channelId: 'unknown-model-channel', + channelEpoch: '1', + direction: 'main-to-agent', + sequence: '1', + kind: 'blob', + payloadLength: 1 + }, + payload: Uint8Array.of(1) + }, + { + ...fixture.context, + channelId: 'unknown-model-channel' + } + ) + ).rejects.toMatchObject({ code: 'identity' }) + }) + + it('starts the exact prepared model bridge before launch and revokes it on completion', async () => { + const fixture = harness() + await invoke( + fixture, + 'runtime/openAcpChannel', + fixture.openRequest + ) + const modelBridge = { + version: 'goodbuddy-model-bridge-v1' as const, + channelId: 'model-channel-1', + channelEpoch: '20', + policy: { + protocol: 'openai-responses' as const, + model: 'private-model', + modelProfileDigest: digest('8'), + supportsImageInput: false + } + } + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ modelBridge }) + ) + expect(fixture.lifecycle).toEqual([ + 'bridge-listen', + 'launch' + ]) + expect(fixture.launchModelBridges).toEqual([ + { + socketPath: '/bridge/model-bridge.sock', + policy: modelBridge.policy + } + ]) + expect(fixture.bridgeDispatch).toEqual(expect.any(Function)) + + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', + operationId: 'request-1', + requestId: 'request-1' + }) + expect(fixture.bridgeCloses).toHaveBeenCalledOnce() + }) + + it('runs open, prepare, ACP delivery, durable output, cursors, and close', async () => { + const fixture = harness() + const opened = await invoke( + fixture, + 'runtime/openAcpChannel', + fixture.openRequest + ) + expect(opened).toMatchObject({ + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000' + }) + + const acceptance = await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + expect(acceptance).toMatchObject({ + bindingId: 'binding-1', + operationId: 'request-1', + workMode: 'ask', + requestId: 'request-1' + }) + expect(fixture.launches).toEqual([ + { workMode: 'ask', scratch: '/scratch' } + ]) + + fixture.journal.appendAcpFrame({ + controllerId: fixture.context.controller.controllerId, + bindingId: 'binding-1', + channelEpoch: '1000', + direction: 'main-to-runtime', + sequence: '1', + payload: Buffer.from('input') + }) + await fixture.backend.onAcpFrame( + acpFrame(fixture.context.controller, 'input'), + { ...fixture.context, channelId: 'binding-1' } + ) + expect( + Buffer.from(fixture.process.writes[0]!).toString() + ).toBe('input') + expect(fixture.journal.delivered).toEqual([]) + fixture.journal.markAcpDelivered({ + bindingId: 'binding-1', + channelEpoch: '1000', + sequence: '1' + }) + expect(fixture.journal.delivered).toEqual(['1']) + + await fixture.process.emit('stderr', 'private diagnostic') + await fixture.process.emit('stdout', 'output') + expect(fixture.outputFrames).toEqual(['output']) + expect( + fixture.journal.frames.filter( + (frame) => frame.direction === 'runtime-to-main' + ) + ).toHaveLength(1) + + const cursors = await invoke( + fixture, + 'runtime/getAcpCursors', + { bindingId: 'binding-1' } + ) + expect(cursors).toEqual({ + lastOutboundJournaledSequence: '1', + lastOutboundDeliveredSequence: '1', + lastInboundJournaledSequence: '1', + lastMainAckSequence: '0' + }) + + await expect( + invoke(fixture, 'runtime/closeAcpChannel', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000', + reason: 'released' + }) + ).resolves.toEqual({ + bindingId: 'binding-1', + channelEpoch: '1000', + closed: true + }) + expect(fixture.process.stops).toEqual(['binding-closed']) + expect(fixture.journal.retired).toEqual([ + { + controllerId: 'controller-1', + bindingId: 'binding-1', + channelEpoch: '1000' + } + ]) + }) + + it.each(['ask', 'execute'] as const)( + 'accepts %s as the complete prompt authorization', + async (workMode) => { + const fixture = harness({ workMode }) + await open(fixture) + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + ).resolves.toMatchObject({ workMode }) + } + ) + + it('is idempotent for stable identities and rejects conflicting payloads', async () => { + const fixture = harness() + const firstOpen = await open(fixture) + await expect(open(fixture)).resolves.toEqual(firstOpen) + const first = await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + ).resolves.toEqual(first) + expect(fixture.launches).toHaveLength(1) + + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + deadlineAt: new Date(62_000).toISOString() + }) + ) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + invoke(fixture, 'runtime/openAcpChannel', { + ...fixture.openRequest, + workspaceIdentity: 'workspace-other' + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('completes two prompts idempotently while keeping one process alive', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + const completion = { + bindingId: 'binding-1', + operationId: 'request-1', + requestId: 'request-1' + } + const first = await invoke( + fixture, + 'runtime/completePrompt', + completion + ) + await expect( + invoke(fixture, 'runtime/completePrompt', completion) + ).resolves.toEqual(first) + await expect( + invoke(fixture, 'runtime/reconcilePrompt', completion) + ).resolves.toEqual({ + status: 'terminal', + terminalState: 'completed', + processTree: 'running' + }) + + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + operationId: 'request-2', + requestId: 'request-2', + budget: { + maximumInputBytes: 2_048, + maximumOutputBytes: 1_024 + } + }) + ) + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', + operationId: 'request-2', + requestId: 'request-2' + }) + + expect(fixture.launches).toHaveLength(1) + expect(fixture.process.prompts).toHaveLength(1) + expect(fixture.process.prompts[0]!.maximumInputBytes).toBe(2_048) + expect(fixture.process.completions).toBe(2) + expect(fixture.process.stops).toEqual([]) + }) + + it('rejects completion conflicts and unsafe stable reopen', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await expect( + invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', + operationId: 'request-other', + requestId: 'request-other' + }) + ).rejects.toMatchObject({ code: 'identity' }) + + 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) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await invoke(fixture, 'runtime/closeAcpChannel', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000', + reason: 'released' + }) + const reopened = await fixture.backend.methods[ + 'runtime/openAcpChannel' + ]!( + fixture.openRequest, + protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + ) + expect(reopened).toMatchObject({ + bindingId: 'binding-1', + channelEpoch: '1001' + }) + }) + + it('rejects cross-controller access and expired preparations', async () => { + const fixture = harness() + await open(fixture) + await expect( + fixture.backend.methods['runtime/getAcpCursors']!( + { bindingId: 'binding-1' }, + protocolContext({ + controllerId: 'controller-other', + connectionId: 'connection-other' + }) + ) + ).rejects.toMatchObject({ code: 'stale-controller' }) + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + deadlineAt: new Date(1_000).toISOString() + }) + ) + ).rejects.toMatchObject({ code: 'deadline' }) + }) + + it('stops the process when its output quota is exceeded', async () => { + const fixture = harness({ maximumOutputBytes: 3 }) + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await expect( + fixture.process.emit('stdout', 'four') + ).rejects.toMatchObject({ code: 'output-quota' }) + expect(fixture.process.stops).toEqual(['output-quota']) + expect(fixture.outputFrames).toEqual([]) + }) + + it('stops with output quota when the detached durable journal is exhausted', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + fixture.journal.failAppend = true + await expect( + fixture.process.emit('stdout', 'offline') + ).rejects.toMatchObject({ code: 'output-quota' }) + expect(fixture.process.stops).toEqual(['output-quota']) + expect(fixture.outputFrames).toEqual([]) + }) + + it('escalates cancellation but detaches without stopping on disconnect', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + await expect( + invoke(fixture, 'runtime/escalateCancellation', { + bindingId: 'binding-1', + sessionId: 'session-1', + operationId: 'request-1', + requestId: 'request-1', + reason: 'requested' + }) + ).resolves.toEqual({ bindingId: 'binding-1', stopped: true }) + expect(fixture.process.stops).toEqual(['user-cancelled']) + + const disconnected = harness() + await open(disconnected) + await invoke( + disconnected, + 'runtime/preparePrompt', + disconnected.preparation() + ) + disconnected.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + expect(disconnected.process.stops).toEqual([]) + await disconnected.process.emit('stdout', 'offline') + expect(disconnected.outputFrames).toEqual([]) + expect( + disconnected.journal.frames.filter( + (frame) => frame.direction === 'runtime-to-main' + ).map((frame) => Buffer.from(frame.payload).toString()) + ).toEqual(['offline']) + }) + + it('closes an idle model bridge on detach without poisoning or stopping Runtime work', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + modelBridge: { + version: 'goodbuddy-model-bridge-v1', + channelId: 'model-channel-idle', + channelEpoch: '20', + policy: { + protocol: 'openai-responses', + model: 'private-model', + modelProfileDigest: digest('8'), + supportsImageInput: false + } + } + }) + ) + + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + expect(fixture.bridgeCloses).toHaveBeenCalledOnce() + expect(fixture.process.stops).toEqual([]) + await fixture.process.emit('stdout', 'idle-bridge-offline') + expect(fixture.outputFrames).toEqual([]) + + const resumedContext = protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + await expect( + fixture.backend.methods['runtime/resumeAcpChannel']!( + { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000' + }, + resumedContext + ) + ).resolves.toMatchObject({ + bindingId: 'binding-1', + cursors: { lastInboundJournaledSequence: '1' } + }) + }) + + it('poisons an in-flight Provider exchange on detach without stopping Runtime work', async () => { + const fixture = harness({ bridgeCloseError: true }) + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + modelBridge: { + version: 'goodbuddy-model-bridge-v1', + channelId: 'model-channel-active', + channelEpoch: '21', + policy: { + protocol: 'openai-responses', + model: 'private-model', + modelProfileDigest: digest('8'), + supportsImageInput: false + } + } + }) + ) + const providerExchange = fixture.bridgeDispatch!( + { + method: 'POST', + path: '/v1/responses', + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'private-model' }) + ).toString('base64') + }, + { + requestId: 'provider-request-1', + signal: new AbortController().signal + } + ) + await vi.waitFor(() => + expect(fixture.blobSink).toHaveBeenCalledOnce() + ) + + fixture.context.abort.abort() + await expect(providerExchange).rejects.toMatchObject({ + code: 'cancelled' + }) + expect(fixture.process.stops).toEqual([]) + await fixture.process.emit('stdout', 'poisoned-bridge-offline') + expect( + fixture.journal.frames + .filter((frame) => frame.direction === 'runtime-to-main') + .map((frame) => Buffer.from(frame.payload).toString()) + ).toEqual(['poisoned-bridge-offline']) + + await expect( + fixture.backend.methods['runtime/resumeAcpChannel']!( + { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000' + }, + protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + ) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + it('resumes the exact detached binding and replays ACK-exclusive output before live output', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + fixture.context.abort.abort() + await new Promise((resolve) => setImmediate(resolve)) + await fixture.process.emit('stdout', 'offline-1') + await fixture.process.emit('stdout', 'offline-2') + expect(fixture.outputFrames).toEqual([]) + + const resumedContext = protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + await expect( + fixture.backend.methods['runtime/resumeAcpChannel']!( + { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000' + }, + resumedContext + ) + ).resolves.toMatchObject({ + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000', + cursors: { + lastInboundJournaledSequence: '2', + lastMainAckSequence: '0' + } + }) + await expect( + fixture.backend.methods['runtime/replayAcpChannel']!( + { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '1000', + acknowledgedSequence: '1' + }, + resumedContext + ) + ).resolves.toMatchObject({ + replayedThroughSequence: '2', + live: true + }) + await fixture.process.emit('stdout', 'live-3') + expect(fixture.outputFrames).toEqual(['offline-2', 'live-3']) + + await expect( + fixture.backend.methods['runtime/getAcpCursors']!( + { bindingId: 'binding-1' }, + fixture.context + ) + ).rejects.toMatchObject({ code: 'stale-controller' }) + }) + + it('fails closed on supervised process identity conflict', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + fixture.process.reconciliation = { + identity: { + ...fixture.process.identity, + processId: 'different-process' + }, + state: 'running', + processTree: 'running' + } + await expect( + invoke(fixture, 'runtime/reconcilePrompt', { + bindingId: 'binding-1', + operationId: 'request-1', + requestId: 'request-1' + }) + ).rejects.toMatchObject({ code: 'identity' }) + expect(fixture.process.stops).toEqual(['identity-conflict']) + }) + + it('boundedly stops and reconciles every owned process on disposal', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + + await expect(fixture.backend.dispose(100)).resolves.toBeUndefined() + expect(fixture.process.stops).toEqual(['binding-closed']) + await expect( + invoke( + fixture, + 'runtime/openAcpChannel', + fixture.openRequest + ) + ).rejects.toMatchObject({ code: 'closed' }) + }) + + it('rejects a process identity reused by another binding', async () => { + const fixture = harness() + await open(fixture) + await invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + const secondOpen = { + ...fixture.openRequest, + bindingId: 'binding-2', + workspaceIdentity: 'workspace-identity-2' + } + await invoke( + fixture, + 'runtime/openAcpChannel', + secondOpen + ) + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation({ + bindingId: 'binding-2', + operationId: 'request-2', + requestId: 'request-2', + channelEpoch: '1001', + workspaceIdentity: 'workspace-identity-2' + }) + ) + ).rejects.toMatchObject({ code: 'identity' }) + expect(fixture.process.stops).toEqual(['identity-conflict']) + }) +}) + +async function open(fixture: Harness): Promise> { + return (await invoke( + fixture, + 'runtime/openAcpChannel', + fixture.openRequest + )) as Record +} + +async function invoke( + fixture: Harness, + method: string, + params: unknown +): Promise { + return await fixture.backend.methods[method]!(params, fixture.context) +} + +function protocolContext( + overrides: Partial = {} +): ProtocolMethodContext & { abort: AbortController } { + const abort = new AbortController() + return { + controller: { + controllerId: 'controller-1', + connectionId: 'connection-1', + generation: 1, + leaseExpiresAt: 1_000_000, + capabilityGeneration: 1, + ownedObjects: {}, + ...overrides + }, + channelId: 'control-channel', + signal: abort.signal, + abort + } +} + +function resolvedBundle(): RuntimeAcpResolvedBundle { + return { + entry: { + runtimeId: 'opencode', + bundleDigest: digest('1'), + acpCapabilitiesDigest: digest('6') + }, + bundleDirectory: '/runtime/opencode' + } +} + +function verifiedBundle(): RuntimeAcpVerifiedBundle { + return { + bundleDirectory: '/runtime/opencode', + executablePath: '/runtime/opencode/bin/opencode', + manifest: { + runtimeId: 'opencode', + provider: 'opencode', + bundleDigest: digest('1'), + adapterDigest: digest('2'), + acpCapabilitiesDigest: digest('6'), + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 16 * 1024 * 1024, + maximumPromptOutputBytes: 10_000 + } + } as RemoteRuntimeBundleManifest, + manifestDigest: digest('5') + } +} + +function acpFrame( + controller: ControllerLease, + value: string +) { + const payload = Buffer.from(value) + return { + header: { + protocolMajor: 1, + protocolMinor: 0, + connectionId: controller.connectionId, + generation: controller.generation, + channelId: 'binding-1', + channelEpoch: '1000', + direction: 'main-to-agent' as const, + sequence: '1', + kind: 'acp' as const, + payloadLength: payload.byteLength + }, + payload + } +} diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts new file mode 100644 index 00000000..fdd11ed3 --- /dev/null +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -0,0 +1,2080 @@ +import { z } from 'zod' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + acpCompletePromptRequestSchema as promptCompletionSchema, + acpCompletePromptResultSchema, + acpEscalateCancellationRequestSchema as cancellationEscalationSchema, + acpGetCursorsRequestSchema as getCursorsRequestSchema, + acpReplayChannelRequestSchema, + acpReplayChannelResultSchema, + acpResumeChannelRequestSchema, + acpResumeChannelResultSchema, + acpCloseChannelRequestSchema, + acpCloseChannelResultSchema, + acpOpenChannelRequestSchema, + acpOpenChannelResultSchema, + acpReconcilePromptRequestSchema as promptReconciliationSchema, + agentIdentifierSchema, + positiveAgentSequenceSchema, + sha256DigestSchema, + type AgentFrame +} from '../shared/agent-protocol' +import { canonicalJson } from '../shared/agent-protocol/canonical' +import { + acpJournalCursorSchema, + remotePromptOperationAcceptanceSchema, + remotePromptOperationPreparationSchema, + type AcpJournalCursor, + type RemotePromptOperationAcceptance, + type RemotePromptOperationPreparation +} from '../shared/remote-agent-contracts' +import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' +import type { ModelBridgePolicy } from '../shared/model-bridge-contracts' +import { + ModelBridgeBlobClient, + ModelBridgeClientError +} from './model-bridge-client' +import { + ModelBridgeBrokerServer, + type ModelBridgeBrokerDispatch +} from './model-bridge-broker' +import type { + ProtocolMethodContext, + ProtocolMethodHandler +} from './protocol-server' + +const ZERO_CURSOR = '0' +const DEFAULT_MAXIMUM_BINDINGS = 128 +const DEFAULT_MAXIMUM_CONTROLLER_BINDINGS = 32 +const DEFAULT_MAXIMUM_LIFETIME_BINDINGS = 4_096 +const DEFAULT_MAXIMUM_PENDING_CONTROL = 256 +const DEFAULT_MAXIMUM_PENDING_INPUT_PER_BINDING = 128 +const DEFAULT_MAXIMUM_PENDING_INPUT_GLOBAL = 1_024 +const DEFAULT_MAXIMUM_PENDING_OUTPUT_PER_BINDING = 128 +const DEFAULT_MAXIMUM_PENDING_OUTPUT_GLOBAL = 1_024 +const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_PER_BINDING = + AGENT_PROTOCOL_LIMITS.runEventJournalBytes +const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL = + AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes +const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000 +const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000 + +export type RuntimeAcpBackendErrorCode = + | 'capacity' + | 'closed' + | 'conflict' + | 'deadline' + | 'identity' + | 'not-found' + | 'output-quota' + | 'process' + | 'stale-controller' + | 'untrusted-runtime' + | 'workspace' + +export class RuntimeAcpBackendError extends Error { + constructor( + message: string, + readonly code: RuntimeAcpBackendErrorCode + ) { + super(message) + this.name = 'RuntimeAcpBackendError' + } +} + +export type RuntimeAcpJournal = { + appendAcpFrame(input: { + controllerId: string + bindingId: string + channelEpoch: string + direction: 'main-to-runtime' | 'runtime-to-main' + sequence: string + payload: Uint8Array + }): { created: boolean } + getAcpCursor( + bindingId: string, + channelEpoch: string, + direction: 'main-to-runtime' | 'runtime-to-main' + ): AcpJournalCursor | undefined + acknowledgeAcpFromMain?(input: { + bindingId: string + channelEpoch: string + sequence: string + }): void + retireAcpBinding?(input: { + controllerId: string + bindingId: string + channelEpoch: string + }): void + replayAcpFrames?(input: { + bindingId: string + channelEpoch: string + direction: 'runtime-to-main' + afterSequence: string + limit?: number + }): Array<{ sequence: string; payload: Uint8Array; createdAt: number }> +} + +export type RuntimeAcpResolvedBundle = { + entry: { + runtimeId: string + bundleDigest: string + acpCapabilitiesDigest: string + manifestDigest?: string + } + bundleDirectory: string +} + +export type RuntimeAcpVerifiedBundle = { + bundleDirectory: string + executablePath: string + manifest: RemoteRuntimeBundleManifest + manifestDigest: string +} + +export type RuntimeAcpWorkspace = { + workspaceIdentity: string + workspaceDirectory: string + scratchDirectory: string + bridgeDirectory?: string +} + +export type RuntimeAcpProcessIdentity = { + launchId: string + processId: string + supervisorIdentityDigest: string +} + +export type RuntimeAcpProcessOutput = { + stream: 'stdout' | 'stderr' + data: Uint8Array +} + +export type RuntimeAcpProcessReconciliation = { + identity: RuntimeAcpProcessIdentity + state: + | 'starting' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted' + | 'outcome-unknown' + processTree: 'running' | 'empty' | 'unknown' +} + +export type RuntimeAcpProcessStopReason = + | 'binding-closed' + | 'controller-disconnected' + | 'deadline-exceeded' + | 'identity-conflict' + | 'output-quota' + | 'user-cancelled' + +export type RuntimeAcpProcessOwner = { + identity: RuntimeAcpProcessIdentity + beginPrompt(input: { + deadlineAt: string + maximumInputBytes: number + }): void | Promise + completePrompt(): void | Promise + writeStdin(payload: Uint8Array): void | Promise + stop(input: { + reason: RuntimeAcpProcessStopReason + deadlineAt: string + }): void | Promise + reconcile(): RuntimeAcpProcessReconciliation | Promise + subscribeOutput( + listener: (output: RuntimeAcpProcessOutput) => void | Promise + ): void | (() => void) + subscribeExit?( + listener: () => void | Promise + ): void | (() => void) +} + +export type RuntimeAcpProcessLaunch = { + manifest: RemoteRuntimeBundleManifest + bundle: RuntimeAcpVerifiedBundle + workspace: RuntimeAcpWorkspace + scratch: string + workMode: 'ask' | 'execute' + deadlineAt: string + budget: RemotePromptOperationPreparation['budget'] + modelBridge?: { + socketPath: string + policy: ModelBridgePolicy + } +} + +export type RuntimeAcpBackendLimits = { + maximumBindings: number + maximumControllerBindings: number + maximumLifetimeBindings: number + maximumPendingControl: number + maximumPendingInputPerBinding: number + maximumPendingInputGlobal: number + maximumPendingOutputPerBinding: number + maximumPendingOutputGlobal: number + maximumPendingOutputBytesPerBinding: number + maximumPendingOutputBytesGlobal: number + maximumOperationsPerBinding: number +} + +export type RuntimeAcpBackendOptions = { + journal: RuntimeAcpJournal + resolveRuntimeBundle( + runtimeId: string, + runtimeBundleDigest: string + ): RuntimeAcpResolvedBundle | Promise + reverifyRuntimeBundle( + resolved: RuntimeAcpResolvedBundle, + preparation: RemotePromptOperationPreparation + ): RuntimeAcpVerifiedBundle | Promise + resolveWorkspace( + preparation: RemotePromptOperationPreparation, + context: ProtocolMethodContext + ): RuntimeAcpWorkspace | Promise + launchProcess( + launch: RuntimeAcpProcessLaunch + ): RuntimeAcpProcessOwner | Promise + outputSink( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ): void | Promise + blobSink?: ( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ) => void | Promise + createModelBridgeBroker?: (options: { + bridgeDirectory: string + dispatch: ModelBridgeBrokerDispatch + }) => ModelBridgeBrokerServer + now?: () => number + limits?: Partial +} + +type PreparedOperation = { + canonicalPreparation: string + acceptance: RemotePromptOperationAcceptance + budget: RemotePromptOperationPreparation['budget'] + completion?: z.infer +} + +type BindingState = { + request: z.infer + openResult: z.infer + controllerId: string + controllerGeneration: number + connectionId: string + transportState: 'live' | 'detached' | 'replaying' + resolvedBundle: RuntimeAcpResolvedBundle + state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown' + workMode?: 'ask' | 'execute' + process?: RuntimeAcpProcessOwner + unsubscribeOutput?: () => void + unsubscribeExit?: () => void + stopRequested: boolean + operations: Map + activeOperationId?: string + terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted' + inputBytes: number + outputBytes: number + maximumOutputBytes?: number + nextOutputSequence: bigint + pendingInput: number + pendingOutput: number + pendingOutputBytes: number + inputTail: Promise + outputTail: Promise + deadlineTimer?: NodeJS.Timeout + modelBridgePolicy?: ModelBridgePolicy + modelBridgeClient?: ModelBridgeBlobClient + modelBridgeBroker?: ModelBridgeBrokerServer + poisoned: boolean +} + +export class RuntimeAcpBackend { + readonly methods: Readonly> + readonly #options: RuntimeAcpBackendOptions + readonly #limits: RuntimeAcpBackendLimits + readonly #bindings = new Map() + readonly #processIdentityBindings = new Map() + readonly #boundConnectionSignals = new WeakSet() + #nextChannelEpoch: bigint + #lifetimeBindings = 0 + #pendingControl = 0 + #pendingInputGlobal = 0 + #pendingOutputGlobal = 0 + #pendingOutputBytesGlobal = 0 + #controlTail: Promise = Promise.resolve() + #disposed = false + #disposePromise?: Promise + + constructor(options: RuntimeAcpBackendOptions) { + this.#options = options + this.#limits = { + maximumBindings: boundedLimit( + options.limits?.maximumBindings, + DEFAULT_MAXIMUM_BINDINGS + ), + maximumControllerBindings: boundedLimit( + options.limits?.maximumControllerBindings, + DEFAULT_MAXIMUM_CONTROLLER_BINDINGS + ), + maximumLifetimeBindings: boundedLimit( + options.limits?.maximumLifetimeBindings, + DEFAULT_MAXIMUM_LIFETIME_BINDINGS + ), + maximumPendingControl: boundedLimit( + options.limits?.maximumPendingControl, + DEFAULT_MAXIMUM_PENDING_CONTROL + ), + maximumPendingInputPerBinding: boundedLimit( + options.limits?.maximumPendingInputPerBinding, + DEFAULT_MAXIMUM_PENDING_INPUT_PER_BINDING + ), + maximumPendingInputGlobal: boundedLimit( + options.limits?.maximumPendingInputGlobal, + DEFAULT_MAXIMUM_PENDING_INPUT_GLOBAL + ), + maximumPendingOutputPerBinding: boundedLimit( + options.limits?.maximumPendingOutputPerBinding, + DEFAULT_MAXIMUM_PENDING_OUTPUT_PER_BINDING + ), + maximumPendingOutputGlobal: boundedLimit( + options.limits?.maximumPendingOutputGlobal, + DEFAULT_MAXIMUM_PENDING_OUTPUT_GLOBAL + ), + maximumPendingOutputBytesPerBinding: boundedLimit( + options.limits?.maximumPendingOutputBytesPerBinding, + DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_PER_BINDING + ), + maximumPendingOutputBytesGlobal: boundedLimit( + options.limits?.maximumPendingOutputBytesGlobal, + DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL + ), + maximumOperationsPerBinding: boundedLimit( + options.limits?.maximumOperationsPerBinding, + DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING + ) + } + const initialEpoch = Math.max(1, Math.trunc(options.now?.() ?? Date.now())) + this.#nextChannelEpoch = BigInt( + Math.min(initialEpoch, Number.MAX_SAFE_INTEGER - 1) + ) + this.methods = { + 'runtime/openAcpChannel': (params, context) => + this.#enqueuePublicControl(() => this.#open(params, context)), + 'runtime/closeAcpChannel': (params, context) => + this.#enqueuePublicControl(() => this.#close(params, context)), + 'runtime/resumeAcpChannel': (params, context) => + this.#enqueuePublicControl(() => this.#resume(params, context)), + 'runtime/replayAcpChannel': (params, context) => + this.#enqueuePublicControl(() => this.#replay(params, context)), + 'runtime/preparePrompt': (params, context) => + this.#enqueuePublicControl(() => this.#prepare(params, context)), + 'runtime/completePrompt': (params, context) => + this.#enqueuePublicControl(() => this.#complete(params, context)), + 'runtime/getAcpCursors': (params, context) => + this.#enqueuePublicControl(() => this.#getCursors(params, context)), + 'runtime/escalateCancellation': (params, context) => + this.#enqueuePublicControl(() => this.#escalate(params, context)), + 'runtime/reconcilePrompt': (params, context) => + this.#enqueuePublicControl(() => this.#reconcile(params, context)) + } + } + + readonly onAcpFrame = async ( + frame: AgentFrame, + context: ProtocolMethodContext + ): Promise => { + this.#assertNotDisposed() + if ( + frame.header.kind !== 'acp' || + frame.header.direction !== 'main-to-agent' + ) { + throw new RuntimeAcpBackendError( + 'Invalid ACP frame direction or kind', + 'identity' + ) + } + const binding = this.#bindingForContext(frame.header.channelId, context) + if ( + context.channelId !== binding.request.bindingId || + frame.header.channelEpoch !== binding.openResult.channelEpoch || + frame.header.connectionId !== binding.connectionId || + frame.header.generation !== binding.controllerGeneration + ) { + throw new RuntimeAcpBackendError( + 'ACP frame identity does not match its binding', + 'identity' + ) + } + if ( + binding.process === undefined || + binding.state !== 'running' || + binding.transportState !== 'live' + ) { + throw new RuntimeAcpBackendError( + 'ACP Runtime process is not running', + 'closed' + ) + } + this.#assertBeforeDeadline(binding) + const cursor = this.#cursor(binding, 'main-to-runtime') + if ( + cursor !== undefined && + BigInt(cursor.deliveredSequence) >= BigInt(frame.header.sequence) + ) { + return + } + if ( + cursor === undefined || + BigInt(cursor.journaledSequence) < BigInt(frame.header.sequence) + ) { + throw new RuntimeAcpBackendError( + 'ACP input was not durably journaled before delivery', + 'identity' + ) + } + if ( + binding.inputBytes + frame.payload.byteLength > + this.#activeBudget(binding).maximumInputBytes + ) { + throw new RuntimeAcpBackendError( + 'ACP input quota reached', + 'capacity' + ) + } + await this.#enqueueInput(binding, async () => { + try { + await binding.process!.writeStdin(frame.payload) + } catch { + throw new RuntimeAcpBackendError( + 'Runtime input delivery failed', + 'process' + ) + } + binding.inputBytes += frame.payload.byteLength + }) + } + + readonly onBlobFrame = async ( + frame: AgentFrame, + context: ProtocolMethodContext + ): Promise => { + this.#assertNotDisposed() + if ( + frame.header.kind !== 'blob' || + frame.header.direction !== 'main-to-agent' + ) { + throw new RuntimeAcpBackendError( + 'Invalid model bridge frame direction or kind', + 'identity' + ) + } + const matches = [...this.#bindings.values()].filter( + (binding) => + binding.controllerId === context.controller.controllerId && + binding.controllerGeneration === context.controller.generation && + binding.connectionId === context.controller.connectionId && + binding.modelBridgeClient?.binding.channelId === + frame.header.channelId + ) + if (matches.length !== 1) { + throw new RuntimeAcpBackendError( + 'Blob channel has no exact active model bridge authority', + 'identity' + ) + } + const binding = matches[0]! + if ( + binding.poisoned || + binding.activeOperationId === undefined || + context.channelId !== frame.header.channelId + ) { + throw new RuntimeAcpBackendError( + 'Blob frame is not authorized for an active prompt', + 'identity' + ) + } + try { + await binding.modelBridgeClient!.onBlobFrame(frame) + } catch (error) { + if ( + error instanceof ModelBridgeClientError && + (error.poisoned || error.outcomeUnknown) + ) { + await this.#poisonBinding(binding) + } + throw error + } + } + + readonly authorizeBlobFrame = ( + frame: AgentFrame, + controller: { + controllerId: string + connectionId: string + generation: number + } + ): boolean => + frame.header.kind === 'blob' && + frame.header.direction === 'agent-to-main' && + [...this.#bindings.values()].some( + (binding) => + !binding.poisoned && + binding.activeOperationId !== undefined && + binding.controllerId === controller.controllerId && + binding.controllerGeneration === controller.generation && + binding.connectionId === controller.connectionId && + binding.modelBridgeClient?.binding.channelId === + frame.header.channelId && + binding.modelBridgeClient.binding.channelEpoch === + frame.header.channelEpoch && + frame.header.connectionId === binding.connectionId && + frame.header.generation === binding.controllerGeneration + ) + + dispose(timeoutMs = DEFAULT_DISPOSE_TIMEOUT_MS): Promise { + if (this.#disposePromise !== undefined) { + return this.#disposePromise + } + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 60_000 + ) { + return Promise.reject( + new RangeError('Invalid Runtime backend disposal timeout') + ) + } + this.#disposed = true + const cleanup = this.#controlTail + .catch(() => undefined) + .then(async () => { + const results = await Promise.allSettled( + [...this.#bindings.values()] + .filter((binding) => binding.state !== 'closed') + .map(async (binding) => + await this.#stopAndReconcile( + binding, + 'binding-closed' + ) + ) + ) + const failures = results + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected' + ) + .map((result) => result.reason) + if (failures.length > 0) { + throw new AggregateError( + failures, + 'Runtime process shutdown did not reconcile every process tree' + ) + } + }) + this.#controlTail = cleanup + this.#disposePromise = rejectAfter( + cleanup, + timeoutMs, + 'Runtime backend shutdown timed out' + ) + return this.#disposePromise + } + + async #open( + params: unknown, + context: ProtocolMethodContext + ): Promise> { + const request = acpOpenChannelRequestSchema.parse(params) + this.#assertControllerLive(context) + const existing = this.#bindings.get(request.bindingId) + if (existing !== undefined) { + if (existing.poisoned) { + throw new RuntimeAcpBackendError( + 'ACP binding was poisoned by uncertain model delivery', + 'process' + ) + } + if (canonicalJson(existing.request) !== canonicalJson(request)) { + throw new RuntimeAcpBackendError( + 'ACP binding identity conflicts with an existing binding', + 'conflict' + ) + } + if (existing.controllerId !== context.controller.controllerId) { + throw new RuntimeAcpBackendError( + 'ACP binding belongs to another controller', + 'stale-controller' + ) + } + if (existing.state !== 'closed' || existing.process !== undefined) { + this.#assertBindingOwner(existing, context) + return acpOpenChannelResultSchema.parse(existing.openResult) + } + this.#assertOpenCapacity(context.controller.controllerId) + const channelEpoch = this.#allocateChannelEpoch() + existing.openResult = acpOpenChannelResultSchema.parse({ + bindingId: request.bindingId, + channelId: request.bindingId, + channelEpoch, + acpCapabilitiesDigest: + existing.resolvedBundle.entry.acpCapabilitiesDigest + }) + existing.controllerGeneration = context.controller.generation + existing.connectionId = context.controller.connectionId + existing.transportState = 'live' + existing.state = 'open' + existing.stopRequested = false + existing.terminalState = undefined + existing.inputBytes = 0 + existing.outputBytes = 0 + existing.maximumOutputBytes = undefined + existing.nextOutputSequence = 1n + existing.pendingInput = 0 + existing.pendingOutput = 0 + existing.pendingOutputBytes = 0 + existing.inputTail = Promise.resolve() + existing.outputTail = Promise.resolve() + this.#bindConnectionLifetime(existing, context.signal) + return existing.openResult + } + this.#assertOpenCapacity(context.controller.controllerId) + let resolved: RuntimeAcpResolvedBundle + try { + resolved = await this.#options.resolveRuntimeBundle( + request.runtimeId, + request.runtimeBundleDigest + ) + } catch { + throw new RuntimeAcpBackendError( + 'Runtime bundle is not trusted', + 'untrusted-runtime' + ) + } + if ( + resolved.entry.runtimeId !== request.runtimeId || + resolved.entry.bundleDigest !== request.runtimeBundleDigest + ) { + throw new RuntimeAcpBackendError( + 'Resolved Runtime bundle identity does not match', + 'untrusted-runtime' + ) + } + const channelEpoch = this.#allocateChannelEpoch() + const openResult = acpOpenChannelResultSchema.parse({ + bindingId: request.bindingId, + channelId: request.bindingId, + channelEpoch, + acpCapabilitiesDigest: resolved.entry.acpCapabilitiesDigest + }) + const binding: BindingState = { + request, + openResult, + controllerId: context.controller.controllerId, + controllerGeneration: context.controller.generation, + connectionId: context.controller.connectionId, + transportState: 'live', + resolvedBundle: resolved, + state: 'open', + stopRequested: false, + operations: new Map(), + inputBytes: 0, + outputBytes: 0, + nextOutputSequence: 1n, + pendingInput: 0, + pendingOutput: 0, + pendingOutputBytes: 0, + inputTail: Promise.resolve(), + outputTail: Promise.resolve(), + poisoned: false + } + this.#bindings.set(request.bindingId, binding) + this.#lifetimeBindings += 1 + this.#bindConnectionLifetime(binding, context.signal) + return openResult + } + + async #close( + params: unknown, + context: ProtocolMethodContext + ): Promise> { + const request = acpCloseChannelRequestSchema.parse(params) + const binding = this.#bindingForContext(request.bindingId, context) + if ( + request.channelId !== binding.openResult.channelId || + request.channelEpoch !== binding.openResult.channelEpoch + ) { + throw new RuntimeAcpBackendError( + 'ACP close identity does not match its binding', + 'identity' + ) + } + if (binding.state !== 'closed') { + await this.#stopAndReconcile( + binding, + request.reason === 'cancelled' + ? 'user-cancelled' + : 'binding-closed' + ) + } + return acpCloseChannelResultSchema.parse({ + bindingId: request.bindingId, + channelEpoch: request.channelEpoch, + closed: true + }) + } + + async #resume( + params: unknown, + context: ProtocolMethodContext + ): Promise> { + const request = acpResumeChannelRequestSchema.parse(params) + this.#assertControllerLive(context) + const binding = this.#bindings.get(request.bindingId) + if (binding === undefined) { + throw new RuntimeAcpBackendError( + 'ACP binding does not exist', + 'not-found' + ) + } + if ( + binding.controllerId !== context.controller.controllerId || + context.controller.generation <= binding.controllerGeneration || + request.channelId !== binding.openResult.channelId || + request.channelEpoch !== binding.openResult.channelEpoch + ) { + throw new RuntimeAcpBackendError( + 'ACP resume identity does not exactly match its binding', + 'identity' + ) + } + if ( + binding.transportState !== 'detached' || + binding.process === undefined || + binding.state !== 'running' || + binding.poisoned + ) { + throw new RuntimeAcpBackendError( + 'ACP binding is not detached and resumable', + 'conflict' + ) + } + this.#assertBeforeDeadline(binding) + await binding.outputTail.catch(() => undefined) + let reconciliation: RuntimeAcpProcessReconciliation + try { + reconciliation = await binding.process.reconcile() + } catch { + throw new RuntimeAcpBackendError( + 'Runtime process reconciliation failed', + 'process' + ) + } + if ( + !sameProcessIdentity(binding.process.identity, reconciliation.identity) || + reconciliation.processTree !== 'running' || + reconciliation.state !== 'running' + ) { + throw new RuntimeAcpBackendError( + 'Detached Runtime process identity or state cannot be proven', + 'identity' + ) + } + binding.controllerGeneration = context.controller.generation + binding.connectionId = context.controller.connectionId + binding.transportState = 'replaying' + this.#bindConnectionLifetime(binding, context.signal) + const acceptance = this.#activeAcceptance(binding)! + return acpResumeChannelResultSchema.parse({ + bindingId: binding.request.bindingId, + channelId: binding.openResult.channelId, + channelEpoch: binding.openResult.channelEpoch, + deadlineAt: acceptance.deadlineAt, + cursors: this.#bindingCursors(binding) + }) + } + + async #replay( + params: unknown, + context: ProtocolMethodContext + ): Promise> { + const request = acpReplayChannelRequestSchema.parse(params) + const binding = this.#bindingForContext(request.bindingId, context) + if ( + request.channelId !== binding.openResult.channelId || + request.channelEpoch !== binding.openResult.channelEpoch || + binding.transportState !== 'replaying' + ) { + throw new RuntimeAcpBackendError( + 'ACP replay identity or phase does not match its binding', + 'identity' + ) + } + this.#assertBeforeDeadline(binding) + const initialCursor = this.#cursor(binding, 'runtime-to-main') + if ( + BigInt(request.acknowledgedSequence) > + BigInt(initialCursor?.journaledSequence ?? ZERO_CURSOR) + ) { + throw new RuntimeAcpBackendError( + 'ACP replay ACK exceeds durable output', + 'identity' + ) + } + this.#options.journal.acknowledgeAcpFromMain?.({ + bindingId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch, + sequence: request.acknowledgedSequence + }) + if (this.#options.journal.replayAcpFrames === undefined) { + throw new RuntimeAcpBackendError( + 'Durable ACP replay is unavailable', + 'process' + ) + } + let replayedThrough = request.acknowledgedSequence + for (;;) { + const frames = this.#options.journal.replayAcpFrames({ + bindingId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch, + direction: 'runtime-to-main', + afterSequence: replayedThrough, + limit: AGENT_PROTOCOL_LIMITS.runPendingEvents + }) + for (const replay of frames) { + await this.#deliverOutput(binding, replay.sequence, replay.payload) + replayedThrough = replay.sequence + } + const cursor = this.#cursor(binding, 'runtime-to-main') + if ( + frames.length === 0 || + BigInt(replayedThrough) >= + BigInt(cursor?.journaledSequence ?? ZERO_CURSOR) + ) { + break + } + } + binding.transportState = 'live' + return acpReplayChannelResultSchema.parse({ + bindingId: binding.request.bindingId, + channelId: binding.openResult.channelId, + channelEpoch: binding.openResult.channelEpoch, + replayedThroughSequence: replayedThrough, + live: true + }) + } + + async #prepare( + params: unknown, + context: ProtocolMethodContext + ): Promise { + const preparation = remotePromptOperationPreparationSchema.parse(params) + const binding = this.#bindingForContext(preparation.bindingId, context) + this.#assertPreparationIdentity(binding, preparation, context) + this.#assertBeforeDeadline(binding, preparation.deadlineAt) + const canonicalPreparation = canonicalJson(preparation) + const existing = binding.operations.get(preparation.operationId) + if (existing !== undefined) { + if (existing.canonicalPreparation !== canonicalPreparation) { + throw new RuntimeAcpBackendError( + 'Prompt operation identity is already bound to different content', + 'conflict' + ) + } + return remotePromptOperationAcceptanceSchema.parse( + existing.acceptance + ) + } + if (binding.activeOperationId !== undefined) { + throw new RuntimeAcpBackendError( + 'Another prompt operation is still active', + 'conflict' + ) + } + if ( + binding.operations.size >= + this.#limits.maximumOperationsPerBinding + ) { + throw new RuntimeAcpBackendError( + 'ACP binding operation lifetime is exhausted', + 'capacity' + ) + } + if ( + binding.workMode !== undefined && + binding.workMode !== preparation.workMode + ) { + throw new RuntimeAcpBackendError( + 'Runtime process work mode cannot change', + 'conflict' + ) + } + + let verified: RuntimeAcpVerifiedBundle + try { + verified = await this.#options.reverifyRuntimeBundle( + binding.resolvedBundle, + preparation + ) + } catch { + throw new RuntimeAcpBackendError( + 'Runtime verification failed', + 'untrusted-runtime' + ) + } + let workspace: RuntimeAcpWorkspace + try { + workspace = await this.#options.resolveWorkspace( + preparation, + context + ) + } catch { + throw new RuntimeAcpBackendError( + 'Workspace verification failed', + 'workspace' + ) + } + this.#assertVerifiedIdentity(binding, preparation, verified) + if (workspace.workspaceIdentity !== preparation.workspaceIdentity) { + throw new RuntimeAcpBackendError( + 'Workspace identity does not match its binding', + 'workspace' + ) + } + if (binding.poisoned) { + throw new RuntimeAcpBackendError( + 'ACP binding was poisoned by uncertain model delivery', + 'process' + ) + } + if ( + preparation.modelBridge !== undefined && + binding.modelBridgePolicy !== undefined && + canonicalJson(binding.modelBridgePolicy) !== + canonicalJson(preparation.modelBridge.policy) + ) { + throw new RuntimeAcpBackendError( + 'Model bridge policy cannot change within a Runtime binding', + 'conflict' + ) + } + if (preparation.modelBridge !== undefined) { + for (const candidate of this.#bindings.values()) { + if ( + candidate !== binding && + candidate.modelBridgeClient?.binding.channelId === + preparation.modelBridge.channelId + ) { + throw new RuntimeAcpBackendError( + 'Model bridge channel is already bound', + 'conflict' + ) + } + } + await this.#startModelBridge(binding, preparation, workspace) + } + const acceptedAt = new Date(this.#now()).toISOString() + const acceptance = remotePromptOperationAcceptanceSchema.parse({ + bindingId: preparation.bindingId, + operationId: preparation.operationId, + requestId: preparation.requestId, + workMode: preparation.workMode, + deadlineAt: preparation.deadlineAt, + acceptedAt + }) + + if (binding.process === undefined) { + let process: RuntimeAcpProcessOwner + try { + process = await this.#options.launchProcess({ + manifest: verified.manifest, + bundle: verified, + workspace, + scratch: workspace.scratchDirectory, + workMode: preparation.workMode, + deadlineAt: preparation.deadlineAt, + budget: preparation.budget, + ...(preparation.modelBridge === undefined || + binding.modelBridgeBroker === undefined + ? {} + : { + modelBridge: { + socketPath: binding.modelBridgeBroker.socketPath, + policy: preparation.modelBridge.policy + } + }) + }) + assertProcessIdentity(process.identity) + } catch { + await this.#closeModelBridge(binding, false).catch( + () => undefined + ) + throw new RuntimeAcpBackendError( + 'Runtime process launch failed', + 'process' + ) + } + const processKey = processIdentityKey(process.identity) + const identityOwner = this.#processIdentityBindings.get(processKey) + if ( + identityOwner !== undefined && + identityOwner !== binding.request.bindingId + ) { + await Promise.resolve( + process.stop({ + reason: 'identity-conflict', + deadlineAt: new Date(this.#now() + 10_000).toISOString() + }) + ).catch(() => undefined) + await Promise.resolve(process.reconcile()).catch(() => undefined) + throw new RuntimeAcpBackendError( + 'Supervised process identity is already bound', + 'identity' + ) + } + this.#processIdentityBindings.set( + processKey, + binding.request.bindingId + ) + binding.process = process + binding.workMode = preparation.workMode + binding.state = 'running' + binding.operations.set(preparation.operationId, { + canonicalPreparation, + acceptance, + budget: preparation.budget + }) + try { + const unsubscribe = process.subscribeOutput((output) => + this.#acceptProcessOutput(binding, output) + ) + if (unsubscribe !== undefined) { + binding.unsubscribeOutput = unsubscribe + } + const unsubscribeExit = process.subscribeExit?.(() => { + void this.#enqueueControl(async () => { + await this.#closeModelBridge(binding, true) + }).catch(() => undefined) + }) + if (unsubscribeExit !== undefined) { + binding.unsubscribeExit = unsubscribeExit + } + } catch { + binding.operations.delete(preparation.operationId) + await this.#stopAndReconcile(binding, 'identity-conflict').catch( + () => undefined + ) + throw new RuntimeAcpBackendError( + 'Runtime output subscription failed', + 'process' + ) + } + } else { + try { + await binding.process.beginPrompt({ + deadlineAt: preparation.deadlineAt, + maximumInputBytes: preparation.budget.maximumInputBytes + }) + } catch { + await this.#closeModelBridge(binding, false).catch( + () => undefined + ) + throw new RuntimeAcpBackendError( + 'Runtime prompt budget could not be renewed', + 'process' + ) + } + } + binding.inputBytes = 0 + binding.outputBytes = 0 + binding.maximumOutputBytes = Math.min( + preparation.budget.maximumOutputBytes, + verified.manifest.limits.maximumPromptOutputBytes + ) + if (!binding.operations.has(preparation.operationId)) { + binding.operations.set(preparation.operationId, { + canonicalPreparation, + acceptance, + budget: preparation.budget + }) + } + binding.activeOperationId = preparation.operationId + this.#scheduleDeadline(binding, preparation.deadlineAt) + return acceptance + } + + async #complete( + params: unknown, + context: ProtocolMethodContext + ): Promise> { + const operation = promptCompletionSchema.parse(params) + const binding = this.#bindingForContext(operation.bindingId, context) + if ( + operation.operationId !== operation.requestId || + !binding.operations.has(operation.operationId) + ) { + throw new RuntimeAcpBackendError( + 'Prompt completion identity does not match a prepared operation', + 'identity' + ) + } + const prepared = binding.operations.get(operation.operationId)! + if (prepared.completion !== undefined) { + return acpCompletePromptResultSchema.parse(prepared.completion) + } + if ( + binding.activeOperationId !== operation.operationId || + binding.process === undefined || + binding.state !== 'running' + ) { + throw new RuntimeAcpBackendError( + 'Prompt completion conflicts with the active operation', + 'conflict' + ) + } + await this.#closeModelBridge(binding, true) + if (binding.poisoned) { + throw new RuntimeAcpBackendError( + 'Model response delivery was not proven before prompt completion', + 'process' + ) + } + try { + await binding.process.completePrompt() + } catch { + throw new RuntimeAcpBackendError( + 'Runtime prompt completion failed', + 'process' + ) + } + const completion = acpCompletePromptResultSchema.parse({ + bindingId: operation.bindingId, + operationId: operation.operationId, + requestId: operation.requestId, + status: 'completed', + processTree: 'running' + }) + prepared.completion = completion + binding.activeOperationId = undefined + binding.inputBytes = 0 + binding.outputBytes = 0 + binding.maximumOutputBytes = undefined + if (binding.deadlineTimer !== undefined) { + clearTimeout(binding.deadlineTimer) + binding.deadlineTimer = undefined + } + return completion + } + + async #startModelBridge( + binding: BindingState, + preparation: RemotePromptOperationPreparation, + workspace: RuntimeAcpWorkspace + ): Promise { + const bridge = preparation.modelBridge + if ( + bridge === undefined || + this.#options.blobSink === undefined || + workspace.bridgeDirectory === undefined || + bridge.channelId === binding.request.bindingId + ) { + throw new RuntimeAcpBackendError( + 'Managed model bridge composition is unavailable or invalid', + 'identity' + ) + } + if ( + binding.modelBridgeClient !== undefined || + binding.modelBridgeBroker !== undefined + ) { + throw new RuntimeAcpBackendError( + 'Another model bridge is still active', + 'conflict' + ) + } + const client = new ModelBridgeBlobClient({ + binding: { + bindingId: binding.request.bindingId, + promptOperationId: preparation.operationId, + controllerId: binding.controllerId, + controllerGeneration: binding.controllerGeneration, + connectionId: binding.connectionId, + acpChannelEpoch: binding.openResult.channelEpoch, + channelId: bridge.channelId, + channelEpoch: bridge.channelEpoch, + runtimeId: binding.request.runtimeId, + workspaceIdentity: binding.request.workspaceIdentity, + policy: bridge.policy, + deadlineAt: preparation.deadlineAt + }, + sendBlobFrame: async (frame) => + await this.#options.blobSink!(frame, { + bindingId: binding.request.bindingId, + controllerId: binding.controllerId, + controllerGeneration: binding.controllerGeneration + }), + onPoison: () => { + binding.poisoned = true + binding.state = 'outcome-unknown' + void this.#enqueueControl(async () => { + await this.#stopAndReconcile( + binding, + 'identity-conflict' + ) + }).catch(() => undefined) + }, + now: this.#options.now + }) + const broker = + this.#options.createModelBridgeBroker?.({ + bridgeDirectory: workspace.bridgeDirectory, + dispatch: async (request, context) => + await client.exchange(request, context) + }) ?? + new ModelBridgeBrokerServer({ + scratchDirectory: workspace.bridgeDirectory, + dispatch: async (request, context) => + await client.exchange(request, context) + }) + binding.modelBridgeClient = client + binding.modelBridgeBroker = broker + binding.modelBridgePolicy = bridge.policy + try { + await broker.listen() + } catch { + binding.modelBridgeClient = undefined + binding.modelBridgeBroker = undefined + await client.close({ poisonIfActive: false }) + throw new RuntimeAcpBackendError( + 'Managed model bridge broker could not start', + 'process' + ) + } + } + + async #closeModelBridge( + binding: BindingState, + poisonIfActive: boolean, + preserveRuntime = false + ): Promise { + const broker = binding.modelBridgeBroker + const client = binding.modelBridgeClient + binding.modelBridgeBroker = undefined + binding.modelBridgeClient = undefined + let brokerError: unknown + try { + await broker?.close() + } catch (error) { + brokerError = error + } + if (preserveRuntime && client?.active === true) { + binding.poisoned = true + } + await client?.close({ + poisonIfActive: preserveRuntime ? false : poisonIfActive + }) + if (brokerError !== undefined) { + throw brokerError + } + } + + async #poisonBinding(binding: BindingState): Promise { + binding.poisoned = true + binding.state = 'outcome-unknown' + await this.#closeModelBridge(binding, true).catch( + () => undefined + ) + await this.#stopAndReconcile( + binding, + 'identity-conflict' + ).catch(() => undefined) + } + + #getCursors( + params: unknown, + context: ProtocolMethodContext + ): { + lastOutboundJournaledSequence: string + lastOutboundDeliveredSequence: string + lastInboundJournaledSequence: string + lastMainAckSequence: string + } { + const request = getCursorsRequestSchema.parse(params) + const binding = this.#bindingForContext(request.bindingId, context) + return this.#bindingCursors(binding) + } + + #bindingCursors(binding: BindingState): { + lastOutboundJournaledSequence: string + lastOutboundDeliveredSequence: string + lastInboundJournaledSequence: string + lastMainAckSequence: string + } { + const outbound = this.#cursor(binding, 'main-to-runtime') + const inbound = this.#cursor(binding, 'runtime-to-main') + return { + lastOutboundJournaledSequence: + outbound?.journaledSequence ?? ZERO_CURSOR, + lastOutboundDeliveredSequence: + outbound?.deliveredSequence ?? ZERO_CURSOR, + lastInboundJournaledSequence: + inbound?.journaledSequence ?? ZERO_CURSOR, + lastMainAckSequence: inbound?.mainAckSequence ?? ZERO_CURSOR + } + } + + async #escalate( + params: unknown, + context: ProtocolMethodContext + ): Promise<{ bindingId: string; stopped: true }> { + const escalation = cancellationEscalationSchema.parse(params) + const binding = this.#bindingForContext(escalation.bindingId, context) + if ( + binding.activeOperationId !== undefined && + (binding.activeOperationId !== escalation.operationId || + escalation.operationId !== escalation.requestId) + ) { + throw new RuntimeAcpBackendError( + 'Cancellation operation identity does not match', + 'identity' + ) + } + await this.#stopAndReconcile(binding, 'user-cancelled') + return { bindingId: escalation.bindingId, stopped: true } + } + + async #reconcile( + params: unknown, + context: ProtocolMethodContext + ): Promise< + | { + status: 'terminal' + terminalState: + | 'completed' + | 'failed' + | 'cancelled' + | 'interrupted' + processTree: 'running' | 'empty' + } + | { + status: 'running' | 'outcome-unknown' + processTree: 'running' | 'unknown' + } + > { + const operation = promptReconciliationSchema.parse(params) + const binding = this.#bindingForContext(operation.bindingId, context) + if (operation.operationId !== operation.requestId) { + throw new RuntimeAcpBackendError( + 'Reconciliation operation identity does not match', + 'identity' + ) + } + const prepared = binding.operations.get(operation.operationId) + if (prepared === undefined) { + throw new RuntimeAcpBackendError( + 'Reconciliation operation was not prepared', + 'identity' + ) + } + if (prepared.completion !== undefined) { + return { + status: 'terminal', + terminalState: 'completed', + processTree: prepared.completion.processTree + } + } + if (binding.activeOperationId !== operation.operationId) { + return { status: 'outcome-unknown', processTree: 'unknown' } + } + if (binding.process === undefined) { + if (binding.state === 'closed') { + return { + status: 'terminal', + terminalState: binding.terminalState ?? 'interrupted', + processTree: 'empty' + } + } + return { status: 'outcome-unknown', processTree: 'unknown' } + } + let reconciliation: RuntimeAcpProcessReconciliation + try { + reconciliation = await binding.process.reconcile() + } catch { + binding.state = 'outcome-unknown' + return { status: 'outcome-unknown', processTree: 'unknown' } + } + if (!sameProcessIdentity(binding.process.identity, reconciliation.identity)) { + binding.state = 'outcome-unknown' + await Promise.resolve( + binding.process.stop({ + reason: 'identity-conflict', + deadlineAt: new Date(this.#now() + 10_000).toISOString() + }) + ).catch(() => undefined) + throw new RuntimeAcpBackendError( + 'Supervised process identity conflicts with its binding', + 'identity' + ) + } + if (reconciliation.processTree !== 'empty') { + binding.state = + reconciliation.processTree === 'running' + ? 'running' + : 'outcome-unknown' + return reconciliation.processTree === 'running' + ? { status: 'running', processTree: 'running' } + : { status: 'outcome-unknown', processTree: 'unknown' } + } + const completedState = terminalState(reconciliation.state) + await this.#closeModelBridge(binding, true) + this.#finishProcess(binding) + binding.state = 'open' + binding.terminalState = completedState + return { + status: 'terminal', + terminalState: completedState, + processTree: 'empty' + } + } + + async #acceptProcessOutput( + binding: BindingState, + output: RuntimeAcpProcessOutput + ): Promise { + if (output.stream === 'stderr' || output.data.byteLength === 0) { + return + } + if (binding.state !== 'running') { + throw new RuntimeAcpBackendError( + 'Runtime output arrived after the binding stopped', + 'closed' + ) + } + this.#assertBeforeDeadline(binding) + const budget = this.#activeBudget(binding) + const maximumOutputBytes = Math.min( + budget.maximumOutputBytes, + binding.maximumOutputBytes ?? budget.maximumOutputBytes + ) + if (binding.outputBytes + output.data.byteLength > maximumOutputBytes) { + await this.#stopAndReconcile(binding, 'output-quota').catch( + () => undefined + ) + throw new RuntimeAcpBackendError( + 'Runtime output quota reached', + 'output-quota' + ) + } + binding.outputBytes += output.data.byteLength + for ( + let offset = 0; + offset < output.data.byteLength; + offset += AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes + ) { + const payload = output.data.slice( + offset, + offset + AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes + ) + try { + await this.#enqueueOutput(binding, payload, async () => { + const sequence = binding.nextOutputSequence.toString() + this.#options.journal.appendAcpFrame({ + controllerId: binding.controllerId, + bindingId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch, + direction: 'runtime-to-main', + sequence, + payload + }) + binding.nextOutputSequence += 1n + if (binding.transportState === 'live') { + try { + await this.#deliverOutput(binding, sequence, payload) + } catch { + await this.#detachBinding(binding) + } + } + }) + } catch (error) { + await this.#stopAndReconcile(binding, 'output-quota').catch( + () => undefined + ) + if ( + error instanceof RuntimeAcpBackendError && + error.code === 'output-quota' + ) { + throw error + } + throw new RuntimeAcpBackendError( + 'Runtime durable output journal quota reached', + 'output-quota' + ) + } + } + } + + async #deliverOutput( + binding: BindingState, + sequence: string, + payload: Uint8Array + ): Promise { + const frame: AgentFrame = { + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: binding.connectionId, + generation: binding.controllerGeneration, + channelId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch, + direction: 'agent-to-main', + sequence, + kind: 'acp', + payloadLength: payload.byteLength + }, + payload + } + await this.#options.outputSink(frame, { + bindingId: binding.request.bindingId, + controllerId: binding.controllerId, + controllerGeneration: binding.controllerGeneration + }) + } + + async #detachBinding(binding: BindingState): Promise { + if (binding.transportState === 'detached') { + return + } + binding.transportState = 'detached' + await this.#closeModelBridge(binding, false, true).catch( + () => undefined + ) + } + + async #stopAndReconcile( + binding: BindingState, + reason: RuntimeAcpProcessStopReason + ): Promise { + if (binding.state === 'closed') { + return + } + await this.#closeModelBridge(binding, true) + if (binding.process === undefined) { + this.#finishBinding(binding, 'interrupted') + return + } + binding.state = 'stopping' + if (!binding.stopRequested) { + binding.stopRequested = true + try { + await binding.process.stop({ + reason, + deadlineAt: new Date(this.#now() + 10_000).toISOString() + }) + } catch { + binding.state = 'outcome-unknown' + throw new RuntimeAcpBackendError( + 'Runtime process stop failed', + 'process' + ) + } + } + let reconciliation: RuntimeAcpProcessReconciliation + try { + reconciliation = await binding.process.reconcile() + } catch { + binding.state = 'outcome-unknown' + throw new RuntimeAcpBackendError( + 'Runtime process reconciliation failed', + 'process' + ) + } + if (!sameProcessIdentity(binding.process.identity, reconciliation.identity)) { + binding.state = 'outcome-unknown' + throw new RuntimeAcpBackendError( + 'Supervised process identity conflicts with its binding', + 'identity' + ) + } + if (reconciliation.processTree !== 'empty') { + binding.state = 'outcome-unknown' + throw new RuntimeAcpBackendError( + 'Runtime process tree is not empty', + 'process' + ) + } + this.#finishBinding(binding, terminalState(reconciliation.state)) + } + + #finishBinding( + binding: BindingState, + state: 'completed' | 'failed' | 'cancelled' | 'interrupted' + ): void { + this.#finishProcess(binding) + this.#options.journal.retireAcpBinding?.({ + controllerId: binding.controllerId, + bindingId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch + }) + binding.state = 'closed' + binding.terminalState = state + } + + #finishProcess(binding: BindingState): void { + if (binding.process !== undefined) { + this.#processIdentityBindings.delete( + processIdentityKey(binding.process.identity) + ) + } + binding.unsubscribeOutput?.() + binding.unsubscribeOutput = undefined + binding.unsubscribeExit?.() + binding.unsubscribeExit = undefined + binding.process = undefined + binding.stopRequested = false + binding.activeOperationId = undefined + if (binding.deadlineTimer !== undefined) { + clearTimeout(binding.deadlineTimer) + binding.deadlineTimer = undefined + } + } + + #scheduleDeadline(binding: BindingState, deadlineAt: string): void { + if (binding.deadlineTimer !== undefined) { + clearTimeout(binding.deadlineTimer) + } + const delay = Math.max( + 1, + Math.min(Date.parse(deadlineAt) - this.#now(), 0x7fff_ffff) + ) + binding.deadlineTimer = setTimeout(() => { + void this.#enqueueControl(() => + this.#stopAndReconcile(binding, 'deadline-exceeded') + ).catch(() => undefined) + }, delay) + binding.deadlineTimer.unref?.() + } + + #bindConnectionLifetime( + binding: BindingState, + signal: AbortSignal | undefined + ): void { + if (signal === undefined || this.#boundConnectionSignals.has(signal)) { + return + } + this.#boundConnectionSignals.add(signal) + const disconnect = (): void => { + for (const candidate of this.#bindings.values()) { + if ( + candidate.controllerId === binding.controllerId && + candidate.controllerGeneration === binding.controllerGeneration && + candidate.connectionId === binding.connectionId && + candidate.state !== 'closed' + ) { + void this.#enqueueControl(() => + this.#detachBinding(candidate) + ).catch(() => undefined) + } + } + } + if (signal.aborted) { + disconnect() + } else { + signal.addEventListener('abort', disconnect, { once: true }) + } + } + + #assertPreparationIdentity( + binding: BindingState, + preparation: RemotePromptOperationPreparation, + context: ProtocolMethodContext + ): void { + if ( + preparation.controllerId !== binding.controllerId || + preparation.controllerGeneration !== binding.controllerGeneration || + preparation.connectionGeneration !== binding.controllerGeneration || + preparation.channelEpoch !== binding.openResult.channelEpoch || + preparation.bindingId !== binding.request.bindingId || + preparation.runtimeId !== binding.request.runtimeId || + preparation.runtimeBundleDigest !== + binding.request.runtimeBundleDigest || + preparation.workspaceIdentity !== binding.request.workspaceIdentity || + context.controller.connectionId !== binding.connectionId + ) { + throw new RuntimeAcpBackendError( + 'Prompt preparation identity does not match its ACP binding', + 'identity' + ) + } + if (preparation.operationId !== preparation.requestId) { + throw new RuntimeAcpBackendError( + 'Prompt operation and request identities must match', + 'identity' + ) + } + } + + #assertVerifiedIdentity( + binding: BindingState, + preparation: RemotePromptOperationPreparation, + verified: RuntimeAcpVerifiedBundle + ): void { + const manifest = verified.manifest + if ( + manifest.provider !== 'opencode' || + manifest.runtimeId !== preparation.runtimeId || + manifest.bundleDigest !== preparation.runtimeBundleDigest || + manifest.adapterDigest !== preparation.runtimeAdapterDigest || + manifest.acpCapabilitiesDigest !== + binding.openResult.acpCapabilitiesDigest || + (binding.resolvedBundle.entry.manifestDigest !== undefined && + binding.resolvedBundle.entry.manifestDigest !== + verified.manifestDigest) || + verified.bundleDirectory !== binding.resolvedBundle.bundleDirectory + ) { + throw new RuntimeAcpBackendError( + 'Reverified Runtime identity does not match the prompt binding', + 'untrusted-runtime' + ) + } + } + + #bindingForContext( + bindingId: string, + context: ProtocolMethodContext + ): BindingState { + this.#assertControllerLive(context) + const binding = this.#bindings.get(bindingId) + if (binding === undefined) { + throw new RuntimeAcpBackendError( + 'ACP binding does not exist', + 'not-found' + ) + } + this.#assertBindingOwner(binding, context) + return binding + } + + #assertBindingOwner( + binding: BindingState, + context: ProtocolMethodContext + ): void { + if ( + binding.controllerId !== context.controller.controllerId || + binding.controllerGeneration !== context.controller.generation || + binding.connectionId !== context.controller.connectionId + ) { + throw new RuntimeAcpBackendError( + 'ACP binding belongs to another controller generation', + 'stale-controller' + ) + } + } + + #assertControllerLive(context: ProtocolMethodContext): void { + if ( + context.signal?.aborted === true || + context.controller.leaseExpiresAt <= this.#now() + ) { + throw new RuntimeAcpBackendError( + 'Controller connection is no longer current', + 'stale-controller' + ) + } + } + + #assertOpenCapacity(controllerId: string): void { + const active = [...this.#bindings.values()].filter( + (binding) => binding.state !== 'closed' + ) + if ( + active.length >= this.#limits.maximumBindings || + active.filter((binding) => binding.controllerId === controllerId) + .length >= this.#limits.maximumControllerBindings || + this.#lifetimeBindings >= this.#limits.maximumLifetimeBindings + ) { + throw new RuntimeAcpBackendError( + 'ACP binding capacity reached', + 'capacity' + ) + } + } + + #assertBeforeDeadline( + binding: BindingState, + deadlineAt = this.#activeAcceptance(binding)?.deadlineAt + ): void { + if ( + deadlineAt === undefined || + !Number.isFinite(Date.parse(deadlineAt)) || + Date.parse(deadlineAt) <= this.#now() + ) { + throw new RuntimeAcpBackendError( + 'Prompt operation deadline has expired', + 'deadline' + ) + } + } + + #activeAcceptance( + binding: BindingState + ): RemotePromptOperationAcceptance | undefined { + if (binding.activeOperationId === undefined) { + return undefined + } + return binding.operations.get(binding.activeOperationId)?.acceptance + } + + #activeBudget( + binding: BindingState + ): RemotePromptOperationPreparation['budget'] { + const acceptance = this.#activeAcceptance(binding) + if (acceptance === undefined) { + throw new RuntimeAcpBackendError( + 'Prompt operation is not prepared', + 'identity' + ) + } + const operation = binding.operations.get(binding.activeOperationId!) + if (operation === undefined) { + throw new RuntimeAcpBackendError( + 'Prompt operation is not active', + 'identity' + ) + } + return operation.budget + } + + #cursor( + binding: BindingState, + direction: 'main-to-runtime' | 'runtime-to-main' + ): AcpJournalCursor | undefined { + const cursor = this.#options.journal.getAcpCursor( + binding.request.bindingId, + binding.openResult.channelEpoch, + direction + ) + return cursor === undefined + ? undefined + : acpJournalCursorSchema.parse(cursor) + } + + async #enqueueControl(action: () => Promise | T): Promise { + if (this.#pendingControl >= this.#limits.maximumPendingControl) { + throw new RuntimeAcpBackendError( + 'ACP global control queue is full', + 'capacity' + ) + } + this.#pendingControl += 1 + const previous = this.#controlTail + let release!: () => void + this.#controlTail = new Promise((resolve) => { + release = resolve + }) + await previous + try { + return await action() + } finally { + this.#pendingControl -= 1 + release() + } + } + + #enqueuePublicControl( + action: () => Promise | T + ): Promise { + this.#assertNotDisposed() + return this.#enqueueControl(action) + } + + #assertNotDisposed(): void { + if (this.#disposed) { + throw new RuntimeAcpBackendError( + 'Runtime backend is closed', + 'closed' + ) + } + } + + async #enqueueInput( + binding: BindingState, + action: () => Promise + ): Promise { + if ( + binding.pendingInput >= + this.#limits.maximumPendingInputPerBinding || + this.#pendingInputGlobal >= + this.#limits.maximumPendingInputGlobal + ) { + throw new RuntimeAcpBackendError( + 'ACP input queue is full', + 'capacity' + ) + } + binding.pendingInput += 1 + this.#pendingInputGlobal += 1 + const previous = binding.inputTail + let release!: () => void + binding.inputTail = new Promise((resolve) => { + release = resolve + }) + await previous + try { + await action() + } finally { + binding.pendingInput -= 1 + this.#pendingInputGlobal -= 1 + release() + } + } + + async #enqueueOutput( + binding: BindingState, + payload: Uint8Array, + action: () => Promise + ): Promise { + if ( + binding.pendingOutput >= + this.#limits.maximumPendingOutputPerBinding || + this.#pendingOutputGlobal >= + this.#limits.maximumPendingOutputGlobal || + binding.pendingOutputBytes + payload.byteLength > + this.#limits.maximumPendingOutputBytesPerBinding || + this.#pendingOutputBytesGlobal + payload.byteLength > + this.#limits.maximumPendingOutputBytesGlobal + ) { + throw new RuntimeAcpBackendError( + 'ACP output queue is full', + 'capacity' + ) + } + binding.pendingOutput += 1 + binding.pendingOutputBytes += payload.byteLength + this.#pendingOutputGlobal += 1 + this.#pendingOutputBytesGlobal += payload.byteLength + const previous = binding.outputTail + let release!: () => void + binding.outputTail = new Promise((resolve) => { + release = resolve + }) + await previous + try { + await action() + } finally { + binding.pendingOutput -= 1 + binding.pendingOutputBytes -= payload.byteLength + this.#pendingOutputGlobal -= 1 + this.#pendingOutputBytesGlobal -= payload.byteLength + release() + } + } + + #allocateChannelEpoch(): string { + if (this.#nextChannelEpoch > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RuntimeAcpBackendError( + 'ACP channel epoch lifetime is exhausted', + 'capacity' + ) + } + const epoch = positiveAgentSequenceSchema.parse( + this.#nextChannelEpoch.toString() + ) + this.#nextChannelEpoch += 1n + return epoch + } + + #now(): number { + return this.#options.now?.() ?? Date.now() + } +} + +function assertProcessIdentity(identity: RuntimeAcpProcessIdentity): void { + agentIdentifierSchema.parse(identity.launchId) + agentIdentifierSchema.parse(identity.processId) + sha256DigestSchema.parse(identity.supervisorIdentityDigest) +} + +function sameProcessIdentity( + left: RuntimeAcpProcessIdentity, + right: RuntimeAcpProcessIdentity +): boolean { + try { + assertProcessIdentity(right) + } catch { + return false + } + return ( + left.launchId === right.launchId && + left.processId === right.processId && + left.supervisorIdentityDigest === right.supervisorIdentityDigest + ) +} + +function processIdentityKey(identity: RuntimeAcpProcessIdentity): string { + return [ + identity.launchId, + identity.processId, + identity.supervisorIdentityDigest + ].join('\0') +} + +function terminalState( + state: RuntimeAcpProcessReconciliation['state'] +): 'completed' | 'failed' | 'cancelled' | 'interrupted' { + switch (state) { + case 'completed': + return 'completed' + case 'failed': + return 'failed' + case 'cancelled': + return 'cancelled' + default: + return 'interrupted' + } +} + +function rejectAfter( + promise: Promise, + timeoutMs: number, + message: string +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs) + timeout.unref?.() + promise.then( + (value) => { + clearTimeout(timeout) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timeout) + reject(error) + } + ) + }) +} + +function boundedLimit(value: number | undefined, fallback: number): number { + const candidate = value ?? fallback + if ( + !Number.isSafeInteger(candidate) || + candidate < 1 || + candidate > Number.MAX_SAFE_INTEGER + ) { + throw new RangeError('Invalid Runtime ACP backend limit') + } + return candidate +} diff --git a/src/agent-daemon/runtime-bundle-registry.test.ts b/src/agent-daemon/runtime-bundle-registry.test.ts new file mode 100644 index 00000000..3b4db281 --- /dev/null +++ b/src/agent-daemon/runtime-bundle-registry.test.ts @@ -0,0 +1,289 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' +import { RuntimeBundleRegistry } from './runtime-bundle-registry' +import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('Runtime bundle registry', () => { + it('registers and resolves the current signed digest atomically', () => { + const fixture = createRegistry() + const verified = runtimeBundle(fixture, '1.18.9', 'a') + + const entry = fixture.registry.register(verified) + + expect(entry).toMatchObject({ + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + architecture: 'x64', + bundleDigest: verified.manifest.bundleDigest + }) + expect( + fixture.registry.resolve( + 'opencode', + verified.manifest.bundleDigest, + 'x64' + ) + ).toEqual({ + entry, + bundleDirectory: verified.bundleDirectory + }) + expect( + new RuntimeBundleRegistry({ + runtimeRoot: fixture.runtimeRoot + }).snapshot() + ).toEqual(fixture.registry.snapshot()) + }) + + it('reuses an exact bundle and replaces it with another signed identity', () => { + const fixture = createRegistry() + const first = runtimeBundle(fixture, '1.18.9', 'a') + const second = runtimeBundle(fixture, '1.19.0', 'b') + + expect(fixture.registry.register(first)).toEqual( + fixture.registry.register(first) + ) + fixture.registry.register(second) + + expect(fixture.registry.snapshot().current).toEqual([ + expect.objectContaining({ + runtimeVersion: '1.19.0', + bundleDigest: second.manifest.bundleDigest + }) + ]) + expect(() => + fixture.registry.resolve( + 'opencode', + first.manifest.bundleDigest, + 'x64' + ) + ).toThrow('not the verified current bundle') + }) + + it('rewrites the previous sequence registry into the minimal format', () => { + const fixture = createRegistry() + writeFileSync( + fixture.storagePath, + `${JSON.stringify({ + formatVersion: 1, + minimumTrustedReleaseSequence: 1, + current: [{ + runtimeId: 'opencode', + provider: 'opencode', + runtimeVersion: '1.18.9', + releaseSequence: 3, + architecture: 'x64', + signingKeyId: 'test-key', + bundleDigest: `sha256:${'a'.repeat(64)}`, + manifestDigest: `sha256:${'b'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}` + }] + }, null, 2)}\n`, + { mode: 0o600 } + ) + + expect( + new RuntimeBundleRegistry({ + runtimeRoot: fixture.runtimeRoot + }).snapshot() + ).toEqual({ + formatVersion: 1, + current: [{ + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + architecture: 'x64', + bundleDigest: `sha256:${'a'.repeat(64)}`, + manifestDigest: `sha256:${'b'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}` + }] + }) + expect(readFileSync(fixture.storagePath, 'utf8')).not.toContain( + 'releaseSequence' + ) + }) + + it('observes activations from another registry instance without no-op writes', () => { + const fixture = createRegistry() + const activator = new RuntimeBundleRegistry({ + runtimeRoot: fixture.runtimeRoot + }) + const first = runtimeBundle(fixture, '1.18.9', 'a') + const firstEntry = activator.register(first) + + expect(fixture.registry.current('x64')).toEqual([firstEntry]) + expect( + fixture.registry.resolve( + 'opencode', + first.manifest.bundleDigest, + 'x64' + ) + ).toEqual({ + entry: firstEntry, + bundleDirectory: first.bundleDirectory + }) + const beforeNoOp = fileIdentity(fixture.storagePath) + expect(fixture.registry.register(first)).toEqual(firstEntry) + expect(fileIdentity(fixture.storagePath)).toEqual(beforeNoOp) + + const second = runtimeBundle(fixture, '1.19.0', 'b') + const secondEntry = new RuntimeBundleRegistry({ + runtimeRoot: fixture.runtimeRoot + }).register(second) + const beforeSecondNoOp = fileIdentity(fixture.storagePath) + expect(fixture.registry.register(second)).toEqual(secondEntry) + expect(fileIdentity(fixture.storagePath)).toEqual( + beforeSecondNoOp + ) + expect(fixture.registry.current('x64')).toEqual([secondEntry]) + expect(() => + fixture.registry.resolve( + 'opencode', + first.manifest.bundleDigest, + 'x64' + ) + ).toThrow('not the verified current bundle') + }) + + it('fails closed when the live registry becomes corrupt', () => { + const fixture = createRegistry() + writeFileSync(fixture.storagePath, '{"formatVersion":1}', { + mode: 0o600 + }) + expect(() => fixture.registry.current('x64')).toThrow( + /corrupt/iu + ) + expect(() => + new RuntimeBundleRegistry({ + runtimeRoot: fixture.runtimeRoot + }) + ).toThrow(/corrupt/iu) + expect(readFileSync(fixture.storagePath, 'utf8')).toContain( + 'formatVersion' + ) + }) +}) + +function fileIdentity(path: string): { + ino: bigint + mtimeNs: bigint + ctimeNs: bigint +} { + const stat = statSync(path, { bigint: true }) + return { + ino: stat.ino, + mtimeNs: stat.mtimeNs, + ctimeNs: stat.ctimeNs + } +} + +function createRegistry(): { + registry: RuntimeBundleRegistry + runtimeRoot: string + storagePath: string +} { + const root = resolve( + mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-registry-')) + ) + temporaryPaths.push(root) + if (process.platform !== 'win32') { + chmodSync(root, 0o700) + } + const runtimeRoot = resolve(root, 'runtimes') + const storagePath = resolve(runtimeRoot, 'registry.json') + return { + registry: new RuntimeBundleRegistry({ runtimeRoot }), + runtimeRoot, + storagePath + } +} + +function runtimeBundle( + fixture: ReturnType, + runtimeVersion: string, + digestCharacter: string +): VerifiedRuntimeBundle { + const rawBundleDigest = digestCharacter.repeat(64) + const bundleDigest = `sha256:${rawBundleDigest}` + const bundleDirectory = + fixture.registry.bundleDirectory('opencode', bundleDigest) + mkdirSync(bundleDirectory, { + recursive: true, + mode: 0o700 + }) + const manifest = { + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion, + provider: 'opencode', + platform: 'linux', + architecture: 'x64', + signingKeyId: 'runtime-test', + bundleDigest, + adapterDigest: `sha256:${'a'.repeat(64)}`, + sourcePackage: { + name: 'opencode-linux-x64-baseline', + integrity: + 'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ==' + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: 'c'.repeat(64), + argvPrefix: ['acp'] + }, + files: [ + { + path: 'bin/opencode', + size: 64, + sha256: 'c'.repeat(64), + mode: '0755' + }, + { + path: 'licenses/opencode.txt', + size: 4, + sha256: 'd'.repeat(64), + mode: '0644' + } + ], + licenses: [ + { + package: 'opencode-ai', + version: runtimeVersion, + spdx: 'MIT', + path: 'licenses/opencode.txt' + } + ], + allowedEnvironmentNames: ['HOME'], + protocol: { major: 1, minor: 0 }, + acpCapabilitiesDigest: `sha256:${'e'.repeat(64)}`, + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 4096, + maximumPromptOutputBytes: 1024 * 1024 + } + } as const satisfies RemoteRuntimeBundleManifest + return { + bundleDirectory, + executablePath: resolve(bundleDirectory, 'bin', 'opencode'), + manifest, + manifestDigest: `sha256:${rawBundleDigest}` + } +} diff --git a/src/agent-daemon/runtime-bundle-registry.ts b/src/agent-daemon/runtime-bundle-registry.ts new file mode 100644 index 00000000..0798bff0 --- /dev/null +++ b/src/agent-daemon/runtime-bundle-registry.ts @@ -0,0 +1,343 @@ +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import type { + AgentArchitecture, + AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import type { DaemonCapabilities } from '../shared/agent-protocol' +import { + agentIdentifierSchema, + sha256DigestSchema +} from '../shared/agent-protocol/contracts' +import { + runtimeRegistryEntrySchema, + runtimeRegistryStateSchema, + parseRuntimeRegistryState, + type RuntimeRegistryEntry, + type RuntimeRegistryState +} from '../shared/remote-environment-registry-contracts' +import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts' +import { + assertAbsoluteManagedPath, + assertPrivateRegularFile, + ensurePrivateDirectory, + writePrivateFileAtomic +} from './managed-paths' +import type { InstalledBundleVerificationEnvironment } from './installed-bundle-verifier' +import { + verifyRuntimeBundle, + type VerifiedRuntimeBundle +} from './runtime-bundle-verifier' + +export { + runtimeRegistryEntrySchema, + runtimeRegistryStateSchema +} +export type { + RuntimeRegistryEntry, + RuntimeRegistryState +} + +export class RuntimeBundleRegistry { + readonly #runtimeRoot: string + readonly #storagePath: string + #state: RuntimeRegistryState + + constructor(options: { + runtimeRoot: string + storagePath?: string + }) { + this.#runtimeRoot = assertAbsoluteManagedPath( + resolve(options.runtimeRoot) + ) + this.#storagePath = assertAbsoluteManagedPath( + resolve( + options.storagePath ?? + join(this.#runtimeRoot, 'registry.json') + ) + ) + ensurePrivateDirectory(this.#runtimeRoot) + ensurePrivateDirectory(dirname(this.#storagePath)) + this.#state = this.#load() + } + + snapshot(): RuntimeRegistryState { + return structuredClone(this.#state) + } + + register(verified: VerifiedRuntimeBundle): RuntimeRegistryEntry { + const entry = entryFromVerified(verified) + const expectedDirectory = this.bundleDirectory( + entry.runtimeId, + entry.bundleDigest + ) + if (resolve(verified.bundleDirectory) !== expectedDirectory) { + throw new Error( + 'Verified Runtime bundle is outside its digest directory' + ) + } + this.#state = this.#load({ createIfMissing: false }) + const existing = this.#state.current.find( + (candidate) => + candidate.runtimeId === entry.runtimeId && + candidate.architecture === entry.architecture + ) + if (existing !== undefined && entryEquals(existing, entry)) { + return existing + } + this.#commit({ + formatVersion: 1, + current: [ + ...this.#state.current.filter( + (candidate) => + candidate.runtimeId !== entry.runtimeId || + candidate.architecture !== entry.architecture + ), + entry + ].sort((left, right) => + `${left.runtimeId}:${left.architecture}`.localeCompare( + `${right.runtimeId}:${right.architecture}` + ) + ) + }) + return entry + } + + current( + architecture: AgentArchitecture + ): RuntimeRegistryEntry[] { + this.#state = this.#load({ createIfMissing: false }) + return this.#state.current + .filter((entry) => entry.architecture === architecture) + .map((entry) => ({ ...entry })) + } + + resolve( + runtimeIdInput: string, + bundleDigestInput: string, + architecture: AgentArchitecture + ): { + entry: RuntimeRegistryEntry + bundleDirectory: string + } { + const runtimeId = agentIdentifierSchema.parse(runtimeIdInput) + const bundleDigest = sha256DigestSchema.parse(bundleDigestInput) + this.#state = this.#load({ createIfMissing: false }) + const entry = this.#state.current.find( + (candidate) => + candidate.runtimeId === runtimeId && + candidate.bundleDigest === bundleDigest && + candidate.architecture === architecture + ) + if (entry === undefined) { + throw new Error( + 'Runtime bundle is not the verified current bundle' + ) + } + return { + entry: { ...entry }, + bundleDirectory: this.bundleDirectory( + runtimeId, + bundleDigest + ) + } + } + + bundleDirectory( + runtimeIdInput: string, + bundleDigestInput: string + ): string { + const runtimeId = agentIdentifierSchema.parse(runtimeIdInput) + const bundleDigest = sha256DigestSchema.parse(bundleDigestInput) + return resolve( + this.#runtimeRoot, + runtimeId, + bundleDigest.slice('sha256:'.length) + ) + } + + #load( + options: { createIfMissing?: boolean } = {} + ): RuntimeRegistryState { + let value: unknown + try { + assertPrivateRegularFile(this.#storagePath) + value = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + if ( + options.createIfMissing !== false && + isNodeError(error) && + error.code === 'ENOENT' + ) { + const initial: RuntimeRegistryState = { + formatVersion: 1, + current: [] + } + writePrivateFileAtomic( + this.#storagePath, + `${JSON.stringify(initial, null, 2)}\n` + ) + return initial + } + throw new Error('Runtime bundle registry is corrupt', { + cause: error + }) + } + let parsed: RuntimeRegistryState + try { + parsed = parseRuntimeRegistryState(value) + } catch (error) { + throw new Error( + 'Runtime bundle registry is corrupt', + { cause: error } + ) + } + if (JSON.stringify(parsed) !== JSON.stringify(value)) { + writePrivateFileAtomic( + this.#storagePath, + `${JSON.stringify(parsed, null, 2)}\n` + ) + } + return parsed + } + + #commit(stateInput: RuntimeRegistryState): void { + const state = runtimeRegistryStateSchema.parse(stateInput) + let persisted: unknown + try { + assertPrivateRegularFile(this.#storagePath) + persisted = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + throw new Error( + 'Runtime bundle registry changed or became corrupt before commit', + { cause: error } + ) + } + const parsedPersisted = + runtimeRegistryStateSchema.safeParse(persisted) + if ( + !parsedPersisted.success || + JSON.stringify(parsedPersisted.data) !== JSON.stringify(this.#state) + ) { + throw new Error( + 'Runtime bundle registry changed concurrently; refusing to overwrite it' + ) + } + writePrivateFileAtomic( + this.#storagePath, + `${JSON.stringify(state, null, 2)}\n` + ) + this.#state = state + } +} + +export function createVerifiedRuntimeCapabilitySource(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 { + 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 { + const capabilities: DaemonCapabilities['runtimes'] = [] + for (const entry of options.registry.current(options.architecture)) { + try { + const verified = await verifyRuntimeBundle( + 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' + ) + } + capabilities.push({ + runtimeId: entry.runtimeId, + version: entry.runtimeVersion, + bundleDigest: entry.bundleDigest, + acpCapabilitiesDigest: entry.acpCapabilitiesDigest, + sessionLoad: true, + sessionResume: true + }) + } catch (error) { + options.reportError?.( + `Verified Runtime unavailable: ${entry.runtimeId}`, + error + ) + } + } + return capabilities +} + +function entryFromVerified( + verified: VerifiedRuntimeBundle +): RuntimeRegistryEntry { + return runtimeRegistryEntrySchema.parse({ + runtimeId: verified.manifest.runtimeId, + runtimeVersion: verified.manifest.runtimeVersion, + architecture: verified.manifest.architecture, + bundleDigest: verified.manifest.bundleDigest, + manifestDigest: verified.manifestDigest, + acpCapabilitiesDigest: + verified.manifest.acpCapabilitiesDigest + }) +} + +function entryEquals( + left: RuntimeRegistryEntry, + right: RuntimeRegistryEntry +): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/agent-daemon/runtime-bundle-test-fixture.ts b/src/agent-daemon/runtime-bundle-test-fixture.ts new file mode 100644 index 00000000..2f082807 --- /dev/null +++ b/src/agent-daemon/runtime-bundle-test-fixture.ts @@ -0,0 +1,242 @@ +import { + createHash, + generateKeyPairSync, + sign +} from 'node:crypto' +import { + chmodSync, + mkdirSync, + mkdtempSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import type { + AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import { + digestRemoteRuntimeBundleIdentity, + remoteRuntimeBundleManifestSchema, + type RemoteRuntimeBundleManifest, + type RemoteRuntimeLock +} from '../shared/remote-runtime-launch-contracts' +import { + canonicalRuntimeManifestBytes, + runtimeManifestSignaturePayload +} from './runtime-bundle-verifier' + +const RAW_DIGEST = 'a'.repeat(64) + +export const TEST_REMOTE_RUNTIME_LOCK: RemoteRuntimeLock = { + formatVersion: 1, + runtimes: { + opencode: { + version: '1.18.9', + provider: 'opencode', + entrypoint: 'bin/opencode', + entrypointIdentity: 'opencode-acp', + argvPrefix: ['acp'], + allowedEnvironmentNames: [ + '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==' + }, + arm64: { + package: 'opencode-linux-arm64', + integrity: + 'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A==' + } + } + } + } +} + +export async function createRuntimeBundleTestFixture( + overrides: { + sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] + directoryDigest?: string + } = {} +): Promise<{ + root: string + bundleDirectory: string + manifest: RemoteRuntimeBundleManifest + registry: AgentReleaseKeyRegistry +}> { + const root = resolve( + mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-bundle-')) + ) + if (process.platform !== 'win32') { + chmodSync(root, 0o700) + } + const binary = elfHeader('x64') + const license = Buffer.from('MIT\n', 'utf8') + const initial = remoteRuntimeBundleManifestSchema.parse({ + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + provider: 'opencode', + platform: 'linux', + architecture: 'x64', + signingKeyId: 'runtime-test', + bundleDigest: `sha256:${RAW_DIGEST}`, + adapterDigest: `sha256:${'b'.repeat(64)}`, + sourcePackage: + overrides.sourcePackage ?? + { + name: + TEST_REMOTE_RUNTIME_LOCK.runtimes.opencode.targets.x64 + .package, + integrity: + TEST_REMOTE_RUNTIME_LOCK.runtimes.opencode.targets.x64 + .integrity + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: sha256(binary), + argvPrefix: ['acp'] + }, + files: [ + { + path: 'bin/opencode', + size: binary.byteLength, + sha256: sha256(binary), + mode: '0755' + }, + { + path: 'licenses/opencode.txt', + size: license.byteLength, + sha256: sha256(license), + mode: '0644' + } + ], + licenses: [ + { + package: 'opencode-ai', + version: '1.18.9', + spdx: 'MIT', + path: 'licenses/opencode.txt' + } + ], + 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:${'d'.repeat(64)}`, + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 4096, + maximumPromptOutputBytes: 1024 * 1024 + } + }) + const manifest = remoteRuntimeBundleManifestSchema.parse({ + ...initial, + bundleDigest: + await digestRemoteRuntimeBundleIdentity(initial) + }) + const directoryDigest = + overrides.directoryDigest ?? manifest.bundleDigest + const bundleDirectory = resolve( + root, + 'opencode', + directoryDigest.slice('sha256:'.length) + ) + writePayload( + join(bundleDirectory, 'bin', 'opencode'), + binary, + 0o755 + ) + writePayload( + join(bundleDirectory, 'licenses', 'opencode.txt'), + license, + 0o644 + ) + + const { publicKey, privateKey } = + generateKeyPairSync('ed25519') + const registry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [ + { + keyId: 'runtime-test', + publicKeySpkiBase64: publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + environment: 'test' + } + ], + revocations: [] + } + const manifestBytes = canonicalRuntimeManifestBytes(manifest) + writePayload( + join(bundleDirectory, 'manifest.json'), + manifestBytes, + 0o644 + ) + writePayload( + join(bundleDirectory, 'manifest.sig'), + Buffer.from( + `${sign( + null, + runtimeManifestSignaturePayload(manifestBytes), + privateKey + ).toString('base64')}\n`, + 'utf8' + ), + 0o644 + ) + if (process.platform !== 'win32') { + chmodSync(bundleDirectory, 0o700) + chmodSync(join(bundleDirectory, 'bin'), 0o700) + chmodSync(join(bundleDirectory, 'licenses'), 0o700) + } + return { root, bundleDirectory, manifest, registry } +} + +function writePayload( + path: string, + contents: Uint8Array, + mode: number +): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, contents, { mode }) + if (process.platform !== 'win32') { + chmodSync(path, mode) + } +} + +function elfHeader(architecture: 'x64' | 'arm64'): Buffer { + const header = Buffer.alloc(64) + header[0] = 0x7f + header.write('ELF', 1, 'ascii') + header[4] = 2 + header[5] = 1 + header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18) + return header +} + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} diff --git a/src/agent-daemon/runtime-bundle-verifier.test.ts b/src/agent-daemon/runtime-bundle-verifier.test.ts new file mode 100644 index 00000000..337c1470 --- /dev/null +++ b/src/agent-daemon/runtime-bundle-verifier.test.ts @@ -0,0 +1,220 @@ +import { + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type RemoteRuntimeBundleManifest +} from '../shared/remote-runtime-launch-contracts' +import { verifyRuntimeBundle } from './runtime-bundle-verifier' +import { + RuntimeBundleRegistry, + createVerifiedRuntimeCapabilitySource +} from './runtime-bundle-registry' +import { + createRuntimeBundleTestFixture, + TEST_REMOTE_RUNTIME_LOCK +} from './runtime-bundle-test-fixture' + +const temporaryPaths: string[] = [] +const lock = TEST_REMOTE_RUNTIME_LOCK + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('Remote Runtime bundle verifier', () => { + it('verifies an authentic digest-addressed OpenCode bundle', async () => { + const fixture = await createBundle() + + await expect( + verifyRuntimeBundle(fixture.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).resolves.toMatchObject({ + bundleDirectory: fixture.bundleDirectory, + executablePath: join( + fixture.bundleDirectory, + 'bin', + 'opencode' + ), + manifest: fixture.manifest, + manifestDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u) + }) + }) + + it('fails closed on test signing keys in production verification', async () => { + const fixture = await createBundle() + + await expect( + verifyRuntimeBundle(fixture.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: lock, + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/production key registry is empty/iu) + }) + + it('rejects a forged signature and payload mutation', async () => { + const forged = await createBundle() + const signaturePath = join( + forged.bundleDirectory, + 'manifest.sig' + ) + const signature = Buffer.from( + readFileSync(signaturePath, 'utf8').trim(), + 'base64' + ) + signature[0] ^= 0xff + writeFileSync( + signaturePath, + `${signature.toString('base64')}\n` + ) + await expect( + verifyRuntimeBundle(forged.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: forged.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/signature verification failed/iu) + + const mutated = await createBundle() + writeFileSync( + join(mutated.bundleDirectory, 'licenses', 'opencode.txt'), + 'mutated' + ) + await expect( + verifyRuntimeBundle(mutated.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: mutated.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/payload (?:size|hash) mismatch/iu) + }) + + it('rejects a validly signed bundle outside the locked profile', async () => { + const fixture = await createBundle({ + sourcePackage: { + name: 'opencode-linux-x64', + integrity: + 'sha512-VrvzV5Agrj0T2ZPvr5gzmh8xc4zqQ5pW8UeNgTzt5cJ/9Cbdxw6oFywgv7nfJqlpSfQqTWYJYH+LIHt3QdCS5g==' + } + }) + + await expect( + verifyRuntimeBundle(fixture.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/locked OpenCode profile/iu) + }) + + it('rejects undeclared files and a mismatched digest directory', async () => { + const undeclared = await createBundle() + writeFileSync( + join(undeclared.bundleDirectory, 'unexpected.txt'), + 'unexpected' + ) + await expect( + verifyRuntimeBundle(undeclared.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: undeclared.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/undeclared or missing/iu) + + const mismatched = await createBundle({ + directoryDigest: `sha256:${'f'.repeat(64)}` + }) + await expect( + verifyRuntimeBundle(mismatched.bundleDirectory, { + architecture: 'x64', + releaseKeyRegistry: mismatched.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + ).rejects.toThrow(/managed directory/iu) + }) + + it('advertises only a bundle that still verifies against its registry', async () => { + const fixture = await createBundle() + const runtimeRoot = dirname(dirname(fixture.bundleDirectory)) + const registry = new RuntimeBundleRegistry({ + runtimeRoot + }) + const verified = await verifyRuntimeBundle( + fixture.bundleDirectory, + { + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + } + ) + new RuntimeBundleRegistry({ runtimeRoot }).register(verified) + const reportError = vi.fn() + const capabilities = createVerifiedRuntimeCapabilitySource({ + registry, + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: lock, + verificationEnvironment: 'test', + filesystemPlatform: 'win32', + reportError + }) + await expect(capabilities()).resolves.toEqual([ + { + runtimeId: 'opencode', + version: '1.18.9', + bundleDigest: fixture.manifest.bundleDigest, + acpCapabilitiesDigest: + fixture.manifest.acpCapabilitiesDigest, + sessionLoad: true, + sessionResume: true + } + ]) + + writeFileSync( + join(fixture.bundleDirectory, 'licenses', 'opencode.txt'), + 'mutated' + ) + await expect(capabilities()).resolves.toEqual([]) + expect(reportError).toHaveBeenCalledWith( + 'Verified Runtime unavailable: opencode', + expect.any(Error) + ) + }) +}) + +async function createBundle( + overrides: { + sourcePackage?: RemoteRuntimeBundleManifest['sourcePackage'] + directoryDigest?: string + } = {} +): Promise +>> { + const fixture = await createRuntimeBundleTestFixture(overrides) + temporaryPaths.push(fixture.root) + return fixture +} diff --git a/src/agent-daemon/runtime-bundle-verifier.ts b/src/agent-daemon/runtime-bundle-verifier.ts new file mode 100644 index 00000000..4d7456df --- /dev/null +++ b/src/agent-daemon/runtime-bundle-verifier.ts @@ -0,0 +1,12 @@ +export { + canonicalRuntimeManifestBytes, + readRemoteRuntimeLock, + runtimeManifestSignaturePayload, + verifyRuntimeBundle, + verifyRuntimeManifestSignature +} from '../shared/node/runtime-bundle-verifier' + +export type { + VerifiedRuntimeBundle, + VerifyRuntimeBundleOptions +} from '../shared/node/runtime-bundle-verifier' diff --git a/src/agent-daemon/runtime-composition.test.ts b/src/agent-daemon/runtime-composition.test.ts new file mode 100644 index 00000000..5c82a506 --- /dev/null +++ b/src/agent-daemon/runtime-composition.test.ts @@ -0,0 +1,272 @@ +import { + chmodSync, + lstatSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ControllerRegistry } from './controller-registry' +import { EventJournal } from './event-journal' +import { RuntimeBundleRegistry } from './runtime-bundle-registry' +import { + createRuntimeBundleTestFixture, + TEST_REMOTE_RUNTIME_LOCK +} from './runtime-bundle-test-fixture' +import { verifyRuntimeBundle } from './runtime-bundle-verifier' +import { + createProductionRuntimeProtocol, + derivePrivateModelBridgeDirectory, + derivePrivateRuntimeScratchDirectory +} from './runtime-composition' +import { + MODEL_BRIDGE_BROKER_SOCKET_NAME +} from './model-bridge-broker' +import { WorkspaceRegistry } from './workspace-registry' + +const temporaryPaths: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('production Runtime composition', () => { + it('keeps ACP live and reloads managed metadata installed after daemon startup', async () => { + const fixture = await createRuntimeBundleTestFixture() + const root = fixture.root + temporaryPaths.push(root) + const runtimeRoot = root + const stateDirectory = resolve(root, 'state') + mkdirSync(stateDirectory, { mode: 0o700 }) + const agentExecutablePath = resolve(root, 'goodbuddy-agent') + writeFileSync(agentExecutablePath, 'agent', { mode: 0o755 }) + const reportError = vi.fn() + const events = new EventJournal(resolve(root, 'events.sqlite')) + const workspaces = new WorkspaceRegistry({ + controllers: new ControllerRegistry() + }) + + const protocol = await createProductionRuntimeProtocol({ + runtimeRoot, + stateDirectory, + architecture: 'x64', + events, + workspaces, + outputSink: async () => {}, + blobSink: async () => {}, + agentExecutablePath, + reportError, + prerequisitesAvailable: async () => true, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + + expect(Object.keys(protocol.methods)).toContain( + 'runtime/openAcpChannel' + ) + await expect(protocol.runtimes()).resolves.toEqual([]) + expect(reportError).toHaveBeenCalledWith( + expect.stringContaining('Runtime metadata'), + expect.anything() + ) + + writeFileSync( + resolve(runtimeRoot, 'release-keys.json'), + `${JSON.stringify(fixture.registry, null, 2)}\n`, + { mode: 0o600 } + ) + writeFileSync( + resolve(runtimeRoot, 'remote-runtime-lock.json'), + `${JSON.stringify(TEST_REMOTE_RUNTIME_LOCK, null, 2)}\n`, + { mode: 0o600 } + ) + const verified = await verifyRuntimeBundle( + fixture.bundleDirectory, + { + architecture: 'x64', + releaseKeyRegistry: fixture.registry, + runtimeLock: TEST_REMOTE_RUNTIME_LOCK, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + } + ) + new RuntimeBundleRegistry({ runtimeRoot }).register(verified) + reportError.mockClear() + + await expect(protocol.runtimes()).resolves.toEqual([ + { + runtimeId: 'opencode', + version: '1.18.9', + bundleDigest: fixture.manifest.bundleDigest, + acpCapabilitiesDigest: + fixture.manifest.acpCapabilitiesDigest, + sessionLoad: true, + sessionResume: true + } + ]) + expect(reportError).not.toHaveBeenCalled() + + writeFileSync( + resolve(runtimeRoot, 'release-keys.json'), + `${JSON.stringify({ + formatVersion: 1, + keys: [], + revocations: [] + }, null, 2)}\n`, + { mode: 0o600 } + ) + await expect(protocol.runtimes()).resolves.toEqual([]) + expect(reportError).toHaveBeenCalledWith( + 'Verified Runtime unavailable: opencode', + expect.any(Error) + ) + + await expect(protocol.dispose()).resolves.toBeUndefined() + events.close() + }) + + it('composes all ACP v3 methods but advertises no Runtime when prerequisites fail', async () => { + const root = temporaryDirectory() + const runtimeRoot = resolve(root, 'runtimes') + const stateDirectory = resolve(root, 'state') + mkdirSync(stateDirectory, { mode: 0o700 }) + const releaseKeyRegistry = { + formatVersion: 1 as const, + keys: [], + revocations: [] + } + const registry = new RuntimeBundleRegistry({ + runtimeRoot + }) + const events = new EventJournal(resolve(root, 'events.sqlite')) + const workspaces = new WorkspaceRegistry({ + controllers: new ControllerRegistry() + }) + const protocol = await createProductionRuntimeProtocol({ + runtimeRoot, + stateDirectory, + architecture: 'x64', + events, + workspaces, + outputSink: async () => {}, + releaseKeyRegistry, + runtimeLock: {} as RemoteRuntimeLock, + registry, + prerequisitesAvailable: async () => false, + verificationEnvironment: 'test', + filesystemPlatform: 'win32' + }) + + expect(Object.keys(protocol!.methods).sort()).toEqual([ + 'runtime/closeAcpChannel', + 'runtime/completePrompt', + 'runtime/escalateCancellation', + 'runtime/getAcpCursors', + 'runtime/openAcpChannel', + 'runtime/preparePrompt', + 'runtime/reconcilePrompt', + 'runtime/replayAcpChannel', + 'runtime/resumeAcpChannel' + ]) + await expect(protocol!.runtimes()).resolves.toEqual([]) + await expect(protocol!.dispose()).resolves.toBeUndefined() + events.close() + }) + + it('derives private binding scratch only beneath Agent state', () => { + const root = temporaryDirectory() + const stateDirectory = resolve(root, 'state') + mkdirSync(stateDirectory, { mode: 0o700 }) + const binding = { + bindingId: 'binding-1', + runtimeBundleDigest: `sha256:${'a'.repeat(64)}`, + workspaceIdentity: 'workspace-1', + controllerId: 'controller-1', + controllerGeneration: 1 + } + const first = derivePrivateRuntimeScratchDirectory( + stateDirectory, + binding + ) + const second = derivePrivateRuntimeScratchDirectory( + stateDirectory, + binding + ) + const other = derivePrivateRuntimeScratchDirectory( + stateDirectory, + { ...binding, bindingId: 'binding-2' } + ) + const nextGeneration = derivePrivateRuntimeScratchDirectory( + stateDirectory, + { + ...binding, + controllerGeneration: 2, + controllerId: 'controller-2' + } + ) + + expect(first).toBe(second) + expect(first).not.toBe(other) + expect(nextGeneration).toBe(first) + expect(first.startsWith(resolve(stateDirectory, 'runtime-scratch'))).toBe( + true + ) + expect(lstatSync(first).isDirectory()).toBe(true) + if (process.platform !== 'win32') { + expect(lstatSync(first).mode & 0o777).toBe(0o700) + } + }) + + it('keeps the Linux model bridge endpoint within the Unix socket limit', () => { + const root = temporaryDirectory() + const stateDirectory = resolve(root, 'state') + mkdirSync(stateDirectory, { mode: 0o700 }) + const bridgeDirectory = derivePrivateModelBridgeDirectory( + stateDirectory, + { + bindingId: 'binding-1', + runtimeBundleDigest: `sha256:${'a'.repeat(64)}`, + workspaceIdentity: 'workspace-1' + } + ) + + if (process.platform === 'linux') { + temporaryPaths.push(bridgeDirectory) + expect( + Buffer.byteLength( + join( + bridgeDirectory, + MODEL_BRIDGE_BROKER_SOCKET_NAME + ), + 'utf8' + ) + ).toBeLessThanOrEqual(107) + expect(bridgeDirectory).toMatch( + /^\/tmp\/goodbuddy-[0-9]+-[0-9a-f]{20}\/mb\/[0-9a-f]{32}$/u + ) + } else { + expect( + bridgeDirectory.startsWith( + resolve(stateDirectory, 'model-bridges') + ) + ).toBe(true) + } + }) +}) + +function temporaryDirectory(): string { + const path = mkdtempSync( + join(tmpdir(), 'goodbuddy-runtime-composition-') + ) + temporaryPaths.push(path) + if (process.platform !== 'win32') { + chmodSync(path, 0o700) + } + return path +} diff --git a/src/agent-daemon/runtime-composition.ts b/src/agent-daemon/runtime-composition.ts new file mode 100644 index 00000000..f89b1570 --- /dev/null +++ b/src/agent-daemon/runtime-composition.ts @@ -0,0 +1,474 @@ +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { join, resolve } from 'node:path' +import type { + AgentArchitecture, + AgentReleaseKeyRegistry +} from '../shared/agent-installation-contracts' +import type { AgentFrame } from '../shared/agent-protocol' +import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts' +import type { EventJournal } from './event-journal' +import { + derivePrivateTemporaryRoot, + ensurePrivateDirectory, + ensurePrivateDirectoryTree, + ensurePrivateTemporaryRoot +} from './managed-paths' +import { readManagedAgentReleaseKeyRegistry } from './installed-bundle-verifier' +import { + RuntimeBundleRegistry, + createVerifiedRuntimeCapabilitySource +} from './runtime-bundle-registry' +import { + readRemoteRuntimeLock, + verifyRuntimeBundle +} from './runtime-bundle-verifier' +import { RuntimeAcpBackend } from './runtime-acp-backend' +import type { ProtocolMethodContext } from './protocol-server' +import { + launchDirectLinuxStdioProcessOwner, + reconcileOrphanedDirectLinuxStdioProcesses +} from './direct-linux-stdio-process-owner' +import { RuntimeOwnerRegistry } from './runtime-owner-registry' +import type { WorkspaceRegistry } from './workspace-registry' + +const BWRAP_EXECUTABLE = '/usr/bin/bwrap' + +export type ProductionRuntimeProtocol = { + runtimes: ReturnType + methods: RuntimeAcpBackend['methods'] + onAcpFrame: RuntimeAcpBackend['onAcpFrame'] + onBlobFrame?: RuntimeAcpBackend['onBlobFrame'] + authorizeBlobFrame?: RuntimeAcpBackend['authorizeBlobFrame'] + dispose: () => Promise +} + +export type ProductionRuntimeCompositionOptions = { + runtimeRoot: string + stateDirectory: string + architecture: AgentArchitecture + events: EventJournal + workspaces: WorkspaceRegistry + outputSink: ( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ) => void | Promise + blobSink?: ( + frame: AgentFrame, + context: { + bindingId: string + controllerId: string + controllerGeneration: number + } + ) => void | Promise + agentExecutablePath?: string + installationId?: string + agentServiceUnitName?: string + releaseKeyRegistry?: AgentReleaseKeyRegistry + runtimeLock?: RemoteRuntimeLock + registry?: RuntimeBundleRegistry + prerequisitesAvailable?: () => boolean | Promise + launchProcess?: typeof launchDirectLinuxStdioProcessOwner + reconcileOrphanedProcesses?: ( + installationId: string + ) => Promise + ownerRegistry?: RuntimeOwnerRegistry + verificationEnvironment?: 'production' | 'test' + filesystemPlatform?: NodeJS.Platform + reportError?: (message: string, error: unknown) => void +} + +export async function createProductionRuntimeProtocol( + options: ProductionRuntimeCompositionOptions +): Promise { + const runtimeRoot = resolve(options.runtimeRoot) + const stateDirectory = resolve(options.stateDirectory) + const verificationEnvironment = + options.verificationEnvironment ?? 'production' + const installationId = + options.installationId ?? + (verificationEnvironment === 'test' + ? 'test-installation' + : undefined) + if (installationId === undefined) { + throw new Error( + 'Production Runtime composition requires its Agent installation identity' + ) + } + ensurePrivateDirectory(stateDirectory, { create: false }) + const ownerRegistry = + options.ownerRegistry ?? + new RuntimeOwnerRegistry(join(stateDirectory, 'runtime-owners.sqlite')) + let registry = options.registry + const currentRegistry = (): RuntimeBundleRegistry => { + if (registry === undefined) { + registry = new RuntimeBundleRegistry({ runtimeRoot }) + } + 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 + } + const verifiedCapabilities = async () => { + const source = createVerifiedRuntimeCapabilitySource({ + registry: currentRegistry(), + ...(await currentVerificationOptions()), + reportError: options.reportError + }) + return await source() + } + const prerequisites = + options.prerequisitesAvailable ?? + productionRuntimePrerequisitesAvailable + const launchProcess = + options.launchProcess ?? launchDirectLinuxStdioProcessOwner + let backend: RuntimeAcpBackend + try { + if ( + verificationEnvironment === 'production' && + process.platform === 'linux' + ) { + await ( + options.reconcileOrphanedProcesses ?? + (async (currentInstallationId: string) => + await reconcileOrphanedDirectLinuxStdioProcesses({ + installationId: currentInstallationId, + registry: ownerRegistry + })) + )(installationId) + } + ensurePrivateDirectoryTree( + join(stateDirectory, 'runtime-scratch'), + stateDirectory + ) + ensurePrivateDirectoryTree( + join(stateDirectory, 'model-bridges'), + stateDirectory + ) + backend = new RuntimeAcpBackend({ + journal: options.events, + resolveRuntimeBundle: (runtimeId, bundleDigest) => + currentRegistry().resolve( + runtimeId, + bundleDigest, + options.architecture + ), + reverifyRuntimeBundle: async (resolved) => { + const verified = await verifyRuntimeBundle( + resolved.bundleDirectory, + await currentVerificationOptions() + ) + 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( + options.workspaces, + stateDirectory, + preparation, + context + ), + launchProcess: async (launch) => + await launchProcess({ + manifest: launch.manifest, + profileInput: { + bundleDirectory: launch.bundle.bundleDirectory, + workspaceDirectory: launch.workspace.workspaceDirectory, + scratchDirectory: launch.scratch, + workMode: launch.workMode, + ...(launch.modelBridge === undefined || + options.agentExecutablePath === undefined + ? {} + : { + modelBridge: { + agentExecutablePath: resolve( + options.agentExecutablePath + ), + bridgeDirectory: + launch.workspace.bridgeDirectory!, + socketPath: launch.modelBridge.socketPath, + policy: launch.modelBridge.policy + } + }) + }, + identity: { + launchId: `launch-${randomUUID()}`, + processId: `process-${randomUUID()}` + }, + installationId, + registry: ownerRegistry, + deadlineAt: launch.deadlineAt, + maximumInputBytes: launch.budget.maximumInputBytes + }), + outputSink: options.outputSink, + ...(options.blobSink === undefined + ? {} + : { blobSink: options.blobSink }) + }) + } catch (error) { + if (options.ownerRegistry === undefined) { + ownerRegistry.close() + } + throw error + } + + return { + runtimes: async () => { + try { + if ( + options.blobSink === undefined || + options.agentExecutablePath === undefined || + !(await executableAvailable( + resolve(options.agentExecutablePath) + )) || + !(await prerequisites()) + ) { + return [] + } + } catch (error) { + options.reportError?.( + 'Agent Runtime prerequisites are unavailable', + error + ) + return [] + } + try { + return await verifiedCapabilities() + } catch (error) { + options.reportError?.( + 'Agent Runtime metadata is unavailable; Runtime remains disabled', + error + ) + return [] + } + }, + methods: backend.methods, + onAcpFrame: backend.onAcpFrame, + ...(options.blobSink === undefined || + options.agentExecutablePath === undefined + ? {} + : { + onBlobFrame: backend.onBlobFrame, + authorizeBlobFrame: backend.authorizeBlobFrame + }), + dispose: async () => { + await backend.dispose() + if (options.ownerRegistry === undefined) { + ownerRegistry.close() + } + } + } +} + +async function executableAvailable(path: string): Promise { + try { + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +async function resolveCurrentWorkspace( + workspaces: WorkspaceRegistry, + stateDirectory: string, + preparation: { + bindingId: string + runtimeBundleDigest: string + workspaceIdentity: string + }, + context: ProtocolMethodContext +): Promise<{ + workspaceIdentity: string + workspaceDirectory: string + scratchDirectory: string + bridgeDirectory?: string +}> { + const lease = await workspaces.getCurrentByIdentity( + preparation.workspaceIdentity, + context.controller, + { signal: context.signal } + ) + const scratchDirectory = derivePrivateRuntimeScratchDirectory( + stateDirectory, + { + bindingId: preparation.bindingId, + runtimeBundleDigest: preparation.runtimeBundleDigest, + workspaceIdentity: preparation.workspaceIdentity + } + ) + const bridgeDirectory = + preparation.modelBridge === undefined + ? undefined + : derivePrivateModelBridgeDirectory( + stateDirectory, + { + bindingId: preparation.bindingId, + runtimeBundleDigest: + preparation.runtimeBundleDigest, + workspaceIdentity: + preparation.workspaceIdentity + } + ) + return { + workspaceIdentity: lease.handle.workspaceIdentity, + workspaceDirectory: lease.access.root.canonicalPath, + scratchDirectory, + ...(bridgeDirectory === undefined + ? {} + : { bridgeDirectory }) + } +} + +export function derivePrivateModelBridgeDirectory( + stateDirectoryInput: string, + binding: { + bindingId: string + runtimeBundleDigest: string + workspaceIdentity: string + controllerId?: string + controllerGeneration?: number + } +): string { + if (process.platform === 'linux') { + const stateDirectory = resolve(stateDirectoryInput) + ensurePrivateDirectory(stateDirectory, { create: false }) + const userRoot = ensurePrivateTemporaryRoot( + derivePrivateTemporaryRoot() + ) + const bridgeRoot = join(userRoot, 'mb') + ensurePrivateDirectoryTree(bridgeRoot, userRoot) + const bindingDirectory = join( + bridgeRoot, + createHash('sha256') + .update( + JSON.stringify([ + 'goodbuddy-model-bridge-v2', + stateDirectory, + binding.bindingId, + binding.runtimeBundleDigest, + binding.workspaceIdentity + ]) + ) + .digest('hex') + .slice(0, 32) + ) + ensurePrivateDirectoryTree(bindingDirectory, bridgeRoot) + return bindingDirectory + } + return derivePrivateBindingDirectory( + stateDirectoryInput, + 'model-bridges', + 'goodbuddy-model-bridge-v1', + binding + ) +} + +export function derivePrivateRuntimeScratchDirectory( + stateDirectoryInput: string, + binding: { + bindingId: string + runtimeBundleDigest: string + workspaceIdentity: string + controllerId?: string + controllerGeneration?: number + } +): string { + return derivePrivateBindingDirectory( + stateDirectoryInput, + 'runtime-scratch', + 'goodbuddy-runtime-scratch-v1', + binding + ) +} + +function derivePrivateBindingDirectory( + stateDirectoryInput: string, + rootName: 'model-bridges' | 'runtime-scratch', + digestDomain: + | 'goodbuddy-model-bridge-v1' + | 'goodbuddy-runtime-scratch-v1', + binding: { + bindingId: string + runtimeBundleDigest: string + workspaceIdentity: string + } +): string { + const stateDirectory = resolve(stateDirectoryInput) + ensurePrivateDirectory(stateDirectory, { create: false }) + const bindingRoot = join(stateDirectory, rootName) + ensurePrivateDirectoryTree(bindingRoot, stateDirectory) + const bindingDigest = bindingDirectoryDigest( + digestDomain, + binding + ) + const bindingDirectory = join(bindingRoot, bindingDigest) + ensurePrivateDirectoryTree(bindingDirectory, bindingRoot) + return bindingDirectory +} + +function bindingDirectoryDigest( + digestDomain: + | 'goodbuddy-model-bridge-v1' + | 'goodbuddy-runtime-scratch-v1', + binding: { + bindingId: string + runtimeBundleDigest: string + workspaceIdentity: string + } +): string { + return createHash('sha256') + .update(JSON.stringify([ + digestDomain, + binding.bindingId, + binding.runtimeBundleDigest, + binding.workspaceIdentity + ])) + .digest('hex') +} + +async function productionRuntimePrerequisitesAvailable(): Promise { + try { + await access(BWRAP_EXECUTABLE, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/src/agent-daemon/runtime-owner-registry.test.ts b/src/agent-daemon/runtime-owner-registry.test.ts new file mode 100644 index 00000000..c3836a00 --- /dev/null +++ b/src/agent-daemon/runtime-owner-registry.test.ts @@ -0,0 +1,68 @@ +import { chmodSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RuntimeOwnerRegistry } from './runtime-owner-registry' + +const paths: string[] = [] +afterEach(() => { + for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true }) +}) + +describe('RuntimeOwnerRegistry', () => { + it('durably tracks only active owners per installation', () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owner-registry-')) + paths.push(root) + if (process.platform !== 'win32') chmodSync(root, 0o700) + const path = join(root, 'owners.sqlite') + const registry = new RuntimeOwnerRegistry(path, { now: () => 10 }) + registry.reserve(fixture()) + registry.markRunning('owner-1', identity()) + registry.markStopping('owner-1') + registry.remove('owner-1', 'stopping') + expect(registry.listForInstallation('installation-1')).toEqual([]) + expect(registry.listForInstallation('installation-2')).toEqual([]) + expect(() => registry.markStopping('owner-1')).toThrow(/transition conflict/iu) + registry.close() + + const reopened = new RuntimeOwnerRegistry(path) + expect(reopened.get('owner-1')).toBeUndefined() + reopened.close() + }) + + it('persists running owner identity across restarts', () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owner-registry-')) + paths.push(root) + if (process.platform !== 'win32') chmodSync(root, 0o700) + const registry = new RuntimeOwnerRegistry(join(root, 'owners.sqlite')) + registry.reserve(fixture()) + registry.markRunning('owner-1', identity()) + registry.close() + const reopened = new RuntimeOwnerRegistry(join(root, 'owners.sqlite')) + expect(reopened.get('owner-1')).toMatchObject({ + state: 'running', + processIdentity: identity() + }) + reopened.close() + }) +}) + +function fixture() { + return { + ownerId: 'owner-1', + launchId: 'launch-1', + processId: 'process-1', + installationId: 'installation-1', + ownerToken: 'a'.repeat(32) + } +} + +function identity() { + return { + bootId: '11111111-1111-1111-1111-111111111111', + pid: 42, + startTimeTicks: 100n, + processGroupId: 42, + executablePath: '/usr/bin/bwrap' + } +} diff --git a/src/agent-daemon/runtime-owner-registry.ts b/src/agent-daemon/runtime-owner-registry.ts new file mode 100644 index 00000000..0c66fb99 --- /dev/null +++ b/src/agent-daemon/runtime-owner-registry.ts @@ -0,0 +1,232 @@ +import type { DatabaseSync, StatementSync } from 'node:sqlite' +import { agentIdentifierSchema } from '../shared/agent-protocol/contracts' +import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database' +import { + parseSerializedLinuxRuntimeProcessIdentity, + serializeLinuxRuntimeProcessIdentity, + type LinuxRuntimeProcessIdentity +} from './runtime-process-identity' + +export type RuntimeOwnerState = 'reserved' | 'running' | 'stopping' + +export type RuntimeOwnerRecord = { + ownerId: string + launchId: string + processId: string + installationId: string + ownerToken: string + state: RuntimeOwnerState + processIdentity?: LinuxRuntimeProcessIdentity + createdAt: number + updatedAt: number +} + +type RuntimeOwnerRow = { + owner_id: string + launch_id: string + process_id: string + installation_id: string + owner_token: string + state: RuntimeOwnerState + process_identity_json: string | null + created_at: number + updated_at: number +} + +export class RuntimeOwnerRegistry { + readonly #database: DatabaseSync + readonly #now: () => number + readonly #statements: { + insert: StatementSync + get: StatementSync + listInstallation: StatementSync + transition: StatementSync + remove: StatementSync + } + + constructor(path: string, options: { now?: () => number } = {}) { + this.#now = options.now ?? Date.now + this.#database = openPrivateSqliteDatabase(path) + this.#database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS active_runtime_owners ( + owner_id TEXT PRIMARY KEY, + launch_id TEXT NOT NULL UNIQUE, + process_id TEXT NOT NULL UNIQUE, + installation_id TEXT NOT NULL, + owner_token TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'running', 'stopping')), + process_identity_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS active_runtime_owners_installation + ON active_runtime_owners(installation_id, created_at); + `) + this.#migrateLegacyRecords() + this.#statements = { + insert: this.#database.prepare(` + INSERT INTO active_runtime_owners ( + owner_id, launch_id, process_id, installation_id, owner_token, + state, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?) + `), + get: this.#database.prepare( + 'SELECT * FROM active_runtime_owners WHERE owner_id = ?' + ), + listInstallation: this.#database.prepare(` + SELECT * FROM active_runtime_owners + WHERE installation_id = ? + ORDER BY created_at, owner_id + `), + transition: this.#database.prepare(` + UPDATE active_runtime_owners + SET state = ?, process_identity_json = ?, updated_at = ? + WHERE owner_id = ? AND state = ? + `), + remove: this.#database.prepare(` + DELETE FROM active_runtime_owners WHERE owner_id = ? AND state = ? + `) + } + } + + reserve(input: Omit< + RuntimeOwnerRecord, + 'state' | 'processIdentity' | 'createdAt' | 'updatedAt' + >): RuntimeOwnerRecord { + const record = validateReservation(input) + const now = this.#now() + this.#statements.insert.run( + record.ownerId, + record.launchId, + record.processId, + record.installationId, + record.ownerToken, + now, + now + ) + return { ...record, state: 'reserved', createdAt: now, updatedAt: now } + } + + get(ownerIdInput: string): RuntimeOwnerRecord | undefined { + const ownerId = agentIdentifierSchema.parse(ownerIdInput) + const row = this.#statements.get.get(ownerId) as RuntimeOwnerRow | undefined + return row === undefined ? undefined : decodeRow(row) + } + + listForInstallation(installationIdInput: string): RuntimeOwnerRecord[] { + const installationId = agentIdentifierSchema.parse(installationIdInput) + return ( + this.#statements.listInstallation.all(installationId) as RuntimeOwnerRow[] + ).map(decodeRow) + } + + markRunning( + ownerId: string, + identity: LinuxRuntimeProcessIdentity + ): RuntimeOwnerRecord { + return this.#transition(ownerId, 'reserved', 'running', identity) + } + + markStopping(ownerId: string): RuntimeOwnerRecord { + return this.#transition(ownerId, 'running', 'stopping') + } + + remove(ownerIdInput: string, from: RuntimeOwnerState): void { + const ownerId = agentIdentifierSchema.parse(ownerIdInput) + if (this.#statements.remove.run(ownerId, from).changes !== 1) { + throw new Error(`Runtime owner transition conflict: expected ${from}`) + } + } + + close(): void { + this.#database.close() + } + + #transition( + ownerIdInput: string, + from: RuntimeOwnerState, + to: RuntimeOwnerState, + identity?: LinuxRuntimeProcessIdentity + ): RuntimeOwnerRecord { + const ownerId = agentIdentifierSchema.parse(ownerIdInput) + const existing = this.get(ownerId) + if (existing === undefined || existing.state !== from) { + throw new Error(`Runtime owner transition conflict: expected ${from}`) + } + const processIdentity = identity ?? existing.processIdentity + if ( + this.#statements.transition.run( + to, + processIdentity === undefined + ? null + : serializeLinuxRuntimeProcessIdentity(processIdentity), + this.#now(), + ownerId, + from + ).changes !== 1 + ) { + throw new Error('Runtime owner transition conflict') + } + return this.get(ownerId)! + } + + #migrateLegacyRecords(): void { + const legacy = this.#database.prepare(` + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'runtime_process_owners' + `).get() + if (legacy === undefined) return + this.#database.exec(` + BEGIN IMMEDIATE; + INSERT OR IGNORE INTO active_runtime_owners ( + owner_id, launch_id, process_id, installation_id, owner_token, + state, process_identity_json, created_at, updated_at + ) + SELECT owner_id, launch_id, process_id, installation_id, owner_token, + state, process_identity_json, created_at, updated_at + FROM runtime_process_owners + WHERE state IN ('reserved', 'running', 'stopping'); + DROP TABLE runtime_process_owners; + COMMIT; + `) + } +} + +function validateReservation( + input: Omit< + RuntimeOwnerRecord, + 'state' | 'processIdentity' | 'createdAt' | 'updatedAt' + > +): typeof input { + agentIdentifierSchema.parse(input.ownerId) + agentIdentifierSchema.parse(input.launchId) + agentIdentifierSchema.parse(input.processId) + agentIdentifierSchema.parse(input.installationId) + if (!/^[a-f0-9]{32}$/u.test(input.ownerToken)) { + throw new Error('Runtime owner token marker is malformed') + } + return { ...input } +} + +function decodeRow(row: RuntimeOwnerRow): RuntimeOwnerRecord { + return { + ownerId: row.owner_id, + launchId: row.launch_id, + processId: row.process_id, + installationId: row.installation_id, + ownerToken: row.owner_token, + state: row.state, + ...(row.process_identity_json === null + ? {} + : { + processIdentity: parseSerializedLinuxRuntimeProcessIdentity( + row.process_identity_json + ) + }), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} diff --git a/src/agent-daemon/runtime-process-identity.test.ts b/src/agent-daemon/runtime-process-identity.test.ts new file mode 100644 index 00000000..ac982503 --- /dev/null +++ b/src/agent-daemon/runtime-process-identity.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + parseLinuxProcStat, + parseSerializedLinuxRuntimeProcessIdentity, + sameLinuxRuntimeProcessIdentity, + serializeLinuxRuntimeProcessIdentity +} from './runtime-process-identity' + +describe('Linux Runtime process identity', () => { + it('parses proc stat command names containing spaces and parentheses', () => { + const fields = ['S', '10', '20', '30', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '987654', '0'] + const parsed = parseLinuxProcStat(`123 (bwrap worker (one)) ${fields.join(' ')}`) + expect(parsed).toMatchObject({ + pid: 123, + command: 'bwrap worker (one)', + parentPid: 10, + processGroupId: 20, + sessionId: 30, + startTimeTicks: 987654n + }) + }) + + it('round trips the minimal owner identity and detects PID reuse', () => { + const identity = { + bootId: '11111111-1111-1111-1111-111111111111', + pid: 42, + startTimeTicks: 100n, + processGroupId: 42, + executablePath: '/usr/bin/bwrap' + } + expect( + parseSerializedLinuxRuntimeProcessIdentity( + serializeLinuxRuntimeProcessIdentity(identity) + ) + ).toEqual(identity) + expect( + sameLinuxRuntimeProcessIdentity(identity, { + ...identity, + startTimeTicks: 101n + }) + ).toBe(false) + }) +}) diff --git a/src/agent-daemon/runtime-process-identity.ts b/src/agent-daemon/runtime-process-identity.ts new file mode 100644 index 00000000..67a62852 --- /dev/null +++ b/src/agent-daemon/runtime-process-identity.ts @@ -0,0 +1,164 @@ +import { readFile, readlink } from 'node:fs/promises' +import { posix } from 'node:path' + +export type LinuxRuntimeProcessIdentity = { + bootId: string + pid: number + startTimeTicks: bigint + processGroupId: number + executablePath: string +} + +export type LinuxProcStat = { + pid: number + command: string + state: string + parentPid: number + processGroupId: number + sessionId: number + startTimeTicks: bigint +} + +export function parseLinuxProcStat(value: string): LinuxProcStat { + const open = value.indexOf('(') + const close = value.lastIndexOf(')') + if (open < 1 || close <= open || value[close + 1] !== ' ') { + throw new Error('Linux process stat is malformed') + } + const pid = positiveInteger(Number(value.slice(0, open).trim()), 'PID') + const command = value.slice(open + 1, close) + const fields = value.slice(close + 2).trim().split(/\s+/u) + if (fields.length < 20 || !/^[A-Z]$/u.test(fields[0] ?? '')) { + throw new Error('Linux process stat has too few fields') + } + const integerField = (index: number, label: string): number => { + const text = fields[index] + if (text === undefined || !/^-?\d+$/u.test(text)) { + throw new Error(`Linux process ${label} is malformed`) + } + const parsed = Number(text) + if (!Number.isSafeInteger(parsed)) { + throw new Error(`Linux process ${label} is outside the safe range`) + } + return parsed + } + const startTime = fields[19] + if (startTime === undefined || !/^\d+$/u.test(startTime)) { + throw new Error('Linux process start time is malformed') + } + return { + pid, + command, + state: fields[0]!, + parentPid: nonnegativeInteger(integerField(1, 'parent PID'), 'Parent PID'), + processGroupId: positiveInteger(integerField(2, 'process group'), 'Process group'), + sessionId: positiveInteger(integerField(3, 'session'), 'Session'), + startTimeTicks: BigInt(startTime) + } +} + +export async function readLinuxBootId( + path = '/proc/sys/kernel/random/boot_id' +): Promise { + const value = (await readFile(path, 'utf8')).trim().toLowerCase() + if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) { + throw new Error('Linux boot identity is malformed') + } + return value +} + +export async function readLinuxRuntimeProcessIdentity( + pidInput: number, + options: { procRoot?: string; bootId?: string } = {} +): Promise { + const pid = positiveInteger(pidInput, 'PID') + const procRoot = options.procRoot ?? '/proc' + if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) { + throw new Error('Linux proc root must be absolute') + } + const processRoot = posix.join(procRoot, String(pid)) + const [statText, executablePath] = await Promise.all([ + readFile(posix.join(processRoot, 'stat'), 'utf8'), + readlink(posix.join(processRoot, 'exe')) + ]) + const parsed = parseLinuxProcStat(statText) + if (parsed.pid !== pid) { + throw new Error('Linux process stat PID does not match its path') + } + return { + bootId: + options.bootId ?? + await readLinuxBootId( + posix.join(procRoot, 'sys', 'kernel', 'random', 'boot_id') + ), + pid, + startTimeTicks: parsed.startTimeTicks, + processGroupId: parsed.processGroupId, + executablePath + } +} + +export function sameLinuxRuntimeProcessIdentity( + expected: LinuxRuntimeProcessIdentity, + actual: LinuxRuntimeProcessIdentity +): boolean { + return ( + expected.bootId === actual.bootId && + expected.pid === actual.pid && + expected.startTimeTicks === actual.startTimeTicks && + expected.processGroupId === actual.processGroupId && + expected.executablePath === actual.executablePath + ) +} + +export function serializeLinuxRuntimeProcessIdentity( + identity: LinuxRuntimeProcessIdentity +): string { + return JSON.stringify({ + ...identity, + startTimeTicks: identity.startTimeTicks.toString() + }) +} + +export function parseSerializedLinuxRuntimeProcessIdentity( + value: string +): LinuxRuntimeProcessIdentity { + const parsed: unknown = JSON.parse(value) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Stored Runtime process identity is malformed') + } + const row = parsed as Record + if ( + typeof row.bootId !== 'string' || + !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(row.bootId) || + typeof row.pid !== 'number' || + typeof row.processGroupId !== 'number' || + typeof row.startTimeTicks !== 'string' || + !/^\d+$/u.test(row.startTimeTicks) || + typeof row.executablePath !== 'string' || + !posix.isAbsolute(row.executablePath) + ) { + throw new Error('Stored Runtime process identity is malformed') + } + return { + bootId: row.bootId, + pid: positiveInteger(row.pid, 'PID'), + startTimeTicks: BigInt(row.startTimeTicks), + processGroupId: positiveInteger(row.processGroupId, 'Process group'), + executablePath: row.executablePath + } +} + +function positiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive safe integer`) + } + return value +} + +function nonnegativeInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a nonnegative safe integer`) + } + return value +} diff --git a/src/agent-daemon/structured-command-runner.ts b/src/agent-daemon/structured-command-runner.ts new file mode 100644 index 00000000..5ce8e378 --- /dev/null +++ b/src/agent-daemon/structured-command-runner.ts @@ -0,0 +1,182 @@ +import { spawn } from 'node:child_process' + +export type SpawnResult = { + exitCode: number + stdout: string + stderr: string + signal?: NodeJS.Signals + terminationReason?: 'timeout' | 'aborted' +} + +export type StructuredCommandOptions = { + env?: Readonly + signal?: AbortSignal + timeoutMs?: number + terminationGraceMs?: number +} + +export interface StructuredCommandRunner { + run( + executable: string, + args: readonly string[], + options?: StructuredCommandOptions + ): Promise +} + +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000 +const MAXIMUM_COMMAND_TIMEOUT_MS = 120_000 +const DEFAULT_TERMINATION_GRACE_MS = 1_000 +const MAXIMUM_TERMINATION_GRACE_MS = 10_000 + +export class NodeStructuredCommandRunner implements StructuredCommandRunner { + async run( + executable: string, + args: readonly string[], + options: StructuredCommandOptions = {} + ): Promise { + const timeoutMs = boundedDuration( + options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, + 'Command timeout', + MAXIMUM_COMMAND_TIMEOUT_MS + ) + const terminationGraceMs = boundedDuration( + options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, + 'Command termination grace', + MAXIMUM_TERMINATION_GRACE_MS + ) + if (options.signal?.aborted === true) { + return { + exitCode: -1, + stdout: '', + stderr: '', + terminationReason: 'aborted' + } + } + return await new Promise((resolveResult) => { + const child = spawn(executable, [...args], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + ...(options.env === undefined ? {} : { env: options.env }) + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let stdoutBytes = 0 + let stderrBytes = 0 + const maximumBytes = 64 * 1024 + let settled = false + let terminationReason: SpawnResult['terminationReason'] + const timeout = setTimeout(() => terminate('timeout'), timeoutMs) + let escalation: NodeJS.Timeout | undefined + let closeDeadline: NodeJS.Timeout | undefined + let spawnError: Error | undefined + child.stdout.on('data', (chunk: Buffer) => { + if (stdoutBytes < maximumBytes) { + const bounded = chunk.subarray(0, maximumBytes - stdoutBytes) + stdout.push(bounded) + stdoutBytes += bounded.byteLength + } + }) + child.stderr.on('data', (chunk: Buffer) => { + if (stderrBytes < maximumBytes) { + const bounded = chunk.subarray(0, maximumBytes - stderrBytes) + stderr.push(bounded) + stderrBytes += bounded.byteLength + } + }) + const finish = ( + exitCode: number | null, + signal: NodeJS.Signals | null + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (escalation !== undefined) { + clearTimeout(escalation) + } + if (closeDeadline !== undefined) { + clearTimeout(closeDeadline) + } + options.signal?.removeEventListener('abort', abort) + const capturedStderr = Buffer.concat(stderr).toString('utf8') + resolveResult({ + exitCode: exitCode ?? -1, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: + capturedStderr || + (spawnError === undefined ? '' : spawnError.message), + ...(signal === null ? {} : { signal }), + ...(terminationReason === undefined ? {} : { terminationReason }) + }) + } + const terminate = ( + reason: NonNullable + ): void => { + if (settled || terminationReason !== undefined) { + return + } + terminationReason = reason + if (child.exitCode !== null || child.signalCode !== null) { + return + } + child.kill('SIGTERM') + escalation = setTimeout(() => { + if ( + settled || + child.exitCode !== null || + child.signalCode !== null + ) { + return + } + child.kill('SIGKILL') + closeDeadline = setTimeout( + () => finish(child.exitCode, child.signalCode), + terminationGraceMs + ) + }, terminationGraceMs) + } + const abort = (): void => terminate('aborted') + child.once('error', (error) => { + spawnError = error + }) + child.once('close', finish) + options.signal?.addEventListener('abort', abort, { once: true }) + if (options.signal?.aborted === true) { + abort() + } + }) + } +} + +export function formatSpawnFailure( + command: string, + result: SpawnResult +): string { + const status = + result.terminationReason === 'timeout' + ? 'timed out' + : result.terminationReason === 'aborted' + ? 'was cancelled' + : result.signal !== undefined + ? `terminated by ${result.signal}` + : `exited with status ${result.exitCode}` + return `${command} ${status}: ${boundedError(result.stderr)}` +} + +function boundedDuration( + value: number, + name: string, + maximum: number +): number { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new Error(`${name} must be between 1 and ${maximum} milliseconds`) + } + return value +} + +function boundedError(error: string): string { + const normalized = error.replaceAll(/\s+/gu, ' ').trim() + return normalized.slice(0, 1000) || 'unknown error' +} diff --git a/src/agent-daemon/workspace-git-service.test.ts b/src/agent-daemon/workspace-git-service.test.ts new file mode 100644 index 00000000..8bf694fd --- /dev/null +++ b/src/agent-daemon/workspace-git-service.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from 'node:child_process' +import { + appendFileSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseGitConfigListOutput, + resolveVerifiedGitExecutable, + WorkspaceGitService +} from './workspace-git-service' +import { WorkspacePathAccess } from './workspace-path-access' + +const temporaryPaths: string[] = [] +const linuxIt = process.platform === 'linux' ? it : it.skip + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('WorkspaceGitService', () => { + it('parses Git null-delimited key-newline-value records', () => { + expect( + parseGitConfigListOutput( + Buffer.from( + 'core.bare\nfalse\0user.name\nGoodBuddy Test\0section.empty\n\0' + ) + ) + ).toEqual([ + ['core.bare', 'false'], + ['user.name', 'GoodBuddy Test'], + ['section.empty', ''] + ]) + }) + + it('rejects malformed Git null-delimited configuration records', () => { + expect(() => + parseGitConfigListOutput( + Buffer.from('core.bare\0') + ) + ).toThrow('Git configuration output is malformed') + }) + + linuxIt('returns bounded read-only status and diff snapshots', async () => { + const root = temporaryDirectory() + const executable = await resolveVerifiedGitExecutable() + git(executable, root, 'init', '--quiet') + git(executable, root, 'config', 'user.name', 'GoodBuddy Test') + git(executable, root, 'config', 'user.email', 'test@example.invalid') + writeFileSync(join(root, 'tracked.txt'), 'before\n') + git(executable, root, 'add', 'tracked.txt') + git(executable, root, 'commit', '--quiet', '-m', 'initial') + writeFileSync(join(root, 'tracked.txt'), 'after\n') + writeFileSync(join(root, 'untracked.txt'), 'new\n') + const access = accessFor(root) + const service = new WorkspaceGitService({ gitExecutable: executable }) + + await expect(service.inspect(access)).resolves.toBe('available') + const status = await service.status(access, { + workspaceId: 'workspace-a', + generation: 1, + includeIgnored: false, + maximumEntries: 10 + }) + expect(status.repositoryIdentity).toMatch(/^repository-[a-f0-9]{64}$/u) + expect(status.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relativePath: 'tracked.txt', + worktree: 'modified' + }), + expect.objectContaining({ + relativePath: 'untracked.txt', + index: 'untracked', + worktree: 'untracked' + }) + ]) + ) + + const first = await service.diff(access, { + workspaceId: 'workspace-a', + generation: 1, + staged: false, + maximumBytes: 20 + }) + expect(Buffer.byteLength(first.patch)).toBeLessThanOrEqual(20) + expect(first.truncated).toBe(true) + expect(first.nextCursor).toBeDefined() + const second = await service.diff(access, { + workspaceId: 'workspace-a', + generation: 1, + staged: false, + cursor: first.nextCursor, + maximumBytes: 4 * 1024 + }) + expect(`${first.patch}${second.patch}`).toContain('-before') + expect(`${first.patch}${second.patch}`).toContain('+after') + }) + + linuxIt('rejects includes, worktrees, and object alternates', async () => { + const executable = await resolveVerifiedGitExecutable() + const unsafeConfigRoot = temporaryDirectory() + git(executable, unsafeConfigRoot, 'init', '--quiet') + appendFileSync( + join(unsafeConfigRoot, '.git', 'config'), + '\n[include]\n\tpath = /tmp/not-allowed\n' + ) + const service = new WorkspaceGitService({ gitExecutable: executable }) + await expect(service.inspect(accessFor(unsafeConfigRoot))).rejects.toMatchObject({ + code: 'git-unsafe' + }) + + const alternateRoot = temporaryDirectory() + git(executable, alternateRoot, 'init', '--quiet') + const info = join(alternateRoot, '.git', 'objects', 'info') + mkdirSync(info, { recursive: true }) + writeFileSync(join(info, 'alternates'), '/tmp/not-allowed\n') + await expect(service.inspect(accessFor(alternateRoot))).rejects.toMatchObject({ + code: 'git-unsafe' + }) + }) +}) + +function git(executable: string, cwd: string, ...args: string[]): void { + execFileSync(executable, args, { + cwd, + env: { + HOME: cwd, + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0' + }, + stdio: 'ignore' + }) +} + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-git-')) + temporaryPaths.push(path) + return path +} + +function accessFor(root: string): WorkspacePathAccess { + const metadata = statSync(root) + return new WorkspacePathAccess({ + canonicalPath: root, + device: metadata.dev.toString(), + inode: metadata.ino.toString(), + workspaceIdentity: 'workspace-identity-test' + }) +} diff --git a/src/agent-daemon/workspace-git-service.ts b/src/agent-daemon/workspace-git-service.ts new file mode 100644 index 00000000..f65c4602 --- /dev/null +++ b/src/agent-daemon/workspace-git-service.ts @@ -0,0 +1,1198 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { + access, + lstat, + open, + opendir, + readFile, + realpath, + stat +} from 'node:fs/promises' +import { delimiter, isAbsolute, join, relative, resolve } from 'node:path' +import { spawn } from 'node:child_process' +import { + remoteRelativePathSchema, + type RemoteGitDiffRequest, + type RemoteGitDiffResult, + type RemoteGitStatusRequest, + type RemoteGitStatusResult +} from '../shared/remote-agent-contracts' +import { + checkCancelled, + WorkspacePathAccess, + WorkspaceServiceError, + type WorkspaceIoOptions +} from './workspace-path-access' + +const NULL_DEVICE = '/dev/null' +const GIT_TIMEOUT_MS = 15_000 +const MAXIMUM_GIT_PROCESS_BYTES = 16 * 1024 * 1024 +const MAXIMUM_FILTER_DRIVERS = 256 +const MAXIMUM_CONFIG_BYTES = 1024 * 1024 +const MAXIMUM_GIT_METADATA_ENTRIES = 500_000 +const MAXIMUM_GIT_METADATA_DEPTH = 64 +const MAXIMUM_DIFF_SNAPSHOTS = 32 +const MAXIMUM_DIFF_SNAPSHOT_BYTES = 64 * 1024 * 1024 + +type Repository = { + rootPath: string + gitDirectory: string + commonDirectory: string + repositoryIdentity: string + configOverrides: ReadonlyArray +} + +type GitSnapshot = { + root: string + gitDirectory: string + head: string + index: string +} + +type CommandResult = { + exitCode: number + stdout: Buffer + stderr: Buffer + truncated: boolean +} + +export class WorkspaceGitService { + readonly #getGitExecutable: () => Promise + readonly #diffSnapshots = new Map< + string, + { + repositoryIdentity: string + bytes: Buffer + } + >() + + constructor(options: { + gitExecutable?: string | Promise + environment?: NodeJS.ProcessEnv + } = {}) { + let executable: Promise | undefined + this.#getGitExecutable = () => { + executable ??= + options.gitExecutable === undefined + ? resolveVerifiedGitExecutable(options.environment) + : Promise.resolve(options.gitExecutable).then( + verifyGitExecutable + ) + return executable + } + } + + async inspect( + accessService: WorkspacePathAccess, + options: WorkspaceIoOptions = {} + ): Promise<'available' | 'not-a-repository' | 'unavailable'> { + try { + await this.#repository(accessService, options) + return 'available' + } catch (error) { + if ( + error instanceof WorkspaceServiceError && + error.code === 'git-unavailable' + ) { + return 'not-a-repository' + } + if ( + error instanceof WorkspaceServiceError && + (error.code === 'git-unsafe' || + error.code === 'stale-workspace' || + error.code === 'aborted' || + error.code === 'deadline-exceeded') + ) { + throw error + } + return 'unavailable' + } + } + + async status( + accessService: WorkspacePathAccess, + request: RemoteGitStatusRequest, + options: WorkspaceIoOptions = {} + ): Promise { + const repository = await this.#repository(accessService, options) + const before = await repositorySnapshot(repository, accessService) + const result = await this.#run( + repository, + [ + 'status', + '--porcelain=v2', + '--branch', + '-z', + '--ignore-submodules=all', + request.includeIgnored ? '--ignored=matching' : '--ignored=no', + '--untracked-files=all' + ], + options, + MAXIMUM_GIT_PROCESS_BYTES + ) + if (result.exitCode !== 0) { + throw gitFailure(result) + } + const middle = await repositorySnapshot(repository, accessService) + assertSameSnapshot(before, middle) + const verification = await this.#run( + repository, + [ + 'status', + '--porcelain=v2', + '--branch', + '-z', + '--ignore-submodules=all', + request.includeIgnored ? '--ignored=matching' : '--ignored=no', + '--untracked-files=all' + ], + options, + MAXIMUM_GIT_PROCESS_BYTES + ) + if ( + verification.exitCode !== 0 || + verification.truncated !== result.truncated || + !verification.stdout.equals(result.stdout) + ) { + throw new WorkspaceServiceError( + 'Git status changed while its snapshot was read', + 'stale-workspace' + ) + } + const after = await repositorySnapshot(repository, accessService) + assertSameSnapshot(middle, after) + const parsed = parseStatus( + result.stdout, + request.maximumEntries, + result.truncated + ) + return { + repositoryIdentity: repository.repositoryIdentity, + branch: parsed.branch, + ...(parsed.headDigest === undefined + ? {} + : { headDigest: parsed.headDigest }), + entries: parsed.entries, + truncated: result.truncated || parsed.truncated + } + } + + async diff( + accessService: WorkspacePathAccess, + request: RemoteGitDiffRequest, + options: WorkspaceIoOptions = {} + ): Promise { + const repository = await this.#repository(accessService, options) + if (request.relativePath !== undefined && request.relativePath !== '') { + await accessService.stat(request.relativePath, options) + } + const cursor = parseCursor(request.cursor) + if (cursor > MAXIMUM_GIT_PROCESS_BYTES) { + throw new WorkspaceServiceError( + 'Git diff cursor exceeds the bounded snapshot', + 'capacity-exceeded' + ) + } + const snapshotKey = diffSnapshotKey( + request.workspaceId, + request.generation, + request.staged, + request.relativePath + ) + const args = [ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--no-color', + '--no-renames', + '--ignore-submodules=all', + ...(request.staged ? ['--cached'] : []), + ...(request.relativePath === undefined || + request.relativePath === '' + ? [] + : ['--', request.relativePath]) + ] + if (cursor > 0) { + const cached = this.#diffSnapshots.get(snapshotKey) + const current = + cached === undefined + ? undefined + : await this.#run( + repository, + args, + options, + MAXIMUM_GIT_PROCESS_BYTES + ) + if ( + cached === undefined || + cached.repositoryIdentity !== repository.repositoryIdentity || + current === undefined || + current.exitCode !== 0 || + current.truncated || + !current.stdout.equals(cached.bytes) || + cursor > cached.bytes.byteLength + ) { + this.#diffSnapshots.delete(snapshotKey) + throw new WorkspaceServiceError( + 'Git diff cursor is no longer valid', + 'stale-generation' + ) + } + return diffPage( + cached.repositoryIdentity, + cached.bytes, + cursor, + request.maximumBytes, + false + ) + } + const before = await repositorySnapshot(repository, accessService) + const result = await this.#run( + repository, + args, + options, + MAXIMUM_GIT_PROCESS_BYTES + ) + if (result.exitCode !== 0) { + throw gitFailure(result) + } + const middle = await repositorySnapshot(repository, accessService) + assertSameSnapshot(before, middle) + const verification = await this.#run( + repository, + args, + options, + MAXIMUM_GIT_PROCESS_BYTES + ) + if ( + verification.exitCode !== 0 || + verification.truncated !== result.truncated || + !verification.stdout.equals(result.stdout) + ) { + throw new WorkspaceServiceError( + 'Git diff changed while its snapshot was read', + 'stale-workspace' + ) + } + const after = await repositorySnapshot(repository, accessService) + assertSameSnapshot(middle, after) + if (result.truncated) { + this.#diffSnapshots.delete(snapshotKey) + } else { + this.#cacheDiffSnapshot(snapshotKey, { + repositoryIdentity: repository.repositoryIdentity, + bytes: Buffer.from(result.stdout) + }) + } + return diffPage( + repository.repositoryIdentity, + result.stdout, + 0, + request.maximumBytes, + result.truncated + ) + } + + async #repository( + accessService: WorkspacePathAccess, + options: WorkspaceIoOptions + ): Promise { + await accessService.assertCurrent(options) + const rootPath = accessService.root.canonicalPath + const dotGit = join(rootPath, '.git') + let gitMetadata + try { + gitMetadata = await lstat(dotGit) + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw new WorkspaceServiceError( + 'Workspace is not a Git repository', + 'git-unavailable' + ) + } + throw error + } + if (gitMetadata.isSymbolicLink() || !gitMetadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Git directory must be a non-symbolic directory inside the workspace', + 'git-unsafe' + ) + } + await this.#getGitExecutable() + const gitDirectory = await realpath(dotGit) + assertInside(rootPath, gitDirectory, 'Git directory') + let commonDirectory = gitDirectory + const commonPath = join(gitDirectory, 'commondir') + const commonMetadata = await lstat(commonPath).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) { + return undefined + } + throw error + }) + if (commonMetadata !== undefined) { + if (commonMetadata.isSymbolicLink() || !commonMetadata.isFile()) { + throw new WorkspaceServiceError( + 'Git common directory metadata is unsafe', + 'git-unsafe' + ) + } + const commonValue = (await readBoundedFile(commonPath, 4096)).trim() + if (commonValue === '' || commonValue.includes('\0')) { + throw new WorkspaceServiceError( + 'Git common directory metadata is invalid', + 'git-unsafe' + ) + } + commonDirectory = await realpath( + resolve(gitDirectory, commonValue) + ) + assertInside(rootPath, commonDirectory, 'Git common directory') + if (!(await stat(commonDirectory)).isDirectory()) { + throw new WorkspaceServiceError( + 'Git common directory is not a directory', + 'git-unsafe' + ) + } + } + await assertSafeGitTree(rootPath, gitDirectory, options) + if (commonDirectory !== gitDirectory) { + await assertSafeGitTree(rootPath, commonDirectory, options) + } + await rejectAlternates(rootPath, commonDirectory) + const configPath = join(commonDirectory, 'config') + const configMetadata = await lstat(configPath) + if (configMetadata.isSymbolicLink() || !configMetadata.isFile()) { + throw new WorkspaceServiceError( + 'Git configuration is not a regular repository file', + 'git-unsafe' + ) + } + if (configMetadata.size > MAXIMUM_CONFIG_BYTES) { + throw new WorkspaceServiceError( + 'Git configuration exceeds its safety limit', + 'git-unsafe' + ) + } + const config = await this.#readConfig(configPath, rootPath, options) + const gitDirectoryMetadata = await stat(gitDirectory) + const repositoryIdentity = `repository-${createHash('sha256') + .update( + [ + accessService.root.device, + accessService.root.inode, + gitDirectoryMetadata.dev.toString(), + gitDirectoryMetadata.ino.toString() + ].join('\0'), + 'utf8' + ) + .digest('hex')}` + return { + rootPath, + gitDirectory, + commonDirectory, + repositoryIdentity, + configOverrides: config + } + } + + async #readConfig( + configPath: string, + rootPath: string, + options: WorkspaceIoOptions + ): Promise> { + const executable = await this.#getGitExecutable() + const result = await runBoundedCommand( + executable, + ['config', '--file', configPath, '--no-includes', '--null', '--list'], + rootPath, + gitEnvironment([]), + options, + MAXIMUM_CONFIG_BYTES + ) + if (result.exitCode !== 0 || result.truncated) { + throw new WorkspaceServiceError( + 'Git configuration could not be parsed safely', + 'git-unsafe' + ) + } + const overrides: Array = [] + const filters = new Set() + for (const [rawKey, value] of parseGitConfigListOutput( + result.stdout + )) { + const key = rawKey.toLocaleLowerCase('en-US') + if ( + key === 'include.path' || + key.startsWith('includeif.') || + key === 'core.worktree' || + key === 'core.attributesfile' || + key === 'core.excludesfile' || + key === 'extensions.worktreeconfig' + ) { + throw new WorkspaceServiceError( + 'Git includes and external worktrees are not allowed', + 'git-unsafe' + ) + } + const filter = /^(filter\..*)\.(?:clean|smudge|process|required)$/u.exec( + key + )?.[1] + if (filter !== undefined) { + filters.add(filter) + if (filters.size > MAXIMUM_FILTER_DRIVERS) { + throw new WorkspaceServiceError( + 'Git filter configuration exceeds its safety limit', + 'git-unsafe' + ) + } + } + if ( + key === 'extensions.objectformat' && + value !== '' && + value !== 'sha1' && + value !== 'sha256' + ) { + throw new WorkspaceServiceError( + 'Git object format is unsupported', + 'git-unsafe' + ) + } + } + for (const filter of filters) { + overrides.push( + [`${filter}.clean`, ''], + [`${filter}.smudge`, ''], + [`${filter}.process`, ''], + [`${filter}.required`, 'false'] + ) + } + return overrides + } + + async #run( + repository: Repository, + args: readonly string[], + options: WorkspaceIoOptions, + maximumBytes: number + ): Promise { + const executable = await this.#getGitExecutable() + return await runBoundedCommand( + executable, + [ + '--no-pager', + '--no-optional-locks', + '-c', + `core.hooksPath=${NULL_DEVICE}`, + '-c', + 'core.fsmonitor=false', + '-c', + 'core.askPass=', + '-c', + 'credential.helper=', + '-c', + 'diff.external=', + '-c', + 'interactive.diffFilter=', + ...args + ], + repository.rootPath, + gitEnvironment(repository.configOverrides), + options, + maximumBytes + ) + } + + #cacheDiffSnapshot( + key: string, + snapshot: { + repositoryIdentity: string + bytes: Buffer + } + ): void { + this.#diffSnapshots.delete(key) + let totalBytes = [...this.#diffSnapshots.values()].reduce( + (total, value) => total + value.bytes.byteLength, + 0 + ) + while ( + this.#diffSnapshots.size >= MAXIMUM_DIFF_SNAPSHOTS || + totalBytes + snapshot.bytes.byteLength > + MAXIMUM_DIFF_SNAPSHOT_BYTES + ) { + const oldest = this.#diffSnapshots.entries().next().value as + | [string, { repositoryIdentity: string; bytes: Buffer }] + | undefined + if (oldest === undefined) { + break + } + this.#diffSnapshots.delete(oldest[0]) + totalBytes -= oldest[1].bytes.byteLength + } + this.#diffSnapshots.set(key, snapshot) + } +} + +export function parseGitConfigListOutput( + output: Buffer +): ReadonlyArray { + const records = output.toString('utf8').split('\0') + if (records.at(-1) === '') { + records.pop() + } + return records.map((record) => { + const separator = record.indexOf('\n') + if (separator <= 0) { + throw new WorkspaceServiceError( + 'Git configuration output is malformed', + 'git-unsafe' + ) + } + return [ + record.slice(0, separator), + record.slice(separator + 1) + ] as const + }) +} + +export async function resolveVerifiedGitExecutable( + environment: NodeJS.ProcessEnv = process.env +): Promise { + const path = environment.PATH ?? environment.Path ?? environment.path ?? '' + for (const directory of path.split(delimiter)) { + if (directory === '' || !isAbsolute(directory)) { + continue + } + const candidate = join(directory, 'git') + try { + await access(candidate, constants.X_OK) + return await verifyGitExecutable(candidate) + } catch { + // Try the next absolute PATH entry. + } + } + throw new WorkspaceServiceError( + 'A verified absolute Git executable is unavailable', + 'unavailable' + ) +} + +async function verifyGitExecutable(input: string): Promise { + if (!isAbsolute(input)) { + throw new WorkspaceServiceError( + 'Git executable must be an absolute path', + 'git-unsafe' + ) + } + const metadata = await lstat(input) + if (metadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Git executable cannot be a symbolic link', + 'git-unsafe' + ) + } + const canonical = await realpath(input) + const canonicalMetadata = await stat(canonical) + if (!canonicalMetadata.isFile()) { + throw new WorkspaceServiceError( + 'Git executable is not a regular file', + 'git-unsafe' + ) + } + await access(canonical, constants.X_OK) + return canonical +} + +function gitEnvironment( + overrides: ReadonlyArray +): NodeJS.ProcessEnv { + const values: Array = [ + ...overrides + ] + const environment: NodeJS.ProcessEnv = { + HOME: NULL_DEVICE, + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + GIT_ASKPASS: '', + GIT_ATTR_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: NULL_DEVICE, + GIT_CONFIG_NOSYSTEM: '1', + GIT_OPTIONAL_LOCKS: '0', + GIT_PAGER: '', + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'false', + GCM_INTERACTIVE: 'Never', + SSH_ASKPASS: '', + ...(values.length === 0 + ? {} + : { GIT_CONFIG_COUNT: String(values.length) }) + } + values.forEach(([key, value], index) => { + environment[`GIT_CONFIG_KEY_${index}`] = key + environment[`GIT_CONFIG_VALUE_${index}`] = value + }) + return environment +} + +async function runBoundedCommand( + executable: string, + args: readonly string[], + cwd: string, + environment: NodeJS.ProcessEnv, + options: WorkspaceIoOptions, + maximumBytes: number +): Promise { + checkCancelled(options) + return await new Promise((resolveResult, reject) => { + const child = spawn(executable, [...args], { + cwd, + env: environment, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'] + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let stdoutBytes = 0 + let stderrBytes = 0 + let truncated = false + let settled = false + const configuredDeadline = + options.deadlineAt === undefined + ? Date.now() + GIT_TIMEOUT_MS + : Math.min(options.deadlineAt, Date.now() + GIT_TIMEOUT_MS) + const timeout = setTimeout( + () => terminate('deadline-exceeded'), + Math.max(1, configuredDeadline - Date.now()) + ) + const capture = ( + target: Buffer[], + chunk: Buffer, + stdoutTarget: boolean + ): void => { + const current = stdoutTarget ? stdoutBytes : stderrBytes + const limit = stdoutTarget ? maximumBytes : 64 * 1024 + const remaining = limit - current + if (remaining <= 0) { + truncated = true + return + } + const selected = chunk.subarray(0, remaining) + target.push(selected) + if (stdoutTarget) { + stdoutBytes += selected.byteLength + } else { + stderrBytes += selected.byteLength + } + truncated ||= chunk.byteLength > selected.byteLength + } + const finish = (action: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + options.signal?.removeEventListener('abort', abort) + action() + } + const terminate = ( + code: 'aborted' | 'deadline-exceeded' + ): void => { + child.kill('SIGKILL') + finish(() => + reject( + new WorkspaceServiceError( + code === 'aborted' + ? 'Git operation was aborted' + : 'Git operation deadline exceeded', + code + ) + ) + ) + } + const abort = (): void => terminate('aborted') + options.signal?.addEventListener('abort', abort, { once: true }) + child.stdout.on('data', (chunk: Buffer) => + capture(stdout, chunk, true) + ) + child.stderr.on('data', (chunk: Buffer) => + capture(stderr, chunk, false) + ) + child.once('error', (error) => { + finish(() => reject(error)) + }) + child.once('close', (code) => { + finish(() => + resolveResult({ + exitCode: code ?? -1, + stdout: Buffer.concat(stdout, stdoutBytes), + stderr: Buffer.concat(stderr, stderrBytes), + truncated + }) + ) + }) + }) +} + +async function repositorySnapshot( + repository: Repository, + accessService: WorkspacePathAccess +): Promise { + await accessService.assertCurrent() + return { + root: await metadataFingerprint(repository.rootPath), + gitDirectory: await metadataFingerprint(repository.gitDirectory), + head: await optionalFileFingerprint( + join(repository.commonDirectory, 'HEAD') + ), + index: await optionalFileFingerprint( + join(repository.gitDirectory, 'index') + ) + } +} + +async function metadataFingerprint(path: string): Promise { + const value = await lstat(path) + return [ + value.dev, + value.ino, + value.size, + value.mtimeMs, + value.ctimeMs + ].join(':') +} + +async function optionalFileFingerprint(path: string): Promise { + try { + const metadata = await lstat(path) + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new WorkspaceServiceError( + 'Git snapshot metadata is unsafe', + 'git-unsafe' + ) + } + const data = + metadata.size <= 4096 + ? await readFile(path) + : Buffer.alloc(0) + return `${await metadataFingerprint(path)}:${createHash('sha256') + .update(data) + .digest('hex')}` + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return 'absent' + } + throw error + } +} + +function assertSameSnapshot( + before: GitSnapshot, + after: GitSnapshot +): void { + if ( + before.root !== after.root || + before.gitDirectory !== after.gitDirectory || + before.head !== after.head || + before.index !== after.index + ) { + throw new WorkspaceServiceError( + 'Git repository changed while its snapshot was read', + 'stale-workspace' + ) + } +} + +function parseStatus( + output: Buffer, + maximumEntries: number, + sourceTruncated: boolean +): { + branch: string | null + headDigest?: string + entries: RemoteGitStatusResult['entries'] + truncated: boolean +} { + const completeOutput = + sourceTruncated && output.at(-1) !== 0 + ? output.subarray(0, Math.max(0, output.lastIndexOf(0) + 1)) + : output + const records = completeOutput.toString('utf8').split('\0') + let branch: string | null = null + let headDigest: string | undefined + const entries: RemoteGitStatusResult['entries'] = [] + let truncated = false + for (let index = 0; index < records.length; index += 1) { + let record = records[index]! + if (record === '') { + continue + } + while (record.startsWith('# ')) { + const newline = record.indexOf('\n') + const header = newline < 0 ? record : record.slice(0, newline) + record = newline < 0 ? '' : record.slice(newline + 1) + if (header.startsWith('# branch.oid ')) { + const value = header.slice('# branch.oid '.length) + if (/^[a-f0-9]{40,64}$/u.test(value)) { + headDigest = value + } + } else if (header.startsWith('# branch.head ')) { + const value = header.slice('# branch.head '.length) + branch = value === '(detached)' ? null : value + } + } + if (record === '') { + continue + } + let relativePath: string + let originalRelativePath: string | undefined + let statusCode: string + if (record.startsWith('1 ')) { + const fields = splitFixedFields(record, 9) + statusCode = fields[1]! + relativePath = fields[8]! + } else if (record.startsWith('2 ')) { + const fields = splitFixedFields(record, 10) + statusCode = fields[1]! + relativePath = fields[9]! + originalRelativePath = records[++index] + if (originalRelativePath === undefined) { + throw unsafeStatus() + } + } else if (record.startsWith('u ')) { + const fields = splitFixedFields(record, 11) + statusCode = fields[1]! + relativePath = fields[10]! + } else if (record.startsWith('? ')) { + statusCode = '??' + relativePath = record.slice(2) + } else if (record.startsWith('! ')) { + statusCode = '!!' + relativePath = record.slice(2) + } else { + throw unsafeStatus() + } + if (entries.length >= maximumEntries) { + truncated = true + continue + } + entries.push({ + relativePath: remoteRelativePathSchema.parse(relativePath), + index: mapStatus(statusCode[0]!, statusCode), + worktree: mapStatus(statusCode[1]!, statusCode), + ...(originalRelativePath === undefined + ? {} + : { + originalRelativePath: + remoteRelativePathSchema.parse(originalRelativePath) + }) + }) + } + return { + branch, + ...(headDigest === undefined ? {} : { headDigest }), + entries, + truncated + } +} + +function splitFixedFields( + record: string, + count: number +): string[] { + const fields: string[] = [] + let start = 0 + for (let field = 1; field < count; field += 1) { + const separator = record.indexOf(' ', start) + if (separator < 0) { + throw unsafeStatus() + } + fields.push(record.slice(start, separator)) + start = separator + 1 + } + fields.push(record.slice(start)) + if (fields.at(-1) === '') { + throw unsafeStatus() + } + return fields +} + +function mapStatus( + value: string, + pair: string +): RemoteGitStatusResult['entries'][number]['index'] { + if (pair === '??') { + return 'untracked' + } + if (pair === '!!') { + return 'ignored' + } + return ( + { + '.': 'unmodified', + ' ': 'unmodified', + A: 'added', + M: 'modified', + D: 'deleted', + R: 'renamed', + C: 'copied', + U: 'unmerged', + T: 'modified' + } as const + )[value] ?? 'unmerged' +} + +function unsafeStatus(): WorkspaceServiceError { + return new WorkspaceServiceError( + 'Git returned an invalid status snapshot', + 'git-failed' + ) +} + +function gitFailure(result: CommandResult): WorkspaceServiceError { + const message = result.stderr.toString('utf8').trim().slice(0, 2_000) + return new WorkspaceServiceError( + message === '' ? 'Git read operation failed' : message, + 'git-failed' + ) +} + +async function assertSafeGitTree( + rootPath: string, + metadataRoot: string, + options: WorkspaceIoOptions +): Promise { + const queue: Array<{ path: string; depth: number }> = [ + { path: metadataRoot, depth: 0 } + ] + let entries = 0 + while (queue.length > 0) { + checkCancelled(options) + const current = queue.shift()! + if (current.depth > MAXIMUM_GIT_METADATA_DEPTH) { + throw new WorkspaceServiceError( + 'Git metadata exceeds its directory depth limit', + 'git-unsafe' + ) + } + const directory = await opendir(current.path) + try { + for await (const entry of directory) { + checkCancelled(options) + entries += 1 + if (entries > MAXIMUM_GIT_METADATA_ENTRIES) { + throw new WorkspaceServiceError( + 'Git metadata exceeds its entry limit', + 'git-unsafe' + ) + } + const candidate = join(current.path, entry.name) + assertInside(rootPath, candidate, 'Git metadata') + const metadata = await lstat(candidate) + if (metadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Git metadata cannot contain symbolic links', + 'git-unsafe' + ) + } + if (metadata.isDirectory()) { + queue.push({ + path: candidate, + depth: current.depth + 1 + }) + } else if (!metadata.isFile()) { + throw new WorkspaceServiceError( + 'Git metadata cannot contain special files', + 'git-unsafe' + ) + } + } + } finally { + await directory.close().catch(() => undefined) + } + } +} + +async function rejectAlternates( + rootPath: string, + commonDirectory: string +): Promise { + const alternatesPath = join( + commonDirectory, + 'objects', + 'info', + 'alternates' + ) + const metadata = await lstat(alternatesPath).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) { + return undefined + } + throw error + }) + if (metadata === undefined) { + return + } + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new WorkspaceServiceError( + 'Git object alternates metadata is unsafe', + 'git-unsafe' + ) + } + const data = await readBoundedFile(alternatesPath, 64 * 1024) + for (const line of data.split(/\r?\n/u)) { + if (line.trim() === '') { + continue + } + const candidate = resolve( + join(commonDirectory, 'objects'), + line + ) + assertInside(rootPath, candidate, 'Git object alternate') + throw new WorkspaceServiceError( + 'Git object alternates are not supported', + 'git-unsafe' + ) + } +} + +async function readBoundedFile( + path: string, + maximumBytes: number +): Promise { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + const metadata = await handle.stat() + if (!metadata.isFile() || metadata.size > maximumBytes) { + throw new WorkspaceServiceError( + 'Git metadata exceeds its safety limit', + 'git-unsafe' + ) + } + return (await handle.readFile()).toString('utf8') + } finally { + await handle.close() + } +} + +function assertInside( + rootPath: string, + candidatePath: string, + label: string +): void { + const difference = relative(rootPath, candidatePath) + if ( + difference === '' || + difference.startsWith('..') || + isAbsolute(difference) + ) { + throw new WorkspaceServiceError( + `${label} must remain beneath the workspace root`, + 'git-unsafe' + ) + } +} + +function parseCursor(value: string | undefined): number { + if (value === undefined) { + return 0 + } + const cursor = Number(value) + if (!Number.isSafeInteger(cursor) || cursor < 0) { + throw new WorkspaceServiceError( + 'Git diff cursor is invalid', + 'stale-generation' + ) + } + return cursor +} + +function diffSnapshotKey( + workspaceId: string, + generation: number, + staged: boolean, + relativePath: string | undefined +): string { + return [ + workspaceId, + generation.toString(), + staged ? 'staged' : 'worktree', + relativePath ?? '' + ].join('\0') +} + +function diffPage( + repositoryIdentity: string, + bytes: Buffer, + cursor: number, + maximumBytes: number, + sourceTruncated: boolean +): RemoteGitDiffResult { + if (cursor > bytes.byteLength) { + throw new WorkspaceServiceError( + 'Git diff cursor is no longer valid', + 'stale-generation' + ) + } + const requestedEnd = Math.min( + bytes.byteLength, + cursor + maximumBytes + ) + const end = utf8BoundaryAtOrBefore(bytes, requestedEnd, cursor) + const patchBytes = bytes.subarray(cursor, end) + if (end === cursor && cursor < bytes.byteLength) { + throw new WorkspaceServiceError( + 'Git diff page is too small for the next UTF-8 character', + 'capacity-exceeded' + ) + } + const patch = decodeGitUtf8(patchBytes) + const hasMore = end < bytes.byteLength || sourceTruncated + const resumable = !sourceTruncated && end < bytes.byteLength + return { + repositoryIdentity, + patch, + byteLength: patchBytes.byteLength, + ...(resumable && end > cursor ? { nextCursor: String(end) } : {}), + truncated: hasMore + } +} + +function utf8BoundaryAtOrBefore( + value: Buffer, + requested: number, + minimum: number +): number { + let end = requested + while (end > minimum) { + try { + new TextDecoder('utf-8', { fatal: true }).decode( + value.subarray(minimum, end) + ) + return end + } catch { + end -= 1 + } + } + return minimum +} + +function decodeGitUtf8(value: Uint8Array): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(value) + } catch (error) { + throw new WorkspaceServiceError( + error instanceof Error + ? `Git output is not valid UTF-8: ${error.message}` + : 'Git output is not valid UTF-8', + 'git-failed' + ) + } +} + +function isErrno(error: unknown, code: string): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === code + ) +} diff --git a/src/agent-daemon/workspace-path-access.test.ts b/src/agent-daemon/workspace-path-access.test.ts new file mode 100644 index 00000000..de9f145c --- /dev/null +++ b/src/agent-daemon/workspace-path-access.test.ts @@ -0,0 +1,149 @@ +import { + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + WorkspacePathAccess, + WorkspaceServiceError +} from './workspace-path-access' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('WorkspacePathAccess', () => { + it('lists deterministically and reads bounded UTF-8 with a digest', async () => { + const root = temporaryDirectory() + mkdirSync(join(root, 'directory')) + writeFileSync(join(root, 'z.txt'), 'zero\nneedle here\n') + writeFileSync(join(root, 'a.txt'), 'alpha') + const service = accessFor(root) + const page = await service.list({ + workspaceId: 'workspace-a', + generation: 1, + relativePath: '', + limit: 2 + }) + + expect(page.entries.map((entry) => entry.name)).toEqual([ + 'directory', + 'a.txt' + ]) + expect(page.nextCursor).toBe('2') + const secondPage = await service.list({ + workspaceId: 'workspace-a', + generation: 1, + relativePath: '', + cursor: page.nextCursor, + limit: 2 + }) + expect(secondPage.entries.map((entry) => entry.name)).toEqual(['z.txt']) + + const read = await service.readText({ + workspaceId: 'workspace-a', + generation: 1, + relativePath: 'z.txt', + offsetBytes: 0, + maximumBytes: 5 + }) + expect(read).toMatchObject({ + content: 'zero\n', + bytesRead: 5, + totalBytes: 17, + truncated: true + }) + expect(read.digest).toMatch(/^sha256:[a-f0-9]{64}$/u) + await expect( + service.readText({ + workspaceId: 'workspace-a', + generation: 1, + relativePath: 'z.txt', + offsetBytes: 0, + maximumBytes: 5, + expectedDigest: `sha256:${'0'.repeat(64)}` + }) + ).rejects.toMatchObject({ code: 'digest-mismatch' }) + }) + + it('searches with result cursors and does not follow links', async () => { + const root = temporaryDirectory() + mkdirSync(join(root, 'nested')) + writeFileSync(join(root, 'nested', 'one.txt'), 'needle one\nneedle two') + const external = temporaryDirectory() + writeFileSync(join(external, 'secret.txt'), 'needle secret') + symlinkSync( + external, + join(root, 'linked'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + const service = accessFor(root) + + const first = await service.search({ + workspaceId: 'workspace-a', + generation: 1, + query: 'needle', + caseSensitive: true, + limit: 1 + }) + expect(first.matches).toHaveLength(1) + expect(first.matches[0]?.relativePath).toBe('nested/one.txt') + expect(first.nextCursor).toBe('1') + expect(first.truncated).toBe(true) + await expect(service.stat('linked/secret.txt')).rejects.toMatchObject({ + code: 'symlink-rejected' + }) + }) + + it('rejects cancellation and stale root replacement', async () => { + const root = temporaryDirectory() + writeFileSync(join(root, 'file.txt'), 'value') + const service = accessFor(root) + const controller = new AbortController() + controller.abort() + await expect( + service.stat('file.txt', { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + await expect( + service.stat('file.txt', { deadlineAt: Date.now() - 1 }) + ).rejects.toMatchObject({ code: 'deadline-exceeded' }) + + const moved = `${root}-moved` + renameSync(root, moved) + temporaryPaths.push(moved) + mkdirSync(root) + await expect(service.stat('file.txt')).rejects.toBeInstanceOf( + WorkspaceServiceError + ) + await expect(service.stat('file.txt')).rejects.toMatchObject({ + code: 'stale-workspace' + }) + }) +}) + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-workspace-')) + temporaryPaths.push(path) + return path +} + +function accessFor(root: string): WorkspacePathAccess { + const metadata = statSync(root) + return new WorkspacePathAccess({ + canonicalPath: root, + device: metadata.dev.toString(), + inode: metadata.ino.toString(), + workspaceIdentity: 'workspace-identity-test' + }) +} diff --git a/src/agent-daemon/workspace-path-access.ts b/src/agent-daemon/workspace-path-access.ts new file mode 100644 index 00000000..0e55207d --- /dev/null +++ b/src/agent-daemon/workspace-path-access.ts @@ -0,0 +1,765 @@ +import { createHash } from 'node:crypto' +import { + constants, + type Dirent, + type Stats +} from 'node:fs' +import { + access, + lstat, + open, + opendir, + realpath, + stat +} from 'node:fs/promises' +import { + basename, + isAbsolute, + join, + relative +} from 'node:path' +import { + REMOTE_WORKSPACE_LIMITS, + remoteAbsolutePathSchema, + remoteRelativePathSchema, + type RemoteWorkspaceEntry, + type RemoteWorkspaceListRequest, + type RemoteWorkspaceListResult, + type RemoteWorkspaceReadTextRequest, + type RemoteWorkspaceReadTextResult, + type RemoteWorkspaceSearchRequest, + type RemoteWorkspaceSearchResult, + type RemoteWorkspaceStatResult +} from '../shared/remote-agent-contracts' + +export const WORKSPACE_SEARCH_LIMITS = { + maximumEntries: 100_000, + maximumDepth: 64, + maximumScannedBytes: 64 * 1024 * 1024, + maximumFileBytes: REMOTE_WORKSPACE_LIMITS.maximumReadBytes +} as const + +const READ_BUFFER_BYTES = 64 * 1024 +const SEARCH_DIRECTORY_PAGE = 1_000 +const MAXIMUM_READABLE_FILE_BYTES = 64 * 1024 * 1024 +const noFollow = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + +export type WorkspaceRootIdentity = { + canonicalPath: string + device: string + inode: string + workspaceIdentity: string +} + +export type WorkspaceIoOptions = { + signal?: AbortSignal + deadlineAt?: number +} + +export class WorkspaceServiceError extends Error { + constructor( + message: string, + readonly code: + | 'aborted' + | 'capacity-exceeded' + | 'deadline-exceeded' + | 'digest-mismatch' + | 'git-failed' + | 'git-unsafe' + | 'git-unavailable' + | 'invalid-path' + | 'invalid-utf8' + | 'not-directory' + | 'not-owner' + | 'read-only' + | 'special-file' + | 'stale-generation' + | 'stale-workspace' + | 'symlink-rejected' + | 'unavailable' + | 'workspace-not-found' + ) { + super(message) + this.name = 'WorkspaceServiceError' + } +} + +export async function inspectWorkspaceRoot( + rootPathInput: string, + options: WorkspaceIoOptions = {} +): Promise { + checkCancelled(options) + const rootPath = remoteAbsolutePathSchema.parse(rootPathInput) + await assertSafeAbsoluteRootPath(rootPath, options) + const rootMetadata = await lstat(rootPath) + checkCancelled(options) + if (rootMetadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Workspace root cannot be a symbolic link', + 'symlink-rejected' + ) + } + if (!rootMetadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Workspace root is not a directory', + 'not-directory' + ) + } + const canonicalPath = await realpath(rootPath) + if (!isAbsolute(canonicalPath)) { + throw new WorkspaceServiceError( + 'Workspace root did not resolve to an absolute path', + 'invalid-path' + ) + } + const canonicalMetadata = await stat(canonicalPath) + if ( + !canonicalMetadata.isDirectory() || + !sameFileIdentity(rootMetadata, canonicalMetadata) + ) { + throw new WorkspaceServiceError( + 'Workspace root changed while it was validated', + 'stale-workspace' + ) + } + await access( + canonicalPath, + constants.R_OK | constants.X_OK + ) + const device = canonicalMetadata.dev.toString() + const inode = canonicalMetadata.ino.toString() + return { + canonicalPath, + device, + inode, + workspaceIdentity: `workspace-${createHash('sha256') + .update(`linux\0${device}\0${inode}`, 'utf8') + .digest('hex')}` + } +} + +async function assertSafeAbsoluteRootPath( + rootPath: string, + options: WorkspaceIoOptions +): Promise { + const rawSegments = rootPath.split('/') + const segments = rawSegments.slice(1) + if (segments.at(-1) === '') { + segments.pop() + } + if ( + segments.some( + (segment) => + segment === '' || + segment === '.' || + segment === '..' || + Buffer.byteLength(segment, 'utf8') > + REMOTE_WORKSPACE_LIMITS.maximumPathSegmentBytes + ) + ) { + throw new WorkspaceServiceError( + 'Workspace root contains traversal or empty path segments', + 'invalid-path' + ) + } + let current = '/' + for (const segment of segments) { + checkCancelled(options) + current = join(current, segment) + const metadata = await lstat(current) + if (metadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Workspace root path cannot contain symbolic links', + 'symlink-rejected' + ) + } + if (!metadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Workspace root path segment is not a directory', + 'not-directory' + ) + } + } +} + +export class WorkspacePathAccess { + readonly root: WorkspaceRootIdentity + + constructor(root: WorkspaceRootIdentity) { + this.root = { ...root } + } + + async assertCurrent(options: WorkspaceIoOptions = {}): Promise { + checkCancelled(options) + await assertSafeAbsoluteRootPath( + this.root.canonicalPath, + options + ) + let metadata: Stats + try { + metadata = await lstat(this.root.canonicalPath) + } catch (error) { + throw staleRoot(error) + } + checkCancelled(options) + if ( + metadata.isSymbolicLink() || + !metadata.isDirectory() || + metadata.dev.toString() !== this.root.device || + metadata.ino.toString() !== this.root.inode + ) { + throw new WorkspaceServiceError( + 'Workspace root identity is stale', + 'stale-workspace' + ) + } + } + + async resolveDirectory( + relativePathInput: string, + options: WorkspaceIoOptions = {} + ): Promise { + return await this.#resolveExisting( + relativePathInput, + 'directory', + options + ) + } + + async list( + request: RemoteWorkspaceListRequest, + options: WorkspaceIoOptions = {} + ): Promise { + const directoryPath = await this.resolveDirectory( + request.relativePath, + options + ) + const entries: Dirent[] = [] + const directory = await opendir(directoryPath) + try { + for await (const entry of directory) { + checkCancelled(options) + entries.push(entry) + if ( + entries.length > + REMOTE_WORKSPACE_LIMITS.maximumDirectoryEntriesPerPage * 100 + ) { + throw new WorkspaceServiceError( + 'Directory exceeds the bounded enumeration limit', + 'capacity-exceeded' + ) + } + } + } finally { + await directory.close().catch(() => undefined) + } + entries.sort(compareDirectoryEntries) + const offset = parseCursor(request.cursor) + if (offset > entries.length) { + throw new WorkspaceServiceError( + 'Directory cursor is no longer valid', + 'stale-generation' + ) + } + const selected = entries.slice(offset, offset + request.limit) + const resultEntries: RemoteWorkspaceEntry[] = [] + for (const entry of selected) { + checkCancelled(options) + const relativePath = appendRelative(request.relativePath, entry.name) + const metadata = await lstat(join(directoryPath, entry.name)) + resultEntries.push(entryFromMetadata(relativePath, entry.name, metadata)) + } + const nextOffset = offset + selected.length + return { + entries: resultEntries, + ...(nextOffset < entries.length + ? { nextCursor: String(nextOffset) } + : {}) + } + } + + async stat( + relativePathInput: string, + options: WorkspaceIoOptions = {} + ): Promise { + const relativePath = remoteRelativePathSchema.parse(relativePathInput) + const candidate = await this.#resolveExisting( + relativePath, + 'entry', + options + ) + const metadata = await lstat(candidate) + rejectUnsafeMetadata(metadata) + return entryFromMetadata( + relativePath, + relativePath === '' + ? basename(this.root.canonicalPath) || '/' + : basename(candidate), + metadata + ) + } + + async readText( + request: RemoteWorkspaceReadTextRequest, + options: WorkspaceIoOptions = {} + ): Promise { + const relativePath = remoteRelativePathSchema.parse(request.relativePath) + if (relativePath === '') { + throw new WorkspaceServiceError( + 'Workspace root is not a regular file', + 'special-file' + ) + } + const filePath = await this.#resolveExisting( + relativePath, + 'file', + options + ) + const handle = await open(filePath, noFollow) + try { + const before = await handle.stat() + if (!before.isFile()) { + throw new WorkspaceServiceError( + 'Workspace read target is not a regular file', + 'special-file' + ) + } + if ( + request.offsetBytes > before.size || + before.size > Number.MAX_SAFE_INTEGER + ) { + throw new WorkspaceServiceError( + 'Workspace read range is invalid', + 'invalid-path' + ) + } + if (before.size > MAXIMUM_READABLE_FILE_BYTES) { + throw new WorkspaceServiceError( + 'Workspace file exceeds the bounded read limit', + 'capacity-exceeded' + ) + } + const digest = createHash('sha256') + const chunks: Buffer[] = [] + let position = 0 + let selectedBytes = 0 + while (position < before.size) { + checkCancelled(options) + const buffer = Buffer.allocUnsafe( + Math.min(READ_BUFFER_BYTES, before.size - position) + ) + const result = await handle.read( + buffer, + 0, + buffer.byteLength, + position + ) + if (result.bytesRead === 0) { + break + } + const chunk = buffer.subarray(0, result.bytesRead) + digest.update(chunk) + const chunkStart = position + const chunkEnd = position + result.bytesRead + const selectionStart = Math.max(request.offsetBytes, chunkStart) + const selectionEnd = Math.min( + request.offsetBytes + request.maximumBytes, + chunkEnd + ) + if (selectionStart < selectionEnd) { + const selected = chunk.subarray( + selectionStart - chunkStart, + selectionEnd - chunkStart + ) + chunks.push(selected) + selectedBytes += selected.byteLength + } + position = chunkEnd + } + const after = await handle.stat() + if ( + position !== before.size || + !sameSnapshot(before, after) + ) { + throw new WorkspaceServiceError( + 'Workspace file changed while it was read', + 'stale-workspace' + ) + } + const contentBuffer = Buffer.concat(chunks, selectedBytes) + const content = decodeUtf8(contentBuffer) + const contentDigest = `sha256:${digest.digest('hex')}` + if ( + request.expectedDigest !== undefined && + request.expectedDigest !== contentDigest + ) { + throw new WorkspaceServiceError( + 'Workspace file digest does not match', + 'digest-mismatch' + ) + } + return { + relativePath, + content, + offsetBytes: request.offsetBytes, + bytesRead: contentBuffer.byteLength, + totalBytes: before.size, + digest: contentDigest, + truncated: + request.offsetBytes + contentBuffer.byteLength < before.size + } + } finally { + await handle.close() + } + } + + async search( + request: RemoteWorkspaceSearchRequest, + options: WorkspaceIoOptions = {} + ): Promise { + const prefix = request.pathPrefix ?? '' + await this.resolveDirectory(prefix, options) + const query = request.caseSensitive + ? request.query + : request.query.toLocaleLowerCase('en-US') + const cursor = parseCursor(request.cursor) + const matches: RemoteWorkspaceSearchResult['matches'] = [] + const queue: Array<{ relativePath: string; depth: number }> = [ + { relativePath: prefix, depth: 0 } + ] + let visitedEntries = 0 + let scannedBytes = 0 + let seenMatches = 0 + let truncated = false + while (queue.length > 0 && !truncated) { + checkCancelled(options) + const current = queue.shift()! + if (current.depth > WORKSPACE_SEARCH_LIMITS.maximumDepth) { + truncated = true + break + } + const page = await this.list( + { + workspaceId: request.workspaceId, + generation: request.generation, + relativePath: current.relativePath, + limit: SEARCH_DIRECTORY_PAGE + }, + options + ) + for (const entry of page.entries) { + visitedEntries += 1 + if (visitedEntries > WORKSPACE_SEARCH_LIMITS.maximumEntries) { + truncated = true + break + } + if (entry.kind === 'directory') { + if (current.depth >= WORKSPACE_SEARCH_LIMITS.maximumDepth) { + truncated = true + break + } + queue.push({ + relativePath: entry.relativePath, + depth: current.depth + 1 + }) + continue + } + if (entry.kind !== 'file') { + continue + } + const size = entry.byteLength ?? 0 + if ( + size > WORKSPACE_SEARCH_LIMITS.maximumFileBytes || + scannedBytes + size > + WORKSPACE_SEARCH_LIMITS.maximumScannedBytes + ) { + truncated = true + break + } + scannedBytes += size + let text: string + try { + text = ( + await this.readText( + { + workspaceId: request.workspaceId, + generation: request.generation, + relativePath: entry.relativePath, + offsetBytes: 0, + maximumBytes: Math.max(1, size) + }, + options + ) + ).content + } catch (error) { + if ( + error instanceof WorkspaceServiceError && + (error.code === 'invalid-utf8' || + error.code === 'special-file') + ) { + continue + } + throw error + } + const lines = text.split(/\r?\n/u) + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]! + const searchable = request.caseSensitive + ? line + : line.toLocaleLowerCase('en-US') + let from = 0 + while (from <= searchable.length) { + const column = searchable.indexOf(query, from) + if (column < 0) { + break + } + if (seenMatches >= cursor) { + matches.push({ + relativePath: entry.relativePath, + line: lineIndex + 1, + column: column + 1, + snippet: boundedUtf8Snippet(line) + }) + } + seenMatches += 1 + if (matches.length > request.limit) { + truncated = true + break + } + from = column + Math.max(1, query.length) + } + if (truncated) { + break + } + } + if (truncated) { + break + } + } + if (page.nextCursor !== undefined) { + truncated = true + } + } + const hasNextPage = matches.length > request.limit + if (hasNextPage) { + matches.length = request.limit + } + return { + matches, + ...((hasNextPage || truncated) && matches.length > 0 + ? { nextCursor: String(cursor + matches.length) } + : {}), + truncated: hasNextPage || truncated + } + } + + async #resolveExisting( + relativePathInput: string, + expected: 'directory' | 'entry' | 'file', + options: WorkspaceIoOptions + ): Promise { + const relativePath = remoteRelativePathSchema.parse(relativePathInput) + await this.assertCurrent(options) + if (relativePath === '') { + if (expected === 'file') { + throw new WorkspaceServiceError( + 'Workspace root is not a regular file', + 'special-file' + ) + } + return this.root.canonicalPath + } + let current = this.root.canonicalPath + const segments = relativePath.split('/') + for (let index = 0; index < segments.length; index += 1) { + checkCancelled(options) + current = join(current, segments[index]!) + const difference = relative(this.root.canonicalPath, current) + if ( + difference.startsWith('..') || + isAbsolute(difference) + ) { + throw new WorkspaceServiceError( + 'Workspace path escapes its root', + 'invalid-path' + ) + } + const metadata = await lstat(current) + if (metadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Workspace paths cannot contain symbolic links', + 'symlink-rejected' + ) + } + const final = index === segments.length - 1 + if (!final && !metadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Workspace path segment is not a directory', + 'not-directory' + ) + } + if (final) { + if (expected === 'directory' && !metadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Workspace target is not a directory', + 'not-directory' + ) + } + if (expected === 'file' && !metadata.isFile()) { + throw new WorkspaceServiceError( + 'Workspace target is not a regular file', + 'special-file' + ) + } + if (expected === 'entry') { + rejectUnsafeMetadata(metadata) + } + } + } + const canonical = await realpath(current) + const difference = relative(this.root.canonicalPath, canonical) + if (difference.startsWith('..') || isAbsolute(difference)) { + throw new WorkspaceServiceError( + 'Workspace path resolved outside its root', + 'symlink-rejected' + ) + } + return canonical + } +} + +export function checkCancelled(options: WorkspaceIoOptions): void { + if ( + options.deadlineAt !== undefined && + Date.now() >= options.deadlineAt + ) { + throw new WorkspaceServiceError( + 'Workspace operation deadline exceeded', + 'deadline-exceeded' + ) + } + if (options.signal?.aborted === true) { + throw new WorkspaceServiceError( + 'Workspace operation was aborted', + 'aborted' + ) + } +} + +function staleRoot(cause: unknown): WorkspaceServiceError { + return new WorkspaceServiceError( + cause instanceof Error + ? `Workspace root is stale: ${cause.message}` + : 'Workspace root is stale', + 'stale-workspace' + ) +} + +function rejectUnsafeMetadata(metadata: Stats): void { + if (metadata.isSymbolicLink()) { + throw new WorkspaceServiceError( + 'Symbolic links are not workspace entries', + 'symlink-rejected' + ) + } + if (!metadata.isFile() && !metadata.isDirectory()) { + throw new WorkspaceServiceError( + 'Special files are not supported workspace entries', + 'special-file' + ) + } +} + +function sameFileIdentity(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino +} + +function sameSnapshot(left: Stats, right: Stats): boolean { + return ( + sameFileIdentity(left, right) && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ) +} + +function entryFromMetadata( + relativePath: string, + name: string, + metadata: Stats +): RemoteWorkspaceEntry { + const kind = metadata.isSymbolicLink() + ? 'symlink' + : metadata.isDirectory() + ? 'directory' + : metadata.isFile() + ? 'file' + : 'other' + return { + relativePath, + name, + kind, + ...(metadata.isFile() ? { byteLength: metadata.size } : {}), + modifiedAt: metadata.mtime.toISOString(), + executable: metadata.isFile() && (metadata.mode & 0o111) !== 0 + } +} + +function compareDirectoryEntries(left: Dirent, right: Dirent): number { + const leftDirectory = left.isDirectory() && !left.isSymbolicLink() + const rightDirectory = right.isDirectory() && !right.isSymbolicLink() + if (leftDirectory !== rightDirectory) { + return leftDirectory ? -1 : 1 + } + return Buffer.from(left.name).compare(Buffer.from(right.name)) +} + +function parseCursor(value: string | undefined): number { + if (value === undefined) { + return 0 + } + const cursor = Number(value) + if (!Number.isSafeInteger(cursor) || cursor < 0) { + throw new WorkspaceServiceError( + 'Workspace cursor is invalid', + 'invalid-path' + ) + } + return cursor +} + +function appendRelative(parent: string, name: string): string { + return remoteRelativePathSchema.parse(parent === '' ? name : `${parent}/${name}`) +} + +function decodeUtf8(value: Uint8Array): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(value) + } catch (error) { + throw new WorkspaceServiceError( + error instanceof Error + ? `Workspace file is not valid UTF-8: ${error.message}` + : 'Workspace file is not valid UTF-8', + 'invalid-utf8' + ) + } +} + +function boundedUtf8Snippet(line: string): string { + const maximum = REMOTE_WORKSPACE_LIMITS.maximumSearchSnippetBytes + const encoded = Buffer.from(line, 'utf8') + if (encoded.byteLength <= maximum) { + return line + } + let end = maximum + while (end > 0) { + try { + return new TextDecoder('utf-8', { fatal: true }).decode( + encoded.subarray(0, end) + ) + } catch { + end -= 1 + } + } + return '' +} diff --git a/src/agent-daemon/workspace-protocol-methods.test.ts b/src/agent-daemon/workspace-protocol-methods.test.ts new file mode 100644 index 00000000..2876a107 --- /dev/null +++ b/src/agent-daemon/workspace-protocol-methods.test.ts @@ -0,0 +1,133 @@ +import { + mkdtempSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ControllerRegistry } from './controller-registry' +import { WorkspaceGitService } from './workspace-git-service' +import { createWorkspaceProtocolMethods } from './workspace-protocol-methods' +import { WorkspaceRegistry } from './workspace-registry' + +const temporaryPaths: string[] = [] +const linuxIt = process.platform === 'linux' ? it : it.skip + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('workspace protocol methods', () => { + linuxIt('registers typed reads and typed read-only failures', async () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-methods-')) + temporaryPaths.push(root) + writeFileSync(join(root, 'hello.txt'), 'hello') + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-test') + const git = new WorkspaceGitService({ + gitExecutable: '/bin/false' + }) + const workspaces = new WorkspaceRegistry({ + controllers, + inspectGit: async () => 'not-a-repository' + }) + const methods = createWorkspaceProtocolMethods({ + workspaces, + git, + requestTimeoutMs: 1_000 + }) + const context = { controller, channelId: 'channel-test' } + const validated = (await methods['workspace/validate']( + { + remoteRootPath: root, + requestedAccess: 'read-only', + requiredCapabilities: ['read-text'] + }, + context + )) as { + handle: { + workspaceId: string + generation: number + } + } + await expect( + methods['workspace/readText']( + { + workspaceId: validated.handle.workspaceId, + generation: validated.handle.generation, + relativePath: 'hello.txt', + offsetBytes: 0, + maximumBytes: 100 + }, + context + ) + ).resolves.toMatchObject({ content: 'hello' }) + await expect( + methods['workspace/writeTextAtomic']({}, context) + ).rejects.toMatchObject({ code: 'read-only' }) + await expect( + methods['git/operation']({}, context) + ).rejects.toMatchObject({ code: 'read-only' }) + }) + + linuxIt('revokes all connection grants when the connection closes', async () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-methods-')) + temporaryPaths.push(root) + writeFileSync(join(root, 'hello.txt'), 'hello') + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-test') + const workspaces = new WorkspaceRegistry({ + controllers, + inspectGit: async () => 'not-a-repository' + }) + const methods = createWorkspaceProtocolMethods({ + workspaces, + git: new WorkspaceGitService({ gitExecutable: '/bin/false' }) + }) + const connection = new AbortController() + const context = { + controller, + channelId: 'channel-test', + signal: connection.signal + } + const validated = (await methods['workspace/validate']( + { + remoteRootPath: root, + requestedAccess: 'read-only', + requiredCapabilities: [] + }, + context + )) as { + handle: { + workspaceId: string + workspaceIdentity: string + generation: number + } + } + const opened = (await methods['workspace/open']( + { + workspaceIdentity: validated.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + context + )) as { + workspaceId: string + generation: number + } + expect(workspaces.activeHandleCount()).toBe(2) + + connection.abort() + + expect(workspaces.activeHandleCount()).toBe(0) + await expect( + workspaces.get( + opened.workspaceId, + opened.generation, + controller + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + }) +}) diff --git a/src/agent-daemon/workspace-protocol-methods.ts b/src/agent-daemon/workspace-protocol-methods.ts new file mode 100644 index 00000000..6162d386 --- /dev/null +++ b/src/agent-daemon/workspace-protocol-methods.ts @@ -0,0 +1,309 @@ +import { + remoteGitDiffRequestSchema, + remoteGitDiffResultSchema, + remoteGitStatusRequestSchema, + remoteGitStatusResultSchema, + remoteWorkspaceCloseRequestSchema, + remoteWorkspaceCloseResultSchema, + remoteWorkspaceListRequestSchema, + remoteWorkspaceListResultSchema, + remoteWorkspaceOpenRequestSchema, + remoteWorkspaceOpenResultSchema, + remoteWorkspaceReadTextRequestSchema, + remoteWorkspaceReadTextResultSchema, + remoteWorkspaceResumeRequestSchema, + remoteWorkspaceResumeResultSchema, + remoteWorkspaceSearchRequestSchema, + remoteWorkspaceSearchResultSchema, + remoteWorkspaceStatRequestSchema, + remoteWorkspaceStatResultSchema, + remoteWorkspaceValidateRequestSchema, + remoteWorkspaceValidateResultSchema +} from '../shared/remote-agent-contracts' +import type { + ProtocolMethodContext, + ProtocolMethodHandler +} from './protocol-server' +import type { WorkspaceGitService } from './workspace-git-service' +import type { WorkspaceRegistry } from './workspace-registry' +import { + WorkspaceServiceError, + type WorkspaceIoOptions +} from './workspace-path-access' + +const DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS = 30_000 +const MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS = 120_000 + +export const WORKSPACE_PROTOCOL_METHODS = [ + 'workspace/validate', + 'workspace/open', + 'workspace/resume', + 'workspace/close', + 'workspace/list', + 'workspace/stat', + 'workspace/readText', + 'workspace/search', + 'git/status', + 'git/diff', + 'workspace/writeTextAtomic', + 'workspace/applyChangeSet', + 'git/operation' +] as const + +export type WorkspaceProtocolMethodMap = Readonly< + Record<(typeof WORKSPACE_PROTOCOL_METHODS)[number], ProtocolMethodHandler> +> + +export function createWorkspaceProtocolMethods(options: { + workspaces: WorkspaceRegistry + git: WorkspaceGitService + requestTimeoutMs?: number +}): WorkspaceProtocolMethodMap { + const timeoutMs = boundedTimeout( + options.requestTimeoutMs ?? + DEFAULT_WORKSPACE_REQUEST_TIMEOUT_MS + ) + const boundConnectionSignals = new WeakSet() + const invoke = async ( + context: ProtocolMethodContext, + action: (io: WorkspaceIoOptions) => Promise + ): Promise => { + bindControllerLifetime( + options.workspaces, + context, + boundConnectionSignals + ) + return await withDeadline(timeoutMs, action, context.signal) + } + + return { + 'workspace/validate': async (params, context) => + await invoke(context, async (io) => + remoteWorkspaceValidateResultSchema.parse( + await options.workspaces.validate( + remoteWorkspaceValidateRequestSchema.parse(params), + context.controller, + io + ) + ) + ), + 'workspace/open': async (params, context) => + await invoke(context, async (io) => + remoteWorkspaceOpenResultSchema.parse( + await options.workspaces.open( + remoteWorkspaceOpenRequestSchema.parse(params), + context.controller, + io + ) + ) + ), + 'workspace/resume': async (params, context) => + await invoke(context, async (io) => + remoteWorkspaceResumeResultSchema.parse( + await options.workspaces.resume( + remoteWorkspaceResumeRequestSchema.parse(params), + context.controller, + io + ) + ) + ), + 'workspace/close': async (params, context) => { + const request = remoteWorkspaceCloseRequestSchema.parse(params) + return await invoke(context, async () => + remoteWorkspaceCloseResultSchema.parse( + await options.workspaces.close( + request.workspaceId, + request.generation, + context.controller + ) + ) + ) + }, + 'workspace/list': async (params, context) => { + const request = remoteWorkspaceListRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + return remoteWorkspaceListResultSchema.parse( + await workspace.access.list(request, io) + ) + }) + }, + 'workspace/stat': async (params, context) => { + const request = remoteWorkspaceStatRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + return remoteWorkspaceStatResultSchema.parse( + await workspace.access.stat(request.relativePath, io) + ) + }) + }, + 'workspace/readText': async (params, context) => { + const request = remoteWorkspaceReadTextRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + return remoteWorkspaceReadTextResultSchema.parse( + await workspace.access.readText(request, io) + ) + }) + }, + 'workspace/search': async (params, context) => { + const request = remoteWorkspaceSearchRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + return remoteWorkspaceSearchResultSchema.parse( + await workspace.access.search(request, io) + ) + }) + }, + 'git/status': async (params, context) => { + const request = remoteGitStatusRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + assertGitAvailable(workspace.handle.git) + return remoteGitStatusResultSchema.parse( + await options.git.status(workspace.access, request, io) + ) + }) + }, + 'git/diff': async (params, context) => { + const request = remoteGitDiffRequestSchema.parse(params) + return await invoke(context, async (io) => { + const workspace = await options.workspaces.get( + request.workspaceId, + request.generation, + context.controller, + io + ) + assertGitAvailable(workspace.handle.git) + return remoteGitDiffResultSchema.parse( + await options.git.diff(workspace.access, request, io) + ) + }) + }, + 'workspace/writeTextAtomic': readOnlyUnavailable, + 'workspace/applyChangeSet': readOnlyUnavailable, + 'git/operation': readOnlyUnavailable + } +} + +function bindControllerLifetime( + workspaces: WorkspaceRegistry, + context: ProtocolMethodContext, + boundSignals: WeakSet +): void { + const signal = context.signal + if (signal === undefined || boundSignals.has(signal)) { + return + } + boundSignals.add(signal) + const closeController = (): void => { + workspaces.closeController(context.controller) + } + if (signal.aborted) { + closeController() + } else { + signal.addEventListener('abort', closeController, { once: true }) + } +} + +async function readOnlyUnavailable(): Promise { + throw new WorkspaceServiceError( + 'Remote workspace mutation is unavailable in this read-only service', + 'read-only' + ) +} + +function assertGitAvailable( + availability: 'available' | 'not-a-repository' | 'unavailable' +): void { + if (availability !== 'available') { + throw new WorkspaceServiceError( + availability === 'not-a-repository' + ? 'Workspace is not a Git repository' + : 'Git read service is unavailable', + 'git-unavailable' + ) + } +} + +async function withDeadline( + timeoutMs: number, + action: (options: WorkspaceIoOptions) => Promise, + upstreamSignal?: AbortSignal +): Promise { + const controller = new AbortController() + const abortFromUpstream = (): void => { + controller.abort( + new WorkspaceServiceError( + 'Workspace operation was aborted', + 'aborted' + ) + ) + } + if (upstreamSignal?.aborted === true) { + abortFromUpstream() + } else { + upstreamSignal?.addEventListener('abort', abortFromUpstream, { + once: true + }) + } + const deadlineAt = Date.now() + timeoutMs + const timer = setTimeout(() => { + controller.abort( + new WorkspaceServiceError( + 'Workspace operation deadline exceeded', + 'deadline-exceeded' + ) + ) + }, timeoutMs) + timer.unref() + try { + const result = await action({ + signal: controller.signal, + deadlineAt + }) + if (controller.signal.aborted) { + throw controller.signal.reason + } + return result + } finally { + clearTimeout(timer) + upstreamSignal?.removeEventListener('abort', abortFromUpstream) + } +} + +function boundedTimeout(value: number): number { + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > MAXIMUM_WORKSPACE_REQUEST_TIMEOUT_MS + ) { + throw new RangeError('Invalid workspace request timeout') + } + return value +} diff --git a/src/agent-daemon/workspace-registry.test.ts b/src/agent-daemon/workspace-registry.test.ts new file mode 100644 index 00000000..b1147d6c --- /dev/null +++ b/src/agent-daemon/workspace-registry.test.ts @@ -0,0 +1,411 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ControllerRegistry } from './controller-registry' +import { WorkspaceRegistry } from './workspace-registry' + +const temporaryPaths: string[] = [] +const linuxIt = process.platform === 'linux' ? it : it.skip + +afterEach(() => { + vi.restoreAllMocks() + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('WorkspaceRegistry', () => { + linuxIt('binds ephemeral handles to owner and connection generation', async () => { + const root = temporaryDirectory() + const controllers = new ControllerRegistry() + const owner = controllers.attach('controller-owner') + const other = controllers.attach('controller-other') + const registry = workspaceRegistry(controllers) + const validated = await validate(registry, root, owner) + + await expect( + registry.get( + validated.handle.workspaceId, + validated.handle.generation, + other + ) + ).rejects.toMatchObject({ code: 'not-owner' }) + + controllers.disconnect(owner.controllerId, owner.generation) + const replacement = controllers.attach(owner.controllerId) + await expect( + registry.resume( + { + workspaceId: validated.handle.workspaceId, + generation: validated.handle.generation, + workspaceIdentity: validated.handle.workspaceIdentity + }, + replacement + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + await expect( + registry.open( + { + workspaceIdentity: validated.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + replacement + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + }) + + linuxIt('resolves only a current Workspace lease owned by the controller', async () => { + const root = temporaryDirectory() + const controllers = new ControllerRegistry() + const owner = controllers.attach('controller-owner') + const other = controllers.attach('controller-other') + const registry = workspaceRegistry(controllers) + const validated = await validate(registry, root, owner) + + await expect( + registry.getCurrentByIdentity( + validated.handle.workspaceIdentity, + owner + ) + ).resolves.toMatchObject({ + controllerId: owner.controllerId, + controllerGeneration: owner.generation, + handle: { + workspaceIdentity: validated.handle.workspaceIdentity + } + }) + await expect( + registry.getCurrentByIdentity( + validated.handle.workspaceIdentity, + other + ) + ).rejects.toMatchObject({ code: 'not-owner' }) + + controllers.disconnect(owner.controllerId, owner.generation) + const replacement = controllers.attach(owner.controllerId) + await expect( + registry.getCurrentByIdentity( + validated.handle.workspaceIdentity, + replacement + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + }) + + linuxIt('revokes a validation grant and all handles opened from it', async () => { + const root = temporaryDirectory() + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-owner') + const registry = workspaceRegistry(controllers) + const validated = await validate(registry, root, controller) + const opened = await registry.open( + { + workspaceIdentity: validated.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + controller + ) + + await expect( + registry.close( + validated.handle.workspaceId, + validated.handle.generation, + controller + ) + ).resolves.toMatchObject({ closed: true }) + expect(registry.activeHandleCount()).toBe(0) + await expect( + registry.get(opened.workspaceId, opened.generation, controller) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + await expect( + registry.open( + { + workspaceIdentity: validated.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + controller + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + }) + + linuxIt('enforces read-only policy, caps, and symlink-root rejection', async () => { + const root = temporaryDirectory() + const linked = join(tmpdir(), `goodbuddy-linked-${Date.now()}`) + symlinkSync(root, linked, 'dir') + temporaryPaths.push(linked) + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-owner') + const registry = new WorkspaceRegistry({ + controllers, + maximumControllerHandles: 1, + inspectGit: async () => 'not-a-repository' + }) + await expect( + registry.validate( + { + remoteRootPath: root, + requestedAccess: 'read-write', + requiredCapabilities: [] + }, + controller + ) + ).rejects.toMatchObject({ code: 'read-only' }) + await expect( + registry.validate( + { + remoteRootPath: linked, + requestedAccess: 'read-only', + requiredCapabilities: [] + }, + controller + ) + ).rejects.toMatchObject({ code: 'symlink-rejected' }) + + const validated = await validate(registry, root, controller) + await expect( + registry.open( + { + workspaceIdentity: validated.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + controller + ) + ).rejects.toMatchObject({ code: 'capacity-exceeded' }) + }) + + linuxIt('does not mutate workspace or registry storage during validation', async () => { + const state = temporaryDirectory() + const root = join(state, 'workspace') + mkdirSync(root) + writeFileSync(join(root, 'existing.txt'), 'unchanged') + const before = readdirSync(root) + const storagePath = join(state, 'workspaces.json') + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-owner') + const registry = new WorkspaceRegistry({ + controllers, + storagePath, + inspectGit: async () => 'not-a-repository' + }) + + const validated = await validate(registry, root, controller) + expect(existsSync(storagePath)).toBe(false) + expect(readdirSync(root)).toEqual(before) + expect(readFileSync(join(root, 'existing.txt'), 'utf8')).toBe( + 'unchanged' + ) + + await registry.close( + validated.handle.workspaceId, + validated.handle.generation, + controller + ) + expect(existsSync(storagePath)).toBe(false) + }) + + linuxIt('leaves no grant when validation is cancelled', async () => { + const state = temporaryDirectory() + const root = join(state, 'workspace') + mkdirSync(root) + const storagePath = join(state, 'workspaces.json') + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-owner') + const abort = new AbortController() + let releaseInspection: ((value: 'not-a-repository') => void) | undefined + let markInspectionStarted: (() => void) | undefined + const inspectionStarted = new Promise((resolve) => { + markInspectionStarted = resolve + }) + const registry = new WorkspaceRegistry({ + controllers, + storagePath, + inspectGit: async () => { + markInspectionStarted?.() + return await new Promise<'not-a-repository'>((resolve) => { + releaseInspection = resolve + }) + } + }) + + const pending = validate(registry, root, controller, { + signal: abort.signal + }) + await inspectionStarted + abort.abort(new Error('cancelled')) + releaseInspection?.('not-a-repository') + + await expect(pending).rejects.toThrow('cancelled') + expect(registry.activeHandleCount()).toBe(0) + expect(existsSync(storagePath)).toBe(false) + }) + + linuxIt('requires path revalidation after daemon restart', async () => { + const state = temporaryDirectory() + const root = join(state, 'workspace') + mkdirSync(root) + const storagePath = join(state, 'workspaces.json') + const firstControllers = new ControllerRegistry() + const firstController = firstControllers.attach('controller-owner') + const firstRegistry = new WorkspaceRegistry({ + controllers: firstControllers, + storagePath, + inspectGit: async () => 'not-a-repository' + }) + const first = await validate(firstRegistry, root, firstController) + firstRegistry.closeAll() + + const replacementControllers = new ControllerRegistry() + const replacement = replacementControllers.attach('controller-owner') + const replacementRegistry = new WorkspaceRegistry({ + controllers: replacementControllers, + storagePath, + inspectGit: async () => 'not-a-repository' + }) + await expect( + replacementRegistry.resume( + { + workspaceId: first.handle.workspaceId, + generation: first.handle.generation, + workspaceIdentity: first.handle.workspaceIdentity + }, + replacement + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + await expect( + replacementRegistry.open( + { + workspaceIdentity: first.handle.workspaceIdentity, + requestedAccess: 'read-only' + }, + replacement + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + + const revalidated = await validate( + replacementRegistry, + root, + replacement + ) + expect(revalidated.handle.workspaceIdentity).toBe( + first.handle.workspaceIdentity + ) + expect(revalidated.handle.workspaceId).not.toBe( + first.handle.workspaceId + ) + }) + + it('clears legacy persisted roots and never authorizes them', async () => { + const state = temporaryDirectory() + const root = join(state, 'workspace') + mkdirSync(root) + const storagePath = join(state, 'workspaces.json') + writeFileSync( + storagePath, + JSON.stringify({ + version: 1, + roots: [ + { + canonicalPath: root, + device: '1', + inode: '2', + workspaceIdentity: 'workspace-legacy', + git: 'not-a-repository', + trustAttestationRevision: 1, + authorizedControllers: ['controller-owner'] + } + ], + leases: [ + { + handle: { + workspaceId: 'workspace-legacy-handle', + workspaceIdentity: 'workspace-legacy', + canonicalDisplayPath: root, + access: 'read-only', + git: 'not-a-repository', + capabilities: ['list', 'stat', 'read-text', 'search'], + generation: 1 + }, + controllerId: 'controller-owner', + controllerGeneration: controllerGenerationForLegacyFixture + } + ] + }), + { mode: 0o600 } + ) + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-owner') + const registry = new WorkspaceRegistry({ + controllers, + storagePath, + inspectGit: async () => 'not-a-repository' + }) + + expect(JSON.parse(readFileSync(storagePath, 'utf8'))).toEqual({ + version: 2, + authorization: 'ephemeral' + }) + await expect( + registry.open( + { + workspaceIdentity: 'workspace-legacy', + requestedAccess: 'read-only' + }, + controller + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + await expect( + registry.resume( + { + workspaceId: 'workspace-legacy-handle', + workspaceIdentity: 'workspace-legacy', + generation: 1 + }, + controller + ) + ).rejects.toMatchObject({ code: 'workspace-not-found' }) + expect(registry.activeHandleCount()).toBe(0) + }) +}) + +function workspaceRegistry( + controllers: ControllerRegistry +): WorkspaceRegistry { + return new WorkspaceRegistry({ + controllers, + inspectGit: async () => 'not-a-repository' + }) +} + +const controllerGenerationForLegacyFixture = 1 + +async function validate( + registry: WorkspaceRegistry, + root: string, + controller: ReturnType, + options: { signal?: AbortSignal } = {} +) { + return await registry.validate( + { + remoteRootPath: root, + requestedAccess: 'read-only', + requiredCapabilities: [] + }, + controller, + options + ) +} + +function temporaryDirectory(): string { + const path = mkdtempSync(join(tmpdir(), 'goodbuddy-registry-')) + temporaryPaths.push(path) + return path +} diff --git a/src/agent-daemon/workspace-registry.ts b/src/agent-daemon/workspace-registry.ts new file mode 100644 index 00000000..ea11120c --- /dev/null +++ b/src/agent-daemon/workspace-registry.ts @@ -0,0 +1,622 @@ +import { randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + REMOTE_WORKSPACE_READ_CAPABILITIES, + type RemoteWorkspaceCloseResult, + type RemoteWorkspaceHandle, + type RemoteWorkspaceOpenRequest, + type RemoteWorkspaceResumeRequest, + type RemoteWorkspaceResumeResult, + type RemoteWorkspaceValidateRequest, + type RemoteWorkspaceValidateResult +} from '../shared/remote-agent-contracts' +import { + type ControllerLease, + type ControllerRegistry +} from './controller-registry' +import { + inspectWorkspaceRoot, + WorkspacePathAccess, + WorkspaceServiceError, + type WorkspaceIoOptions +} from './workspace-path-access' +import { + assertPrivateRegularFile, + writePrivateFileAtomic +} from './managed-paths' + +const DEFAULT_MAXIMUM_CONTROLLER_HANDLES = 32 +const DEFAULT_MAXIMUM_DAEMON_HANDLES = 256 +const DEFAULT_MAXIMUM_VALIDATED_ROOTS = 256 +const EPHEMERAL_REGISTRY_MARKER = Object.freeze({ + version: 2, + authorization: 'ephemeral' +}) + +type GitAvailability = + | 'available' + | 'not-a-repository' + | 'unavailable' + +type ValidatedRoot = { + access: WorkspacePathAccess + git: GitAvailability +} + +export type WorkspaceLease = { + handle: RemoteWorkspaceHandle + controllerId: string + controllerGeneration: number + access: WorkspacePathAccess +} + +type MutableWorkspaceLease = WorkspaceLease & { + closed: boolean + authorizesOpen: boolean + grantWorkspaceId: string +} + +export type WorkspaceRegistryOptions = { + controllers: ControllerRegistry + maximumControllerHandles?: number + maximumDaemonHandles?: number + maximumValidatedRoots?: number + inspectGit?: ( + access: WorkspacePathAccess, + options?: WorkspaceIoOptions + ) => Promise + now?: () => number + storagePath?: string +} + +export class WorkspaceRegistry { + readonly #controllers: ControllerRegistry + readonly #maximumControllerHandles: number + readonly #maximumDaemonHandles: number + readonly #maximumValidatedRoots: number + readonly #inspectGit: NonNullable + readonly #now: () => number + readonly #storagePath?: string + readonly #validatedRoots = new Map() + readonly #leases = new Map() + readonly #nextGenerationByIdentity = new Map() + + constructor(options: WorkspaceRegistryOptions) { + this.#controllers = options.controllers + this.#maximumControllerHandles = boundedCap( + options.maximumControllerHandles ?? + DEFAULT_MAXIMUM_CONTROLLER_HANDLES, + 'per-controller workspace handle' + ) + this.#maximumDaemonHandles = boundedCap( + options.maximumDaemonHandles ?? DEFAULT_MAXIMUM_DAEMON_HANDLES, + 'daemon workspace handle' + ) + this.#maximumValidatedRoots = boundedCap( + options.maximumValidatedRoots ?? DEFAULT_MAXIMUM_VALIDATED_ROOTS, + 'validated workspace root' + ) + this.#inspectGit = + options.inspectGit ?? + (async () => 'unavailable') + this.#now = options.now ?? Date.now + this.#storagePath = options.storagePath + this.#load() + } + + async validate( + request: RemoteWorkspaceValidateRequest, + controller: ControllerLease, + options: WorkspaceIoOptions = {} + ): Promise { + this.#assertController(controller) + this.#assertReadOnly( + request.requestedAccess, + request.requiredCapabilities + ) + const rootIdentity = await inspectWorkspaceRoot( + request.remoteRootPath, + options + ) + const access = new WorkspacePathAccess(rootIdentity) + const existing = this.#validatedRoots.get( + rootIdentity.workspaceIdentity + ) + if ( + existing !== undefined && + existing.access.root.canonicalPath !== rootIdentity.canonicalPath + ) { + throw new WorkspaceServiceError( + 'Workspace file identity is already bound to another canonical root', + 'stale-workspace' + ) + } + const git = await this.#inspectGit(access, options) + const capabilities = capabilitiesFor(git) + for (const capability of request.requiredCapabilities) { + if (!capabilities.includes(capability)) { + throw new WorkspaceServiceError( + `Required workspace capability is unavailable: ${capability}`, + 'unavailable' + ) + } + } + if ( + existing === undefined && + this.#validatedRoots.size >= this.#maximumValidatedRoots + ) { + throw new WorkspaceServiceError( + 'Validated workspace root capacity reached', + 'capacity-exceeded' + ) + } + this.#validatedRoots.set(rootIdentity.workspaceIdentity, { + access, + git + }) + let lease: WorkspaceLease + try { + lease = await this.#createLease( + this.#validatedRoots.get(rootIdentity.workspaceIdentity)!, + controller, + true, + undefined, + options + ) + } catch (error) { + if (existing === undefined) { + this.#validatedRoots.delete(rootIdentity.workspaceIdentity) + } else { + this.#validatedRoots.set(rootIdentity.workspaceIdentity, existing) + } + throw error + } + return { + handle: lease.handle, + validatedAt: new Date(this.#now()).toISOString() + } + } + + async open( + request: RemoteWorkspaceOpenRequest, + controller: ControllerLease, + options: WorkspaceIoOptions = {} + ): Promise { + this.#assertController(controller) + this.#assertReadOnly(request.requestedAccess, []) + const root = this.#validatedRoots.get(request.workspaceIdentity) + if (root === undefined) { + throw new WorkspaceServiceError( + 'Workspace identity has no live validation grant', + 'workspace-not-found' + ) + } + const liveGrants = [...this.#leases.values()].filter( + (lease) => + !lease.closed && + lease.authorizesOpen && + lease.handle.workspaceIdentity === request.workspaceIdentity + ) + if ( + !liveGrants.some( + (lease) => + lease.controllerId === controller.controllerId && + lease.controllerGeneration === controller.generation + ) + ) { + if ( + liveGrants.some( + (lease) => lease.controllerId !== controller.controllerId + ) + ) { + throw new WorkspaceServiceError( + 'Workspace identity belongs to another controller', + 'not-owner' + ) + } + throw new WorkspaceServiceError( + 'Workspace identity has no live validation grant', + 'workspace-not-found' + ) + } + return ( + await this.#createLease( + root, + controller, + false, + liveGrants.find( + (lease) => + lease.controllerId === controller.controllerId && + lease.controllerGeneration === controller.generation + )?.grantWorkspaceId, + options + ) + ).handle + } + + async resume( + request: RemoteWorkspaceResumeRequest, + controller: ControllerLease, + options: WorkspaceIoOptions = {} + ): Promise { + this.#assertController(controller) + const lease = this.#leases.get(request.workspaceId) + if ( + lease === undefined || + lease.closed || + lease.handle.workspaceIdentity !== request.workspaceIdentity + ) { + throw new WorkspaceServiceError( + 'Workspace handle is unavailable', + 'workspace-not-found' + ) + } + if (lease.controllerId !== controller.controllerId) { + throw new WorkspaceServiceError( + 'Workspace handle belongs to another controller', + 'not-owner' + ) + } + if (lease.controllerGeneration !== controller.generation) { + throw new WorkspaceServiceError( + 'Workspace handle is unavailable after reconnect', + 'workspace-not-found' + ) + } + if (lease.handle.generation !== request.generation) { + throw new WorkspaceServiceError( + 'Workspace handle generation is stale', + 'stale-generation' + ) + } + await lease.access.assertCurrent(options) + return { resumed: true, handle: { ...lease.handle } } + } + + async close( + workspaceId: string, + generation: number, + controller: ControllerLease + ): Promise { + const lease = this.#assertLease( + workspaceId, + generation, + controller + ) + if (lease.authorizesOpen) { + this.#revokeGrant(lease.grantWorkspaceId) + } else { + lease.closed = true + this.#leases.delete(workspaceId) + this.#removeUnusedRoot(lease.handle.workspaceIdentity) + } + return { workspaceId, generation, closed: true } + } + + async get( + workspaceId: string, + generation: number, + controller: ControllerLease, + options: WorkspaceIoOptions = {} + ): Promise { + const lease = this.#assertLease( + workspaceId, + generation, + controller + ) + await lease.access.assertCurrent(options) + return snapshotLease(lease) + } + + async getCurrentByIdentity( + workspaceIdentity: string, + controller: ControllerLease, + options: WorkspaceIoOptions = {} + ): Promise { + this.#assertController(controller) + const leases = [...this.#leases.values()].filter( + (lease) => + !lease.closed && + lease.handle.workspaceIdentity === workspaceIdentity && + lease.controllerId === controller.controllerId && + lease.controllerGeneration === controller.generation + ) + if (leases.length === 0) { + const ownedByAnotherController = [...this.#leases.values()].some( + (lease) => + !lease.closed && + lease.handle.workspaceIdentity === workspaceIdentity && + lease.controllerId !== controller.controllerId + ) + throw new WorkspaceServiceError( + ownedByAnotherController + ? 'Workspace identity belongs to another controller' + : 'Workspace identity has no current controller lease', + ownedByAnotherController ? 'not-owner' : 'workspace-not-found' + ) + } + const lease = + leases.find((candidate) => candidate.authorizesOpen) ?? + leases[0]! + await lease.access.assertCurrent(options) + return snapshotLease(lease) + } + + closeAll(): void { + for (const lease of this.#leases.values()) { + lease.closed = true + } + this.#leases.clear() + this.#validatedRoots.clear() + } + + closeController( + controller: Pick + ): void { + const affectedIdentities = new Set() + for (const [workspaceId, lease] of this.#leases) { + if ( + lease.controllerId !== controller.controllerId || + lease.controllerGeneration !== controller.generation + ) { + continue + } + lease.closed = true + this.#leases.delete(workspaceId) + affectedIdentities.add(lease.handle.workspaceIdentity) + } + for (const workspaceIdentity of affectedIdentities) { + this.#removeUnusedRoot(workspaceIdentity) + } + } + + activeHandleCount(): number { + return this.#leases.size + } + + #assertLease( + workspaceId: string, + generation: number, + controller: ControllerLease + ): MutableWorkspaceLease { + this.#assertController(controller) + const lease = this.#leases.get(workspaceId) + if (lease === undefined || lease.closed) { + throw new WorkspaceServiceError( + 'Workspace handle is unavailable', + 'workspace-not-found' + ) + } + if (lease.controllerId !== controller.controllerId) { + throw new WorkspaceServiceError( + 'Workspace handle belongs to another controller', + 'not-owner' + ) + } + if (lease.controllerGeneration !== controller.generation) { + throw new WorkspaceServiceError( + 'Workspace handle is unavailable after reconnect', + 'workspace-not-found' + ) + } + if (lease.handle.generation !== generation) { + throw new WorkspaceServiceError( + 'Workspace handle generation is stale', + 'stale-generation' + ) + } + return lease + } + + async #createLease( + root: ValidatedRoot, + controller: ControllerLease, + authorizesOpen: boolean, + grantWorkspaceId: string | undefined, + options: WorkspaceIoOptions + ): Promise { + this.#assertCapacity(controller.controllerId) + await root.access.assertCurrent(options) + throwIfAborted(options.signal) + const workspaceIdentity = root.access.root.workspaceIdentity + const generation = + (this.#nextGenerationByIdentity.get(workspaceIdentity) ?? 0) + 1 + if (generation > 0xffff_ffff) { + throw new WorkspaceServiceError( + 'Workspace generation is exhausted', + 'capacity-exceeded' + ) + } + this.#nextGenerationByIdentity.set(workspaceIdentity, generation) + const workspaceId = `workspace-${randomBytes(18).toString('base64url')}` + if (!authorizesOpen && grantWorkspaceId === undefined) { + throw new WorkspaceServiceError( + 'Workspace identity has no live validation grant', + 'workspace-not-found' + ) + } + const handle: RemoteWorkspaceHandle = { + workspaceId, + workspaceIdentity, + canonicalDisplayPath: root.access.root.canonicalPath, + access: 'read-only', + git: root.git, + capabilities: capabilitiesFor(root.git), + generation + } + const lease: MutableWorkspaceLease = { + handle, + controllerId: controller.controllerId, + controllerGeneration: controller.generation, + access: root.access, + closed: false, + authorizesOpen, + grantWorkspaceId: grantWorkspaceId ?? workspaceId + } + this.#leases.set(workspaceId, lease) + return snapshotLease(lease) + } + + #assertCapacity(controllerId: string): void { + if (this.#leases.size >= this.#maximumDaemonHandles) { + throw new WorkspaceServiceError( + 'Daemon workspace handle capacity reached', + 'capacity-exceeded' + ) + } + let owned = 0 + for (const lease of this.#leases.values()) { + if (!lease.closed && lease.controllerId === controllerId) { + owned += 1 + } + } + if (owned >= this.#maximumControllerHandles) { + throw new WorkspaceServiceError( + 'Controller workspace handle capacity reached', + 'capacity-exceeded' + ) + } + } + + #assertController(controller: ControllerLease): void { + this.#controllers.assertCurrent( + controller.controllerId, + controller.generation + ) + } + + #assertReadOnly( + requestedAccess: 'read-only' | 'read-write', + capabilities: readonly string[] + ): void { + if ( + requestedAccess !== 'read-only' || + capabilities.includes('write-text-atomic') || + capabilities.includes('apply-change-set') + ) { + throw new WorkspaceServiceError( + 'Remote workspace writes are not available', + 'read-only' + ) + } + } + + #load(): void { + if (this.#storagePath === undefined) { + return + } + let parsed: unknown + try { + assertPrivateRegularFile(this.#storagePath) + parsed = JSON.parse(readFileSync(this.#storagePath, 'utf8')) + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return + } + throw error + } + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + throw new Error('Workspace registry is corrupt') + } + const value = parsed as Record + if ( + value.version === EPHEMERAL_REGISTRY_MARKER.version && + value.authorization === EPHEMERAL_REGISTRY_MARKER.authorization && + Object.keys(value).length === 2 + ) { + return + } + if ( + value.version === 1 && + Array.isArray(value.roots) && + Array.isArray(value.leases) + ) { + this.#persistMarker() + return + } + throw new Error('Workspace registry is corrupt') + } + + #persistMarker(): void { + if (this.#storagePath === undefined) { + return + } + writePrivateFileAtomic( + this.#storagePath, + JSON.stringify(EPHEMERAL_REGISTRY_MARKER) + ) + } + + #removeUnusedRoot(workspaceIdentity: string): void { + for (const lease of this.#leases.values()) { + if ( + !lease.closed && + lease.handle.workspaceIdentity === workspaceIdentity + ) { + return + } + } + this.#validatedRoots.delete(workspaceIdentity) + } + + #revokeGrant(grantWorkspaceId: string): void { + const affectedIdentities = new Set() + for (const [workspaceId, lease] of this.#leases) { + if (lease.grantWorkspaceId !== grantWorkspaceId) { + continue + } + lease.closed = true + this.#leases.delete(workspaceId) + affectedIdentities.add(lease.handle.workspaceIdentity) + } + for (const workspaceIdentity of affectedIdentities) { + this.#removeUnusedRoot(workspaceIdentity) + } + } +} + +function capabilitiesFor( + git: GitAvailability +): RemoteWorkspaceHandle['capabilities'] { + return [ + ...REMOTE_WORKSPACE_READ_CAPABILITIES, + ...(git === 'available' + ? (['git-status', 'git-diff'] as const) + : []) + ] +} + +function isErrno(error: unknown, code: string): boolean { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === code + ) +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw signal.reason + } +} + +function boundedCap(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 1 || value > 100_000) { + throw new RangeError(`Invalid ${label} capacity`) + } + return value +} + +function snapshotLease(lease: MutableWorkspaceLease): WorkspaceLease { + return { + handle: { ...lease.handle, capabilities: [...lease.handle.capabilities] }, + controllerId: lease.controllerId, + controllerGeneration: lease.controllerGeneration, + access: lease.access + } +} diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts new file mode 100644 index 00000000..184482a7 --- /dev/null +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -0,0 +1,1930 @@ +import { createHash } from 'node:crypto' +import { + AgentSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type Agent, + type AgentCapabilities, + type PromptRequest, + type PromptResponse +} from '@agentclientprotocol/sdk' +import { describe, expect, it, vi } from 'vitest' +import { canonicalJson } from '../../shared/agent-protocol/canonical' +import type { + RemotePromptOperationAcceptance, + RemotePromptOperationPreparation +} from '../../shared/remote-agent-contracts' +import { + REMOTE_RUNTIME_LAUNCH_LIMITS +} from '../../shared/remote-runtime-launch-contracts' +import type { RuntimeEvent } from './runtime' +import { + AcpRemoteRuntime, + type AcpRemoteRuntimeOptions +} from './acp-remote-runtime' +import type { + RemoteModelBridgeOpenRequest, + RemoteModelBridgeSession, + RemoteCancellationEscalation, + RemotePromptOperationCompletion, + RemotePromptOperationCompletionResult, + RemotePromptOperationReconciliation, + RemotePromptOperationReconciliationResult, + RemoteRuntimeChannel +} from './remote-runtime-channel' +import { MemoryRuntimeSessionBindingStore } from './runtime-session-binding-store' + +type FakeServer = { + channel: RemoteRuntimeChannel + client: AgentSideConnection + generation: { current: boolean } + close: ReturnType + setInboundPaused: ReturnType + prepare: ReturnType< + typeof vi.fn< + ( + value: RemotePromptOperationPreparation + ) => Promise + > + > + complete: ReturnType< + typeof vi.fn< + ( + value: RemotePromptOperationCompletion + ) => Promise + > + > + reconcile: ReturnType< + typeof vi.fn< + ( + value: RemotePromptOperationReconciliation + ) => Promise + > + > + escalate: ReturnType< + typeof vi.fn<(value: RemoteCancellationEscalation) => Promise> + > +} + +function capabilityDigest(capabilities: AgentCapabilities): string { + return `sha256:${createHash('sha256') + .update( + canonicalJson({ + protocolVersion: PROTOCOL_VERSION, + capabilities + }) + ) + .digest('hex')}` +} + +const identity = { + controllerId: 'controller-1', + controllerGeneration: 1, + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + workspaceIdentity: 'workspace-1', + agentInstallationId: 'installation-1', + daemonBootIdAtOpen: 'boot-1', + runtimeBundleDigest: `sha256:${'a'.repeat(64)}`, + runtimeAdapterDigest: `sha256:${'b'.repeat(64)}` +} + +function acceptPrompt( + value: RemotePromptOperationPreparation +): RemotePromptOperationAcceptance { + return { + bindingId: value.bindingId, + operationId: value.operationId, + requestId: value.requestId, + workMode: value.workMode, + deadlineAt: value.deadlineAt, + acceptedAt: new Date().toISOString() + } +} + +function fakeServer( + handlers: { + capabilities?: AgentCapabilities + advertisedAcpCapabilitiesDigest?: string + channelEpoch?: string + generation?: number + initialize?: Agent['initialize'] + newSession?: Agent['newSession'] + prompt?: (request: PromptRequest) => Promise + loadSession?: Agent['loadSession'] + resumeSession?: Agent['resumeSession'] + closeSession?: Agent['closeSession'] + cancel?: Agent['cancel'] + preparePrompt?: ( + value: RemotePromptOperationPreparation + ) => Promise + completePromptOperation?: ( + value: RemotePromptOperationCompletion + ) => Promise + reconcilePromptOperation?: ( + value: RemotePromptOperationReconciliation + ) => Promise + openModelBridge?: ( + value: RemoteModelBridgeOpenRequest + ) => Promise + } = {} +): FakeServer { + const mainToAgent = new TransformStream() + const agentToMain = new TransformStream() + const generation = { current: true } + const close = vi.fn(async () => {}) + const setInboundPaused = vi.fn(async () => {}) + const prepare = vi.fn< + ( + value: RemotePromptOperationPreparation + ) => Promise + >( + handlers.preparePrompt ?? + (async (value) => acceptPrompt(value)) + ) + const reconcile = vi.fn< + ( + value: RemotePromptOperationReconciliation + ) => Promise + >( + handlers.reconcilePromptOperation ?? + (async () => ({ + status: 'terminal', + terminalState: 'completed', + processTree: 'empty' + })) + ) + const complete = vi.fn< + ( + value: RemotePromptOperationCompletion + ) => Promise + >( + handlers.completePromptOperation ?? + (async (value) => ({ + ...value, + status: 'completed', + processTree: 'running' + })) + ) + const escalate = vi.fn< + (value: RemoteCancellationEscalation) => Promise + >(async () => {}) + const agentCapabilities = handlers.capabilities ?? { + loadSession: true, + sessionCapabilities: { + close: {}, + resume: {} + } + } + const connection = new AgentSideConnection( + () => + ({ + initialize: + handlers.initialize ?? + (async () => ({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities + })), + newSession: + handlers.newSession ?? + (async () => ({ sessionId: 'session-1' })), + loadSession: handlers.loadSession ?? (async () => ({})), + resumeSession: handlers.resumeSession ?? (async () => ({})), + closeSession: handlers.closeSession ?? (async () => ({})), + prompt: + handlers.prompt ?? + (async () => ({ stopReason: 'end_turn' })), + cancel: handlers.cancel ?? (async () => {}) + }) as unknown as Agent, + ndJsonStream(agentToMain.writable, mainToAgent.readable) + ) + const channel: RemoteRuntimeChannel = { + input: agentToMain.readable, + output: mainToAgent.writable, + generation: handlers.generation ?? 1, + channelEpoch: handlers.channelEpoch ?? '1', + advertisedAcpCapabilitiesDigest: + handlers.advertisedAcpCapabilitiesDigest ?? + capabilityDigest(agentCapabilities), + capabilities: { + cancellationEscalation: true, + promptOperationReconciliation: true, + modelBridge: handlers.openModelBridge !== undefined + }, + closed: new Promise(() => {}), + isCurrentGeneration: () => generation.current, + getBindingCursors: async () => ({ + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }), + preparePrompt: prepare, + ...(handlers.openModelBridge === undefined + ? {} + : { openModelBridge: handlers.openModelBridge }), + completePromptOperation: complete, + setInboundPaused, + escalateCancellation: escalate, + reconcilePromptOperation: reconcile, + close + } + return { + channel, + client: connection, + generation, + close, + setInboundPaused, + prepare, + complete, + reconcile, + escalate + } +} + +function runtime( + server: FakeServer, + store = new MemoryRuntimeSessionBindingStore(), + cancellationGraceMs = 5, + overrides: Partial = {} +): AcpRemoteRuntime { + return new AcpRemoteRuntime({ + runtimeId: 'opencode', + label: 'Remote ACP', + workspacePath: '/workspace', + identity, + channel: server.channel, + bindingStore: store, + assertHostCurrent: () => {}, + cancellationGraceMs, + operationTimeoutMs: overrides.operationTimeoutMs ?? 100, + promptTimeoutMs: + overrides.promptTimeoutMs ?? + overrides.operationTimeoutMs ?? + 100, + usage: { + runtime: 'opencode', + provider: 'test', + model: 'fake' + }, + ...overrides + }) +} + +function factoryRuntime( + factory: (bindingId: string) => Promise, + store = new MemoryRuntimeSessionBindingStore(), + overrides: Partial = {} +): AcpRemoteRuntime { + return new AcpRemoteRuntime({ + runtimeId: 'opencode', + label: 'Remote ACP', + workspacePath: '/workspace', + identity, + channelFactory: factory, + bindingStore: store, + assertHostCurrent: () => {}, + cancellationGraceMs: 5, + operationTimeoutMs: overrides.operationTimeoutMs ?? 100, + promptTimeoutMs: + overrides.promptTimeoutMs ?? + overrides.operationTimeoutMs ?? + 100, + usage: { + runtime: 'opencode', + provider: 'test', + model: 'fake' + }, + ...overrides, + channel: undefined + }) +} + +async function collect( + stream: AsyncGenerator +): Promise { + const events: RuntimeEvent[] = [] + for await (const event of stream) { + events.push(event) + } + return events +} + +const request = { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53c01', + conversationId: 'conversation-1', + prompt: 'hello', + workMode: 'execute' as const +} + +const modelBridgePolicy = { + protocol: 'openai-responses' as const, + model: 'gpt-test', + modelProfileDigest: `sha256:${'d'.repeat(64)}`, + supportsImageInput: false +} + +function fakeModelBridge( + value: RemoteModelBridgeOpenRequest, + index: number, + result = { clean: true, poisoned: false } +): RemoteModelBridgeSession & { + close: ReturnType +} { + const close = vi.fn(async () => result) + return { + version: 'goodbuddy-model-bridge-v1', + channelId: `model-channel-${index}`, + channelEpoch: String(100 + index), + policy: value.policy, + closed: new Promise(() => {}), + close + } +} + +describe('AcpRemoteRuntime', () => { + it('budgets the complete ACP input stream, not only raw prompt bytes', async () => { + const server = fakeServer() + const instance = runtime( + server, + new MemoryRuntimeSessionBindingStore() + ) + + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(server.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + budget: expect.objectContaining({ + maximumInputBytes: + REMOTE_RUNTIME_LAUNCH_LIMITS.maximumPromptInputBytes + }) + }) + ) + await instance.dispose() + }) + + it('closes a binding after a definitive prepare rejection so retry is safe', async () => { + const store = new MemoryRuntimeSessionBindingStore() + let rejectedBindingId = '' + const newSession = vi.fn(async () => ({ sessionId: 'session-1' })) + const rejected = fakeServer({ + newSession, + preparePrompt: async (preparation) => { + rejectedBindingId = preparation.bindingId + throw Object.assign( + new Error( + 'Remote Runtime runtime/preparePrompt was rejected (RPC -32000, process)' + ), + { + remoteMethod: 'runtime/preparePrompt', + remoteRequestOutcome: 'rejected' + } + ) + } + }) + const first = runtime(rejected, store) + + await expect( + collect( + first.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('runtime/preparePrompt') + expect(newSession).not.toHaveBeenCalled() + await expect(store.getById(rejectedBindingId)).resolves.toMatchObject({ + state: 'closed', + activePromptOperationId: undefined + }) + await expect( + store.getByConversation(request.conversationId) + ).resolves.toBeUndefined() + + const retry = runtime(fakeServer(), store) + await expect( + collect( + retry.run( + { ...request, requestId: 'prompt-retry' }, + new AbortController().signal, + async () => 'once' + ) + ) + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'done' }) + ]) + ) + await retry.dispose() + }) + + it('allocates and cleans a fresh model bridge for each prompt on one ACP channel', async () => { + const bridges: ReturnType[] = [] + const openModelBridge = vi.fn( + async (value: RemoteModelBridgeOpenRequest) => { + const bridge = fakeModelBridge(value, bridges.length + 1) + bridges.push(bridge) + return bridge + } + ) + const server = fakeServer({ + openModelBridge, + preparePrompt: async (preparation) => { + expect(preparation.modelBridge).toMatchObject({ + version: 'goodbuddy-model-bridge-v1', + policy: modelBridgePolicy + }) + return acceptPrompt(preparation) + } + }) + const store = new MemoryRuntimeSessionBindingStore() + const instance = runtime(server, store, 5, { + modelBridgePolicy + }) + + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await collect( + instance.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(openModelBridge).toHaveBeenCalledTimes(2) + expect(bridges.map((bridge) => bridge.channelId)).toEqual([ + 'model-channel-1', + 'model-channel-2' + ]) + expect(bridges.every((bridge) => bridge.close.mock.calls.length === 1)) + .toBe(true) + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'ready', + promptSequence: 1, + modelBridgeVersion: 'goodbuddy-model-bridge-v1', + modelBridgePolicy + }) + await instance.dispose() + }) + + it('closes and replaces an idle binding under a different model policy', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const firstServer = fakeServer({ + openModelBridge: async (value) => fakeModelBridge(value, 1) + }) + const first = runtime(firstServer, store, 5, { + modelBridgePolicy + }) + await collect( + first.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + const oldBinding = await store.getByConversation( + request.conversationId + ) + + const newSession = vi.fn(async () => ({ sessionId: 'session-2' })) + const loadSession = vi.fn(async () => ({})) + const secondServer = fakeServer({ + openModelBridge: async (value) => fakeModelBridge(value, 2), + newSession, + loadSession + }) + const second = runtime(secondServer, store, 5, { + modelBridgePolicy: { + ...modelBridgePolicy, + model: 'different-model' + } + }) + await collect( + second.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal, + async () => 'once' + ) + ) + expect(newSession).toHaveBeenCalledOnce() + expect(loadSession).not.toHaveBeenCalled() + await expect(store.getById(oldBinding!.bindingId)).resolves.toMatchObject({ + state: 'closed' + }) + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + bindingId: expect.not.stringMatching(oldBinding!.bindingId), + acpSessionId: 'session-2', + state: 'ready', + modelBridgePolicy: expect.objectContaining({ + model: 'different-model' + }) + }) + await Promise.all([first.dispose(), second.dispose()]) + }) + + it('replaces an idle binding after Agent and Runtime identity changes', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = runtime(fakeServer(), store) + await collect( + first.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + const oldBinding = await store.getByConversation( + request.conversationId + ) + await first.dispose() + + const server = fakeServer({ + newSession: async () => ({ sessionId: 'session-2' }) + }) + const bindingIds: string[] = [] + const replacement = factoryRuntime( + async (bindingId) => { + bindingIds.push(bindingId) + return server.channel + }, + store, + { + identity: { + ...identity, + agentInstallationId: 'installation-2', + runtimeBundleDigest: `sha256:${'c'.repeat(64)}` + } + } + ) + + await collect( + replacement.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal + ) + ) + + expect(bindingIds).toHaveLength(1) + expect(bindingIds[0]).not.toBe(oldBinding?.bindingId) + await expect(store.getById(oldBinding!.bindingId)).resolves.toMatchObject({ + state: 'closed' + }) + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + bindingId: bindingIds[0], + acpSessionId: 'session-2', + state: 'ready', + agentInstallationId: 'installation-2', + runtimeBundleDigest: `sha256:${'c'.repeat(64)}` + }) + await replacement.dispose() + }) + + it.each(['prompt-running', 'outcome-unknown'] as const)( + 'does not replace a %s binding after identity changes', + async (state) => { + const store = new MemoryRuntimeSessionBindingStore() + const active = { + bindingId: 'binding-active', + ...identity, + conversationId: request.conversationId, + runtimeId: 'opencode' as const, + acpSessionId: 'session-active', + acpCapabilitiesDigest: capabilityDigest({ + loadSession: true, + sessionCapabilities: { + close: {}, + resume: {} + } + }), + state, + activePromptOperationId: request.requestId, + promptSequence: 0, + channelEpoch: '1', + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + await store.put(active) + const factory = vi.fn(async () => fakeServer().channel) + const instance = factoryRuntime(factory, store, { + identity: { + ...identity, + agentInstallationId: 'installation-2' + } + }) + + await expect( + collect( + instance.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal + ) + ) + ).rejects.toThrow('不会自动重放') + + expect(factory).not.toHaveBeenCalled() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + bindingId: active.bindingId, + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + await instance.dispose() + } + ) + + it('persists outcome-unknown and blocks reuse when model delivery is poisoned', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const openModelBridge = vi.fn( + async (value: RemoteModelBridgeOpenRequest) => + fakeModelBridge(value, 1, { + clean: false, + poisoned: true + }) + ) + const instance = runtime( + fakeServer({ openModelBridge }), + store, + 5, + { modelBridgePolicy } + ) + await expect( + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('结果未知') + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + await expect( + collect( + instance.run( + { ...request, requestId: 'prompt-2' }, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow() + expect(openModelBridge).toHaveBeenCalledOnce() + await instance.forceShutdown() + }) + + it('persists and prepares the binding before ACP initialization', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const order: string[] = [] + const server = fakeServer({ + initialize: async () => { + order.push('initialize') + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { + close: {}, + resume: {} + } + } + } + }, + preparePrompt: async (preparation) => { + order.push('prepare') + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + bindingId: preparation.bindingId, + state: 'prompt-running', + activePromptOperationId: request.requestId, + acpCapabilitiesDigest: + capabilityDigest({ + loadSession: true, + sessionCapabilities: { + close: {}, + resume: {} + } + }) + }) + return acceptPrompt(preparation) + } + }) + const instance = runtime(server, store) + + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(order).toEqual(['prepare', 'initialize']) + await instance.dispose() + }) + + it('rejects actual ACP capabilities that differ from signed evidence', async () => { + const server = fakeServer({ + advertisedAcpCapabilitiesDigest: + `sha256:${'f'.repeat(64)}` + }) + const instance = runtime(server) + + await expect( + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('实际能力摘要') + expect(server.close).toHaveBeenCalledOnce() + }) + + it('does not send ACP initialization during a status check', async () => { + const initialize = vi.fn(async () => ({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + sessionCapabilities: { + close: {}, + resume: {} + } + } + })) + const server = fakeServer({ initialize }) + const instance = runtime(server) + + await expect(instance.getStatus()).resolves.toMatchObject({ + available: true + }) + expect(initialize).not.toHaveBeenCalled() + await instance.dispose() + }) + + it('uses one fixed binding and channel per conversation', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = fakeServer() + const second = fakeServer({ + newSession: async () => ({ sessionId: 'session-2' }) + }) + const servers = [first, second] + const bindingIds: string[] = [] + const factory = vi.fn(async (bindingId: string) => { + bindingIds.push(bindingId) + return servers.shift()!.channel + }) + const instance = factoryRuntime(factory, store) + const secondRequest = { + ...request, + requestId: 'f10873fc-c528-48c1-818d-204a75588a48', + conversationId: 'conversation-2' + } + + await Promise.all([ + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ), + collect( + instance.run( + secondRequest, + new AbortController().signal, + async () => 'once' + ) + ) + ]) + + const firstBinding = await store.getByConversation( + request.conversationId + ) + const secondBinding = await store.getByConversation( + secondRequest.conversationId + ) + expect(new Set(bindingIds).size).toBe(2) + expect(bindingIds).toContain(firstBinding?.bindingId) + expect(bindingIds).toContain(secondBinding?.bindingId) + expect(first.prepare).toHaveBeenCalledWith( + expect.objectContaining({ bindingId: bindingIds[0] }) + ) + expect(second.prepare).toHaveBeenCalledWith( + expect.objectContaining({ bindingId: bindingIds[1] }) + ) + + await instance.releaseConversation(request.conversationId) + expect(first.close).toHaveBeenCalledOnce() + expect(second.close).not.toHaveBeenCalled() + await instance.dispose() + expect(second.close).toHaveBeenCalledOnce() + }) + + it('bounds concurrent conversation channels until release', async () => { + const first = fakeServer() + const second = fakeServer() + const servers = [first, second] + const factory = vi.fn(async () => servers.shift()!.channel) + const instance = factoryRuntime(factory, undefined, { + maxConcurrentChannels: 1 + }) + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + const pending = collect( + instance.run( + { + ...request, + requestId: '8c7dcf59-ce69-4900-9f50-6312a2a19ca8', + conversationId: 'conversation-2' + }, + new AbortController().signal, + async () => 'once' + ) + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(factory).toHaveBeenCalledOnce() + + await instance.releaseConversation(request.conversationId) + await expect(pending).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'done' }) + ]) + ) + expect(factory).toHaveBeenCalledTimes(2) + await instance.dispose() + }) + + it('passes a durable binding identity back to the channel factory', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = fakeServer() + const firstIds: string[] = [] + const initial = factoryRuntime(async (bindingId) => { + firstIds.push(bindingId) + return first.channel + }, store) + await collect( + initial.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + const durable = await store.getByConversation(request.conversationId) + await initial.dispose() + + const loadSession = vi.fn(async () => ({})) + const second = fakeServer({ + channelEpoch: '2', + generation: 2, + loadSession + }) + const recoveredIds: string[] = [] + const recovered = factoryRuntime(async (bindingId) => { + recoveredIds.push(bindingId) + return second.channel + }, store, { + identity: { + ...identity, + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2' + } + }) + await collect( + recovered.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(firstIds).toEqual([durable?.bindingId]) + expect(recoveredIds).toEqual([durable?.bindingId]) + expect(loadSession).toHaveBeenCalledOnce() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + bindingId: durable?.bindingId, + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2', + channelEpoch: '2' + }) + expect(second.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + controllerGeneration: 2, + connectionGeneration: 2, + channelEpoch: '2' + }) + ) + await recovered.dispose() + }) + + it('closes a channel when session opening fails and releases capacity', async () => { + const failed = fakeServer({ + newSession: async () => { + throw new Error('open failed') + } + }) + const succeeding = fakeServer() + const servers = [failed, succeeding] + const factory = vi.fn(async () => servers.shift()!.channel) + const instance = factoryRuntime(factory, undefined, { + maxConcurrentChannels: 1 + }) + + await expect( + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('Internal error') + expect(failed.close).toHaveBeenCalledOnce() + + await expect( + collect( + instance.run( + { + ...request, + requestId: '07aac893-73cd-4ddd-85d5-19ee635ebf21', + conversationId: 'conversation-2' + }, + new AbortController().signal, + async () => 'once' + ) + ) + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'done' }) + ]) + ) + expect(factory).toHaveBeenCalledTimes(2) + await instance.dispose() + }) + + it('starts the requested work mode before session and prompt', async () => { + const order: string[] = [] + const server = fakeServer({ + preparePrompt: async (value) => { + order.push(`prepare:${value.workMode}`) + return acceptPrompt(value) + }, + newSession: async () => { + order.push('session') + return { sessionId: 'session-1' } + }, + prompt: async () => { + order.push('prompt') + return { stopReason: 'end_turn' } + } + }) + + await collect( + runtime(server).run( + { ...request, workMode: 'ask' }, + new AbortController().signal + ) + ) + + expect(order).toEqual(['prepare:ask', 'session', 'prompt']) + expect(server.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: request.requestId, + requestId: request.requestId, + workMode: 'ask', + controllerGeneration: 1, + connectionGeneration: 1, + channelEpoch: '1' + }) + ) + }) + + it('passes Execute directly as the authorization contract', async () => { + const server = fakeServer() + + await collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(server.prepare).toHaveBeenCalledWith( + expect.objectContaining({ + workMode: 'execute' + }) + ) + expect(server.prepare.mock.calls[0]?.[0]).not.toHaveProperty( + 'trustTier' + ) + expect(server.prepare.mock.calls[0]?.[0]).not.toHaveProperty( + 'capabilities' + ) + }) + + it('supports Ask and Execute without trust-tier translation', async () => { + const askServer = fakeServer() + const executeServer = fakeServer() + + await collect(runtime(askServer).run( + { ...request, workMode: 'ask' }, + new AbortController().signal + )) + await collect(runtime(executeServer).run( + request, + new AbortController().signal, + async () => 'once' + )) + expect(askServer.prepare).toHaveBeenCalledWith( + expect.objectContaining({ workMode: 'ask' }) + ) + expect(executeServer.prepare).toHaveBeenCalledWith( + expect.objectContaining({ workMode: 'execute' }) + ) + }) + + it('rejects a stale Host binding before a new prompt', async () => { + const server = fakeServer() + const assertion = vi.fn(() => { + throw new Error('Host binding changed') + }) + + await expect( + collect( + runtime(server, undefined, undefined, { + assertHostCurrent: assertion + }).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('Host binding changed') + + expect(assertion).toHaveBeenCalledWith( + expect.objectContaining({ hostId: 'host-1' }) + ) + expect(server.prepare).not.toHaveBeenCalled() + expect(server.close).toHaveBeenCalledOnce() + }) + + it('rechecks the Host binding before resuming a prompt', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const firstServer = fakeServer() + const firstRuntime = runtime(firstServer, store) + await collect( + firstRuntime.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await firstRuntime.dispose() + + const loadSession = vi.fn(async () => ({})) + const secondServer = fakeServer({ loadSession }) + const assertion = vi.fn(() => { + throw new Error('Host binding stale') + }) + await expect( + collect( + runtime(secondServer, store, undefined, { + assertHostCurrent: assertion + }).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('Host binding stale') + + expect(assertion).toHaveBeenCalledWith( + expect.objectContaining({ hostId: 'host-1' }) + ) + expect(secondServer.prepare).not.toHaveBeenCalled() + expect(loadSession).not.toHaveBeenCalled() + expect(secondServer.close).toHaveBeenCalledOnce() + }) + + it('fails closed on a mismatched prompt acceptance before ACP session', async () => { + const newSession = vi.fn(async () => ({ sessionId: 'session-1' })) + const prompt = vi.fn(async () => ({ stopReason: 'end_turn' as const })) + const server = fakeServer({ + preparePrompt: async (value) => ({ + ...acceptPrompt(value), + bindingId: 'different-binding', + }), + newSession, + prompt + }) + + await expect( + collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('稳定操作身份不匹配') + expect(newSession).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + }) + + it('maps message, thought, tool, plan and usage events', async () => { + const client: { value?: AgentSideConnection } = {} + const server = fakeServer({ + prompt: async ({ sessionId }) => { + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'answer' } + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'thinking' } + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Read', + status: 'in_progress' + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'plan', + entries: [ + { + content: 'Inspect', + priority: 'high', + status: 'in_progress' + } + ] + } + }) + await client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'usage_update', + used: 20, + size: 100 + } + }) + return { + stopReason: 'end_turn', + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15 + } + } + } + }) + client.value = server.client + + const events = await collect( + runtime(server).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + expect(events.map((event) => event.type)).toEqual([ + 'status', + 'text', + 'reasoning', + 'tool', + 'status', + 'context-metrics', + 'model-usage', + 'done' + ]) + }) + + it('routes permission through the authorizer and denies Ask mode', async () => { + const client: { value?: AgentSideConnection } = {} + const outcomes: string[] = [] + const server = fakeServer({ + prompt: async ({ sessionId }) => { + const response = await client.value!.requestPermission({ + sessionId, + options: [ + { + optionId: 'allow', + name: 'Allow', + kind: 'allow_once' + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once' + } + ], + toolCall: { + toolCallId: 'tool-1', + title: 'Write', + kind: 'edit', + rawInput: { path: 'a.txt' } + } + }) + outcomes.push( + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : 'cancelled' + ) + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + const authorize = vi.fn(async () => 'once' as const) + await collect( + runtime(server).run( + request, + new AbortController().signal, + authorize + ) + ) + const askClient: { value?: AgentSideConnection } = {} + const askServer = fakeServer({ + prompt: async ({ sessionId }) => { + const response = await askClient.value!.requestPermission({ + sessionId, + options: [ + { + optionId: 'ask-allow', + name: 'Allow', + kind: 'allow_always' + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once' + } + ], + toolCall: { toolCallId: 'tool-2', title: 'Write' } + }) + outcomes.push( + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : 'cancelled' + ) + return { stopReason: 'end_turn' } + } + }) + askClient.value = askServer.client + await collect( + runtime(askServer).run( + { ...request, conversationId: 'conversation-2', workMode: 'ask' }, + new AbortController().signal, + authorize + ) + ) + expect(authorize).toHaveBeenCalledOnce() + expect(outcomes).toEqual(['allow', 'reject']) + }) + + it('auto-allows Execute permissions without an external authorizer', async () => { + const client: { value?: AgentSideConnection } = {} + const outcomes: string[] = [] + const server = fakeServer({ + prompt: async ({ sessionId }) => { + for (const options of [ + [ + { + optionId: 'once', + name: 'Allow once', + kind: 'allow_once' as const + }, + { + optionId: 'always', + name: 'Allow always', + kind: 'allow_always' as const + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once' as const + } + ], + [ + { + optionId: 'once-only', + name: 'Allow once', + kind: 'allow_once' as const + } + ] + ]) { + const response = await client.value!.requestPermission({ + sessionId, + options, + toolCall: { + toolCallId: `tool-${outcomes.length + 1}`, + title: 'Write' + } + }) + outcomes.push( + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : 'cancelled' + ) + } + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + + await collect( + runtime(server).run( + request, + new AbortController().signal + ) + ) + + expect(outcomes).toEqual(['always', 'once-only']) + }) + + it('preserves an explicit Execute authorizer denial', async () => { + const client: { value?: AgentSideConnection } = {} + let outcome = '' + const server = fakeServer({ + prompt: async ({ sessionId }) => { + const response = await client.value!.requestPermission({ + sessionId, + options: [ + { + optionId: 'always', + name: 'Allow always', + kind: 'allow_always' + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once' + } + ], + toolCall: { + toolCallId: 'tool-denied', + title: 'Write' + } + }) + outcome = + response.outcome.outcome === 'selected' + ? response.outcome.optionId + : 'cancelled' + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + const authorize = vi.fn(async () => 'deny' as const) + + await collect( + runtime(server).run( + request, + new AbortController().signal, + authorize + ) + ) + + expect(authorize).toHaveBeenCalledOnce() + expect(outcome).toBe('reject') + }) + + it('loads a durable ready binding on a new channel', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const claim = vi.spyOn(store, 'claimAcpSession') + const first = fakeServer() + await collect( + runtime(first, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await runtime(first, store).dispose() + claim.mockClear() + + const loadSession = vi.fn(async () => ({})) + const second = fakeServer({ loadSession }) + await collect( + runtime(second, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + expect(loadSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ) + expect(claim).toHaveBeenCalledWith( + expect.any(String), + 'session-1' + ) + }) + + it('reconciles an accepted completion when its response is lost without replaying it', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const server = fakeServer({ + completePromptOperation: async () => { + throw new Error('completion response lost') + }, + reconcilePromptOperation: async () => ({ + status: 'terminal', + terminalState: 'completed', + processTree: 'running' + }) + }) + + await expect( + collect( + runtime(server, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'done' }) + ]) + ) + expect(server.complete).toHaveBeenCalledOnce() + expect(server.reconcile).toHaveBeenCalledOnce() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'ready', + activePromptOperationId: undefined + }) + }) + + it('uses resume when loading history is unavailable', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const claim = vi.spyOn(store, 'claimAcpSession') + const capabilities = { + sessionCapabilities: { resume: {} } + } + const first = fakeServer({ capabilities }) + const firstRuntime = runtime(first, store) + await collect( + firstRuntime.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await firstRuntime.dispose() + claim.mockClear() + + const resumeSession = vi.fn(async () => ({})) + const second = fakeServer({ capabilities, resumeSession }) + await collect( + runtime(second, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + expect(resumeSession).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1' }) + ) + expect(claim).toHaveBeenCalledWith( + expect.any(String), + 'session-1' + ) + }) + + it('rejects stale generations and escalates cancellation', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const server = fakeServer({ + prompt: () => new Promise(() => {}), + reconcilePromptOperation: async () => ({ + status: 'outcome-unknown', + processTree: 'unknown' + }) + }) + const instance = runtime(server, store) + const controller = new AbortController() + const pending = collect( + instance.run(request, controller.signal, async () => 'once') + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + controller.abort(new Error('cancelled')) + await expect(pending).rejects.toThrow('结果未知') + expect(server.escalate).toHaveBeenCalledOnce() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + + const stale = fakeServer() + stale.generation.current = false + await expect(stale.channel.isCurrentGeneration()).toBe(false) + await expect( + collect( + runtime(stale).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('代际') + }) + + it('honors cancellation while opening a session before prompt', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const prompt = vi.fn(async () => ({ stopReason: 'end_turn' as const })) + const server = fakeServer({ + newSession: () => new Promise(() => {}), + prompt, + reconcilePromptOperation: async () => ({ + status: 'outcome-unknown', + processTree: 'unknown' + }) + }) + const instance = runtime(server, store) + const controller = new AbortController() + const pending = collect( + instance.run(request, controller.signal, async () => 'once') + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + controller.abort(new Error('cancelled before prompt')) + + await expect(pending).rejects.toThrow('cancelled before prompt') + expect(prompt).not.toHaveBeenCalled() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + expect(server.close).toHaveBeenCalledOnce() + }) + + it('bounds a prompt that never reaches a terminal response', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const cancel = vi.fn(async () => {}) + const server = fakeServer({ + prompt: () => new Promise(() => {}), + cancel, + reconcilePromptOperation: async () => ({ + status: 'outcome-unknown', + processTree: 'unknown' + }) + }) + const instance = runtime(server, store, 5, { + operationTimeoutMs: 20 + }) + + await expect( + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).rejects.toThrow('执行请求超时') + expect(cancel).toHaveBeenCalledOnce() + expect(server.escalate).toHaveBeenCalledOnce() + expect(server.close).toHaveBeenCalledOnce() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + }) + + it('allows prompts to outlive short control-operation timeouts', async () => { + const server = fakeServer({ + prompt: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) + return { stopReason: 'end_turn' as const } + } + }) + const instance = runtime(server, undefined, 5, { + operationTimeoutMs: 10, + promptTimeoutMs: 100 + }) + + await expect( + collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + ).resolves.toEqual([ + expect.objectContaining({ + type: 'status', + message: '远端 ACP Runtime 正在处理请求' + }), + expect.objectContaining({ + type: 'done' + }) + ]) + }) + + it('cancels and truncates a flood when the consumer is slow', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const client: { value?: AgentSideConnection } = {} + const cancel = vi.fn(async () => {}) + const server = fakeServer({ + prompt: async ({ sessionId }) => { + await Promise.all( + Array.from({ length: 20 }, (_value, index) => + client.value!.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `chunk-${index}` } + } + }) + ) + ) + return { stopReason: 'end_turn' } + }, + cancel, + reconcilePromptOperation: async () => ({ + status: 'outcome-unknown', + processTree: 'unknown' + }) + }) + client.value = server.client + const instance = runtime(server, store, 5, { + maxPendingUpdates: 2, + maxPendingUpdateBytes: 1_024 + }) + const stream = instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + + await expect(stream.next()).resolves.toMatchObject({ + value: { type: 'status' }, + done: false + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + await expect(stream.next()).rejects.toThrow('输出已截断') + expect(server.setInboundPaused).toHaveBeenCalledWith(true) + expect(server.setInboundPaused).not.toHaveBeenCalledWith(false) + expect(cancel).toHaveBeenCalledOnce() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + }) + + it('keeps capability digests stable across object key order', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = fakeServer({ + capabilities: { + sessionCapabilities: { resume: {}, close: {} }, + loadSession: true + } + }) + const firstRuntime = runtime(first, store) + await collect( + firstRuntime.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await firstRuntime.dispose() + + const loadSession = vi.fn(async () => ({})) + const second = fakeServer({ + capabilities: { + loadSession: true, + sessionCapabilities: { close: {}, resume: {} } + }, + loadSession + }) + await collect( + runtime(second, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + expect(loadSession).toHaveBeenCalledOnce() + }) + + it('rejects duplicate Runtime-provided sessions across live conversations', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = fakeServer({ + newSession: async () => ({ sessionId: 'shared-session' }) + }) + const second = fakeServer({ + newSession: async () => ({ sessionId: 'shared-session' }) + }) + + const outcomes = await Promise.allSettled([ + collect( + runtime(first, store).run( + request, + new AbortController().signal, + async () => 'once' + ) + ), + collect( + runtime(second, store).run( + { ...request, conversationId: 'conversation-2' }, + new AbortController().signal, + async () => 'once' + ) + ) + ]) + + expect( + outcomes.filter((outcome) => outcome.status === 'fulfilled') + ).toHaveLength(1) + const rejected = outcomes.find( + (outcome): outcome is PromiseRejectedResult => + outcome.status === 'rejected' + ) + expect(String(rejected?.reason)).toContain( + 'already owned by another live binding' + ) + }) + + it('bounds release cleanup and persists interruption', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const closeSession = vi.fn(() => new Promise>( + () => {} + )) + const server = fakeServer({ closeSession }) + const instance = runtime(server, store, 5, { + operationTimeoutMs: 20 + }) + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + + await expect( + instance.releaseConversation(request.conversationId) + ).rejects.toThrow('关闭会话超时') + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ state: 'interrupted' }) + expect(server.close).toHaveBeenCalledOnce() + }) + + it('cancels and cleans up when event consumption ends early', async () => { + const store = new MemoryRuntimeSessionBindingStore() + let finishPrompt: + | ((response: PromptResponse) => void) + | undefined + const cancel = vi.fn(async () => { + finishPrompt?.({ stopReason: 'cancelled' }) + }) + const server = fakeServer({ + prompt: () => + new Promise((resolve) => { + finishPrompt = resolve + }), + cancel + }) + const instance = runtime(server, store) + const stream = instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + await stream.next() + + await stream.return() + expect(cancel).toHaveBeenCalledOnce() + expect(server.escalate).not.toHaveBeenCalled() + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'ready', + activePromptOperationId: undefined + }) + await instance.releaseConversation(request.conversationId) + await instance.beginDrain() + await instance.waitForDrain() + }) + + it('drains until session release and closes session resources', async () => { + const closeSession = vi.fn(async () => ({})) + const server = fakeServer({ closeSession }) + const instance = runtime(server) + await collect( + instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + ) + await instance.beginDrain() + let drained = false + void instance.waitForDrain().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + await instance.releaseConversation(request.conversationId) + await instance.waitForDrain() + expect(closeSession).toHaveBeenCalledOnce() + await instance.dispose() + expect(server.close).toHaveBeenCalledOnce() + }) + + it('force shutdown cancels by stable operation and preserves unknown identity', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const server = fakeServer({ + prompt: () => new Promise(() => {}), + reconcilePromptOperation: async () => ({ + status: 'outcome-unknown', + processTree: 'unknown' + }) + }) + const instance = runtime(server, store, 5, { + operationTimeoutMs: 1_000 + }) + const stream = instance.run( + request, + new AbortController().signal, + async () => 'once' + ) + await stream.next() + + await instance.forceShutdown() + await expect(stream.next()).rejects.toThrow() + expect(server.escalate).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: request.requestId, + requestId: request.requestId + }) + ) + expect(server.reconcile).toHaveBeenCalledWith({ + bindingId: expect.any(String), + operationId: request.requestId, + requestId: request.requestId + }) + await expect( + store.getByConversation(request.conversationId) + ).resolves.toMatchObject({ + state: 'outcome-unknown', + activePromptOperationId: request.requestId + }) + }) +}) diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts new file mode 100644 index 00000000..2713e9d7 --- /dev/null +++ b/src/main/agent/acp-remote-runtime.ts @@ -0,0 +1,2435 @@ +import { createHash, randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { + ClientSideConnection, + PROTOCOL_VERSION, + ndJsonStream, + type AgentCapabilities, + type InitializeResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type SessionUpdate, + type Usage +} from '@agentclientprotocol/sdk' +import type { + AgentRuntimeStatus +} from '../../shared/contracts' +import { + assertRemotePromptAcceptanceMatchesPreparation, + remotePromptOperationAcceptanceSchema, + remotePromptOperationPreparationSchema, + runtimeSessionBindingSchema, + type RemotePromptOperationAcceptance, + type RemotePromptOperationPreparation, + type RuntimeSessionBinding +} from '../../shared/remote-agent-contracts' +import { sha256DigestSchema } from '../../shared/agent-protocol' +import { canonicalJson } from '../../shared/agent-protocol/canonical' +import { + MODEL_BRIDGE_PROTOCOL, + modelBridgePolicySchema, + type ModelBridgePolicy +} from '../../shared/model-bridge-contracts' +import { + REMOTE_RUNTIME_LAUNCH_LIMITS +} from '../../shared/remote-runtime-launch-contracts' +import type { + AgentExecutionRequest, + AgentRuntime, + RuntimeAuthorizer, + RuntimeEvent, + RuntimeModelUsageEvent +} from './runtime' +import { + assertCurrentRemoteRuntimeGeneration, + isDefinitiveRemoteRuntimeRequestRejection, + type RemoteModelBridgeSession, + type RemoteRuntimeChannel +} from './remote-runtime-channel' +import type { RuntimeSessionBindingStore } from './runtime-session-binding-store' + +type BindingIdentity = Pick< + RuntimeSessionBinding, + | 'controllerId' + | 'controllerGeneration' + | 'hostId' + | 'hostRevision' + | 'hostKeyGeneration' + | 'workspaceIdentity' + | 'agentInstallationId' + | 'daemonBootIdAtOpen' + | 'runtimeBundleDigest' + | 'runtimeAdapterDigest' +> + +export type AcpRemoteRuntimeOptions = { + runtimeId: AgentRuntimeStatus['id'] + label: string + workspacePath: string + identity: BindingIdentity + /** + * Legacy single-channel construction. It can own one conversation and is + * closed when that conversation is released. + */ + channel?: RemoteRuntimeChannel + /** + * Creates one virtual ACP channel for one stable binding identity. + * The binding ID passed here is the same ID persisted for recovery. + */ + channelFactory?: ( + bindingId: string + ) => Promise + maxConcurrentChannels?: number + bindingStore: RuntimeSessionBindingStore + assertHostCurrent: ( + identity: Readonly< + Pick< + RuntimeSessionBinding, + | 'controllerId' + | 'hostId' + | 'hostRevision' + | 'hostKeyGeneration' + | 'agentInstallationId' + > + > + ) => void + maxEventCharacters?: number + maxRequestOutputBytes?: number + cancellationGraceMs?: number + operationTimeoutMs?: number + promptTimeoutMs?: number + maxPendingUpdates?: number + maxPendingUpdateBytes?: number + usage?: { + runtime: RuntimeModelUsageEvent['runtime'] + provider: string + model: string + } + modelBridgePolicy?: ModelBridgePolicy +} + +type ActivePrompt = { + requestId: string + operationId: string + sessionId: string + bindingId: string + workMode: 'ask' | 'execute' + authorize?: RuntimeAuthorizer + updates: QueuedSessionUpdate[] + pendingUpdateBytes: number + inboundPaused: boolean + outputCharacters: number + open: boolean + completed: boolean + interruption?: Error + interrupt?: (reason: Error) => void + wake?: () => void + context: ChannelContext +} + +type QueuedSessionUpdate = { + update: SessionUpdate + bytes: number + consumed: () => void +} + +type SessionRecord = { + binding: RuntimeSessionBinding + sessionId: string + context: ChannelContext + modelBridge?: RemoteModelBridgeSession +} + +type AcpState = { + agent: ClientSideConnection + initialization: InitializeResponse + capabilities: AgentCapabilities + capabilitiesDigest: string +} + +type ChannelContext = { + channel: RemoteRuntimeChannel + state?: AcpState + initialization?: Promise + fatalError?: Error + channelHasClosed: boolean + channelClose?: Promise + channelCloseWaiters: Set<() => void> + permitHeld: boolean +} + +const DEFAULT_MAX_EVENT_CHARACTERS = 100_000 +const DEFAULT_MAX_REQUEST_OUTPUT_BYTES = 1_000_000 +const DEFAULT_OPERATION_TIMEOUT_MS = 30_000 +const DEFAULT_PROMPT_TIMEOUT_MS = 120_000 +const DEFAULT_CANCELLATION_GRACE_MS = 1_500 +const DEFAULT_MAX_PENDING_UPDATES = 1_000 +const DEFAULT_MAX_PENDING_UPDATE_BYTES = 4 * 1024 * 1024 +const DEFAULT_MAX_CONCURRENT_CHANNELS = 8 + +function digest(value: unknown): string { + return `sha256:${createHash('sha256') + .update(canonicalJson(value)) + .digest('hex')}` +} + +function safeStringify(value: unknown, maximum: number): string | undefined { + if (value === undefined) { + return undefined + } + try { + const result = JSON.stringify(value) + return result.length > maximum + ? `${result.slice(0, maximum)}…` + : result + } catch { + return undefined + } +} + +class RemoteOperationTimeoutError extends Error { + constructor(readonly operation: string) { + super(`远端 Runtime ${operation}超时`) + this.name = 'RemoteOperationTimeoutError' + } +} + +class RemoteChannelClosedError extends Error { + constructor(readonly operation: string) { + super(`远端 Runtime 在${operation}期间连接已关闭`) + this.name = 'RemoteChannelClosedError' + } +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('This operation was aborted', 'AbortError') +} + +function utf8JsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8') +} + +function permissionRejection( + permission: RequestPermissionRequest +): RequestPermissionResponse { + const rejection = permission.options.find( + (option) => + option.kind === 'reject_once' || + option.kind === 'reject_always' + ) + return rejection + ? { + outcome: { + outcome: 'selected', + optionId: rejection.optionId + } + } + : { outcome: { outcome: 'cancelled' } } +} + +function usageEvent( + requestId: string, + usage: Usage, + options: AcpRemoteRuntimeOptions['usage'], + callId: string +): RuntimeModelUsageEvent | undefined { + if (!options) { + return undefined + } + return { + requestId, + type: 'model-usage', + callId, + runtime: options.runtime, + provider: options.provider, + model: options.model, + inputTokens: Math.max(0, usage.inputTokens), + outputTokens: Math.max(0, usage.outputTokens), + cacheReadTokens: Math.max(0, usage.cachedReadTokens ?? 0), + cacheWriteTokens: Math.max(0, usage.cachedWriteTokens ?? 0), + reportedTotalTokens: Math.max(0, usage.totalTokens) + } +} + +/** + * Generic ACP runtime over an authenticated remote virtual channel. + * + * The class deliberately does not claim confinement from ACP itself. Execute + * is available only when the injected channel attests both sandboxing and the + * pre-side-effect approval bridge. + */ +export class AcpRemoteRuntime implements AgentRuntime { + readonly requiresToolApproval = false + readonly supportsScopedDataTools = false + readonly capability = 'chat' as const + + private disposed = false + private draining = false + private readonly sessions = new Map() + private readonly sessionInitializations = new Map< + string, + Promise + >() + private readonly activePrompts = new Map() + private readonly sessionReservations = new Set() + private readonly drainWaiters = new Set<() => void>() + private readonly contexts = new Map() + private readonly allContexts = new Set() + private readonly channelPermitWaiters = new Set<() => boolean>() + private activeChannelPermits = 0 + private readonly directContext?: ChannelContext + private directContextClaimed = false + + constructor(private readonly options: AcpRemoteRuntimeOptions) { + if (Boolean(options.channel) === Boolean(options.channelFactory)) { + throw new Error( + 'AcpRemoteRuntime requires exactly one channel or channelFactory' + ) + } + if ( + options.maxConcurrentChannels !== undefined && + ( + !Number.isSafeInteger(options.maxConcurrentChannels) || + options.maxConcurrentChannels < 1 || + options.maxConcurrentChannels > 64 + ) + ) { + throw new RangeError( + 'maxConcurrentChannels must be an integer between 1 and 64' + ) + } + if (options.channel) { + this.directContext = this.createContext(options.channel, false) + } + } + + private createContext( + channel: RemoteRuntimeChannel, + permitHeld: boolean + ): ChannelContext { + const context: ChannelContext = { + channel, + channelHasClosed: false, + channelCloseWaiters: new Set(), + permitHeld + } + this.allContexts.add(context) + void channel.closed.then( + () => this.markChannelClosed(context), + () => this.markChannelClosed(context) + ) + return context + } + + private markChannelClosed(context: ChannelContext): void { + if (context.channelHasClosed) { + return + } + context.channelHasClosed = true + for (const reject of context.channelCloseWaiters) { + reject() + } + context.channelCloseWaiters.clear() + } + + private closeContext(context: ChannelContext): Promise { + context.channelClose ??= Promise.resolve() + .then(() => context.channel.close()) + .finally(() => { + this.allContexts.delete(context) + if (context.permitHeld) { + context.permitHeld = false + this.releaseChannelPermit() + } + }) + return context.channelClose + } + + private get maximumConcurrentChannels(): number { + return this.options.channelFactory + ? this.options.maxConcurrentChannels ?? + DEFAULT_MAX_CONCURRENT_CHANNELS + : 1 + } + + private async acquireChannelPermit(signal?: AbortSignal): Promise { + if (this.activeChannelPermits < this.maximumConcurrentChannels) { + this.activeChannelPermits += 1 + return + } + let active = true + let permitGranted = false + let resolvePermit: (() => void) | undefined + const granted = new Promise((resolve) => { + resolvePermit = resolve + }) + const waiter = (): boolean => { + if (!active) { + return false + } + active = false + permitGranted = true + this.activeChannelPermits += 1 + resolvePermit?.() + return true + } + this.channelPermitWaiters.add(waiter) + try { + await this.awaitLocalOperation( + '等待可用远端通道', + granted, + signal + ) + } catch (error) { + if (permitGranted) { + this.releaseChannelPermit() + } + throw error + } finally { + active = false + this.channelPermitWaiters.delete(waiter) + } + } + + private releaseChannelPermit(): void { + this.activeChannelPermits = Math.max( + 0, + this.activeChannelPermits - 1 + ) + for (const waiter of this.channelPermitWaiters) { + this.channelPermitWaiters.delete(waiter) + if (waiter()) { + break + } + } + } + + private async createConversationContext( + conversationId: string, + bindingId: string, + signal?: AbortSignal + ): Promise { + const current = this.contexts.get(conversationId) + if (current) { + return current + } + if (this.directContext) { + if (this.directContextClaimed) { + throw new Error( + 'Legacy direct ACP channel can serve only one conversation' + ) + } + this.directContextClaimed = true + this.contexts.set(conversationId, this.directContext) + return this.directContext + } + await this.acquireChannelPermit(signal) + let context: ChannelContext | undefined + let channelPromise: Promise | undefined + try { + channelPromise = this.options.channelFactory!(bindingId) + const channel = await this.awaitLocalOperation( + '创建远端通道', + channelPromise, + signal + ) + context = this.createContext(channel, true) + this.assertUsable(context) + this.contexts.set(conversationId, context) + return context + } catch (error) { + if (context) { + await this.closeContext(context).catch(() => undefined) + } else { + this.releaseChannelPermit() + if (channelPromise) { + void channelPromise + .then((channel) => channel.close()) + .catch(() => undefined) + } + } + throw error + } + } + + private get operationTimeoutMs(): number { + return this.options.operationTimeoutMs ?? + DEFAULT_OPERATION_TIMEOUT_MS + } + + private get promptTimeoutMs(): number { + return this.options.promptTimeoutMs ?? + DEFAULT_PROMPT_TIMEOUT_MS + } + + private async awaitOperation( + context: ChannelContext, + operation: string, + promise: Promise, + signal?: AbortSignal, + failChannel = true + ): Promise { + signal?.throwIfAborted() + let timer: ReturnType | undefined + let removeAbortListener: (() => void) | undefined + let removeChannelCloseWaiter: (() => void) | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new RemoteOperationTimeoutError(operation)), + this.operationTimeoutMs + ) + }) + const closed = new Promise((_resolve, reject) => { + if (context.channelHasClosed) { + reject(new RemoteChannelClosedError(operation)) + return + } + const onClose = (): void => + reject(new RemoteChannelClosedError(operation)) + context.channelCloseWaiters.add(onClose) + removeChannelCloseWaiter = () => + context.channelCloseWaiters.delete(onClose) + }) + const aborted = signal + ? new Promise((_resolve, reject) => { + const onAbort = (): void => reject(abortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => + signal.removeEventListener('abort', onAbort) + }) + : new Promise(() => {}) + try { + return await Promise.race([promise, timeout, closed, aborted]) + } catch (error) { + if (failChannel) { + this.failContext( + context, + error instanceof Error + ? error + : new Error(`远端 Runtime ${operation}已取消`) + ) + } + throw error + } finally { + if (timer) { + clearTimeout(timer) + } + removeAbortListener?.() + removeChannelCloseWaiter?.() + } + } + + private async awaitLocalOperation( + operation: string, + promise: Promise, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + let timer: ReturnType | undefined + let removeAbortListener: (() => void) | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new RemoteOperationTimeoutError(operation)), + this.operationTimeoutMs + ) + }) + const aborted = signal + ? new Promise((_resolve, reject) => { + const onAbort = (): void => reject(abortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => + signal.removeEventListener('abort', onAbort) + }) + : new Promise(() => {}) + try { + return await Promise.race([promise, timeout, aborted]) + } finally { + if (timer) { + clearTimeout(timer) + } + removeAbortListener?.() + } + } + + get runtimeId(): AgentRuntimeStatus['id'] { + return this.options.runtimeId + } + + get supportsToolExecution(): boolean { + if (this.options.channelFactory) { + return true + } + const capabilities = this.options.channel!.capabilities + return capabilities.promptOperationReconciliation + } + + private assertUsable( + context?: ChannelContext, + allowDraining = false + ): void { + if (this.disposed) { + throw new Error('远端 Runtime 已关闭') + } + if (this.draining && !allowDraining) { + throw new Error('远端 Runtime 正在退役') + } + if (context?.fatalError) { + throw context.fatalError + } + if (!context) { + return + } + try { + assertCurrentRemoteRuntimeGeneration(context.channel) + } catch (error) { + const stale = + error instanceof Error + ? error + : new Error('远端 Runtime 连接代际已失效') + this.failContext(context, stale) + throw stale + } + } + + private async initialize( + context: ChannelContext, + signal?: AbortSignal + ): Promise { + this.assertUsable(context, true) + const connection = new ClientSideConnection( + () => ({ + requestPermission: (permission) => + this.handlePermission(context, permission), + sessionUpdate: (notification) => + this.handleSessionUpdate(context, notification), + extMethod: async () => ({}), + extNotification: async () => {} + }), + ndJsonStream( + context.channel.output, + context.channel.input + ) + ) + const initialization = await this.awaitOperation( + context, + '初始化', + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + terminal: false, + fs: { + readTextFile: false, + writeTextFile: false + }, + _meta: { + 'goodbuddy/channelGeneration': + context.channel.generation + } + }, + clientInfo: { + name: 'GoodBuddy', + version: '1' + } + }), + signal + ) + if (initialization.protocolVersion !== PROTOCOL_VERSION) { + throw new Error('远端 ACP 协议版本不兼容') + } + const capabilities = initialization.agentCapabilities ?? {} + const state = { + agent: connection, + initialization, + capabilities, + capabilitiesDigest: digest({ + protocolVersion: initialization.protocolVersion, + capabilities + }) + } + if ( + state.capabilitiesDigest !== + context.channel.advertisedAcpCapabilitiesDigest + ) { + throw new Error( + '远端 ACP 实际能力摘要与已签名 Runtime 能力摘要不匹配' + ) + } + void connection.closed.then(() => { + if (!this.disposed) { + this.failContext( + context, + new Error('远端 ACP 连接已关闭,活动请求结果未知') + ) + } + }) + this.assertUsable(context, true) + context.state = state + return state + } + + private async getState( + context: ChannelContext, + signal?: AbortSignal + ): Promise { + if (context.state) { + return context.state + } + context.initialization ??= this.initialize(context, signal) + try { + return await this.awaitOperation( + context, + '等待初始化', + context.initialization, + signal + ) + } catch (error) { + context.initialization = undefined + throw error + } + } + + async getStatus(): Promise { + if (this.options.channelFactory) { + this.assertUsable() + return { + id: this.options.runtimeId, + label: this.options.label, + available: true, + supportsToolExecution: true, + detail: this.draining + ? '远端 ACP Runtime 正在排空已有会话' + : '远端 ACP Runtime 将在会话使用时连接' + } + } + const context = this.directContext! + const capabilities = context.channel.capabilities + if (!capabilities.promptOperationReconciliation) { + return { + id: this.options.runtimeId, + label: this.options.label, + available: false, + supportsToolExecution: false, + detail: '远端 Runtime 不可用:缺少稳定操作核对能力' + } + } + try { + this.assertUsable(context) + sha256DigestSchema.parse( + context.channel.advertisedAcpCapabilitiesDigest + ) + } catch (error) { + return { + id: this.options.runtimeId, + label: this.options.label, + available: false, + supportsToolExecution: true, + detail: + error instanceof Error + ? error.message + : '远端 ACP Runtime 不可用' + } + } + return { + id: this.options.runtimeId, + label: this.options.label, + available: true, + supportsToolExecution: true, + detail: this.draining + ? '远端 ACP Runtime 正在排空已有会话' + : '远端 ACP Runtime 控制面已连接' + } + } + + private bindingIdentityMatches( + binding: RuntimeSessionBinding + ): boolean { + const identity = this.options.identity + return ( + binding.controllerId === identity.controllerId && + binding.hostId === identity.hostId && + binding.hostRevision === identity.hostRevision && + binding.hostKeyGeneration === identity.hostKeyGeneration && + binding.workspaceIdentity === identity.workspaceIdentity && + binding.agentInstallationId === identity.agentInstallationId && + binding.runtimeId === this.options.runtimeId && + binding.runtimeBundleDigest === identity.runtimeBundleDigest && + binding.runtimeAdapterDigest === identity.runtimeAdapterDigest && + binding.modelBridgeVersion === + (this.options.modelBridgePolicy === undefined + ? undefined + : MODEL_BRIDGE_PROTOCOL) && + isDeepStrictEqual( + binding.modelBridgePolicy, + this.options.modelBridgePolicy + ) + ) + } + + private bindingMatches( + binding: RuntimeSessionBinding, + context: ChannelContext + ): boolean { + return ( + this.bindingIdentityMatches(binding) && + binding.acpCapabilitiesDigest === + context.channel.advertisedAcpCapabilitiesDigest + ) + } + + private transportMatches( + binding: RuntimeSessionBinding, + context: ChannelContext + ): boolean { + return ( + binding.controllerGeneration === + context.channel.generation && + binding.daemonBootIdAtOpen === + this.options.identity.daemonBootIdAtOpen && + binding.channelEpoch === context.channel.channelEpoch + ) + } + + private async persist( + context: ChannelContext, + binding: RuntimeSessionBinding, + patch: Partial = {}, + signal?: AbortSignal + ): Promise { + const cursors = await this.awaitOperation( + context, + '读取会话游标', + context.channel.getBindingCursors(binding.bindingId), + signal + ) + const next = runtimeSessionBindingSchema.parse({ + ...binding, + ...patch, + ...cursors, + channelEpoch: context.channel.channelEpoch + }) + await this.awaitLocalOperation( + '保存会话绑定', + this.options.bindingStore.put(next), + signal + ) + return next + } + + private async persistInterrupted( + binding: RuntimeSessionBinding + ): Promise { + const interrupted = runtimeSessionBindingSchema.parse({ + ...binding, + state: binding.activePromptOperationId + ? 'outcome-unknown' + : 'interrupted' + }) + await this.awaitLocalOperation( + '保存中断会话绑定', + this.options.bindingStore.put(interrupted) + ) + return interrupted + } + + private async persistRejectedPrompt( + binding: RuntimeSessionBinding + ): Promise { + const closed = runtimeSessionBindingSchema.parse({ + ...binding, + state: 'closed', + activePromptOperationId: undefined + }) + await this.awaitLocalOperation( + '保存已拒绝会话绑定', + this.options.bindingStore.put(closed) + ) + return closed + } + + private async closeIdleBinding( + binding: RuntimeSessionBinding, + signal?: AbortSignal + ): Promise { + if ( + binding.state !== 'ready' || + binding.activePromptOperationId !== undefined + ) { + throw new Error( + '只能替换没有活动请求的远端 ACP 会话绑定' + ) + } + const closed = runtimeSessionBindingSchema.parse({ + ...binding, + state: 'closed' + }) + await this.awaitLocalOperation( + '关闭不兼容会话绑定', + this.options.bindingStore.put(closed), + signal + ) + } + + private unknownBinding( + binding: RuntimeSessionBinding + ): RuntimeSessionBinding { + return runtimeSessionBindingSchema.parse({ + ...binding, + state: binding.activePromptOperationId + ? 'outcome-unknown' + : 'interrupted' + }) + } + + private async reconcilePromptOperation( + context: ChannelContext, + binding: RuntimeSessionBinding, + signal?: AbortSignal + ): Promise<{ + binding: RuntimeSessionBinding + terminal: boolean + }> { + const operationId = binding.activePromptOperationId + if (!operationId) { + return { binding, terminal: true } + } + const result = await this.awaitOperation( + context, + '核对请求终态', + context.channel.reconcilePromptOperation({ + bindingId: binding.bindingId, + operationId, + requestId: operationId + }), + signal, + false + ).catch(() => undefined) + if ( + result?.status === 'terminal' && + (result.processTree === 'empty' || + result.terminalState === 'completed') + ) { + const terminal = await this.persist( + context, + binding, + { + state: 'ready', + activePromptOperationId: undefined + }, + signal + ) + return { binding: terminal, terminal: true } + } + const unknown = this.unknownBinding(binding) + await this.awaitLocalOperation( + '保存结果未知会话绑定', + this.options.bindingStore.put(unknown), + signal + ) + return { binding: unknown, terminal: false } + } + + private async completePromptOperation( + context: ChannelContext, + binding: RuntimeSessionBinding, + signal?: AbortSignal + ): Promise { + const operationId = binding.activePromptOperationId + if (!operationId) { + throw new Error('远端 Runtime 请求缺少稳定操作身份') + } + try { + await this.awaitOperation( + context, + '确认请求完成', + context.channel.completePromptOperation({ + bindingId: binding.bindingId, + operationId, + requestId: operationId + }), + signal, + false + ) + } catch { + const reconciled = await this.reconcilePromptOperation( + context, + binding, + signal + ) + if (reconciled.terminal) { + return reconciled.binding + } + throw new Error( + '远端 Runtime 完成确认响应不确定,且无法核对已接受的完成结果' + ) + } + return await this.persist( + context, + binding, + { + state: 'ready', + activePromptOperationId: undefined + }, + signal + ) + } + + private promptPreparation( + context: ChannelContext, + binding: RuntimeSessionBinding, + request: AgentExecutionRequest, + modelBridge?: RemoteModelBridgeSession + ): RemotePromptOperationPreparation { + const workMode = request.workMode === 'execute' ? 'execute' : 'ask' + const deadlineAt = new Date( + Date.now() + this.promptTimeoutMs + ).toISOString() + const budget = { + // The Agent applies this to the complete ACP stdin stream, not only + // user content. The signed Runtime manifest supplies the effective + // per-Runtime clamp. + maximumInputBytes: + REMOTE_RUNTIME_LAUNCH_LIMITS.maximumPromptInputBytes, + maximumOutputBytes: + this.options.maxRequestOutputBytes ?? + DEFAULT_MAX_REQUEST_OUTPUT_BYTES + } + return remotePromptOperationPreparationSchema.parse({ + bindingId: binding.bindingId, + operationId: request.requestId, + requestId: request.requestId, + workMode, + controllerId: binding.controllerId, + controllerGeneration: binding.controllerGeneration, + connectionGeneration: context.channel.generation, + channelEpoch: binding.channelEpoch, + hostId: binding.hostId, + hostRevision: binding.hostRevision, + hostKeyGeneration: binding.hostKeyGeneration, + workspaceIdentity: binding.workspaceIdentity, + agentInstallationId: binding.agentInstallationId, + runtimeId: binding.runtimeId, + runtimeBundleDigest: binding.runtimeBundleDigest, + runtimeAdapterDigest: binding.runtimeAdapterDigest, + ...(modelBridge === undefined + ? {} + : { + modelBridge: { + version: modelBridge.version, + channelId: modelBridge.channelId, + channelEpoch: modelBridge.channelEpoch, + policy: modelBridge.policy + } + }), + deadlineAt, + budget + }) + } + + private async preparePrompt( + context: ChannelContext, + binding: RuntimeSessionBinding, + request: AgentExecutionRequest, + signal: AbortSignal + ): Promise<{ + acceptance: RemotePromptOperationAcceptance + modelBridge?: RemoteModelBridgeSession + }> { + this.assertHostCurrent(context, binding) + const modelBridge = await this.openModelBridge( + context, + binding, + request + ) + const preparation = this.promptPreparation( + context, + binding, + request, + modelBridge + ) + let raw: RemotePromptOperationAcceptance + try { + raw = await this.awaitOperation( + context, + '启动远端请求', + context.channel.preparePrompt(preparation), + signal + ) + } catch (error) { + await modelBridge?.close('prompt-preparation-failed') + throw error + } + const acceptance = + remotePromptOperationAcceptanceSchema.parse(raw) + try { + assertRemotePromptAcceptanceMatchesPreparation( + preparation, + acceptance + ) + } catch { + await modelBridge?.close('prompt-acceptance-mismatch') + throw new Error('远端请求启动响应与稳定操作身份不匹配') + } + if ( + Date.parse(acceptance.deadlineAt) <= Date.now() || + !context.channel.isCurrentGeneration() + ) { + await modelBridge?.close('prompt-acceptance-invalid') + throw new Error('远端请求启动响应已过期或连接代际已失效') + } + context.channel.setRecoveryBoundary?.( + acceptance.deadlineAt, + signal + ) + return { acceptance, modelBridge } + } + + private async openModelBridge( + context: ChannelContext, + binding: RuntimeSessionBinding, + request: AgentExecutionRequest + ): Promise { + const policy = this.options.modelBridgePolicy + if (policy === undefined) { + return undefined + } + const parsedPolicy = modelBridgePolicySchema.parse(policy) + if ( + binding.modelBridgeVersion !== MODEL_BRIDGE_PROTOCOL || + !isDeepStrictEqual(binding.modelBridgePolicy, parsedPolicy) || + context.channel.capabilities.modelBridge !== true || + context.channel.openModelBridge === undefined + ) { + throw new Error( + '远端 Runtime Model Bridge 策略与持久化会话或 Agent 能力不匹配' + ) + } + return await context.channel.openModelBridge({ + bindingId: binding.bindingId, + promptOperationId: request.requestId, + requestId: request.requestId, + policy: parsedPolicy + }) + } + + private async closeModelBridge( + session: SessionRecord, + binding: RuntimeSessionBinding, + reason: string + ): Promise { + const bridge = session.modelBridge + session.modelBridge = undefined + if (bridge === undefined) { + return binding + } + const result = await bridge.close(reason).catch(() => ({ + clean: false, + poisoned: true + })) + if (result.clean && !result.poisoned) { + return binding + } + const unknown = runtimeSessionBindingSchema.parse({ + ...binding, + state: 'outcome-unknown' + }) + await this.awaitLocalOperation( + '保存模型请求结果未知会话绑定', + this.options.bindingStore.put(unknown) + ) + return unknown + } + + private assertHostCurrent( + context: ChannelContext, + binding: RuntimeSessionBinding + ): void { + try { + this.options.assertHostCurrent({ + controllerId: binding.controllerId, + hostId: binding.hostId, + hostRevision: binding.hostRevision, + hostKeyGeneration: binding.hostKeyGeneration, + agentInstallationId: binding.agentInstallationId + }) + } catch (error) { + const stale = + error instanceof Error + ? error + : new Error('远端 Runtime Host 绑定已失效') + this.failContext(context, stale) + throw stale + } + } + + private newBinding( + conversationId: string, + bindingId: string, + context: ChannelContext + ): RuntimeSessionBinding { + return runtimeSessionBindingSchema.parse({ + bindingId, + ...this.options.identity, + conversationId, + runtimeId: this.options.runtimeId, + acpSessionId: `opening-${randomUUID()}`, + acpCapabilitiesDigest: + context.channel.advertisedAcpCapabilitiesDigest, + ...(this.options.modelBridgePolicy === undefined + ? {} + : { + modelBridgeVersion: MODEL_BRIDGE_PROTOCOL, + modelBridgePolicy: modelBridgePolicySchema.parse( + this.options.modelBridgePolicy + ) + }), + state: 'opening', + channelEpoch: context.channel.channelEpoch, + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }) + } + + private async openSession( + conversationId: string, + request: AgentExecutionRequest, + signal: AbortSignal + ): Promise { + const old = await this.awaitLocalOperation( + '读取会话绑定', + this.options.bindingStore.getByConversation(conversationId), + signal + ) + if ( + old && + old.state !== 'closed' && + old.state !== 'ready' + ) { + await this.persistInterrupted(old) + throw new Error( + old.state === 'prompt-running' + ? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放' + : '远端 ACP 会话恢复状态不确定,已中断且不会自动重放' + ) + } + let binding = old + let bindingId = binding?.bindingId ?? randomUUID() + if ( + binding?.state === 'ready' && + !this.bindingIdentityMatches(binding) + ) { + await this.closeIdleBinding(binding, signal) + binding = undefined + bindingId = randomUUID() + } + let context = await this.createConversationContext( + conversationId, + bindingId, + signal + ) + let pendingModelBridge: RemoteModelBridgeSession | undefined + try { + this.assertUsable(context) + if ( + !context.channel.capabilities.promptOperationReconciliation + ) { + throw new Error( + '远端 Runtime 缺少稳定操作核对能力' + ) + } + sha256DigestSchema.parse( + context.channel.advertisedAcpCapabilitiesDigest + ) + if ( + binding?.state === 'ready' && + !this.bindingMatches(binding, context) + ) { + await this.closeIdleBinding(binding, signal) + binding = undefined + bindingId = randomUUID() + if (this.options.channelFactory) { + this.contexts.delete(conversationId) + await this.awaitLocalOperation( + '关闭不兼容会话通道', + this.closeContext(context), + signal + ) + context = await this.createConversationContext( + conversationId, + bindingId, + signal + ) + this.assertUsable(context) + if ( + !context.channel.capabilities + .promptOperationReconciliation + ) { + throw new Error( + '远端 Runtime 缺少稳定操作核对能力' + ) + } + sha256DigestSchema.parse( + context.channel.advertisedAcpCapabilitiesDigest + ) + } + } + if (binding?.state === 'ready') { + if (!this.transportMatches(binding, context)) { + binding = await this.awaitLocalOperation( + '更新恢复会话传输身份', + this.options.bindingStore.rotateReadyTransport( + binding.bindingId, + { + controllerGeneration: binding.controllerGeneration, + daemonBootIdAtOpen: binding.daemonBootIdAtOpen, + channelEpoch: binding.channelEpoch + }, + { + controllerGeneration: + context.channel.generation, + daemonBootIdAtOpen: + this.options.identity.daemonBootIdAtOpen, + channelEpoch: context.channel.channelEpoch + } + ), + signal + ) + } + this.assertHostCurrent(context, binding) + binding = await this.persist( + context, + binding, + { + state: 'prompt-running', + activePromptOperationId: request.requestId, + promptSequence: nextPromptSequence( + binding.promptSequence + ) + }, + signal + ) + const prepared = await this.preparePrompt( + context, + binding, + request, + signal + ) + pendingModelBridge = prepared.modelBridge + const state = await this.getState(context, signal) + binding = await this.awaitLocalOperation( + '声明恢复会话身份', + this.options.bindingStore.claimAcpSession( + binding.bindingId, + binding.acpSessionId + ), + signal + ) + if (state.capabilities.loadSession === true) { + await this.awaitOperation( + context, + '加载会话', + state.agent.loadSession({ + sessionId: binding.acpSessionId, + cwd: this.options.workspacePath, + mcpServers: [] + }), + signal + ) + } else if (state.capabilities.sessionCapabilities?.resume) { + await this.awaitOperation( + context, + '恢复会话', + state.agent.resumeSession({ + sessionId: binding.acpSessionId, + cwd: this.options.workspacePath, + mcpServers: [] + }), + signal + ) + } else { + throw new Error('远端 ACP Runtime 不支持恢复已有会话') + } + const session = { + binding, + sessionId: binding.acpSessionId, + context, + modelBridge: prepared.modelBridge + } + pendingModelBridge = undefined + return session + } + + binding = this.newBinding( + conversationId, + bindingId, + context + ) + await this.awaitLocalOperation( + '保存新会话绑定', + this.options.bindingStore.put(binding), + signal + ) + this.assertHostCurrent(context, binding) + binding = await this.persist( + context, + binding, + { + state: 'prompt-running', + activePromptOperationId: request.requestId + }, + signal + ) + const prepared = await this.preparePrompt( + context, + binding, + request, + signal + ) + pendingModelBridge = prepared.modelBridge + const state = await this.getState(context, signal) + const response = await this.awaitOperation( + context, + '创建会话', + state.agent.newSession({ + cwd: this.options.workspacePath, + mcpServers: [] + }), + signal + ) + if (!response.sessionId) { + throw new Error('远端 ACP Runtime 未返回会话 ID') + } + binding = await this.awaitLocalOperation( + '声明新会话身份', + this.options.bindingStore.claimAcpSession( + binding.bindingId, + response.sessionId + ), + signal + ) + binding = await this.persist( + context, + binding, + { + state: 'prompt-running', + activePromptOperationId: request.requestId + }, + signal + ) + const session = { + binding, + sessionId: response.sessionId, + context, + modelBridge: prepared.modelBridge + } + pendingModelBridge = undefined + return session + } catch (error) { + await pendingModelBridge + ?.close('session-initialization-failed') + .catch(() => undefined) + if (binding) { + const persist = + isDefinitiveRemoteRuntimeRequestRejection( + error, + 'runtime/preparePrompt' + ) + ? this.persistRejectedPrompt(binding) + : this.persistInterrupted(binding) + await persist.catch(() => undefined) + } + throw error + } + } + + private async getSession( + conversationId: string, + request: AgentExecutionRequest, + signal: AbortSignal + ): Promise { + const current = this.sessions.get(conversationId) + if (current) { + try { + this.assertHostCurrent( + current.context, + current.binding + ) + current.binding = await this.persist( + current.context, + current.binding, + { + state: 'prompt-running', + activePromptOperationId: request.requestId, + promptSequence: nextPromptSequence( + current.binding.promptSequence + ) + }, + signal + ) + const prepared = await this.preparePrompt( + current.context, + current.binding, + request, + signal + ) + current.modelBridge = prepared.modelBridge + return current + } catch (error) { + const persist = + isDefinitiveRemoteRuntimeRequestRejection( + error, + 'runtime/preparePrompt' + ) + ? this.persistRejectedPrompt(current.binding) + : this.persistInterrupted(current.binding) + current.binding = await persist.catch(() => current.binding) + this.sessions.delete(conversationId) + this.contexts.delete(conversationId) + await this.closeContext(current.context).catch(() => undefined) + throw error + } + } + const pending = this.sessionInitializations.get(conversationId) + if (pending) { + return this.awaitLocalOperation( + '等待会话初始化', + pending, + signal + ) + } + const opening = this.openSession(conversationId, request, signal) + .then((session) => { + this.sessions.set(conversationId, session) + return session + }) + .catch(async (error: unknown) => { + const context = this.contexts.get(conversationId) + if (context) { + this.contexts.delete(conversationId) + await this.closeContext(context).catch(() => undefined) + } + throw error + }) + this.sessionInitializations.set(conversationId, opening) + try { + return await this.awaitLocalOperation( + '等待会话初始化', + opening, + signal + ) + } finally { + this.sessionInitializations.delete(conversationId) + this.notifyDrain() + } + } + + private async handleSessionUpdate( + context: ChannelContext, + notification: SessionNotification + ): Promise { + try { + this.assertUsable(context, true) + } catch (error) { + this.failContext( + context, + error instanceof Error + ? error + : new Error('远端 Runtime 代际失效') + ) + return + } + const prompt = [...this.activePrompts.values()].find( + (candidate) => + candidate.context === context && + candidate.sessionId === notification.sessionId + ) + if (!prompt?.open) { + return + } + if (prompt.interruption) { + return + } + const bytes = utf8JsonBytes(notification.update) + const maximumItems = Math.max( + 1, + this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES + ) + const maximumBytes = Math.max( + 1, + this.options.maxPendingUpdateBytes ?? + DEFAULT_MAX_PENDING_UPDATE_BYTES + ) + if ( + prompt.updates.length >= maximumItems || + prompt.pendingUpdateBytes + bytes > maximumBytes + ) { + const overflow = new Error( + '远端 Runtime 待消费事件超过安全上限,输出已截断且请求状态未知' + ) + prompt.interruption = overflow + prompt.interrupt?.(overflow) + prompt.wake?.() + prompt.wake = undefined + throw overflow + } + let consume: (() => void) | undefined + const consumed = new Promise((resolve) => { + consume = resolve + }) + prompt.updates.push({ + update: notification.update, + bytes, + consumed: consume! + }) + prompt.pendingUpdateBytes += bytes + prompt.wake?.() + prompt.wake = undefined + if ( + !prompt.inboundPaused && + ( + prompt.updates.length >= maximumItems || + prompt.pendingUpdateBytes >= maximumBytes + ) + ) { + prompt.inboundPaused = true + await this.awaitOperation( + context, + '暂停入站事件', + context.channel.setInboundPaused(true) + ) + } + await consumed + } + + private consumeUpdate(prompt: ActivePrompt): SessionUpdate | undefined { + const queued = prompt.updates.shift() + if (!queued) { + return undefined + } + prompt.pendingUpdateBytes = Math.max( + 0, + prompt.pendingUpdateBytes - queued.bytes + ) + queued.consumed() + if (prompt.inboundPaused && !prompt.interruption) { + prompt.inboundPaused = false + void this.awaitOperation( + prompt.context, + '恢复入站事件', + prompt.context.channel.setInboundPaused(false) + ).catch(() => undefined) + } + return queued.update + } + + private discardUpdates(prompt: ActivePrompt): void { + for (const queued of prompt.updates.splice(0)) { + queued.consumed() + } + prompt.pendingUpdateBytes = 0 + if (prompt.inboundPaused && !prompt.interruption) { + prompt.inboundPaused = false + void this.awaitOperation( + prompt.context, + '恢复入站事件', + prompt.context.channel.setInboundPaused(false) + ).catch(() => undefined) + } + } + + private async handlePermission( + context: ChannelContext, + permission: RequestPermissionRequest + ): Promise { + try { + this.assertUsable(context, true) + } catch { + return permissionRejection(permission) + } + const prompt = [...this.activePrompts.values()].find( + (candidate) => + candidate.context === context && + candidate.sessionId === permission.sessionId + ) + if ( + !prompt?.open || + prompt.completed || + prompt.workMode !== 'execute' + ) { + return permissionRejection(permission) + } + const allowOnce = permission.options.find( + (option) => option.kind === 'allow_once' + ) + const allowAlways = permission.options.find( + (option) => option.kind === 'allow_always' + ) + if (!prompt.authorize) { + const selected = allowAlways ?? allowOnce + return selected + ? { + outcome: { + outcome: 'selected', + optionId: selected.optionId + } + } + : permissionRejection(permission) + } + const input = safeStringify( + permission.toolCall.rawInput, + this.options.maxEventCharacters ?? + DEFAULT_MAX_EVENT_CHARACTERS + ) + const decision = await prompt + .authorize({ + scopeKey: `acp-remote:${permission.toolCall.kind ?? 'tool'}`, + title: ( + permission.toolCall.title ?? + '远端 Runtime 工具请求' + ).slice(0, 200), + description: '远端 Runtime 请求在副作用发生前执行此工具', + ...(permission.toolCall.kind + ? { toolName: permission.toolCall.kind.slice(0, 200) } + : {}), + ...(input ? { argumentSummary: input } : {}), + allowPermanent: Boolean(allowAlways) + }) + .catch(() => 'deny' as const) + if ( + !prompt.open || + prompt.completed || + this.activePrompts.get(prompt.bindingId) !== prompt || + !context.channel.isCurrentGeneration() + ) { + return permissionRejection(permission) + } + const selected = + (decision === 'session' || decision === 'permanent') && + allowAlways + ? allowAlways + : decision !== 'deny' + ? allowOnce + : undefined + return selected + ? { + outcome: { + outcome: 'selected', + optionId: selected.optionId + } + } + : permissionRejection(permission) + } + + private waitForUpdate(prompt: ActivePrompt): Promise { + if ( + prompt.updates.length > 0 || + !prompt.open || + prompt.interruption !== undefined || + prompt.context.fatalError + ) { + return Promise.resolve() + } + return new Promise((resolve) => { + prompt.wake = resolve + }) + } + + private limitEvent( + prompt: ActivePrompt, + event: RuntimeEvent + ): RuntimeEvent { + const maximum = + this.options.maxEventCharacters ?? + DEFAULT_MAX_EVENT_CHARACTERS + let characters = 0 + if (event.type === 'text' || event.type === 'reasoning') { + characters = event.delta.length + if (characters > maximum) { + event = { ...event, delta: `${event.delta.slice(0, maximum)}…` } + characters = maximum + 1 + } + } else if (event.type === 'tool') { + const fieldMaximum = Math.max(1, Math.floor(maximum / 3)) + const bounded = (value: string | undefined): string | undefined => + value && value.length > fieldMaximum + ? `${value.slice(0, fieldMaximum)}…` + : value + event = { + ...event, + input: bounded(event.input), + output: bounded(event.output), + error: bounded(event.error) + } + characters = + (event.input?.length ?? 0) + + (event.output?.length ?? 0) + + (event.error?.length ?? 0) + } else if (event.type === 'status') { + if (event.message.length > maximum) { + event = { + ...event, + message: `${event.message.slice(0, maximum)}…` + } + } + characters = event.message.length + } + prompt.outputCharacters += characters + if ( + prompt.outputCharacters > + (this.options.maxRequestOutputBytes ?? + DEFAULT_MAX_REQUEST_OUTPUT_BYTES) + ) { + throw new Error('远端 Runtime 请求输出超过安全上限') + } + return event + } + + private mapUpdate( + prompt: ActivePrompt, + update: SessionUpdate + ): RuntimeEvent | undefined { + if ( + (update.sessionUpdate === 'agent_message_chunk' || + update.sessionUpdate === 'agent_thought_chunk') && + update.content.type === 'text' && + update.content.text + ) { + return { + requestId: prompt.requestId, + type: + update.sessionUpdate === 'agent_message_chunk' + ? 'text' + : 'reasoning', + delta: update.content.text + } + } + if ( + update.sessionUpdate === 'tool_call' || + update.sessionUpdate === 'tool_call_update' + ) { + const name = ( + update.title ?? + update.kind ?? + '远端 Runtime 工具' + ).slice(0, 200) + const input = safeStringify(update.rawInput, 100_000) + const output = safeStringify(update.rawOutput, 100_000) + return { + requestId: prompt.requestId, + type: 'tool', + callId: update.toolCallId.slice(0, 256), + name, + state: + update.status === 'in_progress' + ? 'running' + : update.status ?? 'pending', + summary: `远端 Runtime 工具:${name}`, + ...(input ? { input } : {}), + ...(output ? { output } : {}) + } + } + if (update.sessionUpdate === 'plan') { + return { + requestId: prompt.requestId, + type: 'status', + message: update.entries + .map((entry) => `[${entry.status}] ${entry.content}`) + .join('\n') + } + } + if (update.sessionUpdate === 'plan_update') { + const plan = update.plan + return { + requestId: prompt.requestId, + type: 'status', + message: + plan.type === 'items' + ? plan.entries + .map((entry) => `[${entry.status}] ${entry.content}`) + .join('\n') + : plan.type === 'markdown' + ? plan.content + : `计划已更新:${plan.uri}` + } + } + if (update.sessionUpdate === 'plan_removed') { + return { + requestId: prompt.requestId, + type: 'status', + message: `计划已移除:${update.id}` + } + } + if (update.sessionUpdate === 'usage_update') { + return { + requestId: prompt.requestId, + type: 'context-metrics', + contextTokens: Math.max(0, update.used), + effectiveTriggerTokens: Math.max(0, update.size), + contextWindowTokens: Math.max(0, update.size), + compressionEnabled: false, + source: 'provider', + basis: 'conversation' + } + } + return undefined + } + + async *run( + request: AgentExecutionRequest, + signal: AbortSignal, + authorize?: RuntimeAuthorizer + ): AsyncGenerator { + signal.throwIfAborted() + this.assertUsable() + if (!this.supportsToolExecution) { + throw new Error( + '远端 Runtime 缺少逐请求隔离证明或稳定操作核对能力' + ) + } + if (this.sessionReservations.has(request.conversationId)) { + throw new Error('远端 ACP 会话已有活动请求') + } + this.sessionReservations.add(request.conversationId) + let session: SessionRecord + try { + session = await this.getSession( + request.conversationId, + request, + signal + ) + } catch (error) { + this.sessionReservations.delete(request.conversationId) + throw error + } + const context = session.context + const state = context.state! + const operationId = request.requestId + let binding = session.binding + const prompt: ActivePrompt = { + requestId: request.requestId, + operationId, + sessionId: session.sessionId, + bindingId: binding.bindingId, + workMode: + request.workMode === 'execute' ? 'execute' : 'ask', + authorize: + request.workMode === 'execute' ? authorize : undefined, + updates: [], + pendingUpdateBytes: 0, + inboundPaused: false, + outputCharacters: 0, + open: true, + completed: false, + context + } + this.activePrompts.set(binding.bindingId, prompt) + let completed = false + let response: + | Awaited> + | undefined + let promptError: unknown + let promptStarted = false + let cancellationStarted = false + let promptTimeout: ReturnType | undefined + let promptPromise: Promise = Promise.resolve() + const interrupt = (reason: Error): void => { + prompt.interruption ??= reason + void session.modelBridge?.close( + reason instanceof RemoteOperationTimeoutError + ? 'prompt-timeout' + : 'prompt-cancelled' + ) + if (!cancellationStarted && promptStarted) { + cancellationStarted = true + void state.agent + .cancel({ sessionId: session.sessionId }) + .catch(() => undefined) + } + prompt.wake?.() + prompt.wake = undefined + } + prompt.interrupt = interrupt + const cancel = (): void => { + const reason = abortReason(signal) + interrupt( + reason instanceof Error + ? reason + : new Error('远端 Runtime 请求已取消') + ) + } + signal.addEventListener('abort', cancel, { once: true }) + try { + signal.throwIfAborted() + this.assertHostCurrent(context, binding) + promptStarted = true + promptPromise = state.agent + .prompt({ + sessionId: session.sessionId, + prompt: [ + { type: 'text', text: request.prompt }, + ...(request.images ?? []).map((image) => ({ + type: 'image' as const, + data: image.data, + mimeType: image.mediaType + })) + ] + }) + .then((value) => { + response = value + }) + .catch((error: unknown) => { + promptError = error + }) + .finally(() => { + completed = true + prompt.completed = true + prompt.wake?.() + prompt.wake = undefined + }) + promptTimeout = setTimeout( + () => interrupt(new RemoteOperationTimeoutError('执行请求')), + this.promptTimeoutMs + ) + yield { + requestId: request.requestId, + type: 'status', + message: '远端 ACP Runtime 正在处理请求' + } + while (!completed || prompt.updates.length > 0) { + if (prompt.interruption) { + throw prompt.interruption + } + if (context.fatalError) { + throw context.fatalError + } + if (prompt.updates.length === 0) { + await this.waitForUpdate(prompt) + continue + } + const update = this.consumeUpdate(prompt) + if (!update) { + continue + } + const event = this.mapUpdate(prompt, update) + if (event) { + yield this.limitEvent(prompt, event) + } + } + await this.awaitOperation( + context, + '等待请求终态', + promptPromise, + signal + ) + this.assertUsable(context, true) + if (promptError) { + throw promptError + } + if (response?.stopReason === 'cancelled') { + throw signal.aborted + ? signal.reason + : new Error('远端 Runtime 请求已取消') + } + if (response?.usage) { + const event = usageEvent( + request.requestId, + response.usage, + this.options.usage, + operationId + ) + if (event) { + yield event + } + } + binding = await this.closeModelBridge( + session, + binding, + 'prompt-completed' + ) + session.binding = binding + if (binding.state === 'outcome-unknown') { + throw new Error( + '远端模型响应交付未确认,活动请求结果未知且不会自动重放' + ) + } + binding = await this.completePromptOperation( + context, + binding, + signal + ) + session.binding = binding + yield { + requestId: request.requestId, + type: 'done', + sessionId: session.sessionId + } + } catch (caught) { + let error = caught + binding = await this.closeModelBridge( + session, + binding, + 'prompt-failed' + ) + session.binding = binding + if (promptStarted && !completed) { + interrupt( + caught instanceof Error + ? caught + : new Error('远端 Runtime 请求已中断') + ) + await Promise.race([ + promptPromise, + new Promise((resolve) => + setTimeout( + resolve, + this.options.cancellationGraceMs ?? + DEFAULT_CANCELLATION_GRACE_MS + ) + ) + ]) + if (!completed) { + if ( + context.channel.capabilities.cancellationEscalation + ) { + await this.awaitOperation( + context, + '升级取消', + context.channel.escalateCancellation({ + bindingId: binding.bindingId, + sessionId: session.sessionId, + operationId, + requestId: request.requestId, + reason: caught + }), + undefined, + false + ).catch(() => undefined) + } + const detail = + caught instanceof RemoteOperationTimeoutError + ? `${caught.message};` + : '' + const unknown = new Error( + `${detail}远端 Runtime 取消升级后仍无终态,活动请求结果未知` + ) + this.failContext(context, unknown) + error = unknown + } + } + if (binding.state !== 'outcome-unknown') { + const reconciled = await this.reconcilePromptOperation( + context, + binding + ).catch(() => ({ + binding: this.unknownBinding(binding), + terminal: false + })) + binding = reconciled.binding + } + session.binding = binding + throw error + } finally { + context.channel.clearRecoveryBoundary?.() + if (session.modelBridge !== undefined) { + binding = await this.closeModelBridge( + session, + binding, + 'prompt-finalized' + ) + session.binding = binding + } + if (promptStarted && !completed && !prompt.interruption) { + const abandoned = new Error( + '远端 Runtime 事件消费提前结束,请求已中断' + ) + interrupt(abandoned) + await Promise.race([ + promptPromise, + new Promise((resolve) => + setTimeout( + resolve, + this.options.cancellationGraceMs ?? + DEFAULT_CANCELLATION_GRACE_MS + ) + ) + ]) + if ( + !completed && + context.channel.capabilities.cancellationEscalation + ) { + await this.awaitOperation( + context, + '升级提前结束的请求', + context.channel.escalateCancellation({ + bindingId: binding.bindingId, + sessionId: session.sessionId, + operationId, + requestId: request.requestId, + reason: abandoned + }), + undefined, + false + ).catch(() => undefined) + } + if (!completed) { + this.failContext( + context, + new Error( + '远端 Runtime 事件消费结束后仍无终态,活动请求结果未知' + ) + ) + } + if (binding.state !== 'outcome-unknown') { + binding = ( + await this.reconcilePromptOperation( + context, + binding + ).catch(() => ({ + binding: this.unknownBinding(binding), + terminal: false + })) + ).binding + } + session.binding = binding + } + prompt.open = false + prompt.interrupt = undefined + prompt.wake?.() + prompt.wake = undefined + signal.removeEventListener('abort', cancel) + if (promptTimeout) { + clearTimeout(promptTimeout) + } + this.discardUpdates(prompt) + this.activePrompts.delete(binding.bindingId) + this.sessionReservations.delete(request.conversationId) + this.notifyDrain() + } + } + + async releaseConversation( + conversationId: string, + signal?: AbortSignal + ): Promise { + const pending = this.sessionInitializations.get(conversationId) + if (pending) { + await this.awaitLocalOperation( + '等待待释放会话初始化', + pending, + signal + ).catch(() => undefined) + } + const session = this.sessions.get(conversationId) + const context = + session?.context ?? this.contexts.get(conversationId) + if (!context) { + return + } + const state = context.state + try { + if (!session) { + return + } + if (session.binding.activePromptOperationId) { + const reconciled = await this.reconcilePromptOperation( + context, + session.binding, + signal + ) + session.binding = reconciled.binding + if (!reconciled.terminal) { + throw new Error( + '远端 ACP 活动操作结果未知,无法释放会话身份' + ) + } + } + if (state) { + if (state.capabilities.sessionCapabilities?.close) { + await this.awaitOperation( + context, + '关闭会话', + state.agent.closeSession({ + sessionId: session.sessionId + }), + signal + ) + } else { + await this.awaitOperation( + context, + '取消会话', + state.agent.cancel({ sessionId: session.sessionId }), + signal + ) + } + } + session.binding = await this.persist( + context, + session.binding, + { + state: 'closed', + activePromptOperationId: undefined + }, + signal + ) + } catch (error) { + if (session) { + session.binding = await this.persistInterrupted( + session.binding + ).catch(() => ({ + ...this.unknownBinding(session.binding) + })) + } + throw error + } finally { + this.sessions.delete(conversationId) + this.contexts.delete(conversationId) + await this.awaitLocalOperation( + '关闭会话通道', + this.closeContext(context) + ).catch(() => undefined) + this.notifyDrain() + } + } + + beginDrain(): Promise { + this.draining = true + this.notifyDrain() + return Promise.resolve() + } + + waitForDrain(): Promise { + if ( + this.sessions.size === 0 && + this.activePrompts.size === 0 && + this.sessionReservations.size === 0 && + this.sessionInitializations.size === 0 + ) { + return Promise.resolve() + } + return new Promise((resolve) => { + this.drainWaiters.add(resolve) + }) + } + + private notifyDrain(): void { + if ( + this.sessions.size > 0 || + this.activePrompts.size > 0 || + this.sessionReservations.size > 0 || + this.sessionInitializations.size > 0 + ) { + return + } + for (const resolve of this.drainWaiters) { + resolve() + } + this.drainWaiters.clear() + } + + private failContext(context: ChannelContext, error: Error): void { + context.fatalError ??= error + for (const prompt of this.activePrompts.values()) { + if (prompt.context === context) { + prompt.wake?.() + prompt.wake = undefined + } + } + void this.closeContext(context).catch(() => undefined) + } + + async forceShutdown(): Promise { + for (const prompt of this.activePrompts.values()) { + prompt.interrupt?.( + new Error('GoodBuddy 正在强制关闭远端 Runtime') + ) + } + await Promise.all( + [...this.sessions.values()].map(async (session) => { + const context = session.context + const state = context.state + const operationId = + session.binding.activePromptOperationId + if (state) { + await this.awaitOperation( + context, + '强制取消会话', + state.agent.cancel({ sessionId: session.sessionId }), + undefined, + false + ).catch(() => undefined) + } + if (operationId) { + await this.awaitOperation( + context, + '按稳定操作身份升级强制取消', + context.channel.escalateCancellation({ + bindingId: session.binding.bindingId, + sessionId: session.sessionId, + operationId, + requestId: operationId, + reason: new Error('GoodBuddy 正在强制关闭 Runtime') + }), + undefined, + false + ).catch(() => undefined) + session.binding = ( + await this.reconcilePromptOperation( + context, + session.binding + ).catch(() => ({ + binding: this.unknownBinding(session.binding), + terminal: false + })) + ).binding + return + } + session.binding = await this.persistInterrupted( + session.binding + ).catch(() => this.unknownBinding(session.binding)) + }) + ) + await this.dispose() + } + + async dispose(): Promise { + if (this.disposed) { + return + } + this.disposed = true + for (const prompt of this.activePrompts.values()) { + prompt.interrupt?.( + new Error('远端 Runtime 已关闭,活动请求结果需要核对') + ) + prompt.open = false + prompt.wake?.() + this.discardUpdates(prompt) + } + this.activePrompts.clear() + this.sessionReservations.clear() + this.sessions.clear() + this.sessionInitializations.clear() + this.contexts.clear() + await Promise.allSettled( + [...this.allContexts].map((context) => + this.awaitLocalOperation( + '关闭通道', + this.closeContext(context) + ) + ) + ) + this.notifyDrain() + } +} + +function nextPromptSequence(current: number): number { + if ( + !Number.isSafeInteger(current) || + current < 0 || + current >= Number.MAX_SAFE_INTEGER + ) { + throw new Error('远端 Runtime 请求序列已耗尽') + } + return current + 1 +} diff --git a/src/main/agent/bounded-response.ts b/src/main/agent/bounded-response.ts index a742a84a..ef0dcc72 100644 --- a/src/main/agent/bounded-response.ts +++ b/src/main/agent/bounded-response.ts @@ -2,29 +2,56 @@ export type BoundedResponseTextOptions = { maxBytes: number missingBodyMessage?: string tooLargeMessage: string + truncatedMessage?: string } -export async function readBoundedResponseText( +export class BoundedResponseTooLargeError extends Error { + constructor(message: string) { + super(message) + this.name = 'BoundedResponseTooLargeError' + } +} + +export class BoundedResponseTruncatedError extends Error { + constructor(message: string) { + super(message) + this.name = 'BoundedResponseTruncatedError' + } +} + +export async function readBoundedResponseBytes( response: Response, options: BoundedResponseTextOptions -): Promise { +): Promise { const declaredLength = response.headers.get('content-length') + let parsedLength: number | undefined if (declaredLength !== null) { - const parsedLength = Number(declaredLength) + parsedLength = Number(declaredLength) if ( !Number.isSafeInteger(parsedLength) || parsedLength < 0 || parsedLength > options.maxBytes ) { await response.body?.cancel().catch(() => undefined) - throw new Error(options.tooLargeMessage) + throw new BoundedResponseTooLargeError( + options.tooLargeMessage + ) } } if (!response.body) { if (options.missingBodyMessage) { throw new Error(options.missingBodyMessage) } - return '' + if ( + options.truncatedMessage !== undefined && + parsedLength !== undefined && + parsedLength !== 0 + ) { + throw new BoundedResponseTruncatedError( + options.truncatedMessage + ) + } + return new Uint8Array() } const reader = response.body.getReader() @@ -40,7 +67,9 @@ export async function readBoundedResponseText( } total += value.byteLength if (total > options.maxBytes) { - throw new Error(options.tooLargeMessage) + throw new BoundedResponseTooLargeError( + options.tooLargeMessage + ) } chunks.push(value) } @@ -50,5 +79,24 @@ export async function readBoundedResponseText( } reader.releaseLock() } - return Buffer.concat(chunks, total).toString('utf8') + if ( + options.truncatedMessage !== undefined && + parsedLength !== undefined && + response.headers.get('content-encoding') === null && + total !== parsedLength + ) { + throw new BoundedResponseTruncatedError( + options.truncatedMessage + ) + } + return Buffer.concat(chunks, total) +} + +export async function readBoundedResponseText( + response: Response, + options: BoundedResponseTextOptions +): Promise { + return Buffer.from( + await readBoundedResponseBytes(response, options) + ).toString('utf8') } diff --git a/src/main/agent/create-runtime.test.ts b/src/main/agent/create-runtime.test.ts index d4f2dd95..35aeb73b 100644 --- a/src/main/agent/create-runtime.test.ts +++ b/src/main/agent/create-runtime.test.ts @@ -7,6 +7,7 @@ import { createModelProfileRuntime } from './create-runtime' import { AgentRuntimeController } from './runtime-controller' +import type { WorkspaceAccess } from '../workspace' function createBrowserService(): BrowserToolService & { dispose: ReturnType @@ -421,4 +422,32 @@ describe('createAgentRuntime model compatibility', () => { }) await runtime.dispose() }) + + it('rejects remote process runtimes before invoking any local launcher', () => { + const launch = vi.fn() + const workspaceAccess = { + dispose: vi.fn(async () => undefined) + } as unknown as WorkspaceAccess + + expect(() => + createAgentRuntime( + 'C:\\SafeLocalDefault', + settings({ + provider: 'deepseek-harness' + }), + { + deepseekHarnessLauncher: launch, + executionSpace: { + kind: 'ssh', + hostId: 'host-one', + remoteRootPath: '/srv/project', + cacheIdentity: 'remote-one', + routeIdentity: 'remote-one', + workspaceAccess + } + } + ) + ).toThrow('远程执行空间尚不可用') + expect(launch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/agent/create-runtime.ts b/src/main/agent/create-runtime.ts index e34b0412..31d63f2c 100644 --- a/src/main/agent/create-runtime.ts +++ b/src/main/agent/create-runtime.ts @@ -30,6 +30,14 @@ import type { ModelToolProviderLike } from './model-tool-provider' import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' import { ModelToolProvider } from './model-tool-provider' import type { ControlledHarnessExtensionPackage } from './deepseek-harness-extension-loader' +import { + LocalWorkspaceAccess, + type WorkspaceAccess +} from '../workspace' +import { + REMOTE_EXECUTION_SPACE_UNAVAILABLE, + type ExecutionSpaceDescriptor +} from '../execution-space' const noSubagentTools: ModelToolProviderLike = { listTools: async () => [], @@ -55,6 +63,8 @@ export type AgentCapabilityContext = { browserService?: BrowserToolService knowledgeGateway?: KnowledgeMcpGateway webSearchEnabled?: boolean + executionSpace?: ExecutionSpaceDescriptor + workspaceAccess?: WorkspaceAccess } function resolveContextCompression( @@ -145,15 +155,34 @@ export function createAgentRuntime( settings?: ResolvedRuntimeSettings, capabilities: AgentCapabilityContext = {} ): AgentRuntime { + const executionSpace = capabilities.executionSpace + const remote = executionSpace?.kind === 'ssh' const baseUrl = ( settings?.opencodeBaseUrl || process.env.GOODBUDDY_OPENCODE_URL || '' ).trim() const embedded = !baseUrl - const workspace = settings?.workspacePath || defaultWorkspace + const workspace = + executionSpace?.kind === 'local' + ? executionSpace.rootPath + : settings?.workspacePath || defaultWorkspace + const getWorkspaceAccess = (): WorkspaceAccess => + capabilities.workspaceAccess ?? + executionSpace?.workspaceAccess ?? + new LocalWorkspaceAccess(workspace) const provider = settings?.provider ?? defaultRuntimeSettings.provider + if ( + remote && + (provider === 'continue' || + provider === 'opencode' || + provider === 'deepseek-harness' || + provider === 'auto') + ) { + throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) + } + if (provider === 'deepseek-harness') { const profile = settings?.deepseekHarnessModelProfile if (!profile || !isDeepSeekHarnessModelProfile(profile)) { @@ -179,7 +208,7 @@ export function createAgentRuntime( skillPackages: capabilities.skillPackages, extensionPackages: capabilities.deepseekHarnessExtensions, toolProvider: new ModelToolProvider( - workspace, + getWorkspaceAccess(), capabilities.mcpServers, undefined, capabilities.knowledgeGateway, @@ -311,7 +340,8 @@ export function createAgentRuntime( webSearchEnabled: capabilities.webSearchEnabled, contextCompression: settings ? resolveContextCompression(settings, defaultModelProfile) - : undefined + : undefined, + workspaceAccess: getWorkspaceAccess() }) } diff --git a/src/main/agent/goodbuddy-harness-control-plane.ts b/src/main/agent/goodbuddy-harness-control-plane.ts index a6016880..6aac0666 100644 --- a/src/main/agent/goodbuddy-harness-control-plane.ts +++ b/src/main/agent/goodbuddy-harness-control-plane.ts @@ -72,7 +72,7 @@ const MAX_NATIVE_TOOLS = 200 const NATIVE_SNAPSHOT_TIMEOUT_MS = 2_000 const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch']) const GOODBUDDY_EXECUTION_GUIDANCE = [ - 'GoodBuddy controlled execution rules:', + 'GoodBuddy work mode rules:', '- In Execute mode, act through the available tools instead of writing a long implementation plan.', '- Inspect only what is needed, then create or update the requested workspace files promptly.', '- Work in small verifiable steps and use tool results as the source of truth.', diff --git a/src/main/agent/model-call-operation-store.test.ts b/src/main/agent/model-call-operation-store.test.ts new file mode 100644 index 00000000..e580fc08 --- /dev/null +++ b/src/main/agent/model-call-operation-store.test.ts @@ -0,0 +1,649 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import type { + ModelCallIdentity, + PrepareModelCall +} from '../../shared/model-call-operation-contracts' +import { + ModelCallCapacityError, + ModelCallConflictError, + ModelCallOperationStore, + ModelCallStateError +} from './model-call-operation-store' + +const directories: string[] = [] +const digestA = `sha256:${'a'.repeat(64)}` +const digestB = `sha256:${'b'.repeat(64)}` +const profileDigest = `sha256:${'c'.repeat(64)}` +const policy = { + modelProfileDigest: profileDigest +} +const identity: ModelCallIdentity = { + callOperationId: 'call-1', + requestId: 'request-1', + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + promptSequence: 0, + roundIndex: 0, + provider: 'openai', + profile: 'work', + model: 'gpt-model', + protocol: 'responses-v1' +} +const prepared: PrepareModelCall = { + identity, + requestDigest: digestA, + ...policy +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +async function createPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-')) + directories.push(directory) + return join(directory, 'model-calls.sqlite') +} + +function operation(callOperationId: string, roundIndex: number): PrepareModelCall { + return { + identity: { + ...identity, + callOperationId, + promptOperationId: `prompt-${callOperationId}`, + roundIndex: 0 + }, + requestDigest: roundIndex % 2 === 0 ? digestA : digestB, + ...policy + } +} + +function completeCall( + store: ModelCallOperationStore, + callOperationId: string +): void { + store.beginDispatch(callOperationId) + store.complete(callOperationId, { status: 'completed' }) + store.markResponseDelivered(callOperationId) +} + +describe('ModelCallOperationStore', () => { + it('durably preserves prepared and dispatched crash states in WAL mode', async () => { + const path = await createPath() + const store = new ModelCallOperationStore(path) + store.prepare(prepared) + store.prepare({ + identity: { + ...identity, + callOperationId: 'call-2', + promptOperationId: 'prompt-2', + roundIndex: 0 + }, + requestDigest: digestB, + ...policy + }) + expect(store.beginDispatch('call-2').permitted).toBe(true) + store.close() + + const check = new DatabaseSync(path) + expect( + (check.prepare('PRAGMA journal_mode').get() as { journal_mode: string }) + .journal_mode + ).toBe('wal') + check.close() + + const reopened = new ModelCallOperationStore(path) + expect(reopened.get('call-1')?.status).toBe('prepared') + expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined() + expect(reopened.get('call-2')?.status).toBe('dispatched') + expect(reopened.beginDispatch('call-2').permitted).toBe(false) + expect(reopened.listUnresolved().map((record) => record.status)).toEqual([ + 'prepared', + 'dispatched' + ]) + reopened.close() + }) + + it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => { + const store = new ModelCallOperationStore(await createPath()) + expect(store.prepare(prepared).created).toBe(true) + expect(store.prepare(prepared).created).toBe(false) + expect(() => + store.prepare({ ...prepared, requestDigest: digestB }) + ).toThrow(ModelCallConflictError) + expect(() => + store.prepare({ + ...prepared, + identity: { ...identity, model: 'different-model' } + }) + ).toThrow(ModelCallConflictError) + store.close() + }) + + it('grants only one dispatch permit across concurrent store instances', async () => { + const path = await createPath() + const first = new ModelCallOperationStore(path) + const second = new ModelCallOperationStore(path) + first.prepare(prepared) + + const claims = await Promise.all([ + Promise.resolve().then(() => first.beginDispatch('call-1')), + Promise.resolve().then(() => second.beginDispatch('call-1')) + ]) + expect(claims.filter((claim) => claim.permitted)).toHaveLength(1) + expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe( + true + ) + first.close() + second.close() + }) + + it('blocks replay after an outcome becomes unknown, including after reopen', async () => { + const path = await createPath() + const store = new ModelCallOperationStore(path) + store.prepare(prepared) + store.beginDispatch('call-1', { + providerIdempotencyKey: 'provider-key-1' + }) + store.markOutcomeUnknown('call-1', { + status: 'outcome-unknown', + providerRequestId: 'provider-request-1', + reason: { + code: 'connection-lost', + retryable: false + } + }) + expect(store.beginDispatch('call-1').permitted).toBe(false) + store.close() + + const reopened = new ModelCallOperationStore(path) + expect(reopened.beginDispatch('call-1').permitted).toBe(false) + expect(reopened.listUnresolved()).toHaveLength(1) + expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown') + reopened.close() + }) + + it('makes identical completion idempotent and rejects changed evidence', async () => { + const store = new ModelCallOperationStore(await createPath(), { + now: () => 42 + }) + store.prepare(prepared) + store.beginDispatch('call-1') + const evidence = { + status: 'completed' as const, + providerRequestId: 'request-provider-1', + providerResponseId: 'response-provider-1', + result: { finishReason: 'stop', outputDigest: digestB }, + usage: { inputTokens: 10, outputTokens: 5 } + } + const completed = store.complete('call-1', evidence) + expect(store.complete('call-1', evidence)).toEqual(completed) + expect(() => + store.complete('call-1', { + ...evidence, + providerResponseId: 'different-response' + }) + ).toThrow(ModelCallConflictError) + expect(store.listUnresolved()).toEqual([]) + store.close() + }) + + it('strictly rejects invalid transitions and different terminal outcomes', async () => { + const store = new ModelCallOperationStore(await createPath()) + store.prepare(prepared) + expect(() => + store.complete('call-1', { status: 'completed' }) + ).toThrow(ModelCallStateError) + store.beginDispatch('call-1') + store.failDefinitive('call-1', { + status: 'failed-definitive', + error: { + code: 'authentication', + retryable: false + } + }) + expect(() => + store.complete('call-1', { status: 'completed' }) + ).toThrow(ModelCallStateError) + expect(() => + store.markOutcomeUnknown('call-1', { + status: 'outcome-unknown', + reason: { + code: 'uncertain', + retryable: false + } + }) + ).toThrow(ModelCallStateError) + store.close() + }) + + it('guards closed stores and makes close and dispose idempotent', async () => { + const store = new ModelCallOperationStore(await createPath()) + store.prepare(prepared) + store.close() + expect(() => store.get('call-1')).toThrow(ModelCallStateError) + expect(() => store.prepare(prepared)).toThrow(ModelCallStateError) + expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError) + expect(() => store.listUnresolved()).toThrow(ModelCallStateError) + expect(() => store.close()).not.toThrow() + expect(() => store.dispose()).not.toThrow() + }) + + it('fails safely when persisted JSON is corrupt', async () => { + const path = await createPath() + const store = new ModelCallOperationStore(path) + store.prepare(prepared) + store.close() + + const corrupt = new DatabaseSync(path) + corrupt.exec(` + PRAGMA ignore_check_constraints = ON; + UPDATE model_call_operations SET identity_json = '{broken'; + `) + corrupt.close() + + const reopened = new ModelCallOperationStore(path) + expect(() => reopened.get('call-1')).toThrow(ModelCallStateError) + reopened.close() + }) + + it('enforces the hard record cap without dispatching prepared calls', async () => { + const store = new ModelCallOperationStore(await createPath(), { + maximumRecords: 2 + }) + store.prepare(operation('call-1', 0)) + store.prepare(operation('call-2', 1)) + + expect(() => store.prepare(operation('call-3', 2))).toThrow( + ModelCallCapacityError + ) + expect(store.get('call-1')?.status).toBe('prepared') + expect(store.get('call-2')?.status).toBe('prepared') + expect(store.get('call-3')).toBeUndefined() + store.close() + }) + + it('automatically prunes only definitive terminal rows for capacity', async () => { + const store = new ModelCallOperationStore(await createPath(), { + maximumRecords: 3 + }) + store.prepare(operation('call-1', 0)) + completeCall(store, 'call-1') + store.prepare(operation('call-2', 1)) + store.beginDispatch('call-2') + store.failDefinitive('call-2', { + status: 'failed-definitive', + error: { code: 'provider-rejected', retryable: false } + }) + store.prepare(operation('call-3', 2)) + store.beginDispatch('call-3') + + store.prepare(operation('call-4', 3)) + expect(store.get('call-1')).toBeUndefined() + expect(store.get('call-2')?.status).toBe('failed-definitive') + expect(store.get('call-3')?.status).toBe('dispatched') + expect(store.get('call-4')?.status).toBe('prepared') + + store.prepare(operation('call-5', 4)) + expect(store.get('call-2')).toBeUndefined() + expect(store.get('call-3')?.status).toBe('dispatched') + expect(store.get('call-4')?.status).toBe('prepared') + expect(store.get('call-5')?.status).toBe('prepared') + store.close() + }) + + it('never prunes dispatched or outcome-unknown rows for capacity', async () => { + const store = new ModelCallOperationStore(await createPath(), { + maximumRecords: 2 + }) + store.prepare(operation('call-1', 0)) + store.beginDispatch('call-1') + store.prepare(operation('call-2', 1)) + store.beginDispatch('call-2') + store.markOutcomeUnknown('call-2', { + status: 'outcome-unknown', + reason: { code: 'transport-uncertain', retryable: false } + }) + + expect(() => store.prepare(operation('call-3', 2))).toThrow( + ModelCallCapacityError + ) + expect(store.get('call-1')?.status).toBe('dispatched') + expect(store.get('call-2')?.status).toBe('outcome-unknown') + store.close() + }) + + it('reapplies capacity and terminal pruning after reopen', async () => { + const path = await createPath() + const first = new ModelCallOperationStore(path, { maximumRecords: 3 }) + for (let index = 1; index <= 3; index += 1) { + first.prepare(operation(`call-${index}`, index)) + } + completeCall(first, 'call-1') + completeCall(first, 'call-2') + first.close() + + const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 }) + reopened.prepare(operation('call-4', 4)) + expect(reopened.get('call-1')).toBeUndefined() + expect(reopened.get('call-2')).toBeUndefined() + expect(reopened.get('call-3')?.status).toBe('prepared') + expect(reopened.get('call-4')?.status).toBe('prepared') + reopened.close() + }) + + it('prunes terminal records deterministically with bounded options', async () => { + let now = 0 + const store = new ModelCallOperationStore(await createPath(), { + now: () => ++now + }) + store.prepare(operation('call-1', 0)) + completeCall(store, 'call-1') + store.prepare(operation('call-2', 1)) + completeCall(store, 'call-2') + store.prepare(operation('call-3', 2)) + store.beginDispatch('call-3') + store.markOutcomeUnknown('call-3', { + status: 'outcome-unknown', + reason: { code: 'transport-uncertain', retryable: false } + }) + + expect(store.pruneTerminal(1, 6)).toBe(1) + expect(store.get('call-1')).toBeUndefined() + expect(store.get('call-2')?.status).toBe('completed') + expect(store.get('call-3')?.status).toBe('outcome-unknown') + expect(() => store.pruneTerminal(1_001)).toThrow() + store.close() + }) + + it('atomically grants one strictly monotonic next round across stores', async () => { + const path = await createPath() + const first = new ModelCallOperationStore(path) + const second = new ModelCallOperationStore(path) + first.prepare(prepared) + completeCall(first, 'call-1') + const next = { + identity: { + ...identity, + callOperationId: 'call-round-1-a', + roundIndex: 1 + }, + requestDigest: digestB, + ...policy + } + const competing = { + ...next, + identity: { + ...next.identity, + callOperationId: 'call-round-1-b' + } + } + + const results = await Promise.allSettled([ + Promise.resolve().then(() => first.prepare(next)), + Promise.resolve().then(() => second.prepare(competing)) + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1) + first.close() + second.close() + }) + + it('retains replay authority after delivered records are pruned and reopened', async () => { + const path = await createPath() + const first = new ModelCallOperationStore(path) + first.prepare(prepared) + completeCall(first, 'call-1') + first.finalizePrompt({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + promptSequence: 0 + }) + expect(first.pruneTerminal(1)).toBe(1) + expect(first.get('call-1')).toBeUndefined() + first.close() + + const reopened = new ModelCallOperationStore(path) + expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError) + expect( + reopened.prepare({ + ...prepared, + identity: { + ...prepared.identity, + callOperationId: 'call-next-prompt', + requestId: 'request-2', + promptOperationId: 'prompt-2', + promptSequence: 1, + roundIndex: 0 + }, + requestDigest: digestB + }).created + ).toBe(true) + expect(() => + reopened.prepare({ + ...prepared, + identity: { + ...prepared.identity, + callOperationId: 'call-skipped-prompt', + requestId: 'request-3', + promptOperationId: 'prompt-3', + promptSequence: 3, + roundIndex: 0 + } + }) + ).toThrow(ModelCallConflictError) + reopened.close() + }) + + it('compacts finalized prompt authorities below the hard authority cap', async () => { + const store = new ModelCallOperationStore(await createPath(), { + maximumAuthorities: 1 + }) + for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) { + const callOperationId = `call-prompt-${promptSequence}` + const promptOperationId = `prompt-${promptSequence}` + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId, + requestId: `request-${promptSequence}`, + promptOperationId, + promptSequence, + roundIndex: 0 + } + }) + completeCall(store, callOperationId) + store.finalizePrompt({ + bindingId: identity.bindingId, + promptOperationId, + promptSequence + }) + } + expect(() => + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId: 'replayed-finalized-prompt', + promptOperationId: 'prompt-1', + promptSequence: 1, + roundIndex: 0 + } + }) + ).toThrow(ModelCallConflictError) + store.close() + }) + + it('rejects skipped, lower, and profile-swapped rounds', async () => { + const store = new ModelCallOperationStore(await createPath()) + store.prepare(prepared) + completeCall(store, 'call-1') + expect(() => + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId: 'call-skipped', + roundIndex: 2 + } + }) + ).toThrow(ModelCallStateError) + expect(() => + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId: 'call-profile-swap', + roundIndex: 1, + profile: 'other' + }, + modelProfileDigest: digestB + }) + ).toThrow(ModelCallConflictError) + expect(() => + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId: 'call-lower', + roundIndex: 0 + } + }) + ).toThrow(ModelCallConflictError) + store.close() + }) + + it('permits sequential rounds without a call-count or cumulative-token quota', async () => { + const store = new ModelCallOperationStore(await createPath()) + for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) { + const callOperationId = `call-unbounded-${roundIndex}` + store.prepare({ + ...prepared, + identity: { + ...identity, + callOperationId, + requestId: `request-${roundIndex}`, + roundIndex + } + }) + completeCall(store, callOperationId) + } + expect(store.get('call-unbounded-11')).toMatchObject({ + status: 'completed', + responseDeliveredAt: expect.any(Number) + }) + store.close() + }) + + it('requires explicit response delivery before the next round', async () => { + const store = new ModelCallOperationStore(await createPath(), { + now: () => 42 + }) + store.prepare(prepared) + store.beginDispatch('call-1') + store.complete('call-1', { status: 'completed' }) + const next = { + ...prepared, + identity: { + ...identity, + callOperationId: 'call-2', + roundIndex: 1 + }, + requestDigest: digestB + } + expect(() => store.prepare(next)).toThrow(ModelCallStateError) + expect(store.pruneTerminal(1)).toBe(0) + const pending = store.listStartupRecords().records + expect(pending).toMatchObject([{ status: 'completed' }]) + expect(pending[0]).not.toHaveProperty('responseDeliveredAt') + const delivered = store.markResponseDelivered('call-1') + expect(delivered.responseDeliveredAt).toBe(42) + expect(store.markResponseDelivered('call-1')).toEqual(delivered) + expect(store.prepare(next).created).toBe(true) + store.close() + }) + + it('paginates startup and status-filtered records without starvation', async () => { + let now = 0 + const store = new ModelCallOperationStore(await createPath(), { + now: () => ++now + }) + for (const id of ['call-a', 'call-b', 'call-c']) { + store.prepare(operation(id, 0)) + store.beginDispatch(id) + } + store.complete('call-b', { status: 'completed' }) + completeCall(store, 'call-c') + + const first = store.listStartupRecords({ limit: 1 }) + expect(first.records).toHaveLength(1) + expect(first.nextCursor).toBeDefined() + const second = store.listStartupRecords({ + limit: 1, + cursor: first.nextCursor + }) + expect([ + ...first.records, + ...second.records + ].map((record) => record.identity.callOperationId)).toEqual([ + 'call-a', + 'call-b' + ]) + expect( + store.list({ + statuses: ['completed'], + delivery: 'pending-response', + limit: 10 + }).records.map((record) => record.identity.callOperationId) + ).toEqual(['call-b']) + store.close() + }) + + it('includes prepared records in startup reconciliation', async () => { + const store = new ModelCallOperationStore(await createPath()) + store.prepare(operation('call-prepared', 0)) + + expect(store.listStartupRecords().records).toMatchObject([ + { + identity: { callOperationId: 'call-prepared' }, + status: 'prepared' + } + ]) + store.close() + }) + + it('uses the verified private SQLite path opener before initialization', async () => { + const path = await createPath() + const store = new ModelCallOperationStore(path) + store.close() + const databaseStats = await stat(path) + expect(databaseStats.isFile()).toBe(true) + if (process.platform !== 'win32') { + expect(databaseStats.mode & 0o777).toBe(0o600) + } + expect(() => new ModelCallOperationStore(':memory:')).toThrow( + 'SQLite database must be a filesystem path' + ) + + const invalidPath = join( + join(path, '..'), + 'invalid-capacity-must-not-exist.sqlite' + ) + expect( + () => + new ModelCallOperationStore(invalidPath, { + maximumRecords: 0 + }) + ).toThrow() + await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/main/agent/model-call-operation-store.ts b/src/main/agent/model-call-operation-store.ts new file mode 100644 index 00000000..5fe64348 --- /dev/null +++ b/src/main/agent/model-call-operation-store.ts @@ -0,0 +1,1071 @@ +import { DatabaseSync, type StatementSync } from 'node:sqlite' +import { + completeModelCallEvidenceSchema, + failModelCallEvidenceSchema, + modelCallDispatchMetadataSchema, + modelCallIdentitySchema, + modelCallListOptionsSchema, + modelCallRecordSchema, + modelCallStatusSchema, + prepareModelCallSchema, + unknownModelCallEvidenceSchema, + type CompleteModelCallEvidence, + type FailModelCallEvidence, + type ModelCallDispatchMetadata, + type ModelCallListOptions, + type ModelCallOperationPage, + type ModelCallRecord, + type ModelCallStatus, + type PrepareModelCall, + type UnknownModelCallEvidence +} from '../../shared/model-call-operation-contracts' +import { openPrivateSqliteDatabase } from './private-sqlite-database' + +type ModelCallRow = { + call_operation_id: string + binding_id: string + prompt_operation_id: string + round_index: number + identity_json: string + request_digest: string + model_profile_digest: string + maximum_output_tokens: number + status: string + dispatch_metadata_json: string | null + terminal_evidence_json: string | null + prepared_at: number + updated_at: number + dispatched_at: number | null + terminal_at: number | null + response_delivered_at: number | null +} + +type PromptAuthorityRow = { + binding_id: string + prompt_operation_id: string + model_profile_digest: string + provider: string + profile: string + model: string + protocol: string + high_water_round: number + latest_call_operation_id: string + latest_status: string + latest_response_delivered: number +} + +type PromptHighWaterRow = { + finalized_prompt_sequence: number +} + +const LEGACY_UNBOUNDED_QUOTA = Number.MAX_SAFE_INTEGER + +export class ModelCallConflictError extends Error { + constructor(message = 'Model call operation is bound to different identity or evidence') { + super(message) + this.name = 'ModelCallConflictError' + } +} + +export class ModelCallStateError extends Error { + constructor(message: string) { + super(message) + this.name = 'ModelCallStateError' + } +} + +export class ModelCallCapacityError extends Error { + constructor(message = 'Model call operation ledger capacity reached') { + super(message) + this.name = 'ModelCallCapacityError' + } +} + +export type ModelCallOperationStoreOptions = { + now?: () => number + maximumRecords?: number + maximumAuthorities?: number +} + +/** + * Durable Main-owned model-call authority. Only identities, policy digests, + * token reservations, bounded provider metadata, and state transitions are + * persisted. Provider endpoints, credentials, requests, and responses are not. + */ +export class ModelCallOperationStore { + readonly #database: DatabaseSync + readonly #now: () => number + readonly #maximumRecords: number + readonly #maximumAuthorities: number + #closed = false + readonly #statements: { + get: StatementSync + getAuthority: StatementSync + insert: StatementSync + insertAuthority: StatementSync + advanceAuthority: StatementSync + claim: StatementSync + markAuthorityDispatched: StatementSync + terminal: StatementSync + markAuthorityTerminal: StatementSync + deliver: StatementSync + markAuthorityDelivered: StatementSync + unresolved: StatementSync + count: StatementSync + countAuthorities: StatementSync + getPromptHighWater: StatementSync + advancePromptHighWater: StatementSync + deleteAuthority: StatementSync + pruneTerminal: StatementSync + pruneTerminalOlderThan: StatementSync + } + + constructor(path: string, options: ModelCallOperationStoreOptions = {}) { + this.#maximumRecords = maximumRecordsSchema.parse( + options.maximumRecords ?? 10_000 + ) + this.#maximumAuthorities = maximumRecordsSchema.parse( + options.maximumAuthorities ?? + Math.max(10_000, this.#maximumRecords) + ) + this.#now = options.now ?? Date.now + const database = openPrivateSqliteDatabase(path) + try { + database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS model_prompt_authorities ( + binding_id TEXT NOT NULL CHECK (length(binding_id) BETWEEN 1 AND 128), + prompt_operation_id TEXT NOT NULL + CHECK (length(prompt_operation_id) BETWEEN 1 AND 128), + model_profile_digest TEXT NOT NULL CHECK ( + length(model_profile_digest) = 71 AND + substr(model_profile_digest, 1, 7) = 'sha256:' + ), + provider TEXT NOT NULL CHECK (length(provider) BETWEEN 1 AND 256), + profile TEXT NOT NULL CHECK (length(profile) BETWEEN 1 AND 256), + model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 256), + protocol TEXT NOT NULL CHECK (length(protocol) BETWEEN 1 AND 256), + maximum_model_calls INTEGER NOT NULL CHECK (maximum_model_calls > 0), + maximum_total_output_tokens INTEGER NOT NULL + CHECK (maximum_total_output_tokens > 0), + reserved_output_tokens INTEGER NOT NULL CHECK ( + reserved_output_tokens > 0 AND + reserved_output_tokens <= maximum_total_output_tokens + ), + high_water_round INTEGER NOT NULL CHECK (high_water_round >= 0), + latest_call_operation_id TEXT NOT NULL + CHECK (length(latest_call_operation_id) BETWEEN 1 AND 128), + latest_status TEXT NOT NULL CHECK ( + latest_status IN ( + 'prepared', 'dispatched', 'completed', + 'failed-definitive', 'outcome-unknown' + ) + ), + latest_response_delivered INTEGER NOT NULL DEFAULT 0 CHECK ( + latest_response_delivered IN (0, 1) AND + (latest_response_delivered = 0 OR latest_status = 'completed') + ), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY (binding_id, prompt_operation_id) + ) STRICT; + CREATE TABLE IF NOT EXISTS model_call_operations ( + call_operation_id TEXT PRIMARY KEY + CHECK (length(call_operation_id) BETWEEN 1 AND 128), + binding_id TEXT NOT NULL CHECK (length(binding_id) BETWEEN 1 AND 128), + prompt_operation_id TEXT NOT NULL + CHECK (length(prompt_operation_id) BETWEEN 1 AND 128), + round_index INTEGER NOT NULL CHECK (round_index >= 0), + identity_json TEXT NOT NULL + CHECK (length(identity_json) BETWEEN 1 AND 8192) + CHECK (json_valid(identity_json)), + request_digest TEXT NOT NULL CHECK ( + length(request_digest) = 71 AND + substr(request_digest, 1, 7) = 'sha256:' + ), + model_profile_digest TEXT NOT NULL CHECK ( + length(model_profile_digest) = 71 AND + substr(model_profile_digest, 1, 7) = 'sha256:' + ), + maximum_model_calls INTEGER NOT NULL CHECK (maximum_model_calls > 0), + maximum_total_output_tokens INTEGER NOT NULL + CHECK (maximum_total_output_tokens > 0), + maximum_output_tokens INTEGER NOT NULL CHECK ( + maximum_output_tokens > 0 AND + maximum_output_tokens <= maximum_total_output_tokens + ), + status TEXT NOT NULL CHECK ( + status IN ( + 'prepared', 'dispatched', 'completed', + 'failed-definitive', 'outcome-unknown' + ) + ), + dispatch_metadata_json TEXT CHECK ( + dispatch_metadata_json IS NULL OR ( + length(dispatch_metadata_json) BETWEEN 2 AND 4096 AND + json_valid(dispatch_metadata_json) + ) + ), + terminal_evidence_json TEXT CHECK ( + terminal_evidence_json IS NULL OR ( + length(terminal_evidence_json) BETWEEN 2 AND 8192 AND + json_valid(terminal_evidence_json) + ) + ), + prepared_at INTEGER NOT NULL CHECK (prepared_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= prepared_at), + dispatched_at INTEGER, + terminal_at INTEGER, + response_delivered_at INTEGER, + UNIQUE (binding_id, prompt_operation_id, round_index), + CHECK (dispatched_at IS NULL OR dispatched_at >= prepared_at), + CHECK (terminal_at IS NULL OR ( + dispatched_at IS NOT NULL AND terminal_at >= dispatched_at + )), + CHECK (response_delivered_at IS NULL OR ( + status = 'completed' AND terminal_at IS NOT NULL AND + response_delivered_at >= terminal_at + )), + CHECK (dispatched_at IS NULL OR updated_at >= dispatched_at), + CHECK (terminal_at IS NULL OR updated_at >= terminal_at), + CHECK ( + response_delivered_at IS NULL OR updated_at >= response_delivered_at + ), + CHECK ( + (status = 'prepared' AND dispatch_metadata_json IS NULL AND + terminal_evidence_json IS NULL AND dispatched_at IS NULL AND + terminal_at IS NULL AND response_delivered_at IS NULL) OR + (status = 'dispatched' AND dispatch_metadata_json IS NOT NULL AND + terminal_evidence_json IS NULL AND dispatched_at IS NOT NULL AND + terminal_at IS NULL AND response_delivered_at IS NULL) OR + (status IN ('completed', 'failed-definitive', 'outcome-unknown') AND + dispatch_metadata_json IS NOT NULL AND + terminal_evidence_json IS NOT NULL AND + dispatched_at IS NOT NULL AND terminal_at IS NOT NULL) + ) + ) STRICT; + CREATE INDEX IF NOT EXISTS model_call_operations_status_cursor + ON model_call_operations(status, updated_at, call_operation_id); + CREATE INDEX IF NOT EXISTS model_call_operations_delivery_cursor + ON model_call_operations( + response_delivered_at, updated_at, call_operation_id + ); + CREATE TABLE IF NOT EXISTS model_binding_prompt_high_water ( + binding_id TEXT PRIMARY KEY + CHECK (length(binding_id) BETWEEN 1 AND 128), + finalized_prompt_sequence INTEGER NOT NULL + CHECK (finalized_prompt_sequence >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + ) STRICT; + `) + this.#statements = { + get: database.prepare( + 'SELECT * FROM model_call_operations WHERE call_operation_id = ?' + ), + getAuthority: database.prepare( + `SELECT * FROM model_prompt_authorities + WHERE binding_id = ? AND prompt_operation_id = ?` + ), + insert: database.prepare( + `INSERT INTO model_call_operations ( + call_operation_id, binding_id, prompt_operation_id, round_index, + identity_json, request_digest, model_profile_digest, + maximum_model_calls, maximum_total_output_tokens, + maximum_output_tokens, status, prepared_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?)` + ), + insertAuthority: database.prepare( + `INSERT INTO model_prompt_authorities ( + binding_id, prompt_operation_id, model_profile_digest, provider, + profile, model, protocol, maximum_model_calls, + maximum_total_output_tokens, reserved_output_tokens, + high_water_round, latest_call_operation_id, latest_status, + latest_response_delivered, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'prepared', 0, ?)` + ), + advanceAuthority: database.prepare( + `UPDATE model_prompt_authorities + SET high_water_round = ?, latest_call_operation_id = ?, + latest_status = 'prepared', latest_response_delivered = 0, + updated_at = ? + WHERE binding_id = ? AND prompt_operation_id = ? + AND high_water_round = ? AND latest_status = 'completed' + AND latest_response_delivered = 1` + ), + claim: database.prepare( + `UPDATE model_call_operations + SET status = 'dispatched', dispatch_metadata_json = ?, + dispatched_at = ?, updated_at = ? + WHERE call_operation_id = ? AND status = 'prepared'` + ), + markAuthorityDispatched: database.prepare( + `UPDATE model_prompt_authorities + SET latest_status = 'dispatched', updated_at = ? + WHERE binding_id = ? AND prompt_operation_id = ? + AND latest_call_operation_id = ? AND latest_status = 'prepared'` + ), + terminal: database.prepare( + `UPDATE model_call_operations + SET status = ?, terminal_evidence_json = ?, + terminal_at = ?, updated_at = ? + WHERE call_operation_id = ? AND status = 'dispatched'` + ), + markAuthorityTerminal: database.prepare( + `UPDATE model_prompt_authorities + SET latest_status = ?, latest_response_delivered = 0, updated_at = ? + WHERE binding_id = ? AND prompt_operation_id = ? + AND latest_call_operation_id = ? AND latest_status = 'dispatched'` + ), + deliver: database.prepare( + `UPDATE model_call_operations + SET response_delivered_at = ?, updated_at = ? + WHERE call_operation_id = ? AND status = 'completed' + AND response_delivered_at IS NULL` + ), + markAuthorityDelivered: database.prepare( + `UPDATE model_prompt_authorities + SET latest_response_delivered = 1, updated_at = ? + WHERE binding_id = ? AND prompt_operation_id = ? + AND latest_call_operation_id = ? AND latest_status = 'completed' + AND latest_response_delivered = 0` + ), + unresolved: database.prepare( + `SELECT * FROM model_call_operations + WHERE status IN ('prepared', 'dispatched', 'outcome-unknown') + ORDER BY updated_at ASC, call_operation_id ASC LIMIT ?` + ), + count: database.prepare( + 'SELECT COUNT(*) AS count FROM model_call_operations' + ), + countAuthorities: database.prepare( + 'SELECT COUNT(*) AS count FROM model_prompt_authorities' + ), + getPromptHighWater: database.prepare( + `SELECT finalized_prompt_sequence + FROM model_binding_prompt_high_water WHERE binding_id = ?` + ), + advancePromptHighWater: database.prepare( + `INSERT INTO model_binding_prompt_high_water ( + binding_id, finalized_prompt_sequence, updated_at + ) VALUES (?, ?, ?) + ON CONFLICT(binding_id) DO UPDATE SET + finalized_prompt_sequence = excluded.finalized_prompt_sequence, + updated_at = excluded.updated_at + WHERE model_binding_prompt_high_water.finalized_prompt_sequence = + excluded.finalized_prompt_sequence - 1` + ), + deleteAuthority: database.prepare( + `DELETE FROM model_prompt_authorities + WHERE binding_id = ? AND prompt_operation_id = ?` + ), + pruneTerminal: database.prepare( + `DELETE FROM model_call_operations + WHERE call_operation_id IN ( + SELECT call_operation_id FROM model_call_operations + WHERE status = 'failed-definitive' + OR (status = 'completed' AND response_delivered_at IS NOT NULL) + ORDER BY terminal_at ASC, call_operation_id ASC LIMIT ? + )` + ), + pruneTerminalOlderThan: database.prepare( + `DELETE FROM model_call_operations + WHERE call_operation_id IN ( + SELECT call_operation_id FROM model_call_operations + WHERE (status = 'failed-definitive' + OR (status = 'completed' AND response_delivered_at IS NOT NULL)) + AND terminal_at < ? + ORDER BY terminal_at ASC, call_operation_id ASC LIMIT ? + )` + ) + } + this.#database = database + } catch (error) { + try { + database.close() + } catch { + // Preserve the initialization error while still attempting cleanup. + } + throw error + } + } + + prepare(input: PrepareModelCall): { + created: boolean + record: ModelCallRecord + } { + this.#assertOpen() + const parsed = prepareModelCallSchema.parse(input) + const identityJson = JSON.stringify(parsed.identity) + return this.#transaction(() => { + const existing = this.#getRow(parsed.identity.callOperationId) + if (existing) { + if (!samePreparation(existing, parsed, identityJson)) { + throw new ModelCallConflictError() + } + return { created: false, record: rowToRecord(existing) } + } + + const authority = this.#getAuthority( + parsed.identity.bindingId, + parsed.identity.promptOperationId + ) + const finalizedPromptSequence = this.#getPromptHighWater( + parsed.identity.bindingId + ) + if (authority) { + assertPinnedAuthority(authority, parsed) + if (parsed.identity.roundIndex <= authority.high_water_round) { + throw new ModelCallConflictError( + 'Model call round was already reserved with different evidence' + ) + } + if (parsed.identity.roundIndex !== authority.high_water_round + 1) { + throw new ModelCallStateError('Model call rounds cannot be skipped') + } + if ( + authority.latest_status !== 'completed' || + authority.latest_response_delivered !== 1 + ) { + throw new ModelCallStateError( + 'Previous model response has not been completed and delivered' + ) + } + } else if (parsed.identity.roundIndex !== 0) { + throw new ModelCallStateError('First model call round must be zero') + } + + if (!authority) { + const expectedPromptSequence = + finalizedPromptSequence === undefined + ? 0 + : finalizedPromptSequence + 1 + if ( + parsed.identity.promptSequence !== expectedPromptSequence + ) { + throw new ModelCallConflictError( + parsed.identity.promptSequence <= + (finalizedPromptSequence ?? -1) + ? 'Model prompt sequence was already finalized' + : 'Model prompt sequences cannot be skipped' + ) + } + } + + if (!authority) { + const authorityCount = this.#statements.countAuthorities.get() as + | { count: number } + | undefined + if ( + !authorityCount || + !Number.isSafeInteger(authorityCount.count) || + authorityCount.count < 0 + ) { + throw new ModelCallStateError( + 'Model prompt authority count is corrupt' + ) + } + if (authorityCount.count >= this.#maximumAuthorities) { + throw new ModelCallCapacityError( + 'Model prompt authority capacity reached' + ) + } + } + this.#ensureCapacityForPrepare() + const now = this.#timestamp() + this.#statements.insert.run( + parsed.identity.callOperationId, + parsed.identity.bindingId, + parsed.identity.promptOperationId, + parsed.identity.roundIndex, + identityJson, + parsed.requestDigest, + parsed.modelProfileDigest, + LEGACY_UNBOUNDED_QUOTA, + LEGACY_UNBOUNDED_QUOTA, + LEGACY_UNBOUNDED_QUOTA, + now, + now + ) + if (authority) { + const advanced = this.#statements.advanceAuthority.run( + parsed.identity.roundIndex, + parsed.identity.callOperationId, + now, + parsed.identity.bindingId, + parsed.identity.promptOperationId, + authority.high_water_round + ) + if (Number(advanced.changes) !== 1) { + throw new ModelCallStateError( + 'Concurrent model call round reservation rejected' + ) + } + } else { + this.#statements.insertAuthority.run( + parsed.identity.bindingId, + parsed.identity.promptOperationId, + parsed.modelProfileDigest, + parsed.identity.provider, + parsed.identity.profile, + parsed.identity.model, + parsed.identity.protocol, + LEGACY_UNBOUNDED_QUOTA, + LEGACY_UNBOUNDED_QUOTA, + LEGACY_UNBOUNDED_QUOTA, + parsed.identity.roundIndex, + parsed.identity.callOperationId, + now + ) + } + return { + created: true, + record: rowToRecord(this.#requireRow(parsed.identity.callOperationId)) + } + }) + } + + get(callOperationId: string): ModelCallRecord | undefined { + this.#assertOpen() + const id = modelCallIdentitySchema.shape.callOperationId.parse(callOperationId) + const row = this.#getRow(id) + return row ? rowToRecord(row) : undefined + } + + beginDispatch( + callOperationId: string, + metadata: ModelCallDispatchMetadata = {} + ): { permitted: boolean; record: ModelCallRecord } { + this.#assertOpen() + const id = modelCallIdentitySchema.shape.callOperationId.parse(callOperationId) + const metadataJson = JSON.stringify( + modelCallDispatchMetadataSchema.parse(metadata) + ) + return this.#transaction(() => { + const existing = this.#requireRow(id) + if (modelCallStatusSchema.parse(existing.status) !== 'prepared') { + return { permitted: false, record: rowToRecord(existing) } + } + const now = this.#timestamp() + if ( + Number(this.#statements.claim.run(metadataJson, now, now, id).changes) !== + 1 + ) { + return { permitted: false, record: rowToRecord(this.#requireRow(id)) } + } + if ( + Number( + this.#statements.markAuthorityDispatched.run( + now, + existing.binding_id, + existing.prompt_operation_id, + id + ).changes + ) !== 1 + ) { + throw new ModelCallStateError('Prompt dispatch authority is inconsistent') + } + return { permitted: true, record: rowToRecord(this.#requireRow(id)) } + }) + } + + complete( + callOperationId: string, + evidence: CompleteModelCallEvidence + ): ModelCallRecord { + return this.#finish( + callOperationId, + 'completed', + completeModelCallEvidenceSchema.parse(evidence) + ) + } + + failDefinitive( + callOperationId: string, + evidence: FailModelCallEvidence + ): ModelCallRecord { + return this.#finish( + callOperationId, + 'failed-definitive', + failModelCallEvidenceSchema.parse(evidence) + ) + } + + markOutcomeUnknown( + callOperationId: string, + evidence: UnknownModelCallEvidence + ): ModelCallRecord { + return this.#finish( + callOperationId, + 'outcome-unknown', + unknownModelCallEvidenceSchema.parse(evidence) + ) + } + + markResponseDelivered(callOperationId: string): ModelCallRecord { + this.#assertOpen() + const id = modelCallIdentitySchema.shape.callOperationId.parse(callOperationId) + return this.#transaction(() => { + const existing = this.#requireRow(id) + if (existing.status !== 'completed') { + throw new ModelCallStateError( + 'Only completed model responses can be marked delivered' + ) + } + if (existing.response_delivered_at !== null) { + return rowToRecord(existing) + } + const now = this.#timestamp() + if ( + Number(this.#statements.deliver.run(now, now, id).changes) !== 1 || + Number( + this.#statements.markAuthorityDelivered.run( + now, + existing.binding_id, + existing.prompt_operation_id, + id + ).changes + ) !== 1 + ) { + throw new ModelCallStateError( + 'Concurrent response delivery transition rejected' + ) + } + return rowToRecord(this.#requireRow(id)) + }) + } + + finalizePrompt(input: { + bindingId: string + promptOperationId: string + promptSequence: number + }): void { + this.#assertOpen() + const bindingId = + modelCallIdentitySchema.shape.bindingId.parse(input.bindingId) + const promptOperationId = + modelCallIdentitySchema.shape.promptOperationId.parse( + input.promptOperationId + ) + const promptSequence = + modelCallIdentitySchema.shape.promptSequence.parse( + input.promptSequence + ) + this.#transaction(() => { + const finalized = this.#getPromptHighWater(bindingId) + if (finalized !== undefined) { + if (finalized === promptSequence) { + return + } + if (finalized > promptSequence) { + throw new ModelCallConflictError( + 'Model prompt sequence was already finalized' + ) + } + if (promptSequence !== finalized + 1) { + throw new ModelCallStateError( + 'Model prompt sequences cannot be finalized out of order' + ) + } + } else if (promptSequence !== 0) { + throw new ModelCallStateError( + 'First finalized model prompt sequence must be zero' + ) + } + + const authority = this.#getAuthority( + bindingId, + promptOperationId + ) + if (authority !== undefined) { + if ( + authority.latest_status !== 'completed' || + authority.latest_response_delivered !== 1 + ) { + throw new ModelCallStateError( + 'Only a fully delivered model prompt can be finalized' + ) + } + } + const now = this.#timestamp() + if ( + Number( + this.#statements.advancePromptHighWater.run( + bindingId, + promptSequence, + now + ).changes + ) !== 1 + ) { + throw new ModelCallStateError( + 'Concurrent model prompt finalization rejected' + ) + } + if ( + authority !== undefined && + Number( + this.#statements.deleteAuthority.run( + bindingId, + promptOperationId + ).changes + ) !== 1 + ) { + throw new ModelCallStateError( + 'Model prompt authority finalization is inconsistent' + ) + } + }) + } + + list(options: ModelCallListOptions = {}): ModelCallOperationPage { + this.#assertOpen() + const parsed = modelCallListOptionsSchema.parse(options) + const clauses: string[] = [] + const parameters: Array = [] + if (parsed.statuses) { + clauses.push( + `status IN (${parsed.statuses.map(() => '?').join(', ')})` + ) + parameters.push(...parsed.statuses) + } + if (parsed.delivery === 'pending-response') { + clauses.push("status = 'completed' AND response_delivered_at IS NULL") + } else if (parsed.delivery === 'delivered') { + clauses.push("status = 'completed' AND response_delivered_at IS NOT NULL") + } + if (parsed.cursor) { + clauses.push( + '(updated_at > ? OR (updated_at = ? AND call_operation_id > ?))' + ) + parameters.push( + parsed.cursor.updatedAt, + parsed.cursor.updatedAt, + parsed.cursor.callOperationId + ) + } + const rows = this.#database + .prepare( + `SELECT * FROM model_call_operations + ${clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''} + ORDER BY updated_at ASC, call_operation_id ASC LIMIT ?` + ) + .all(...parameters, parsed.limit + 1) as unknown as ModelCallRow[] + return makePage(rows, parsed.limit) + } + + listStartupRecords( + options: Pick = {} + ): ModelCallOperationPage { + this.#assertOpen() + const parsed = modelCallListOptionsSchema + .pick({ cursor: true, limit: true }) + .parse(options) + const parameters: Array = [] + let cursorClause = '' + if (parsed.cursor) { + cursorClause = + 'AND (updated_at > ? OR (updated_at = ? AND call_operation_id > ?))' + parameters.push( + parsed.cursor.updatedAt, + parsed.cursor.updatedAt, + parsed.cursor.callOperationId + ) + } + const rows = this.#database + .prepare( + `SELECT * FROM model_call_operations + WHERE ( + status = 'prepared' OR status = 'dispatched' OR + status = 'outcome-unknown' OR + (status = 'completed' AND response_delivered_at IS NULL) + ) ${cursorClause} + ORDER BY updated_at ASC, call_operation_id ASC LIMIT ?` + ) + .all(...parameters, parsed.limit + 1) as unknown as ModelCallRow[] + return makePage(rows, parsed.limit) + } + + listUnresolved(limit = 100): ModelCallRecord[] { + this.#assertOpen() + const parsedLimit = modelCallListLimitSchema.parse(limit) + return ( + this.#statements.unresolved.all(parsedLimit) as unknown as ModelCallRow[] + ).map(rowToRecord) + } + + pruneTerminal(limit: number, olderThan?: number): number { + this.#assertOpen() + const parsedLimit = pruneLimitSchema.parse(limit) + const parsedOlderThan = + olderThan === undefined ? undefined : timestampSchema.parse(olderThan) + return this.#transaction(() => { + const result = + parsedOlderThan === undefined + ? this.#statements.pruneTerminal.run(parsedLimit) + : this.#statements.pruneTerminalOlderThan.run( + parsedOlderThan, + parsedLimit + ) + return Number(result.changes) + }) + } + + close(): void { + if (!this.#closed) { + this.#database.close() + this.#closed = true + } + } + + dispose(): void { + this.close() + } + + #finish( + callOperationId: string, + status: Exclude, + evidence: + | CompleteModelCallEvidence + | FailModelCallEvidence + | UnknownModelCallEvidence + ): ModelCallRecord { + this.#assertOpen() + const id = modelCallIdentitySchema.shape.callOperationId.parse(callOperationId) + if (evidence.status !== status) { + throw new ModelCallStateError('Terminal evidence status does not match') + } + const evidenceJson = JSON.stringify(evidence) + return this.#transaction(() => { + const existing = this.#requireRow(id) + const currentStatus = modelCallStatusSchema.parse(existing.status) + if (currentStatus === status) { + if (existing.terminal_evidence_json !== evidenceJson) { + throw new ModelCallConflictError('Terminal evidence does not match') + } + return rowToRecord(existing) + } + if (currentStatus !== 'dispatched') { + throw new ModelCallStateError( + `Cannot transition model call from ${currentStatus} to ${status}` + ) + } + const now = this.#timestamp() + if ( + Number( + this.#statements.terminal.run( + status, + evidenceJson, + now, + now, + id + ).changes + ) !== 1 || + Number( + this.#statements.markAuthorityTerminal.run( + status, + now, + existing.binding_id, + existing.prompt_operation_id, + id + ).changes + ) !== 1 + ) { + throw new ModelCallStateError( + 'Concurrent model call transition rejected' + ) + } + return rowToRecord(this.#requireRow(id)) + }) + } + + #getRow(callOperationId: string): ModelCallRow | undefined { + return this.#statements.get.get(callOperationId) as + | ModelCallRow + | undefined + } + + #requireRow(callOperationId: string): ModelCallRow { + const row = this.#getRow(callOperationId) + if (!row) { + throw new ModelCallStateError('Model call operation does not exist') + } + return row + } + + #getAuthority( + bindingId: string, + promptOperationId: string + ): PromptAuthorityRow | undefined { + return this.#statements.getAuthority.get( + bindingId, + promptOperationId + ) as PromptAuthorityRow | undefined + } + + #getPromptHighWater(bindingId: string): number | undefined { + const row = this.#statements.getPromptHighWater.get( + bindingId + ) as PromptHighWaterRow | undefined + if (row === undefined) { + return undefined + } + if ( + !Number.isSafeInteger(row.finalized_prompt_sequence) || + row.finalized_prompt_sequence < 0 + ) { + throw new ModelCallStateError( + 'Model prompt sequence high-water is corrupt' + ) + } + return row.finalized_prompt_sequence + } + + #timestamp(): number { + return timestampSchema.parse(this.#now()) + } + + #ensureCapacityForPrepare(): void { + const row = this.#statements.count.get() as { count: number } | undefined + if (!row || !Number.isSafeInteger(row.count) || row.count < 0) { + throw new ModelCallStateError('Model call operation count is corrupt') + } + const recordsToRemove = row.count - this.#maximumRecords + 1 + if (recordsToRemove <= 0) { + return + } + const removed = Number( + this.#statements.pruneTerminal.run(recordsToRemove).changes + ) + if (removed !== recordsToRemove) { + throw new ModelCallCapacityError() + } + } + + #transaction(action: () => T): T { + this.#assertOpen() + this.#database.exec('BEGIN IMMEDIATE') + try { + const result = action() + this.#database.exec('COMMIT') + return result + } catch (error) { + try { + this.#database.exec('ROLLBACK') + } catch { + // Preserve the action or commit error that caused the rollback. + } + throw error + } + } + + #assertOpen(): void { + if (this.#closed) { + throw new ModelCallStateError('Model call operation store is closed') + } + } +} + +const timestampSchema = modelCallRecordSchema.shape.updatedAt +const modelCallListLimitSchema = modelCallRecordSchema.shape.preparedAt + .positive() + .max(1_000) +const maximumRecordsSchema = modelCallRecordSchema.shape.preparedAt + .positive() + .max(1_000_000) +const pruneLimitSchema = modelCallRecordSchema.shape.preparedAt + .positive() + .max(1_000) + +function samePreparation( + row: ModelCallRow, + input: PrepareModelCall, + identityJson: string +): boolean { + return ( + row.identity_json === identityJson && + row.request_digest === input.requestDigest && + row.model_profile_digest === input.modelProfileDigest + ) +} + +function assertPinnedAuthority( + authority: PromptAuthorityRow, + input: PrepareModelCall +): void { + const identity = input.identity + if ( + authority.model_profile_digest !== input.modelProfileDigest || + authority.provider !== identity.provider || + authority.profile !== identity.profile || + authority.model !== identity.model || + authority.protocol !== identity.protocol + ) { + throw new ModelCallConflictError( + 'Prompt model profile or budget authority cannot change' + ) + } +} + +function makePage(rows: ModelCallRow[], limit: number): ModelCallOperationPage { + const hasMore = rows.length > limit + const pageRows = hasMore ? rows.slice(0, limit) : rows + const records = pageRows.map(rowToRecord) + const last = pageRows.at(-1) + return { + records, + ...(hasMore && last + ? { + nextCursor: { + updatedAt: last.updated_at, + callOperationId: last.call_operation_id + } + } + : {}) + } +} + +function rowToRecord(row: ModelCallRow): ModelCallRecord { + try { + return modelCallRecordSchema.parse({ + identity: decodeJson(row.identity_json, 'identity'), + requestDigest: row.request_digest, + modelProfileDigest: row.model_profile_digest, + status: row.status, + ...(row.dispatch_metadata_json === null + ? {} + : { + dispatchMetadata: decodeJson( + row.dispatch_metadata_json, + 'dispatch metadata' + ) + }), + ...(row.terminal_evidence_json === null + ? {} + : { + terminalEvidence: decodeJson( + row.terminal_evidence_json, + 'terminal evidence' + ) + }), + preparedAt: row.prepared_at, + updatedAt: row.updated_at, + ...(row.dispatched_at === null + ? {} + : { dispatchedAt: row.dispatched_at }), + ...(row.terminal_at === null ? {} : { terminalAt: row.terminal_at }), + ...(row.response_delivered_at === null + ? {} + : { responseDeliveredAt: row.response_delivered_at }) + }) + } catch { + throw new ModelCallStateError('Stored model call record is corrupt') + } +} + +function decodeJson(value: string, label: string): unknown { + try { + return JSON.parse(value) as unknown + } catch { + throw new ModelCallStateError(`Stored model call ${label} is corrupt`) + } +} diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 1109e03b..03d92cd1 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -621,7 +621,7 @@ describe('ModelAgentRuntime', () => { const summaryBody = JSON.parse( fetcher.mock.calls[0]![1]!.body as string ) as { max_tokens: number; system: string; messages: unknown[] } - expect(summaryBody.max_tokens).toBe(8_192) + expect(summaryBody.max_tokens).toBe(32_000) expect(summaryBody.system).toContain('Summarize earlier history.') expect(JSON.stringify(summaryBody.messages)).toContain('old-user-') expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-') @@ -1847,13 +1847,15 @@ describe('ModelAgentRuntime', () => { }) expect(JSON.parse(init?.body as string)).toMatchObject({ model: 'gpt-5', - max_output_tokens: 4096, stream: true, instructions: expect.stringContaining('GoodBuddy'), input: [ expect.objectContaining({ role: 'user', content: '你好' }) ] }) + expect(JSON.parse(init?.body as string)).not.toHaveProperty( + 'max_output_tokens' + ) expect(events).toContainEqual( expect.objectContaining({ type: 'reasoning', @@ -4418,8 +4420,9 @@ describe('ModelAgentRuntime', () => { expect(fetcher).toHaveBeenCalledOnce() }) - it('terminates repeated identical tool rounds without exhausting hard limits', async () => { + it('allows repeated tool rounds until the run is cancelled', async () => { let callId = 0 + const controller = new AbortController() const fetcher = vi.fn(async () => { callId += 1 return Response.json({ @@ -4443,7 +4446,16 @@ describe('ModelAgentRuntime', () => { ] }) }) - const toolProvider = createToolProvider() + let completedCalls = 0 + const toolProvider = createToolProvider({ + callTool: vi.fn(async () => { + completedCalls += 1 + if (completedCalls === 5) { + controller.abort() + } + return createTextToolResult('same result') + }) + }) const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', @@ -4460,16 +4472,149 @@ describe('ModelAgentRuntime', () => { prompt: 'repeat', workMode: 'execute' }, - new AbortController().signal, + controller.signal, async () => 'once' )) { void _event } } - await expect(consume()).rejects.toThrow('没有取得进展') - expect(fetcher).toHaveBeenCalledTimes(3) - expect(toolProvider.callTool).toHaveBeenCalledTimes(2) + await expect(consume()).rejects.toThrow() + expect(fetcher).toHaveBeenCalledTimes(5) + expect(toolProvider.callTool).toHaveBeenCalledTimes(5) + }) + + it('allows more than the legacy 40 tool calls in one model round', async () => { + const toolCalls = Array.from({ length: 45 }, (_, index) => ({ + id: `call-bulk-${index}`, + type: 'function', + function: { + name: 'workspace_read_text', + arguments: JSON.stringify({ path: `file-${index}.txt` }) + } + })) + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: null, + tool_calls: toolCalls + } + } + ] + }) + ) + .mockResolvedValueOnce( + Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: 'all tools completed' + } + } + ] + }) + ) + const toolProvider = createToolProvider() + const runtime = new ModelAgentRuntime({ + baseUrl: 'http://127.0.0.1:11434/v1', + model: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none', + fetcher, + toolProvider + }) + const events = [] + + for await (const event of runtime.run( + { + requestId: crypto.randomUUID(), + conversationId: 'conversation-bulk-tools', + prompt: 'run all tools', + workMode: 'execute' + }, + new AbortController().signal, + async () => 'once' + )) { + events.push(event) + } + + expect(toolProvider.callTool).toHaveBeenCalledTimes(45) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(events.at(-1)).toMatchObject({ type: 'done' }) + }) + + it('continues beyond the legacy 24 model tool rounds', async () => { + let modelRound = 0 + const fetcher = vi.fn(async () => { + modelRound += 1 + if (modelRound <= 25) { + return Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: `call-round-${modelRound}`, + type: 'function', + function: { + name: 'workspace_read_text', + arguments: JSON.stringify({ + path: `round-${modelRound}.txt` + }) + } + } + ] + } + } + ] + }) + } + return Response.json({ + choices: [ + { + message: { + role: 'assistant', + content: 'completed after 25 rounds' + } + } + ] + }) + }) + const toolProvider = createToolProvider() + const runtime = new ModelAgentRuntime({ + baseUrl: 'http://127.0.0.1:11434/v1', + model: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none', + fetcher, + toolProvider + }) + const events = [] + + for await (const event of runtime.run( + { + requestId: crypto.randomUUID(), + conversationId: 'conversation-many-rounds', + prompt: 'continue until done', + workMode: 'execute' + }, + new AbortController().signal, + async () => 'once' + )) { + events.push(event) + } + + expect(toolProvider.callTool).toHaveBeenCalledTimes(25) + expect(fetcher).toHaveBeenCalledTimes(26) + expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('releases provider state for only the requested conversation', async () => { diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 5be23503..19ba84f7 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -10,6 +10,7 @@ import type { } from '../../shared/contracts' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' +import type { WorkspaceAccess } from '../workspace' import { scopedReadToolNames, type KnowledgeMcpGateway @@ -173,13 +174,7 @@ const maxChatResponseBytes = 2 * 1024 * 1024 const maxStreamBlockBytes = 1024 * 1024 const maxToolArgumentBytes = 128 * 1024 const maxToolContextBytes = 1024 * 1024 -const maxToolCallsPerRun = 40 -const maxToolRounds = 24 -const maxRepeatedIdenticalCalls = 3 -const maxIdenticalRoundsWithoutProgress = 2 const defaultModelRequestTimeoutMs = 10 * 60_000 -const defaultModelOutputTokens = 4_096 -const summaryModelOutputTokens = contextSummaryTokenBudget const noModelTools: ModelToolProviderLike = { listTools: async () => [], @@ -220,6 +215,7 @@ export type ModelRuntimeOptions = { imageGenerationQuality?: ImageGenerationQuality skillInstructions?: string defaultWorkspace?: string + workspaceAccess?: WorkspaceAccess mcpServers?: ResolvedMcpServer[] browserService?: BrowserToolService knowledgeGateway?: KnowledgeMcpGateway @@ -227,7 +223,6 @@ export type ModelRuntimeOptions = { toolProvider?: ModelToolProviderLike fetcher?: typeof fetch requestTimeoutMs?: number - maxOutputTokens?: number contextCompression?: { settings: ContextCompressionSettings contextWindowTokens?: number @@ -665,26 +660,6 @@ function parseToolArguments(value: unknown): Record { return parsed as Record } -function canonicalizeToolArguments(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeToolArguments) - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => [key, canonicalizeToolArguments(item)]) - ) - } - return value -} - -function getToolCallFingerprint(call: ModelToolCall): string { - return `${call.name}:${JSON.stringify( - canonicalizeToolArguments(call.arguments) - )}` -} - function validateToolResult(result: ModelToolResult): number { if ( !Array.isArray(result.parts) || @@ -1344,7 +1319,6 @@ async function* readAnthropicToolStream( let answer = '' let reasoning = '' let stopReason: unknown - let toolCallCount = 0 for await (const block of readBoundedSseBlocks(response)) { const parsed = parseSseData(block) @@ -1421,10 +1395,6 @@ async function* readAnthropicToolStream( } } } else if (content.type === 'tool_use') { - toolCallCount += 1 - if (toolCallCount > maxToolCallsPerRun) { - throw new Error('模型单轮工具调用超过安全限制') - } const identity = parseToolCallIdentity(content.id, content.name) next.kind = 'tool_use' next.initialInput = content.input @@ -1555,31 +1525,23 @@ export class ModelAgentRuntime implements AgentRuntime { private readonly fetcher: typeof fetch private readonly toolProvider: ModelToolProviderLike private readonly requestTimeoutMs: number - private readonly maxOutputTokens: number constructor(private readonly options: ModelRuntimeOptions) { this.fetcher = options.fetcher ?? fetch this.requestTimeoutMs = options.requestTimeoutMs ?? defaultModelRequestTimeoutMs - this.maxOutputTokens = - options.maxOutputTokens ?? defaultModelOutputTokens if ( !Number.isSafeInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 1 ) { throw new Error('模型接口请求超时设置无效') } - if ( - !Number.isSafeInteger(this.maxOutputTokens) || - this.maxOutputTokens < 1 || - this.maxOutputTokens > summaryModelOutputTokens - ) { - throw new Error('模型最大输出设置无效') - } this.toolProvider = options.toolProvider ?? new ModelToolProvider( - options.defaultWorkspace ?? process.cwd(), + options.workspaceAccess ?? + options.defaultWorkspace ?? + process.cwd(), options.mcpServers, options.browserService, options.knowledgeGateway, @@ -1831,6 +1793,14 @@ export class ModelAgentRuntime implements AgentRuntime { ) } + private get anthropicProtocolMaximumTokens(): number { + return Math.max( + this.options.contextCompression?.contextWindowTokens ?? + minimumModelContextWindowTokens, + minimumModelContextWindowTokens + ) + } + private getHardSafetyTriggerTokens(): number | undefined { const contextWindowTokens = this.options.contextCompression?.contextWindowTokens @@ -1842,8 +1812,7 @@ export class ModelAgentRuntime implements AgentRuntime { contextWindowTokens, minimumModelContextWindowTokens ) - - this.maxOutputTokens - - 2_048 + 12_000 ) } @@ -1915,8 +1884,7 @@ export class ModelAgentRuntime implements AgentRuntime { supportsImageInput: false, toolProvider: noModelTools, fetcher: this.fetcher, - requestTimeoutMs: this.requestTimeoutMs, - maxOutputTokens: summaryModelOutputTokens + requestTimeoutMs: this.requestTimeoutMs }) const summaryRequest: AgentExecutionRequest = { requestId: request.requestId, @@ -2543,7 +2511,6 @@ export class ModelAgentRuntime implements AgentRuntime { responses ? { model: this.options.model, - max_output_tokens: this.maxOutputTokens, stream: true, instructions: system, input: messages, @@ -2554,7 +2521,7 @@ export class ModelAgentRuntime implements AgentRuntime { : anthropic ? { model: this.options.model, - max_tokens: this.maxOutputTokens, + max_tokens: this.anthropicProtocolMaximumTokens, stream: true, system, messages, @@ -2564,7 +2531,6 @@ export class ModelAgentRuntime implements AgentRuntime { } : { model: this.options.model, - max_tokens: this.maxOutputTokens, stream: true, stream_options: { include_usage: true @@ -2690,8 +2656,7 @@ export class ModelAgentRuntime implements AgentRuntime { const index = toolDelta?.index if ( !Number.isSafeInteger(index) || - (index as number) < 0 || - (index as number) >= maxToolCallsPerRun + (index as number) < 0 ) { throw new Error( 'OpenAI 模型接口返回了无效流式工具调用序号' @@ -3011,14 +2976,9 @@ export class ModelAgentRuntime implements AgentRuntime { rounds: [], compressionCount: 0 } - const seenCallIds = new Set() - let totalToolCalls = 0 let answer = '' - const identicalCallCounts = new Map() - let previousRoundSignature: string | undefined - let identicalRoundsWithoutProgress = 0 - for (let round = 0; round < maxToolRounds; round += 1) { + for (let round = 0; ; round += 1) { signal.throwIfAborted() if (round > 0) { toolSnapshot = await loadToolSnapshot() @@ -3156,25 +3116,6 @@ export class ModelAgentRuntime implements AgentRuntime { } return } - const roundSignature = response.toolCalls - .map(getToolCallFingerprint) - .join('\n') - if (roundSignature === previousRoundSignature) { - identicalRoundsWithoutProgress += 1 - if ( - identicalRoundsWithoutProgress >= - maxIdenticalRoundsWithoutProgress - ) { - throw new Error('直连模型重复了相同工具调用且没有取得进展') - } - } else { - previousRoundSignature = roundSignature - identicalRoundsWithoutProgress = 0 - } - totalToolCalls += response.toolCalls.length - if (totalToolCalls > maxToolCallsPerRun) { - throw new Error('直连模型单次运行的工具调用超过 40 个') - } if (responses) { if (!response.responsesOutput) { throw new Error('OpenAI Responses 工具调用缺少 output') @@ -3205,19 +3146,13 @@ export class ModelAgentRuntime implements AgentRuntime { const responsesResults: Array> = [] const chatImageCarrierContent: Array> = [] let roundContextBytes = 0 + const roundCallIds = new Set() for (const call of response.toolCalls) { signal.throwIfAborted() - const callFingerprint = getToolCallFingerprint(call) - const identicalCallCount = - (identicalCallCounts.get(callFingerprint) ?? 0) + 1 - identicalCallCounts.set(callFingerprint, identicalCallCount) - if (identicalCallCount > maxRepeatedIdenticalCalls) { - throw new Error('直连模型重复请求了完全相同的工具调用') - } - if (seenCallIds.has(call.id)) { + if (roundCallIds.has(call.id)) { throw new Error('模型重复使用了工具调用 ID') } - seenCallIds.add(call.id) + roundCallIds.add(call.id) const tool = toolSnapshot.toolsByName.get(call.name) const displayName = tool?.displayName ?? call.name.slice(0, 128) const input = boundedToolDetail(call.arguments, 4_000) @@ -3421,7 +3356,6 @@ export class ModelAgentRuntime implements AgentRuntime { }) signal.throwIfAborted() } - throw new Error('直连模型工具调用轮次超过 24 轮') } async *run( @@ -3537,7 +3471,6 @@ export class ModelAgentRuntime implements AgentRuntime { responses ? { model: this.options.model, - max_output_tokens: this.maxOutputTokens, stream: true, instructions: system, input: messages @@ -3545,14 +3478,13 @@ export class ModelAgentRuntime implements AgentRuntime { : anthropic ? { model: this.options.model, - max_tokens: this.maxOutputTokens, + max_tokens: this.anthropicProtocolMaximumTokens, stream: true, system, messages } : { model: this.options.model, - max_tokens: this.maxOutputTokens, stream: true, stream_options: { include_usage: true diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index bd267969..6b1e18ad 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' +import type { WorkspaceAccess } from '../workspace' import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' const mocks = vi.hoisted(() => { @@ -201,6 +202,216 @@ describe('ModelToolProvider', () => { ).resolves.toBe('saved') }) + it('delegates workspace tools and disposal to WorkspaceAccess', async () => { + const readText = vi.fn(async () => ({ + path: 'remote.txt', + name: 'remote.txt', + content: 'remote content', + size: 14 + })) + const listDirectory = vi.fn(async () => ({ + path: '', + entries: [ + { name: 'remote.txt', path: 'remote.txt', type: 'file' as const } + ], + truncated: false + })) + const writeTextAtomic = vi.fn(async () => ({ + path: 'output.txt', + bytesWritten: 5 + })) + const dispose = vi.fn(async () => undefined) + const access = { + getIdentity: vi.fn(), + listDirectory, + stat: vi.fn(), + readText, + writeTextAtomic, + search: vi.fn(), + getChanges: vi.fn(), + dispose + } as unknown as WorkspaceAccess + const provider = new ModelToolProvider(access) + const signal = new AbortController().signal + + await expect( + provider.callTool( + 'workspace_read_text', + { path: 'remote.txt' }, + signal, + toolContext + ) + ).resolves.toEqual({ + parts: [{ type: 'text', text: 'remote content' }], + contextBytes: 14 + }) + await provider.callTool( + 'workspace_list_directory', + {}, + signal, + toolContext + ) + await provider.callTool( + 'workspace_write_text', + { path: 'output.txt', content: 'saved' }, + signal, + toolContext + ) + expect(readText).toHaveBeenCalledWith( + expect.objectContaining({ + path: 'remote.txt', + maximumBytes: 256 * 1024, + signal + }) + ) + expect(listDirectory).toHaveBeenCalledWith( + expect.objectContaining({ + path: '.', + maximumEntries: 200, + includeOther: true, + signal + }) + ) + expect(writeTextAtomic).toHaveBeenCalledWith({ + path: 'output.txt', + content: 'saved', + maximumBytes: 512 * 1024, + signal + }) + await provider.dispose() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('denies spoofed mutating tool calls in Ask before reaching sinks', async () => { + const readText = vi.fn(async () => ({ + path: 'remote.txt', + name: 'remote.txt', + content: 'remote content', + size: 14 + })) + const listDirectory = vi.fn(async () => ({ + path: '', + entries: [], + truncated: false + })) + const writeTextAtomic = vi.fn() + const access = { + getIdentity: vi.fn(), + listDirectory, + stat: vi.fn(), + readText, + writeTextAtomic, + search: vi.fn(), + getChanges: vi.fn(), + dispose: vi.fn(async () => undefined) + } as unknown as WorkspaceAccess + const browserService = createBrowserService() + const createMagicNote = vi.fn() + const callGoodBuddyConfigTool = vi.fn() + const gateway = { + createMagicNote, + callGoodBuddyConfigTool, + getAvailableToolNames: vi.fn(() => [ + 'note_create', + 'goodbuddy_config_apply' + ]) + } as unknown as KnowledgeMcpGateway + const provider = new ModelToolProvider( + access, + [createMcpServer()], + browserService, + gateway + ) + const signal = new AbortController().signal + const askContext = { + conversationId: 'spoofed-ask-tools', + workMode: 'ask', + knowledgeCapabilityToken: 'main-only-token' + } satisfies ModelToolCallContext + const denial = 'Ask 模式仅允许调用已声明的只读工具' + + for (const [name, argumentsValue] of [ + [ + 'workspace_write_text', + { path: 'output.txt', content: 'blocked' } + ], + ['browser_click', { ref: 'b_target' }], + ['note_create', { title: 'blocked' }], + [ + 'goodbuddy_config_apply', + { planId: '00000000-0000-4000-8000-000000000702' } + ], + ['mcp_spoofed_mutation', {}] + ] as const) { + await expect( + provider.callTool( + name, + argumentsValue, + signal, + askContext + ) + ).rejects.toThrow(denial) + } + + expect(writeTextAtomic).not.toHaveBeenCalled() + expect(browserService.click).not.toHaveBeenCalled() + expect(createMagicNote).not.toHaveBeenCalled() + expect(callGoodBuddyConfigTool).not.toHaveBeenCalled() + expect(mocks.Client).not.toHaveBeenCalled() + + const writeTool = { + name: 'workspace_write_text', + displayName: '写入工作区文本', + description: 'write', + inputSchema: {}, + source: 'builtin' + } as const + expect(() => + provider.getApproval( + writeTool, + { path: 'output.txt', content: 'blocked' }, + 'output.txt', + askContext + ) + ).toThrow(denial) + expect(() => + provider.getApproval( + { + ...writeTool, + name: 'browser_click', + displayName: '点击浏览器元素' + }, + { ref: 'b_target' }, + 'b_target', + askContext + ) + ).toThrow(denial) + expect(browserService.getOrigin).not.toHaveBeenCalled() + + await expect( + provider.callTool( + 'workspace_read_text', + { path: 'remote.txt' }, + signal, + askContext + ) + ).resolves.toMatchObject({ + parts: [{ type: 'text', text: 'remote content' }] + }) + await expect( + provider.callTool( + 'workspace_list_directory', + {}, + signal, + askContext + ) + ).resolves.toMatchObject({ + parts: [{ type: 'text', text: expect.any(String) }] + }) + expect(readText).toHaveBeenCalledOnce() + expect(listDirectory).toHaveBeenCalledOnce() + }) + it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => { const workspace = await createWorkspace() const search = vi.fn(async () => []) diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 8875f2cb..d0d8c023 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -1,18 +1,5 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { randomUUID } from 'node:crypto' -import { - lstat, - open, - rename, - realpath, - rm, - stat -} from 'node:fs/promises' -import { - dirname, - isAbsolute, - resolve -} from 'node:path' +import { isAbsolute } from 'node:path' import { isIP } from 'node:net' import { z } from 'zod' import { builtinModelTools } from '../../shared/builtin-model-tools' @@ -26,11 +13,9 @@ import { import type { ResolvedMcpServer } from '../capabilities/capability-service' import { createMcpTransport } from '../capabilities/mcp-client-transport' import { - getCanonicalWorkspace, - isPathInside, - listBoundedDirectoryEntries, - readBoundedUtf8File -} from '../workspace-file-access' + LocalWorkspaceAccess, + type WorkspaceAccess +} from '../workspace' import type { RuntimeApprovalRequest } from './runtime' import { BrowserModelTools, @@ -88,6 +73,11 @@ const goodbuddyConfigWriteToolNameSet = new Set( goodbuddyConfigWriteToolNames ) const scopedReadToolNameSet = new Set(scopedReadToolNames) +const builtinModelToolAccessByName = new Map( + builtinModelTools.map((tool) => [tool.name, tool.access]) +) +const ASK_TOOL_DENIAL_MESSAGE = + 'Ask 模式仅允许调用已声明的只读工具' const scopedToolJsonSchemas = new Map( [...scopedDataToolByName].map(([name, definition]) => { const schema = z.toJSONSchema( @@ -309,6 +299,23 @@ function createTextToolResult(text: string): ModelToolResult { } } +function assertToolAuthorizedForWorkMode( + name: string, + context: ModelToolCallContext +): void { + if (context.workMode !== 'ask') { + return + } + const access = + builtinModelToolAccessByName.get(name) ?? + scopedDataToolByName.get( + name as Parameters[0] + )?.access + if (access !== 'read') { + throw new Error(ASK_TOOL_DENIAL_MESSAGE) + } +} + function parseMcpImage( content: Record ): Extract { @@ -472,7 +479,6 @@ function normalizeMcpResult(result: unknown): ModelToolResult { } export class ModelToolProvider implements ModelToolProviderLike { - private canonicalWorkspace?: Promise private mcpConnections?: Promise private webSearchBindings?: Promise> private readonly clients = new Set() @@ -480,12 +486,19 @@ export class ModelToolProvider implements ModelToolProviderLike { private readonly webSearchClients = new Set() constructor( - private readonly workspace: string, + workspace: string | WorkspaceAccess, private readonly mcpServers: ResolvedMcpServer[] = [], private readonly browserService?: BrowserToolService, private readonly knowledgeGateway?: KnowledgeMcpGateway, private readonly webSearchEnabled = false - ) {} + ) { + this.workspaceAccess = + typeof workspace === 'string' + ? new LocalWorkspaceAccess(workspace) + : workspace + } + + private readonly workspaceAccess: WorkspaceAccess private getScopedTools( context: ModelToolCallContext @@ -607,71 +620,6 @@ export class ModelToolProvider implements ModelToolProviderLike { ] } - private async getWorkspace(): Promise { - this.canonicalWorkspace ??= getCanonicalWorkspace( - this.workspace, - '直连模型工作区不是目录' - ) - return this.canonicalWorkspace - } - - private async resolveExistingPath( - inputPath: string, - expected: 'file' | 'directory' - ): Promise { - const root = await this.getWorkspace() - const relativePath = workspacePathSchema.parse(inputPath) - const candidate = resolve(root, relativePath) - if (!isPathInside(root, candidate)) { - throw new Error('工具路径不能超出工作区') - } - const canonical = await realpath(candidate) - if (!isPathInside(root, canonical)) { - throw new Error('工具路径不能通过符号链接超出工作区') - } - const metadata = await stat(canonical) - if ( - (expected === 'file' && !metadata.isFile()) || - (expected === 'directory' && !metadata.isDirectory()) - ) { - throw new Error( - expected === 'file' ? '工具路径不是普通文件' : '工具路径不是目录' - ) - } - return canonical - } - - private async resolveWritablePath(inputPath: string): Promise { - const root = await this.getWorkspace() - const relativePath = workspacePathSchema.parse(inputPath) - const candidate = resolve(root, relativePath) - if (!isPathInside(root, candidate) || candidate === root) { - throw new Error('工具路径不能超出工作区') - } - const canonicalParent = await realpath(dirname(candidate)) - if (!isPathInside(root, canonicalParent)) { - throw new Error('工具路径不能通过符号链接超出工作区') - } - const existing = await lstat(candidate).catch((error: unknown) => { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === 'ENOENT' - ) { - return undefined - } - throw error - }) - if (existing?.isSymbolicLink()) { - throw new Error('工作区写入工具拒绝符号链接') - } - if (existing && !existing.isFile()) { - throw new Error('工作区写入目标不是普通文件') - } - return candidate - } - private getBuiltinTools(): ModelToolDefinition[] { return [ { @@ -1005,6 +953,7 @@ export class ModelToolProvider implements ModelToolProviderLike { argumentSummary: string, context: ModelToolCallContext ): RuntimeApprovalRequest { + assertToolAuthorizedForWorkMode(tool.name, context) const browserTools = this.getBrowserTools(context) if (browserTools?.ownsTool(tool.name)) { return browserTools.getApproval( @@ -1082,6 +1031,7 @@ export class ModelToolProvider implements ModelToolProviderLike { context: ModelToolCallContext ): Promise { signal.throwIfAborted() + assertToolAuthorizedForWorkMode(name, context) if (name === 'knowledge_list') { if ( !this.knowledgeGateway || @@ -1369,28 +1319,27 @@ export class ModelToolProvider implements ModelToolProviderLike { } if (name === 'workspace_read_text') { const input = readInputSchema.parse(argumentsValue) - const filePath = await this.resolveExistingPath(input.path, 'file') return createTextToolResult( ( - await readBoundedUtf8File( - filePath, - MAX_READ_BYTES, - '工作区文本文件超过 256KB 安全限制', - '工作区读取目标不是有效 UTF-8 文本' - ) + await this.workspaceAccess.readText({ + path: input.path, + maximumBytes: MAX_READ_BYTES, + tooLargeMessage: '工作区文本文件超过 256KB 安全限制', + invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本', + signal + }) ).content ) } if (name === 'workspace_list_directory') { const input = listInputSchema.parse(argumentsValue) - const directoryPath = await this.resolveExistingPath( - input.path, - 'directory' - ) - const listing = await listBoundedDirectoryEntries( - directoryPath, - 200 - ) + const listing = await this.workspaceAccess.listDirectory({ + path: input.path, + maximumEntries: 200, + includeGit: true, + includeOther: true, + signal + }) return createTextToolResult( boundedJson( { @@ -1398,11 +1347,7 @@ export class ModelToolProvider implements ModelToolProviderLike { .sort((left, right) => left.name.localeCompare(right.name)) .map((entry) => ({ name: entry.name, - type: entry.isDirectory() - ? 'directory' - : entry.isFile() - ? 'file' - : 'other' + type: entry.type })), truncated: listing.truncated }, @@ -1415,26 +1360,15 @@ export class ModelToolProvider implements ModelToolProviderLike { if (Buffer.byteLength(input.content) > MAX_WRITE_BYTES) { throw new Error('写入内容超过 512KB 安全限制') } - const filePath = await this.resolveWritablePath(input.path) - const temporaryPath = `${filePath}.${randomUUID()}.tmp` - const handle = await open(temporaryPath, 'wx', 0o600) - try { - try { - await handle.writeFile(input.content, 'utf8') - } finally { - await handle.close() - } - await rename(temporaryPath, filePath) - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined) - throw new Error('无法安全写入工作区文件', { cause: error }) - } + const written = await this.workspaceAccess.writeTextAtomic({ + path: input.path, + content: input.content, + maximumBytes: MAX_WRITE_BYTES, + signal + }) return createTextToolResult( boundedJson( - { - path: input.path, - bytesWritten: Buffer.byteLength(input.content) - }, + written, '工作区写入结果无法序列化' ) ) @@ -1499,7 +1433,10 @@ export class ModelToolProvider implements ModelToolProviderLike { this.webSearchClients.clear() this.mcpConnections = undefined this.webSearchBindings = undefined - await Promise.allSettled(clients.map((client) => client.close())) + await Promise.allSettled([ + ...clients.map((client) => client.close()), + this.workspaceAccess.dispose() + ]) } async releaseConversation(conversationId: string): Promise { diff --git a/src/main/agent/private-sqlite-database.test.ts b/src/main/agent/private-sqlite-database.test.ts new file mode 100644 index 00000000..85cbf532 --- /dev/null +++ b/src/main/agent/private-sqlite-database.test.ts @@ -0,0 +1,104 @@ +import { + chmod, + mkdir, + mkdtemp, + rename, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + openPrivateSqliteDatabase, + PreparedPrivateSqliteDatabaseFile +} from './private-sqlite-database' + +describe('private SQLite database opening', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) + }) + + async function privateDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-private-sqlite-') + ) + temporaryDirectories.push(directory) + return directory + } + + it('opens a verified regular filesystem database', async () => { + const path = join(await privateDirectory(), 'verified.sqlite') + const database = openPrivateSqliteDatabase(path) + database.exec('CREATE TABLE verified (value TEXT) STRICT') + database.close() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects a symbolic-link database path', + async () => { + const directory = await privateDirectory() + const target = join(directory, 'target.sqlite') + const link = join(directory, 'link.sqlite') + await writeFile(target, '') + await symlink(target, link) + + expect(() => openPrivateSqliteDatabase(link)).toThrow( + 'non-symbolic-link' + ) + } + ) + + it.runIf(process.platform !== 'win32')( + 'rejects a symbolic-link parent path', + async () => { + const directory = await privateDirectory() + const realParent = join(directory, 'real-parent') + const linkedParent = join(directory, 'linked-parent') + await mkdir(realParent, { mode: 0o700 }) + await symlink(realParent, linkedParent, 'dir') + + expect(() => + openPrivateSqliteDatabase(join(linkedParent, 'database.sqlite')) + ).toThrow('parent must be a real directory') + } + ) + + it.runIf(process.platform !== 'win32')( + 'rejects a parent outside the private same-UID boundary', + async () => { + const directory = await privateDirectory() + await chmod(directory, 0o755) + + expect(() => + openPrivateSqliteDatabase(join(directory, 'database.sqlite')) + ).toThrow('parent must be private') + } + ) + + it.runIf(process.platform !== 'win32')( + 'rejects path replacement while the verified descriptor remains open', + async () => { + const directory = await privateDirectory() + const path = join(directory, 'database.sqlite') + const moved = join(directory, 'moved.sqlite') + const prepared = new PreparedPrivateSqliteDatabaseFile(path) + try { + await rename(path, moved) + await writeFile(path, '') + await expect(() => prepared.openDatabase()).toThrow( + 'path identity changed' + ) + } finally { + prepared.close() + } + } + ) +}) diff --git a/src/main/agent/private-sqlite-database.ts b/src/main/agent/private-sqlite-database.ts new file mode 100644 index 00000000..bbca22ca --- /dev/null +++ b/src/main/agent/private-sqlite-database.ts @@ -0,0 +1,4 @@ +export { + PreparedPrivateSqliteDatabaseFile, + openPrivateSqliteDatabase +} from '../../shared/node/private-sqlite-database' diff --git a/src/main/agent/remote-model-gateway.test.ts b/src/main/agent/remote-model-gateway.test.ts new file mode 100644 index 00000000..171db7e0 --- /dev/null +++ b/src/main/agent/remote-model-gateway.test.ts @@ -0,0 +1,683 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ResolvedModelProfile } from '../runtime-settings-store' +import { ModelCallOperationStore } from './model-call-operation-store' +import { + createModelCallOperationId, + createResolvedModelProfileDigest, + RemoteModelGateway, + type RemoteModelGatewayDispatchContext +} from './remote-model-gateway' + +const stores: ModelCallOperationStore[] = [] +const storePaths = new Map() + +const anthropicProfile: ResolvedModelProfile = { + id: '00000000-0000-4000-8000-000000000001', + name: 'Anthropic', + baseUrl: 'https://provider.example', + modelName: 'claude-test', + protocol: 'anthropic-messages', + authentication: 'api-key', + apiKey: 'main-only-secret' +} + +const context: RemoteModelGatewayDispatchContext = { + requestId: 'request-1', + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + promptSequence: 0, + roundIndex: 0, + modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile), + modelProfile: anthropicProfile +} + +const request = { + method: 'POST', + path: '/v1/messages', + headers: { + accept: 'application/json', + 'content-type': 'application/json' + }, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'claude-test', messages: [] }) + ).toString('base64') +} as const + +afterEach(() => { + vi.restoreAllMocks() + for (const store of stores.splice(0)) { + store.close() + } +}) + +async function createStore(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-')) + const path = join(directory, 'operations.sqlite') + const store = new ModelCallOperationStore(path) + stores.push(store) + storePaths.set(store, path) + return store +} + +describe('RemoteModelGateway', () => { + it('prepares and claims before one authenticated provider fetch', async () => { + const store = await createStore() + const order: string[] = [] + const prepare = vi.spyOn(store, 'prepare') + prepare.mockImplementation(function (input) { + order.push('prepare') + return ModelCallOperationStore.prototype.prepare.call(store, input) + }) + const begin = vi.spyOn(store, 'beginDispatch') + begin.mockImplementation(function (id, metadata) { + order.push('begin') + return ModelCallOperationStore.prototype.beginDispatch.call( + store, + id, + metadata + ) + }) + const complete = vi.spyOn(store, 'complete') + let sentHeaders: Headers | undefined + let sentBody: Record | undefined + const fetcher = vi.fn(async (url: URL, init?: RequestInit) => { + order.push('fetch') + expect(url.toString()).toBe( + 'https://provider.example/v1/messages' + ) + expect(init?.method).toBe('POST') + expect(init?.redirect).toBe('error') + sentHeaders = new Headers(init?.headers) + sentBody = JSON.parse(String(init?.body)) as Record + return new Response( + JSON.stringify({ + id: 'message-1', + stop_reason: 'end_turn', + usage: { + input_tokens: 11, + output_tokens: 7, + cache_read_input_tokens: 3 + } + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'request-provider-1', + authorization: 'must-not-return' + } + } + ) + }) + const gateway = new RemoteModelGateway({ store, fetcher }) + + const result = await gateway.dispatch( + context, + request, + new AbortController().signal + ) + + expect(order).toEqual(['prepare', 'begin', 'fetch']) + expect(fetcher).toHaveBeenCalledOnce() + expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret') + expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01') + expect(sentBody).toEqual({ + model: 'claude-test', + messages: [] + }) + expect(result.status).toBe(200) + expect(result.headers).toEqual({ + 'content-type': 'application/json', + 'x-request-id': 'request-provider-1' + }) + expect(JSON.stringify(result)).not.toContain('main-only-secret') + const completed = complete.mock.results[0]?.value + expect(completed?.status).toBe('completed') + expect(completed?.terminalEvidence).toMatchObject({ + status: 'completed', + providerRequestId: 'request-provider-1', + providerResponseId: 'message-1', + result: { + finishReason: 'end_turn', + outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u) + }, + usage: { + inputTokens: 11, + outputTokens: 7, + cachedInputTokens: 3 + } + }) + expect(JSON.stringify(completed)).not.toContain('main-only-secret') + expect(JSON.stringify(completed)).not.toContain('"messages"') + }) + + it('does not clamp Runtime-selected provider output parameters', async () => { + const store = await createStore() + let sentBody: Record | undefined + const gateway = new RemoteModelGateway({ + store, + fetcher: async (_url, init) => { + sentBody = JSON.parse(String(init?.body)) as Record< + string, + unknown + > + return new Response('{}') + } + }) + const requestWithProviderMaximum = { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [], + max_tokens: 1_000_000 + }) + ).toString('base64') + } + + await gateway.dispatch( + context, + requestWithProviderMaximum, + new AbortController().signal + ) + + expect(sentBody).toMatchObject({ max_tokens: 1_000_000 }) + }) + + it('does not persist endpoints, credentials, or provider bodies', async () => { + const store = await createStore() + const gateway = new RemoteModelGateway({ + store, + fetcher: async () => + new Response(JSON.stringify({ private_response_field: 'secret' })) + }) + await gateway.dispatch(context, request, new AbortController().signal) + store.close() + + const databaseBytes = await readFile(storePaths.get(store)!) + const persisted = databaseBytes.toString('utf8') + expect(persisted).not.toContain('https://provider.example') + expect(persisted).not.toContain('main-only-secret') + expect(persisted).not.toContain('"messages"') + expect(persisted).not.toContain('private_response_field') + }) + + it('completes the ledger for a full HTTP 4xx response', async () => { + const store = await createStore() + const complete = vi.spyOn(store, 'complete') + const gateway = new RemoteModelGateway({ + store, + fetcher: async () => + new Response(JSON.stringify({ error: { type: 'invalid_request' } }), { + status: 400, + headers: { 'content-type': 'application/json' } + }) + }) + + const result = await gateway.dispatch( + context, + request, + new AbortController().signal + ) + + expect(result.status).toBe(400) + expect(complete).toHaveBeenCalledOnce() + expect(complete.mock.results[0]?.value.status).toBe('completed') + }) + + it('permits only one fetch for duplicate canonical dispatches', async () => { + const store = await createStore() + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await gateway.dispatch(context, request, new AbortController().signal) + await expect( + gateway.dispatch(context, request, new AbortController().signal) + ).rejects.toMatchObject({ + code: 'already-dispatched' + }) + expect(fetcher).toHaveBeenCalledOnce() + }) + + it('uses profile-independent round identity and rejects a profile swap', async () => { + const store = await createStore() + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + await gateway.dispatch(context, request, new AbortController().signal) + const swappedProfile = { + ...anthropicProfile, + id: '00000000-0000-4000-8000-000000000002', + modelName: 'claude-other' + } + + await expect( + gateway.dispatch( + { + ...context, + modelProfile: swappedProfile, + modelProfileDigest: + createResolvedModelProfileDigest(swappedProfile) + }, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'claude-other', messages: [] }) + ).toString('base64') + }, + new AbortController().signal + ) + ).rejects.toThrow('different identity or evidence') + expect(fetcher).toHaveBeenCalledOnce() + }) + + it('validates the Main profile snapshot digest before ledger preparation', async () => { + const store = await createStore() + const prepare = vi.spyOn(store, 'prepare') + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await expect( + gateway.dispatch( + { ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` }, + request, + new AbortController().signal + ) + ).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' }) + expect(prepare).not.toHaveBeenCalled() + expect(fetcher).not.toHaveBeenCalled() + }) + + it('fails closed on a different request for the same bound call', async () => { + const store = await createStore() + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await gateway.dispatch(context, request, new AbortController().signal) + await expect( + gateway.dispatch( + context, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [{ role: 'user', content: 'different' }] + }) + ).toString('base64') + }, + new AbortController().signal + ) + ).rejects.toThrow('different identity or evidence') + expect(fetcher).toHaveBeenCalledOnce() + }) + + it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => { + const store = await createStore() + const prepare = vi.spyOn(store, 'prepare') + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + for (const invalidRequest of [ + { ...request, path: 'https://attacker.example/v1/messages' }, + { + ...request, + headers: { authorization: 'Bearer remote-secret' } + }, + { ...request, path: '/v1/responses' }, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'untrusted-model' }) + ).toString('base64') + }, + { ...request, path: '/v1/responses' } + ]) { + await expect( + gateway.dispatch( + context, + invalidRequest as typeof request, + new AbortController().signal + ) + ).rejects.toThrow() + } + expect(prepare).not.toHaveBeenCalled() + expect(fetcher).not.toHaveBeenCalled() + expect(store.listUnresolved()).toEqual([]) + }) + + it('enforces the trusted profile image-input policy before prepare', async () => { + const store = await createStore() + const prepare = vi.spyOn(store, 'prepare') + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + const imageRequest = { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'AA==' + } + } + ] + } + ] + }) + ).toString('base64') + } + + await expect( + gateway.dispatch( + context, + imageRequest, + new AbortController().signal + ) + ).rejects.toMatchObject({ code: 'request-policy-mismatch' }) + expect(prepare).not.toHaveBeenCalled() + expect(fetcher).not.toHaveBeenCalled() + + await expect( + gateway.dispatch( + { + ...context, + modelProfile: { + ...anthropicProfile, + supportsImageInput: true + }, + modelProfileDigest: createResolvedModelProfileDigest({ + ...anthropicProfile, + supportsImageInput: true + }) + }, + imageRequest, + new AbortController().signal + ) + ).resolves.toMatchObject({ status: 200 }) + expect(fetcher).toHaveBeenCalledOnce() + }) + + it.each([ + { background: true }, + { store: true }, + { service_tier: 'priority' }, + { web_search_options: {} }, + { + tools: [ + { + type: 'web_search_20250305', + name: 'web_search' + } + ] + } + ])( + 'rejects independently billed provider capabilities before prepare', + async (providerCapability) => { + const store = await createStore() + const prepare = vi.spyOn(store, 'prepare') + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await expect( + gateway.dispatch( + context, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [], + ...providerCapability + }) + ).toString('base64') + }, + new AbortController().signal + ) + ).rejects.toMatchObject({ code: 'request-policy-mismatch' }) + expect(prepare).not.toHaveBeenCalled() + expect(fetcher).not.toHaveBeenCalled() + } + ) + + it('allows ordinary client-defined tools within the token policy', async () => { + const store = await createStore() + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + + await expect( + gateway.dispatch( + context, + { + ...request, + bodyBase64: Buffer.from( + JSON.stringify({ + model: 'claude-test', + messages: [], + tools: [ + { + name: 'read_file', + description: 'Read a file', + input_schema: { type: 'object' } + } + ] + }) + ).toString('base64') + }, + new AbortController().signal + ) + ).resolves.toMatchObject({ status: 200 }) + expect(fetcher).toHaveBeenCalledOnce() + }) + + it.each([ + { + name: 'network failure', + expectedCode: 'network-error', + run: async () => { + throw new Error('socket exposed sensitive upstream details') + } + }, + { + name: 'timeout', + expectedCode: 'timeout', + run: async (_url: URL, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ) + }), + timeoutMs: 5 + } + ])( + 'marks $name after dispatch as outcome unknown', + async ({ expectedCode, run, timeoutMs }) => { + const store = await createStore() + const gateway = new RemoteModelGateway({ + store, + fetcher: run, + ...(timeoutMs ? { timeoutMs } : {}) + }) + + await expect( + gateway.dispatch(context, request, new AbortController().signal) + ).rejects.toMatchObject({ + code: expectedCode, + message: expect.not.stringContaining('sensitive') + }) + expect(store.listUnresolved()).toHaveLength(1) + expect(store.listUnresolved()[0]).toMatchObject({ + status: 'outcome-unknown', + terminalEvidence: { + status: 'outcome-unknown', + reason: { code: expectedCode, retryable: false } + } + }) + } + ) + + it('marks caller cancellation after dispatch as outcome unknown', async () => { + const store = await createStore() + const controller = new AbortController() + const gateway = new RemoteModelGateway({ + store, + fetcher: async (_url, init) => + await new Promise((_resolve, reject) => { + controller.abort() + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ) + if (init?.signal?.aborted) { + reject(init.signal.reason) + } + }) + }) + + await expect( + gateway.dispatch(context, request, controller.signal) + ).rejects.toMatchObject({ code: 'cancelled' }) + expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown') + }) + + it('marks an oversized response as outcome unknown', async () => { + const store = await createStore() + const gateway = new RemoteModelGateway({ + store, + fetcher: async () => + new Response(Buffer.alloc(768 * 1024 + 1), { + headers: { + 'content-length': String(768 * 1024 + 1) + } + }) + }) + + await expect( + gateway.dispatch(context, request, new AbortController().signal) + ).rejects.toMatchObject({ code: 'response-too-large' }) + expect(store.listUnresolved()[0]).toMatchObject({ + status: 'outcome-unknown', + terminalEvidence: { + reason: { + code: 'response-too-large', + retryable: false + } + } + }) + }) + + it('keeps completion pending until delivery ACK and then permits the next round', async () => { + const store = await createStore() + const fetcher = vi.fn(async () => new Response('{}')) + const gateway = new RemoteModelGateway({ store, fetcher }) + await gateway.dispatch(context, request, new AbortController().signal) + const nextContext = { ...context, roundIndex: 1 } + + await expect( + gateway.dispatch(nextContext, request, new AbortController().signal) + ).rejects.toThrow('not been completed and delivered') + expect(fetcher).toHaveBeenCalledOnce() + const pending = store.listStartupRecords().records + expect(pending).toMatchObject([{ status: 'completed' }]) + expect(pending[0]).not.toHaveProperty('responseDeliveredAt') + + gateway.markResponseDelivered(context) + await gateway.dispatch( + nextContext, + request, + new AbortController().signal + ) + expect(fetcher).toHaveBeenCalledTimes(2) + }) + + it('permits sequential responses without a cumulative output allowance', async () => { + const store = await createStore() + const gateway = new RemoteModelGateway({ + store, + fetcher: async () => new Response('{}') + }) + for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) { + const round = { ...context, roundIndex } + await gateway.dispatch( + round, + request, + new AbortController().signal + ) + gateway.markResponseDelivered(round) + } + expect(store.get( + createModelCallOperationId({ ...context, roundIndex: 11 }) + )).toMatchObject({ + status: 'completed', + responseDeliveredAt: expect.any(Number) + }) + }) + + it('marks schema-invalid bridge responses unknown before terminal completion', async () => { + const store = await createStore() + const response = new Response('{}') + const gateway = new RemoteModelGateway({ + store, + fetcher: async () => + ({ + status: 999, + headers: response.headers, + body: response.body + }) as Response + }) + + await expect( + gateway.dispatch(context, request, new AbortController().signal) + ).rejects.toMatchObject({ code: 'invalid-bridge-response' }) + expect(store.listStartupRecords().records).toMatchObject([ + { + status: 'outcome-unknown', + terminalEvidence: { + reason: { code: 'invalid-bridge-response' } + } + } + ]) + }) + + it('injects OpenAI bearer auth without returning or persisting it', async () => { + const store = await createStore() + const profile: ResolvedModelProfile = { + ...anthropicProfile, + baseUrl: 'https://openai.example/v1', + protocol: 'openai-responses' + } + let authorization: string | null = null + const gateway = new RemoteModelGateway({ + store, + fetcher: async (_url, init) => { + authorization = new Headers(init?.headers).get('authorization') + return new Response('{}') + } + }) + + const result = await gateway.dispatch( + { + ...context, + modelProfile: profile, + modelProfileDigest: createResolvedModelProfileDigest(profile) + }, + { ...request, path: '/v1/responses' }, + new AbortController().signal + ) + + expect(authorization).toBe('Bearer main-only-secret') + expect(JSON.stringify(result)).not.toContain('main-only-secret') + }) +}) diff --git a/src/main/agent/remote-model-gateway.ts b/src/main/agent/remote-model-gateway.ts new file mode 100644 index 00000000..dfb4b01e --- /dev/null +++ b/src/main/agent/remote-model-gateway.ts @@ -0,0 +1,714 @@ +import { createHash } from 'node:crypto' +import { + modelCallIdentitySchema, + modelCallResultMetadataSchema, + modelCallUsageMetadataSchema, + type ModelCallResultMetadata, + type ModelCallUsageMetadata +} from '../../shared/model-call-operation-contracts' +import { + REMOTE_MODEL_GATEWAY_LIMITS, + remoteModelGatewayRequestSchema, + remoteModelGatewayResponseSchema, + type RemoteModelGatewayRequest, + type RemoteModelGatewayResponse +} from '../../shared/remote-model-gateway-contracts' +import type { ResolvedModelProfile } from '../runtime-settings-store' +import { createAnthropicMessagesUrl } from './anthropic-endpoint' +import { ModelCallOperationStore } from './model-call-operation-store' +import { + createOpenAIChatCompletionsUrl, + createOpenAIResponsesUrl +} from './openai-endpoint' +import { + BoundedResponseTooLargeError, + readBoundedResponseBytes +} from './bounded-response' + +const DEFAULT_TIMEOUT_MS = 60_000 +const RESPONSE_HEADER_NAMES = [ + 'content-type', + 'openai-request-id', + 'request-id', + 'x-request-id' +] as const + +export type RemoteModelGatewayDispatchContext = { + requestId: string + bindingId: string + promptOperationId: string + promptSequence: number + roundIndex: number + modelProfileDigest: string + modelProfile: ResolvedModelProfile +} + +export type RemoteModelGatewayOptions = { + store: ModelCallOperationStore + fetcher?: ( + input: URL, + init: RequestInit + ) => Promise + timeoutMs?: number +} + +export class RemoteModelGatewayError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'RemoteModelGatewayError' + this.code = code + } +} + +/** + * Main-process-only, one-shot provider forwarder. Remote runtimes choose only + * a protocol-specific relative path and a bounded payload. Provider location, + * credentials, stable operation identity, and dispatch ownership stay in Main. + */ +export class RemoteModelGateway { + readonly #store: ModelCallOperationStore + readonly #fetcher: NonNullable + readonly #timeoutMs: number + + constructor(options: RemoteModelGatewayOptions) { + this.#store = options.store + this.#fetcher = options.fetcher ?? fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 300_000 + ) { + throw new RangeError('Remote model gateway timeout is invalid') + } + this.#timeoutMs = timeoutMs + } + + async dispatch( + context: RemoteModelGatewayDispatchContext, + request: RemoteModelGatewayRequest, + signal: AbortSignal + ): Promise { + signal.throwIfAborted() + const prepared = prepareDispatch(context, request) + + this.#store.prepare({ + identity: prepared.identity, + requestDigest: prepared.requestDigest, + modelProfileDigest: prepared.modelProfileDigest + }) + const claim = this.#store.beginDispatch( + prepared.identity.callOperationId + ) + if (!claim.permitted) { + throw new RemoteModelGatewayError( + 'already-dispatched', + 'Remote model request was already dispatched' + ) + } + + const timeoutController = new AbortController() + const timeoutError = new RemoteModelGatewayError( + 'timeout', + 'Remote model request timed out' + ) + const timer = setTimeout( + () => timeoutController.abort(timeoutError), + this.#timeoutMs + ) + const requestSignal = AbortSignal.any([ + signal, + timeoutController.signal + ]) + + let response: Response + try { + response = await this.#fetcher(prepared.url, { + method: 'POST', + redirect: 'error', + headers: prepared.headers, + body: prepared.body, + signal: requestSignal + }) + } catch { + clearTimeout(timer) + const code = + timeoutController.signal.aborted + ? 'timeout' + : signal.aborted + ? 'cancelled' + : 'network-error' + return this.#markUnknownAndThrow( + prepared.identity.callOperationId, + code + ) + } + + let responseBody: Uint8Array + try { + responseBody = await readBoundedResponseBytes(response, { + maxBytes: + REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes, + tooLargeMessage: 'Remote model response is too large', + truncatedMessage: 'Remote model response was truncated' + }) + } catch (error) { + clearTimeout(timer) + const code = + timeoutController.signal.aborted + ? 'timeout' + : signal.aborted + ? 'cancelled' + : error instanceof BoundedResponseTooLargeError + ? 'response-too-large' + : 'response-read-failed' + return this.#markUnknownAndThrow( + prepared.identity.callOperationId, + code + ) + } finally { + clearTimeout(timer) + } + if (timeoutController.signal.aborted || signal.aborted) { + const code = timeoutController.signal.aborted + ? 'timeout' + : 'cancelled' + return this.#markUnknownAndThrow( + prepared.identity.callOperationId, + code + ) + } + + const responseHeaders = selectResponseHeaders(response.headers) + const providerRequestId = + safeProviderIdentifier( + response.headers.get('x-request-id') + ) ?? + safeProviderIdentifier( + response.headers.get('openai-request-id') + ) ?? + safeProviderIdentifier(response.headers.get('request-id')) + let bridgeResponse: RemoteModelGatewayResponse + try { + bridgeResponse = remoteModelGatewayResponseSchema.parse({ + status: response.status, + headers: responseHeaders, + bodyBase64: Buffer.from(responseBody).toString('base64') + }) + } catch { + return this.#markUnknownAndThrow( + prepared.identity.callOperationId, + 'invalid-bridge-response' + ) + } + const parsedMetadata = parseResponseMetadata(responseBody) + this.#store.complete(prepared.identity.callOperationId, { + status: 'completed', + ...(providerRequestId ? { providerRequestId } : {}), + ...(parsedMetadata.providerResponseId + ? { providerResponseId: parsedMetadata.providerResponseId } + : {}), + result: { + outputDigest: sha256(responseBody), + ...(parsedMetadata.result ?? {}) + }, + ...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {}) + }) + + return bridgeResponse + } + + markResponseDelivered( + context: Pick< + RemoteModelGatewayDispatchContext, + 'bindingId' | 'promptOperationId' | 'roundIndex' + > + ): void { + this.#store.markResponseDelivered( + createModelCallOperationId(context) + ) + } + + finalizePrompt(context: { + bindingId: string + promptOperationId: string + promptSequence: number + }): void { + this.#store.finalizePrompt(context) + } + + #markUnknownAndThrow( + callOperationId: string, + code: string + ): never { + this.#store.markOutcomeUnknown(callOperationId, { + status: 'outcome-unknown', + reason: { + code, + retryable: false + } + }) + throw new RemoteModelGatewayError( + code, + remoteModelGatewayErrorMessage(code) + ) + } +} + +function prepareDispatch( + context: RemoteModelGatewayDispatchContext, + request: RemoteModelGatewayRequest +): { + identity: ReturnType + requestDigest: string + modelProfileDigest: string + url: URL + headers: Record + body: Uint8Array +} { + const parsedRequest = remoteModelGatewayRequestSchema.parse(request) + const profile = context.modelProfile + const modelProfileDigest = createResolvedModelProfileDigest(profile) + if (modelProfileDigest !== context.modelProfileDigest) { + throw new RemoteModelGatewayError( + 'model-profile-digest-mismatch', + 'Trusted model profile snapshot does not match its digest' + ) + } + const url = providerUrl(profile, parsedRequest.path) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password + ) { + throw new RemoteModelGatewayError( + 'invalid-provider-endpoint', + 'Configured model provider endpoint is invalid' + ) + } + if (profile.authentication === 'api-key' && !profile.apiKey) { + throw new RemoteModelGatewayError( + 'missing-provider-credential', + 'Configured model provider credential is unavailable' + ) + } + const body = normalizeProviderRequestBody( + profile, + Buffer.from(parsedRequest.bodyBase64, 'base64') + ) + const normalizedBodyBase64 = body.toString('base64') + const canonicalRequest = JSON.stringify({ + profileId: profile.id, + protocol: profile.protocol, + authentication: profile.authentication, + model: profile.modelName, + url: url.toString(), + request: { + method: parsedRequest.method, + path: parsedRequest.path, + headers: Object.fromEntries( + Object.entries(parsedRequest.headers).sort(([left], [right]) => + left.localeCompare(right) + ) + ), + bodyBase64: normalizedBodyBase64 + } + }) + const requestDigest = sha256(canonicalRequest) + const callOperationId = createModelCallOperationId(context) + const identity = modelCallIdentitySchema.parse({ + callOperationId, + requestId: context.requestId, + bindingId: context.bindingId, + promptOperationId: context.promptOperationId, + promptSequence: context.promptSequence, + roundIndex: context.roundIndex, + provider: + profile.protocol === 'anthropic-messages' + ? 'anthropic' + : 'openai', + profile: profile.id, + model: profile.modelName, + protocol: profile.protocol + }) + + const headers: Record = { + ...parsedRequest.headers + } + if (profile.protocol === 'anthropic-messages') { + headers['anthropic-version'] = '2023-06-01' + if (profile.authentication === 'api-key') { + headers['x-api-key'] = profile.apiKey! + } + } else if (profile.authentication === 'api-key') { + headers.authorization = `Bearer ${profile.apiKey!}` + } + + return { + identity, + requestDigest, + modelProfileDigest, + url, + headers, + body + } +} + +export function createModelCallOperationId( + context: Pick< + RemoteModelGatewayDispatchContext, + 'bindingId' | 'promptOperationId' | 'roundIndex' + > +): string { + return `model-call-${createHash('sha256') + .update( + JSON.stringify({ + bindingId: context.bindingId, + promptOperationId: context.promptOperationId, + roundIndex: context.roundIndex + }) + ) + .digest('hex')}` +} + +export function createResolvedModelProfileDigest( + profile: ResolvedModelProfile +): string { + return sha256( + JSON.stringify({ + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + modelName: profile.modelName, + protocol: profile.protocol, + authentication: profile.authentication, + supportsImageInput: profile.supportsImageInput === true, + contextWindowTokens: profile.contextWindowTokens ?? null, + imageGenerationQuality: profile.imageGenerationQuality ?? null + }) + ) +} + +function normalizeProviderRequestBody( + profile: ResolvedModelProfile, + body: Buffer +): Buffer { + let value: unknown + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(body) + ) as unknown + } catch { + throw requestPolicyError() + } + if (!isRecord(value) || value.model !== profile.modelName) { + throw requestPolicyError() + } + if ( + profile.supportsImageInput !== true && + containsImageInput(value) + ) { + throw requestPolicyError() + } + const request = { ...value } + assertProviderBodyPolicy(profile, request) + if ( + profile.protocol !== 'anthropic-messages' && + profile.protocol !== 'openai-chat-completions' && + profile.protocol !== 'openai-responses' + ) { + throw requestPolicyError() + } + return Buffer.from(JSON.stringify(request), 'utf8') +} + +function requestPolicyError(): RemoteModelGatewayError { + return new RemoteModelGatewayError( + 'request-policy-mismatch', + 'Remote model request does not match the trusted model policy' + ) +} + +function assertProviderBodyPolicy( + profile: ResolvedModelProfile, + request: Record +): void { + if ( + request.background === true || + request.store === true || + request.web_search_options !== undefined || + request.mcp_servers !== undefined || + request.conversation !== undefined || + request.previous_response_id !== undefined || + (request.service_tier !== undefined && + request.service_tier !== 'auto' && + request.service_tier !== 'default') || + (Array.isArray(request.modalities) && + request.modalities.some((value) => value !== 'text')) + ) { + throw requestPolicyError() + } + if ( + Array.isArray(request.include) && + request.include.some( + (value) => + typeof value !== 'string' || + /(?:web_search|file_search|computer|code_interpreter|mcp|image_generation)/iu.test( + value + ) + ) + ) { + throw requestPolicyError() + } + if (request.tools === undefined) { + return + } + if (!Array.isArray(request.tools)) { + throw requestPolicyError() + } + for (const tool of request.tools) { + if (!isRecord(tool)) { + throw requestPolicyError() + } + const type = tool.type + if (profile.protocol === 'anthropic-messages') { + if (type !== undefined && type !== 'custom') { + throw requestPolicyError() + } + } else if (type !== 'function') { + throw requestPolicyError() + } + } +} + +function providerUrl( + profile: ResolvedModelProfile, + path: string +): URL { + if (profile.protocol === 'anthropic-messages') { + if (path !== '/v1/messages') { + throw protocolPathError() + } + return createAnthropicMessagesUrl(profile.baseUrl) + } + if (profile.protocol === 'openai-chat-completions') { + if ( + path !== '/chat/completions' && + path !== '/v1/chat/completions' + ) { + throw protocolPathError() + } + return createOpenAIChatCompletionsUrl(profile.baseUrl) + } + if (profile.protocol === 'openai-responses') { + if (path !== '/responses' && path !== '/v1/responses') { + throw protocolPathError() + } + return createOpenAIResponsesUrl(profile.baseUrl) + } + throw protocolPathError() +} + +function protocolPathError(): RemoteModelGatewayError { + return new RemoteModelGatewayError( + 'protocol-path-mismatch', + 'Remote model request path does not match the configured protocol' + ) +} + +function selectResponseHeaders( + headers: Headers +): Record { + const selected: Record = {} + for (const name of RESPONSE_HEADER_NAMES) { + const value = headers.get(name) + const encodedBytes = + value === null ? 0 : new TextEncoder().encode(value).byteLength + if ( + value !== null && + encodedBytes > 0 && + !/[\r\n]/u.test(value) && + encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes + ) { + selected[name] = value + } + } + return selected +} + +function parseResponseMetadata(body: Uint8Array): { + providerResponseId?: string + result?: ModelCallResultMetadata + usage?: ModelCallUsageMetadata +} { + let parsed: unknown + try { + parsed = JSON.parse(new TextDecoder().decode(body)) as unknown + } catch { + return {} + } + if (!isRecord(parsed)) { + return {} + } + const providerResponseId = safeProviderIdentifier(parsed.id) + const finishReason = safeFinishReason( + isRecordArray(parsed.choices) && + isRecord(parsed.choices[0]) + ? parsed.choices[0].finish_reason + : parsed.stop_reason + ) + const resultCandidate = { + ...(finishReason ? { finishReason } : {}) + } + const result = modelCallResultMetadataSchema.safeParse(resultCandidate) + const usage = parseUsage(parsed.usage) + return { + ...(providerResponseId ? { providerResponseId } : {}), + ...(result.success && Object.keys(result.data).length > 0 + ? { result: result.data } + : {}), + ...(usage ? { usage } : {}) + } +} + +function parseUsage(value: unknown): ModelCallUsageMetadata | undefined { + if (!isRecord(value)) { + return undefined + } + const promptDetails = isRecord(value.prompt_tokens_details) + ? value.prompt_tokens_details + : undefined + const inputDetails = isRecord(value.input_tokens_details) + ? value.input_tokens_details + : undefined + const completionDetails = isRecord(value.completion_tokens_details) + ? value.completion_tokens_details + : undefined + const outputDetails = isRecord(value.output_tokens_details) + ? value.output_tokens_details + : undefined + const candidate = { + inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens), + outputTokens: safeTokenCount( + value.output_tokens ?? value.completion_tokens + ), + cachedInputTokens: safeTokenCount( + value.cache_read_input_tokens ?? + inputDetails?.cached_tokens ?? + promptDetails?.cached_tokens + ), + reasoningTokens: safeTokenCount( + outputDetails?.reasoning_tokens ?? + completionDetails?.reasoning_tokens + ) + } + const compact = Object.fromEntries( + Object.entries(candidate).filter((entry) => entry[1] !== undefined) + ) + const result = modelCallUsageMetadataSchema.safeParse(compact) + return result.success && Object.keys(result.data).length > 0 + ? result.data + : undefined +} + +function safeTokenCount(value: unknown): number | undefined { + return typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 + ? value + : undefined +} + +function safeProviderIdentifier(value: unknown): string | undefined { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 256 || + value.trim() !== value || + new TextEncoder().encode(value).byteLength > 256 + ) { + return undefined + } + return value +} + +function safeFinishReason(value: unknown): string | undefined { + const result = + modelCallResultMetadataSchema.shape.finishReason.safeParse(value) + return result.success ? result.data : undefined +} + +function isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) +} + +function isRecordArray(value: unknown): value is Record[] { + return Array.isArray(value) && value.every(isRecord) +} + +function containsImageInput(value: unknown): boolean { + const maximumNodes = 100_000 + const pending: unknown[] = [value] + let visited = 0 + while (pending.length > 0) { + visited += 1 + if (visited > maximumNodes) { + throw requestPolicyError() + } + const current = pending.pop() + if (Array.isArray(current)) { + if (visited + pending.length + current.length > maximumNodes) { + throw requestPolicyError() + } + for (const item of current) { + pending.push(item) + } + continue + } + if (!isRecord(current)) { + continue + } + if ( + current.type === 'image' || + current.type === 'image_url' || + current.type === 'input_image' + ) { + return true + } + const values = Object.values(current) + if (visited + pending.length + values.length > maximumNodes) { + throw requestPolicyError() + } + for (const item of values) { + pending.push(item) + } + } + return false +} + +function sha256(value: string | Uint8Array): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}` +} + +function remoteModelGatewayErrorMessage(code: string): string { + switch (code) { + case 'timeout': + return 'Remote model request timed out after dispatch' + case 'cancelled': + return 'Remote model request was cancelled after dispatch' + case 'response-too-large': + return 'Remote model response exceeded the allowed size' + case 'response-read-failed': + return 'Remote model response could not be read' + case 'invalid-bridge-response': + return 'Remote model response could not be safely delivered' + default: + return 'Remote model request outcome is unknown' + } +} diff --git a/src/main/agent/remote-runtime-channel.ts b/src/main/agent/remote-runtime-channel.ts new file mode 100644 index 00000000..e87270ff --- /dev/null +++ b/src/main/agent/remote-runtime-channel.ts @@ -0,0 +1,153 @@ +import type { + AcpBindingCursors, + AcpCompletePromptRequest, + AcpCompletePromptResult, + AcpEscalateCancellationRequest, + AcpReconcilePromptRequest, + AcpReconcilePromptResult +} from '../../shared/agent-protocol' +import type { + RemotePromptOperationAcceptance, + RemotePromptOperationPreparation, +} from '../../shared/remote-agent-contracts' +import type { + ModelBridgePolicy +} from '../../shared/model-bridge-contracts' + +export type RemoteRuntimeChannelCapabilities = { + cancellationEscalation: boolean + promptOperationReconciliation: boolean + modelBridge?: boolean +} + +export type RuntimeSessionBindingCursors = AcpBindingCursors + +export type RemoteCancellationEscalation = Omit< + AcpEscalateCancellationRequest, + 'reason' +> & { + reason: unknown +} + +export type RemotePromptOperationReconciliation = + AcpReconcilePromptRequest + +export type RemotePromptOperationReconciliationResult = + AcpReconcilePromptResult +export type RemotePromptOperationCompletion = AcpCompletePromptRequest +export type RemotePromptOperationCompletionResult = + AcpCompletePromptResult + +export type RemoteModelBridgeOpenRequest = { + bindingId: string + promptOperationId: string + requestId: string + policy: ModelBridgePolicy +} + +export type RemoteModelBridgeCloseResult = { + clean: boolean + poisoned: boolean +} + +export interface RemoteModelBridgeSession { + readonly version: 'goodbuddy-model-bridge-v1' + readonly channelId: string + readonly channelEpoch: string + readonly policy: ModelBridgePolicy + readonly closed: Promise + close(reason?: string): Promise +} + +/** + * A process-neutral, already authenticated ACP virtual channel. + * + * Implementations own framing, journaling and transport generation checks. + * The runtime intentionally receives no SSH client, socket or command API. + */ +export interface RemoteRuntimeChannel { + readonly input: ReadableStream + readonly output: WritableStream + readonly generation: number + readonly channelEpoch: string + /** + * Signed Runtime capability identity advertised by the Agent before the + * untrusted Runtime process starts. + */ + readonly advertisedAcpCapabilitiesDigest: string + readonly capabilities: RemoteRuntimeChannelCapabilities + readonly closed: Promise + + isCurrentGeneration(): boolean + getBindingCursors( + bindingId: string + ): Promise + /** Accepts the prompt and starts its Ask or Execute Runtime process. */ + preparePrompt( + preparation: RemotePromptOperationPreparation + ): Promise + /** + * Bounds same-daemon stream recovery to the signed prompt lifetime and + * caller cancellation. Implementations that cannot recover may omit it. + */ + setRecoveryBoundary?( + deadlineAt: string, + signal: AbortSignal + ): void + clearRecoveryBoundary?(): void + /** + * Allocates and registers a fresh Main-owned model channel for one prompt. + * Legacy channels may omit this when the runtime is configured without a + * model bridge. + */ + openModelBridge?( + request: RemoteModelBridgeOpenRequest + ): Promise + completePromptOperation( + operation: RemotePromptOperationCompletion + ): Promise + /** + * Stops or resumes inbound ACP delivery. Pausing must take effect before + * the returned promise resolves and must not advance the Main ACK cursor. + */ + setInboundPaused(paused: boolean): Promise + escalateCancellation( + escalation: RemoteCancellationEscalation + ): Promise + reconcilePromptOperation( + operation: RemotePromptOperationReconciliation + ): Promise + close(): Promise +} + +export class StaleRemoteRuntimeGenerationError extends Error { + constructor() { + super('远端 Runtime 连接代际已失效') + this.name = 'StaleRemoteRuntimeGenerationError' + } +} + +export function isDefinitiveRemoteRuntimeRequestRejection( + error: unknown, + method: string +): boolean { + if ( + error === null || + typeof error !== 'object' || + !('remoteRequestOutcome' in error) || + error.remoteRequestOutcome !== 'rejected' || + !('remoteMethod' in error) || + error.remoteMethod !== method + ) { + return false + } + return true +} + +export function assertCurrentRemoteRuntimeGeneration( + channel: RemoteRuntimeChannel +): void { + if (!channel.isCurrentGeneration()) { + throw new StaleRemoteRuntimeGenerationError() + } +} diff --git a/src/main/agent/runtime-controller.test.ts b/src/main/agent/runtime-controller.test.ts index 77b16e63..8d4077b8 100644 --- a/src/main/agent/runtime-controller.test.ts +++ b/src/main/agent/runtime-controller.test.ts @@ -5,7 +5,11 @@ import type { AgentRuntimeStatus } from '../../shared/contracts' import type { AgentRuntime, RuntimeAuthorizer } from './runtime' -import { AgentRuntimeController } from './runtime-controller' +import { + AgentRuntimeController, + MAX_DRAINING_RUNTIME_GENERATIONS, + RuntimeReplacementCapacityError +} from './runtime-controller' class TestRuntime implements AgentRuntime { readonly dispose = vi.fn(async () => {}) @@ -65,6 +69,33 @@ class TestRuntime implements AgentRuntime { finish(): void { this.release?.() } + + releaseConversation(): Promise { + return Promise.resolve() + } +} + +class DrainableTestRuntime extends TestRuntime { + readonly beginDrain = vi.fn(async () => {}) + readonly forceShutdown = vi.fn(async () => {}) + private readonly drained: Promise + private resolveDrained!: () => void + + constructor() { + super(true) + this.drained = new Promise((resolve) => { + this.resolveDrained = resolve + }) + } + + waitForDrain(): Promise { + return this.drained + } + + releaseConversation(): Promise { + this.resolveDrained() + return Promise.resolve() + } } describe('AgentRuntimeController', () => { @@ -135,6 +166,212 @@ describe('AgentRuntimeController', () => { await controller.dispose() }) + it('drains remote sessions without interrupting their active prompt', async () => { + const previous = new DrainableTestRuntime() + const next = new TestRuntime() + const controller = new AgentRuntimeController(previous) + const stream = controller.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b13', + conversationId: 'conversation-drain', + prompt: 'test', + workMode: 'ask' + }, + new AbortController().signal + ) + const pending = stream.next() + await previous.started + + await controller.replace(next) + expect(previous.beginDrain).toHaveBeenCalledOnce() + expect(previous.dispose).not.toHaveBeenCalled() + + previous.finish() + await expect(pending).resolves.toMatchObject({ + value: { type: 'text' } + }) + await stream.return() + expect(previous.dispose).not.toHaveBeenCalled() + + await controller.releaseConversation('conversation-drain') + await vi.waitFor(() => + expect(previous.dispose).toHaveBeenCalledOnce() + ) + await controller.dispose() + }) + + it('clears conversation ownership when a Runtime release fails', async () => { + const runtime = new TestRuntime() + runtime.releaseConversation = vi.fn(async () => { + throw new Error('release failed') + }) + const controller = new AgentRuntimeController(runtime) + const stream = controller.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b16', + conversationId: 'conversation-release-failure', + prompt: 'test', + workMode: 'ask' + }, + new AbortController().signal + ) + await stream.next() + await stream.return() + + expect(controller.ownedConversationCount).toBe(1) + await expect( + controller.releaseConversation( + 'conversation-release-failure' + ) + ).rejects.toBeInstanceOf(AggregateError) + expect(controller.ownedConversationCount).toBe(0) + expect(controller.canRetire).toBe(true) + await controller.dispose() + }) + + it('counts a draining generation until disposal actually settles', async () => { + const previous = new DrainableTestRuntime() + let finishDisposal!: () => void + previous.dispose.mockImplementation( + async () => + await new Promise((resolve) => { + finishDisposal = resolve + }) + ) + const controller = new AgentRuntimeController(previous) + + await controller.replace(new TestRuntime()) + await controller.releaseConversation('conversation-disposal') + await vi.waitFor(() => + expect(previous.dispose).toHaveBeenCalledOnce() + ) + expect(controller.replacementStatus.drainingGenerations).toBe(1) + + finishDisposal() + await vi.waitFor(() => + expect( + controller.replacementStatus.drainingGenerations + ).toBe(0) + ) + await controller.dispose() + }) + + it('bounds retained draining generations and rejects further replacements', async () => { + const first = new DrainableTestRuntime() + const second = new DrainableTestRuntime() + const third = new DrainableTestRuntime() + const rejected = new TestRuntime() + const controller = new AgentRuntimeController(first) + + await controller.replace(second) + await controller.replace(third) + + expect(controller.replacementStatus).toEqual({ + drainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS, + failedDrainingGenerations: 0, + pendingReplacements: 0, + maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS, + saturated: true + }) + const replacement = controller.replace(rejected) + await expect(replacement).rejects.toBeInstanceOf( + RuntimeReplacementCapacityError + ) + await expect(replacement).rejects.toMatchObject({ + code: 'runtime-drain-capacity', + occupiedGenerations: MAX_DRAINING_RUNTIME_GENERATIONS, + maximumGenerations: MAX_DRAINING_RUNTIME_GENERATIONS + }) + expect(rejected.dispose).toHaveBeenCalledOnce() + expect(first.forceShutdown).not.toHaveBeenCalled() + expect(second.forceShutdown).not.toHaveBeenCalled() + + await controller.releaseConversation('conversation-drain') + await vi.waitFor(() => { + expect(first.dispose).toHaveBeenCalledOnce() + expect(second.dispose).toHaveBeenCalledOnce() + }) + await controller.dispose() + }) + + it('retains a recoverable generation when drain observation fails', async () => { + const previous = new DrainableTestRuntime() + previous.waitForDrain = vi.fn(async () => { + throw new Error('temporary drain status failure') + }) + const controller = new AgentRuntimeController(previous, 1) + + await controller.replace(new TestRuntime()) + await vi.waitFor(() => + expect(controller.replacementStatus).toMatchObject({ + drainingGenerations: 1, + failedDrainingGenerations: 1 + }) + ) + expect(previous.dispose).not.toHaveBeenCalled() + expect(previous.forceShutdown).not.toHaveBeenCalled() + + await controller.dispose() + expect(previous.forceShutdown).toHaveBeenCalledOnce() + expect(previous.dispose).toHaveBeenCalledOnce() + }) + + it('uses the configured shutdown grace while replacing a busy runtime', async () => { + vi.useFakeTimers() + try { + const previous = new TestRuntime(true) + const controller = new AgentRuntimeController(previous, 25) + const stream = controller.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b14', + conversationId: 'conversation-replacement-grace', + prompt: 'test', + workMode: 'ask' + }, + new AbortController().signal + ) + const pending = stream.next() + await previous.started + + const replacement = controller.replace(new TestRuntime()) + await vi.advanceTimersByTimeAsync(24) + let replaced = false + void replacement.then(() => { + replaced = true + }) + await Promise.resolve() + expect(replaced).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await replacement + expect(replaced).toBe(true) + + previous.finish() + await expect(pending).rejects.toThrow('Runtime 已切换') + await stream.return() + await controller.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('clears bounded-wait timers after fast lifecycle operations', async () => { + vi.useFakeTimers() + try { + const controller = new AgentRuntimeController( + new TestRuntime(), + 25 + ) + await controller.replace(new TestRuntime()) + expect(vi.getTimerCount()).toBe(0) + + await controller.dispose() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('forces runtime disposal when active work does not stop during shutdown', async () => { const runtime = new TestRuntime(true) const controller = new AgentRuntimeController(runtime, 1) diff --git a/src/main/agent/runtime-controller.ts b/src/main/agent/runtime-controller.ts index 7e11a859..cc671c32 100644 --- a/src/main/agent/runtime-controller.ts +++ b/src/main/agent/runtime-controller.ts @@ -16,14 +16,67 @@ type RuntimeSlot = { runtime: AgentRuntime activeRequests: number retiring: boolean + drainable: boolean + drainStarted?: Promise + drainError?: Error disposal?: Promise + disposeStarted?: Promise resolveDisposal?: () => void } +export const MAX_DRAINING_RUNTIME_GENERATIONS = 2 + +export type RuntimeReplacementStatus = { + drainingGenerations: number + failedDrainingGenerations: number + pendingReplacements: number + maximumDrainingGenerations: number + saturated: boolean +} + +export class RuntimeReplacementCapacityError extends Error { + readonly code = 'runtime-drain-capacity' + + constructor( + readonly occupiedGenerations: number, + readonly maximumGenerations: number + ) { + super( + `Runtime replacement is unavailable while previous generations are draining (${occupiedGenerations}/${maximumGenerations})` + ) + this.name = 'RuntimeReplacementCapacityError' + } +} + +export interface DrainableAgentRuntime extends AgentRuntime { + /** Stop accepting new sessions while existing session ownership remains. */ + beginDrain(): Promise + /** Resolve only after all owned sessions are released or interrupted. */ + waitForDrain(): Promise + /** Explicit bounded-shutdown escalation; never used by replacement. */ + forceShutdown(): Promise +} + +function isDrainableRuntime( + runtime: AgentRuntime +): runtime is DrainableAgentRuntime { + const candidate = runtime as Partial + return ( + typeof candidate.beginDrain === 'function' && + typeof candidate.waitForDrain === 'function' && + typeof candidate.forceShutdown === 'function' + ) +} + export class AgentRuntimeController implements AgentRuntime { private current: RuntimeSlot + private readonly retired = new Set() private replacementQueue: Promise = Promise.resolve() + private pendingReplacements = 0 private closing = false + private retirement?: Promise + private disposal?: Promise + private readonly ownedConversationIds = new Set() constructor( runtime: AgentRuntime, @@ -32,7 +85,8 @@ export class AgentRuntimeController implements AgentRuntime { this.current = { runtime, activeRequests: 0, - retiring: false + retiring: false, + drainable: isDrainableRuntime(runtime) } } @@ -56,15 +110,69 @@ export class AgentRuntimeController implements AgentRuntime { return this.current.runtime.capability } + get replacementStatus(): RuntimeReplacementStatus { + const drainingGenerations = [...this.retired].filter( + (slot) => slot.drainable + ).length + const failedDrainingGenerations = [...this.retired].filter( + (slot) => + slot.drainable && + slot.drainError !== undefined + ).length + const occupiedGenerations = + drainingGenerations + this.pendingReplacements + return { + drainingGenerations, + failedDrainingGenerations, + pendingReplacements: this.pendingReplacements, + maximumDrainingGenerations: MAX_DRAINING_RUNTIME_GENERATIONS, + saturated: + occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS + } + } + + get activeRequestCount(): number { + return [...new Set([this.current, ...this.retired])].reduce( + (total, slot) => total + slot.activeRequests, + 0 + ) + } + + get ownedConversationCount(): number { + return this.ownedConversationIds.size + } + + get canRetire(): boolean { + return ( + !this.closing && + this.activeRequestCount === 0 && + this.ownedConversationIds.size === 0 + ) + } + replace(next: AgentRuntime): Promise { if (this.closing) { - return next.dispose().then(() => { + return this.disposeRejectedRuntime(next).then(() => { throw new Error('Agent Runtime 正在关闭') }) } - const operation = this.replacementQueue.then(() => - this.performReplace(next) - ) + const status = this.replacementStatus + const occupiedGenerations = + status.drainingGenerations + status.pendingReplacements + if (occupiedGenerations >= MAX_DRAINING_RUNTIME_GENERATIONS) { + return this.disposeRejectedRuntime(next).then(() => { + throw new RuntimeReplacementCapacityError( + occupiedGenerations, + MAX_DRAINING_RUNTIME_GENERATIONS + ) + }) + } + this.pendingReplacements += 1 + const operation = this.replacementQueue + .then(() => this.performReplace(next)) + .finally(() => { + this.pendingReplacements -= 1 + }) this.replacementQueue = operation.catch(() => undefined) return operation } @@ -74,13 +182,22 @@ export class AgentRuntimeController implements AgentRuntime { this.current = { runtime: next, activeRequests: 0, - retiring: false + retiring: false, + drainable: isDrainableRuntime(next) } - const disposal = this.retire(previous) - await Promise.race([ - disposal, - new Promise((resolve) => setTimeout(resolve, 2_000)) - ]) + const disposal = this.retireSlot(previous) + if (previous.drainable) { + await previous.drainStarted + return + } + await waitWithin(disposal, this.shutdownGraceMs) + } + + private async disposeRejectedRuntime(runtime: AgentRuntime): Promise { + await waitWithin( + runtime.dispose().catch(() => undefined), + this.shutdownGraceMs + ) } async getStatus(): Promise { @@ -107,6 +224,7 @@ export class AgentRuntimeController implements AgentRuntime { request: RuntimeConversationCompactInput, signal: AbortSignal ): Promise { + this.ownedConversationIds.add(request.conversationId) return this.invoke((runtime) => { if (!runtime.compactConversation) { throw new Error('当前 Runtime 不支持手动压缩') @@ -141,7 +259,11 @@ export class AgentRuntimeController implements AgentRuntime { return status } finally { slot.activeRequests -= 1 - if (slot.retiring && slot.activeRequests === 0) { + if ( + slot.retiring && + !slot.drainable && + slot.activeRequests === 0 + ) { await this.disposeSlot(slot) } } @@ -156,6 +278,7 @@ export class AgentRuntimeController implements AgentRuntime { throw new Error('Agent Runtime 正在关闭') } const slot = this.current + this.ownedConversationIds.add(request.conversationId) const toolsAllowed = request.workMode === 'execute' const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed ? authorize @@ -186,24 +309,43 @@ export class AgentRuntimeController implements AgentRuntime { signal, effectiveAuthorize )) { - if (slot !== this.current) { + if (slot !== this.current && !slot.drainable) { throw new Error('Runtime 已切换,当前请求已中断') } yield event } - if (slot !== this.current) { + if (slot !== this.current && !slot.drainable) { throw new Error('Runtime 已切换,当前请求已中断') } } finally { slot.activeRequests -= 1 - if (slot.retiring && slot.activeRequests === 0) { + if ( + slot.retiring && + !slot.drainable && + slot.activeRequests === 0 + ) { await this.disposeSlot(slot) } } } async releaseConversation(conversationId: string): Promise { - await this.current.runtime.releaseConversation?.(conversationId) + const results = await Promise.allSettled([ + this.current.runtime.releaseConversation?.(conversationId), + ...[...this.retired].map((slot) => + slot.runtime.releaseConversation?.(conversationId) + ) + ]) + this.ownedConversationIds.delete(conversationId) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError( + failures, + 'One or more Agent Runtimes failed to release the conversation' + ) + } } async respondToQuestion( @@ -220,52 +362,143 @@ export class AgentRuntimeController implements AgentRuntime { await runtime.respondToQuestion(questionId, answers) } - private retire(slot: RuntimeSlot): Promise { + private retireSlot(slot: RuntimeSlot): Promise { slot.retiring = true + this.retired.add(slot) if (!slot.disposal) { slot.disposal = new Promise((resolve) => { slot.resolveDisposal = resolve }) } - if (slot.activeRequests === 0) { + if (slot.drainable && !slot.drainStarted) { + const runtime = slot.runtime as DrainableAgentRuntime + slot.drainStarted = runtime.beginDrain() + void slot.drainStarted + .then(() => runtime.waitForDrain()) + .then(() => this.disposeSlot(slot)) + .catch((error: unknown) => { + slot.drainError = + error instanceof Error ? error : new Error(String(error)) + }) + } + if (!slot.drainable && slot.activeRequests === 0) { void this.disposeSlot(slot) } return slot.disposal } - private async disposeSlot(slot: RuntimeSlot): Promise { + private async disposeSlot( + slot: RuntimeSlot, + timeoutMs?: number, + abandonOnTimeout = false + ): Promise { if (!slot.resolveDisposal) { return } - const resolve = slot.resolveDisposal - slot.resolveDisposal = undefined + slot.disposeStarted ??= Promise.resolve().then(() => + slot.runtime.dispose() + ) + let completed = true try { - await slot.runtime.dispose() + if (timeoutMs === undefined) { + await slot.disposeStarted + } else { + completed = await waitWithin( + slot.disposeStarted, + timeoutMs + ) + } } catch { - resolve() + completed = true + } + if (!completed && !abandonOnTimeout) { return } + const resolve = slot.resolveDisposal + if (resolve === undefined) { + return + } + slot.resolveDisposal = undefined + this.retired.delete(slot) resolve() } - async dispose(): Promise { + retire(): Promise { + if (this.retirement !== undefined) { + return this.retirement + } + this.closing = true + const operation = this.replacementQueue.then(async () => { + this.retireSlot(this.current) + await Promise.all( + [...this.retired].map( + (slot) => slot.disposal ?? Promise.resolve() + ) + ) + }) + this.retirement = operation + return operation + } + + dispose(): Promise { + if (this.disposal !== undefined) { + return this.disposal + } this.closing = true const operation = this.replacementQueue.then(async () => { const slot = this.current - const disposal = this.retire(slot) - if (slot.activeRequests === 0) { - return disposal - } - await Promise.race([ - disposal, - new Promise((resolve) => - setTimeout(resolve, this.shutdownGraceMs) - ) - ]) - await this.disposeSlot(slot) - return disposal + this.retireSlot(slot) + const slots = [...this.retired] + const disposals = slots.map( + (candidate) => candidate.disposal ?? Promise.resolve() + ) + await waitWithin( + Promise.all(disposals).then(() => undefined), + this.shutdownGraceMs + ) + await Promise.all( + slots.map(async (candidate) => { + if (!candidate.resolveDisposal) { + return + } + if (candidate.drainable) { + await waitWithin( + ( + candidate.runtime as DrainableAgentRuntime + ).forceShutdown().catch(() => undefined), + this.shutdownGraceMs + ) + } + await this.disposeSlot( + candidate, + this.shutdownGraceMs, + true + ) + }) + ) + await Promise.all(disposals) }) - this.replacementQueue = operation.catch(() => undefined) - await operation + this.disposal = operation + return operation + } +} + +async function waitWithin( + promise: Promise, + timeoutMs: number +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + timer.unref?.() + }) + ]) + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } } } diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts index d095e5a1..dae753a6 100644 --- a/src/main/agent/runtime-e2e.manual.test.ts +++ b/src/main/agent/runtime-e2e.manual.test.ts @@ -331,8 +331,7 @@ describe.runIf(enabled)('runtime end-to-end', () => { baseUrl, model: modelName, protocol, - authentication: 'api-key', - maxOutputTokens: 128 + authentication: 'api-key' }) try { @@ -382,7 +381,6 @@ describe.runIf(enabled)('runtime end-to-end', () => { protocol, authentication: 'api-key', supportsImageInput: true, - maxOutputTokens: 128, contextCompression: { settings: { ...defaultContextCompressionSettings, @@ -593,7 +591,6 @@ describe.runIf(enabled)('runtime end-to-end', () => { model: modelName, protocol, authentication: 'api-key', - maxOutputTokens: 128, contextCompression: { settings: { ...defaultContextCompressionSettings, diff --git a/src/main/agent/runtime-session-binding-store.test.ts b/src/main/agent/runtime-session-binding-store.test.ts new file mode 100644 index 00000000..52670207 --- /dev/null +++ b/src/main/agent/runtime-session-binding-store.test.ts @@ -0,0 +1,738 @@ +import { DatabaseSync } from 'node:sqlite' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeSessionBinding } from '../../shared/remote-agent-contracts' +import { + MemoryRuntimeSessionBindingStore, + RuntimeSessionBindingCorruptionError, + RuntimeSessionBindingConflictError, + RuntimeSessionClaimConflictError, + SqliteRuntimeSessionBindingStore +} from './runtime-session-binding-store' + +const digest = `sha256:${'a'.repeat(64)}` + +function binding( + bindingId: string, + conversationId: string +): RuntimeSessionBinding { + return { + bindingId, + controllerId: 'controller-1', + controllerGeneration: 1, + conversationId, + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + workspaceIdentity: 'workspace-1', + agentInstallationId: 'installation-1', + daemonBootIdAtOpen: 'boot-1', + runtimeId: 'runtime-1', + runtimeBundleDigest: digest, + runtimeAdapterDigest: digest, + acpSessionId: `opening-${bindingId}`, + acpCapabilitiesDigest: digest, + state: 'opening', + promptSequence: 0, + channelEpoch: '1', + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } +} + +describe('MemoryRuntimeSessionBindingStore', () => { + it('atomically rejects concurrent claims for one live ACP session', async () => { + const store = new MemoryRuntimeSessionBindingStore() + await store.put(binding('binding-1', 'conversation-1')) + await store.put(binding('binding-2', 'conversation-2')) + + const claims = await Promise.allSettled([ + store.claimAcpSession('binding-1', 'shared-session'), + store.claimAcpSession('binding-2', 'shared-session') + ]) + + expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength(1) + const rejected = claims.find( + (claim): claim is PromiseRejectedResult => + claim.status === 'rejected' + ) + expect(rejected?.reason).toBeInstanceOf( + RuntimeSessionClaimConflictError + ) + }) + + it('allows a closed owner session identity to be claimed again', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const first = binding('binding-1', 'conversation-1') + await store.put(first) + const claimed = await store.claimAcpSession( + first.bindingId, + 'session-1' + ) + await store.put({ ...claimed, state: 'closed' }) + + const second = binding('binding-2', 'conversation-2') + await store.put(second) + await expect( + store.claimAcpSession(second.bindingId, 'session-1') + ).resolves.toMatchObject({ + bindingId: 'binding-2', + acpSessionId: 'session-1' + }) + }) + + it('keeps binding and channel ownership immutable', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const original = binding('binding-1', 'conversation-1') + await store.put(original) + + await expect( + store.put({ ...original, channelEpoch: '2' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put({ + ...original, + controllerGeneration: 2 + }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put({ ...original, hostRevision: 2 }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put( + binding('binding-2', original.conversationId) + ) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + }) + + it('keeps the persisted model bridge version and policy immutable', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const original = { + ...binding('binding-model', 'conversation-model'), + modelBridgeVersion: + 'goodbuddy-model-bridge-v1' as const, + modelBridgePolicy: { + protocol: 'openai-responses' as const, + model: 'gpt-test', + modelProfileDigest: digest, + supportsImageInput: false + } + } + await store.put(original) + await expect( + store.put({ + ...original, + modelBridgePolicy: { + ...original.modelBridgePolicy, + model: 'different-model' + } + }) + ).rejects.toThrow('model bridge policy is immutable') + await expect( + store.put({ + ...original, + modelBridgeVersion: undefined, + modelBridgePolicy: undefined + }) + ).rejects.toThrow('modelBridgeVersion is immutable') + }) + + it('atomically rotates only an idle ready transport and resets its cursors', async () => { + const store = new MemoryRuntimeSessionBindingStore() + const ready = { + ...binding('binding-1', 'conversation-1'), + state: 'ready' as const, + acpSessionId: 'session-1', + lastOutboundJournaledSequence: '4', + lastOutboundDeliveredSequence: '3', + lastInboundJournaledSequence: '6', + lastMainAckSequence: '5' + } + await store.put(ready) + + await expect( + store.rotateReadyTransport( + ready.bindingId, + { + controllerGeneration: 1, + daemonBootIdAtOpen: 'boot-1', + channelEpoch: '1' + }, + { + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2', + channelEpoch: '2' + } + ) + ).resolves.toMatchObject({ + acpSessionId: 'session-1', + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2', + channelEpoch: '2', + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }) + await expect( + store.rotateReadyTransport( + ready.bindingId, + { + controllerGeneration: 1, + daemonBootIdAtOpen: 'boot-1', + channelEpoch: '1' + }, + { + controllerGeneration: 3, + daemonBootIdAtOpen: 'boot-3', + channelEpoch: '3' + } + ) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + }) +}) + +describe('SqliteRuntimeSessionBindingStore', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) + }) + + async function databasePath(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-runtime-bindings-') + ) + temporaryDirectories.push(directory) + return join(directory, 'bindings.sqlite') + } + + it.runIf(process.platform !== 'win32')( + 'creates the database as a private regular file', + async () => { + const path = await databasePath() + const store = new SqliteRuntimeSessionBindingStore(path) + const file = await stat(path) + + expect(file.isFile()).toBe(true) + expect(file.mode & 0o777).toBe(0o600) + store.close() + } + ) + + it('rejects in-memory and file URI database paths', () => { + expect( + () => new SqliteRuntimeSessionBindingStore(':memory:') + ).toThrow('filesystem path') + expect( + () => + new SqliteRuntimeSessionBindingStore( + 'file:runtime-bindings.sqlite' + ) + ).toThrow('filesystem path') + }) + + it('persists bindings and cursors across reopen', async () => { + const path = await databasePath() + const first = new SqliteRuntimeSessionBindingStore(path) + const persisted = { + ...binding('binding-1', 'conversation-1'), + state: 'ready' as const, + modelBridgeVersion: + 'goodbuddy-model-bridge-v1' as const, + modelBridgePolicy: { + protocol: 'openai-responses' as const, + model: 'gpt-test', + modelProfileDigest: digest, + supportsImageInput: false + }, + lastOutboundJournaledSequence: '4', + lastOutboundDeliveredSequence: '3', + lastInboundJournaledSequence: '6', + lastMainAckSequence: '5' + } + await first.put(persisted) + first.close() + + const reopened = new SqliteRuntimeSessionBindingStore(path) + await expect(reopened.getById(persisted.bindingId)).resolves.toEqual( + persisted + ) + const returned = await reopened.getById(persisted.bindingId) + if (!returned) { + throw new Error('Expected persisted binding') + } + returned.lastMainAckSequence = '0' + await expect(reopened.getById(persisted.bindingId)).resolves.toEqual( + persisted + ) + reopened.dispose() + }) + + it('persists an atomic ready transport rotation', async () => { + const path = await databasePath() + const store = new SqliteRuntimeSessionBindingStore(path) + const ready = { + ...binding('binding-1', 'conversation-1'), + state: 'ready' as const, + acpSessionId: 'session-1', + lastOutboundJournaledSequence: '4', + lastOutboundDeliveredSequence: '3', + lastInboundJournaledSequence: '6', + lastMainAckSequence: '5' + } + await store.put(ready) + await store.rotateReadyTransport( + ready.bindingId, + { + controllerGeneration: 1, + daemonBootIdAtOpen: 'boot-1', + channelEpoch: '1' + }, + { + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2', + channelEpoch: '2' + } + ) + store.close() + + const reopened = new SqliteRuntimeSessionBindingStore(path) + await expect(reopened.getById(ready.bindingId)).resolves.toMatchObject({ + acpSessionId: 'session-1', + state: 'ready', + controllerGeneration: 2, + daemonBootIdAtOpen: 'boot-2', + channelEpoch: '2', + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }) + reopened.close() + }) + + it('rejects live conversation conflicts and permits idempotent writes', async () => { + const store = new SqliteRuntimeSessionBindingStore(await databasePath()) + const original = binding('binding-1', 'conversation-1') + await store.put(original) + await expect(store.put(original)).resolves.toBeUndefined() + await expect( + store.put(binding('binding-2', original.conversationId)) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + store.close() + }) + + it('enforces a lower row cap when reopening by pruning only closed rows', async () => { + const path = await databasePath() + const original = new SqliteRuntimeSessionBindingStore(path, { + maximumRows: 3 + }) + await original.put(binding('binding-live-1', 'conversation-live-1')) + await original.put(binding('binding-live-2', 'conversation-live-2')) + await original.put({ + ...binding('binding-closed', 'conversation-closed'), + state: 'closed' + }) + original.close() + + const capped = new SqliteRuntimeSessionBindingStore(path, { + maximumRows: 2 + }) + await expect( + capped.getById('binding-closed') + ).resolves.toBeUndefined() + await expect( + capped.getById('binding-live-1') + ).resolves.toBeDefined() + await expect( + capped.getById('binding-live-2') + ).resolves.toBeDefined() + capped.close() + + expect( + () => + new SqliteRuntimeSessionBindingStore(path, { + maximumRows: 1 + }) + ).toThrow(RuntimeSessionBindingConflictError) + }) + + it('atomically resolves competing claims from separate connections', async () => { + const path = await databasePath() + const first = new SqliteRuntimeSessionBindingStore(path) + const second = new SqliteRuntimeSessionBindingStore(path) + await first.put(binding('binding-1', 'conversation-1')) + await second.put(binding('binding-2', 'conversation-2')) + + const claims = await Promise.allSettled([ + first.claimAcpSession('binding-1', 'shared-session'), + second.claimAcpSession('binding-2', 'shared-session') + ]) + + expect(claims.filter((claim) => claim.status === 'fulfilled')).toHaveLength( + 1 + ) + const failure = claims.find( + (claim): claim is PromiseRejectedResult => claim.status === 'rejected' + ) + expect(failure?.reason).toBeInstanceOf(RuntimeSessionClaimConflictError) + first.close() + second.close() + }) + + it('releases conversation and ACP ownership when a binding closes', async () => { + const store = new SqliteRuntimeSessionBindingStore(await databasePath()) + const first = binding('binding-1', 'conversation-1') + await store.put(first) + const claimed = await store.claimAcpSession(first.bindingId, 'session-1') + await store.put({ ...claimed, state: 'closed' }) + + const replacement = { + ...binding('binding-2', first.conversationId), + acpSessionId: 'session-1' + } + await expect(store.put(replacement)).resolves.toBeUndefined() + await expect( + store.getByConversation(first.conversationId) + ).resolves.toMatchObject({ bindingId: replacement.bindingId }) + store.close() + }) + + it('rejects immutable mutation and direct ACP identity mutation', async () => { + const store = new SqliteRuntimeSessionBindingStore(await databasePath()) + const original = binding('binding-1', 'conversation-1') + await store.put(original) + await expect( + store.put({ ...original, channelEpoch: '2' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put({ ...original, workspaceIdentity: 'workspace-2' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put({ ...original, hostRevision: 2 }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + store.put({ ...original, acpSessionId: 'other-session' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + store.close() + }) + + it('rejects reopening a closed binding and preserves the closed row', async () => { + const store = new SqliteRuntimeSessionBindingStore(await databasePath()) + const original = binding('binding-1', 'conversation-1') + await store.put(original) + const closed = { ...original, state: 'closed' as const } + await store.put(closed) + + await expect( + store.put({ ...closed, state: 'ready' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect(store.getById(original.bindingId)).resolves.toEqual(closed) + store.close() + }) + + it('rejects interrupted bindings becoming ready', async () => { + const store = new SqliteRuntimeSessionBindingStore(await databasePath()) + const original = binding('binding-1', 'conversation-1') + const interrupted = { ...original, state: 'interrupted' as const } + await store.put(original) + await store.put(interrupted) + + await expect( + store.put({ ...interrupted, state: 'ready' }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect(store.getById(original.bindingId)).resolves.toEqual( + interrupted + ) + store.close() + }) + + it('fails closed when persisted JSON is malformed', async () => { + const path = await databasePath() + const store = new SqliteRuntimeSessionBindingStore(path) + const original = binding('binding-1', 'conversation-1') + await store.put(original) + store.close() + + const raw = new DatabaseSync(path) + raw + .prepare(` + UPDATE runtime_session_bindings + SET binding_json = ? + WHERE binding_id = ? + `) + .run('{}', original.bindingId) + raw.close() + + const reopened = new SqliteRuntimeSessionBindingStore(path) + await expect(reopened.put(original)).rejects.toBeInstanceOf( + RuntimeSessionBindingCorruptionError + ) + await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf( + RuntimeSessionBindingCorruptionError + ) + await expect( + reopened.listByController(original.controllerId) + ).rejects.toBeInstanceOf(RuntimeSessionBindingCorruptionError) + reopened.close() + }) + + it('fails closed when indexed identity disagrees with valid JSON', async () => { + const path = await databasePath() + const store = new SqliteRuntimeSessionBindingStore(path) + const original = binding('binding-1', 'conversation-1') + await store.put(original) + store.close() + + const raw = new DatabaseSync(path) + raw + .prepare(` + UPDATE runtime_session_bindings + SET controller_id = ? + WHERE binding_id = ? + `) + .run('controller-corrupt', original.bindingId) + raw.close() + + const reopened = new SqliteRuntimeSessionBindingStore(path) + await expect(reopened.getById(original.bindingId)).rejects.toBeInstanceOf( + RuntimeSessionBindingCorruptionError + ) + reopened.close() + }) +}) + +describe('RuntimeSessionBindingStore lifecycle parity', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) + }) + + it('enforces the same legal and terminal transitions in both stores', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-runtime-bindings-parity-') + ) + temporaryDirectories.push(directory) + const sqliteStore = new SqliteRuntimeSessionBindingStore( + join(directory, 'bindings.sqlite') + ) + const stores: Array<{ + store: + | MemoryRuntimeSessionBindingStore + | SqliteRuntimeSessionBindingStore + close(): void + }> = [ + { + store: new MemoryRuntimeSessionBindingStore(), + close() {} + }, + { + store: sqliteStore, + close() { + sqliteStore.close() + } + } + ] + + for (const entry of stores) { + const original = binding('binding-1', 'conversation-1') + const running = { + ...original, + state: 'prompt-running' as const, + activePromptOperationId: 'operation-1' + } + await entry.store.put(original) + await entry.store.put(running) + const advancedRunning = { + ...running, + lastOutboundJournaledSequence: '1' + } + await entry.store.put(advancedRunning) + await expect( + entry.store.put(running) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + entry.store.put({ + ...advancedRunning, + activePromptOperationId: 'operation-2' + }) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + const ready = { + ...advancedRunning, + state: 'ready' as const, + activePromptOperationId: undefined + } + await entry.store.put(ready) + await entry.store.put({ + ...ready, + state: 'interrupted' + }) + await expect( + entry.store.put(ready) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + const interrupted = { + ...ready, + state: 'interrupted' as const, + lastOutboundJournaledSequence: '2' + } + await entry.store.put(interrupted) + await entry.store.put({ + ...interrupted, + state: 'closed' + }) + await expect( + entry.store.put(ready) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + + const directReady = binding('binding-2', 'conversation-2') + await entry.store.put(directReady) + await entry.store.put({ + ...directReady, + state: 'ready' + }) + entry.close() + } + }) + + it('caps rows by pruning only closed bindings in both stores', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-runtime-bindings-cap-') + ) + temporaryDirectories.push(directory) + const sqliteStore = new SqliteRuntimeSessionBindingStore( + join(directory, 'bindings.sqlite'), + { maximumRows: 3 } + ) + const stores = [ + { + store: new MemoryRuntimeSessionBindingStore({ maximumRows: 3 }), + close() {} + }, + { + store: sqliteStore, + close() { + sqliteStore.close() + } + } + ] + + for (const entry of stores) { + const authority = { + ...binding('binding-live', 'conversation-live'), + state: 'interrupted' as const + } + const closed = { + ...binding('binding-closed', 'conversation-closed'), + state: 'closed' as const + } + const unknown = { + ...binding('binding-unknown', 'conversation-unknown'), + state: 'outcome-unknown' as const, + activePromptOperationId: 'operation-unknown' + } + await entry.store.put(authority) + await entry.store.put(unknown) + await entry.store.put(closed) + await entry.store.put(binding('binding-new', 'conversation-new')) + + await expect( + entry.store.getById(closed.bindingId) + ).resolves.toBeUndefined() + await expect( + entry.store.getById(authority.bindingId) + ).resolves.toEqual(authority) + await expect( + entry.store.getById(unknown.bindingId) + ).resolves.toEqual(unknown) + await expect( + entry.store.put(binding('binding-over', 'conversation-over')) + ).rejects.toBeInstanceOf(RuntimeSessionBindingConflictError) + await expect( + entry.store.getById(authority.bindingId) + ).resolves.toEqual(authority) + entry.close() + } + }) + + it('prunes closed rows deterministically and paginates both stores', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-runtime-bindings-page-') + ) + temporaryDirectories.push(directory) + const sqliteStore = new SqliteRuntimeSessionBindingStore( + join(directory, 'bindings.sqlite'), + { maximumRows: 10 } + ) + const stores = [ + { + store: new MemoryRuntimeSessionBindingStore({ maximumRows: 10 }), + close() {} + }, + { + store: sqliteStore, + close() { + sqliteStore.close() + } + } + ] + + for (const entry of stores) { + for (const id of ['binding-3', 'binding-1', 'binding-2']) { + await entry.store.put(binding(id, `conversation-${id}`)) + } + const firstPage = await entry.store.listByController('controller-1', { + limit: 2 + }) + expect(firstPage.map((item) => item.bindingId)).toEqual([ + 'binding-1', + 'binding-2' + ]) + await expect( + entry.store.listByController('controller-1', { + limit: 2, + afterBindingId: 'binding-2' + }) + ).resolves.toMatchObject([{ bindingId: 'binding-3' }]) + await expect( + entry.store.listByController('controller-1', { limit: 1_001 }) + ).rejects.toBeInstanceOf(RangeError) + + await entry.store.put({ + ...(await entry.store.getById('binding-1'))!, + state: 'closed' + }) + await entry.store.put({ + ...(await entry.store.getById('binding-2'))!, + state: 'closed' + }) + await expect(entry.store.pruneClosed(1)).resolves.toBe(1) + await expect(entry.store.pruneClosed(1_001)).rejects.toBeInstanceOf( + RangeError + ) + await expect( + entry.store.getById('binding-1') + ).resolves.toBeUndefined() + await expect( + entry.store.getById('binding-2') + ).resolves.toBeDefined() + await expect( + entry.store.getById('binding-3') + ).resolves.toBeDefined() + entry.close() + } + }) +}) diff --git a/src/main/agent/runtime-session-binding-store.ts b/src/main/agent/runtime-session-binding-store.ts new file mode 100644 index 00000000..afcead04 --- /dev/null +++ b/src/main/agent/runtime-session-binding-store.ts @@ -0,0 +1,906 @@ +import type { DatabaseSync } from 'node:sqlite' +import { isDeepStrictEqual } from 'node:util' +import { + runtimeSessionBindingSchema, + type RuntimeSessionBinding +} from '../../shared/remote-agent-contracts' +import { openPrivateSqliteDatabase } from './private-sqlite-database' + +export type RuntimeSessionBindingListOptions = { + limit?: number + afterBindingId?: string +} + +export type RuntimeSessionBindingStoreOptions = { + maximumRows?: number +} + +export type RuntimeSessionTransportIdentity = Pick< + RuntimeSessionBinding, + 'controllerGeneration' | 'daemonBootIdAtOpen' | 'channelEpoch' +> + +const defaultMaximumRows = 10_000 +const maximumPageSize = 1_000 +const maximumPruneBatch = 1_000 + +function boundedPositiveInteger( + value: number, + maximum: number, + name: string +): number { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new RangeError(`${name} must be between 1 and ${maximum}`) + } + return value +} + +function resolveMaximumRows(options?: RuntimeSessionBindingStoreOptions): number { + return boundedPositiveInteger( + options?.maximumRows ?? defaultMaximumRows, + 1_000_000, + 'maximumRows' + ) +} + +function resolveListOptions( + options?: RuntimeSessionBindingListOptions +): Required { + return { + limit: boundedPositiveInteger( + options?.limit ?? maximumPageSize, + maximumPageSize, + 'limit' + ), + afterBindingId: options?.afterBindingId ?? '' + } +} + +/** + * Durable Main-side index for ACP session ownership. Implementations must make + * each put atomic; callers never mutate an object returned by this interface. + */ +export interface RuntimeSessionBindingStore { + getByConversation( + conversationId: string + ): Promise + getById(bindingId: string): Promise + /** + * Atomically claims a Runtime-returned ACP session identity for this live + * binding. A session identity cannot be shared by live bindings, even when + * new/load/resume complete concurrently. + */ + claimAcpSession( + bindingId: string, + acpSessionId: string + ): Promise + /** + * Atomically moves an idle durable ACP session onto a newly opened + * transport epoch. Prompt identity and ACP session ownership remain fixed; + * transport cursors restart at zero for the new epoch. + */ + rotateReadyTransport( + bindingId: string, + expected: RuntimeSessionTransportIdentity, + next: RuntimeSessionTransportIdentity + ): Promise + put(binding: RuntimeSessionBinding): Promise + pruneClosed(limit?: number): Promise + listByController( + controllerId: string, + options?: RuntimeSessionBindingListOptions + ): Promise +} + +export class RuntimeSessionClaimConflictError extends Error { + readonly code = 'runtime-session-claim-conflict' + + constructor(readonly acpSessionId: string) { + super('Runtime ACP session identity is already owned by another live binding') + this.name = 'RuntimeSessionClaimConflictError' + } +} + +export class RuntimeSessionBindingConflictError extends Error { + readonly code = 'runtime-session-binding-conflict' + + constructor(message: string) { + super(message) + this.name = 'RuntimeSessionBindingConflictError' + } +} + +export class RuntimeSessionBindingCorruptionError extends Error { + readonly code = 'runtime-session-binding-corruption' + + constructor(readonly bindingId: string, options?: ErrorOptions) { + super(`Persisted Runtime binding ${bindingId} is malformed`, options) + this.name = 'RuntimeSessionBindingCorruptionError' + } +} + +const immutableBindingKeys = [ + 'bindingId', + 'controllerId', + 'controllerGeneration', + 'conversationId', + 'hostId', + 'hostRevision', + 'hostKeyGeneration', + 'workspaceIdentity', + 'agentInstallationId', + 'daemonBootIdAtOpen', + 'runtimeId', + 'runtimeBundleDigest', + 'runtimeAdapterDigest', + 'modelBridgeVersion', + 'acpCapabilitiesDigest', + 'channelEpoch' +] as const + +const monotonicCursorKeys = [ + 'lastOutboundJournaledSequence', + 'lastOutboundDeliveredSequence', + 'lastInboundJournaledSequence', + 'lastMainAckSequence' +] as const + +type RuntimeSessionBindingState = RuntimeSessionBinding['state'] + +const allowedStateTransitions: Readonly< + Record< + RuntimeSessionBindingState, + ReadonlySet + > +> = { + opening: new Set([ + 'opening', + 'ready', + 'prompt-running', + 'interrupted', + 'closed' + ]), + ready: new Set(['ready', 'prompt-running', 'interrupted', 'closed']), + 'prompt-running': new Set([ + 'prompt-running', + 'ready', + 'outcome-unknown', + 'interrupted', + 'closed' + ]), + reconciling: new Set([ + 'reconciling', + 'ready', + 'outcome-unknown', + 'interrupted', + 'closed' + ]), + 'outcome-unknown': new Set([ + 'outcome-unknown', + 'reconciling', + 'ready', + 'closed' + ]), + interrupted: new Set(['interrupted', 'closed']), + closed: new Set(['closed']) +} + +function assertBindingUpdateAllowed( + previous: RuntimeSessionBinding, + next: RuntimeSessionBinding +): void { + for (const key of immutableBindingKeys) { + if (previous[key] !== next[key]) { + throw new RuntimeSessionBindingConflictError( + `Runtime binding ${key} is immutable` + ) + } + } + if ( + !isDeepStrictEqual( + previous.modelBridgePolicy, + next.modelBridgePolicy + ) + ) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding model bridge policy is immutable' + ) + } + if (previous.acpSessionId !== next.acpSessionId) { + throw new RuntimeSessionBindingConflictError( + 'ACP session identity must be changed through atomic claim' + ) + } + for (const key of monotonicCursorKeys) { + if (BigInt(next[key]) < BigInt(previous[key])) { + throw new RuntimeSessionBindingConflictError( + `Runtime binding ${key} cannot regress` + ) + } + } + if ( + previous.activePromptOperationId !== undefined && + next.activePromptOperationId !== undefined && + previous.activePromptOperationId !== + next.activePromptOperationId + ) { + throw new RuntimeSessionBindingConflictError( + 'Active prompt operation identity cannot change before terminalization' + ) + } + const startingPrompt = + previous.activePromptOperationId === undefined && + next.activePromptOperationId !== undefined + const expectedPromptSequence = startingPrompt + ? previous.state === 'opening' + ? previous.promptSequence + : previous.promptSequence + 1 + : previous.promptSequence + if ( + !Number.isSafeInteger(expectedPromptSequence) || + next.promptSequence !== expectedPromptSequence + ) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding prompt sequence is not the next allowed value' + ) + } + if (!allowedStateTransitions[previous.state].has(next.state)) { + throw new RuntimeSessionBindingConflictError( + `Runtime binding cannot transition from ${previous.state} to ${next.state}` + ) + } +} + +function rotateReadyBinding( + current: RuntimeSessionBinding, + bindingId: string, + expected: RuntimeSessionTransportIdentity, + next: RuntimeSessionTransportIdentity +): RuntimeSessionBinding { + if ( + current.bindingId !== bindingId || + current.state !== 'ready' || + current.activePromptOperationId !== undefined + ) { + throw new RuntimeSessionBindingConflictError( + 'Only an idle ready Runtime binding can rotate transport' + ) + } + if ( + current.controllerGeneration !== expected.controllerGeneration || + current.daemonBootIdAtOpen !== expected.daemonBootIdAtOpen || + current.channelEpoch !== expected.channelEpoch + ) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding transport changed before rotation' + ) + } + if ( + expected.controllerGeneration === next.controllerGeneration && + expected.daemonBootIdAtOpen === next.daemonBootIdAtOpen && + expected.channelEpoch === next.channelEpoch + ) { + return structuredClone(current) + } + return runtimeSessionBindingSchema.parse({ + ...current, + ...next, + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }) +} + +type PersistedBindingRow = { + binding_id: string + controller_id: string + conversation_id: string + acp_session_id: string + state: string + binding_json: string +} + +const liveBindingPredicate = "state <> 'closed'" + +/** + * Durable Runtime binding index. The duplicated, constrained identity columns + * are intentional: they let SQLite enforce live ownership while the complete + * strict contract remains the canonical JSON payload. + */ +export class SqliteRuntimeSessionBindingStore + implements RuntimeSessionBindingStore +{ + private database?: DatabaseSync + private readonly maximumRows: number + + constructor( + databasePath: string, + options?: RuntimeSessionBindingStoreOptions + ) { + this.maximumRows = resolveMaximumRows(options) + const database = openPrivateSqliteDatabase(databasePath) + try { + database.exec('PRAGMA journal_mode = WAL') + database.exec('PRAGMA synchronous = FULL') + database.exec('PRAGMA foreign_keys = ON') + database.exec(` + CREATE TABLE IF NOT EXISTS runtime_session_bindings ( + binding_id TEXT PRIMARY KEY + CHECK(length(binding_id) BETWEEN 1 AND 128), + controller_id TEXT NOT NULL + CHECK(length(controller_id) BETWEEN 1 AND 128), + conversation_id TEXT NOT NULL + CHECK(length(conversation_id) BETWEEN 1 AND 128), + acp_session_id TEXT NOT NULL + CHECK(length(acp_session_id) BETWEEN 1 AND 128), + state TEXT NOT NULL CHECK(state IN ( + 'opening', + 'ready', + 'prompt-running', + 'reconciling', + 'closed', + 'interrupted', + 'outcome-unknown' + )), + binding_json TEXT NOT NULL + CHECK(length(binding_json) BETWEEN 2 AND 32768) + ) STRICT; + + CREATE UNIQUE INDEX IF NOT EXISTS + runtime_session_bindings_live_conversation + ON runtime_session_bindings(conversation_id) + WHERE ${liveBindingPredicate}; + CREATE UNIQUE INDEX IF NOT EXISTS + runtime_session_bindings_live_acp_session + ON runtime_session_bindings(acp_session_id) + WHERE ${liveBindingPredicate}; + CREATE INDEX IF NOT EXISTS + runtime_session_bindings_controller + ON runtime_session_bindings(controller_id, binding_id); + `) + this.database = database + this.enforceExistingCapacity(database) + } catch (error) { + this.database = undefined + database.close() + throw error + } + } + + async getByConversation( + conversationId: string + ): Promise { + const rows = this.requireDatabase() + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE conversation_id = ? AND ${liveBindingPredicate} + `) + .all(conversationId) as PersistedBindingRow[] + if (rows.length > 1) { + throw new RuntimeSessionBindingCorruptionError(rows[0]!.binding_id) + } + return rows[0] ? this.decode(rows[0]) : undefined + } + + async getById( + bindingId: string + ): Promise { + const row = this.selectById(bindingId) + return row ? this.decode(row) : undefined + } + + async put(binding: RuntimeSessionBinding): Promise { + const parsed = runtimeSessionBindingSchema.parse(structuredClone(binding)) + const database = this.requireDatabase() + this.transaction(database, () => { + const previousRow = this.selectById(parsed.bindingId) + if (previousRow) { + const previous = this.decode(previousRow) + assertBindingUpdateAllowed(previous, parsed) + } else { + this.makeCapacityForInsert(database) + } + + if (parsed.state !== 'closed') { + this.assertConversationAvailable( + parsed.conversationId, + parsed.bindingId + ) + this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId) + } + + database + .prepare(` + INSERT INTO runtime_session_bindings ( + binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(binding_id) DO UPDATE SET + controller_id = excluded.controller_id, + conversation_id = excluded.conversation_id, + acp_session_id = excluded.acp_session_id, + state = excluded.state, + binding_json = excluded.binding_json + `) + .run( + parsed.bindingId, + parsed.controllerId, + parsed.conversationId, + parsed.acpSessionId, + parsed.state, + JSON.stringify(parsed) + ) + }) + } + + async claimAcpSession( + bindingId: string, + acpSessionId: string + ): Promise { + const database = this.requireDatabase() + let result: RuntimeSessionBinding | undefined + this.transaction(database, () => { + const row = this.selectById(bindingId) + if (!row) { + throw new RuntimeSessionBindingConflictError( + 'Cannot claim an ACP session for an unknown binding' + ) + } + const current = this.decode(row) + if (current.state === 'closed') { + throw new RuntimeSessionBindingConflictError( + 'Cannot claim an ACP session for a closed binding' + ) + } + const claimed = runtimeSessionBindingSchema.parse({ + ...current, + acpSessionId + }) + this.assertSessionAvailable(claimed.acpSessionId, bindingId) + database + .prepare(` + UPDATE runtime_session_bindings + SET acp_session_id = ?, binding_json = ? + WHERE binding_id = ? + `) + .run( + claimed.acpSessionId, + JSON.stringify(claimed), + claimed.bindingId + ) + result = structuredClone(claimed) + }) + if (!result) { + throw new RuntimeSessionBindingConflictError( + 'ACP session claim did not produce a binding' + ) + } + return structuredClone(result) + } + + async rotateReadyTransport( + bindingId: string, + expected: RuntimeSessionTransportIdentity, + next: RuntimeSessionTransportIdentity + ): Promise { + const database = this.requireDatabase() + let result: RuntimeSessionBinding | undefined + this.transaction(database, () => { + const row = this.selectById(bindingId) + if (!row) { + throw new RuntimeSessionBindingConflictError( + 'Cannot rotate transport for an unknown Runtime binding' + ) + } + const rotated = rotateReadyBinding( + this.decode(row), + bindingId, + expected, + next + ) + database + .prepare(` + UPDATE runtime_session_bindings + SET binding_json = ? + WHERE binding_id = ? + `) + .run(JSON.stringify(rotated), rotated.bindingId) + result = structuredClone(rotated) + }) + if (!result) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding transport rotation did not produce a binding' + ) + } + return structuredClone(result) + } + + async listByController( + controllerId: string, + options?: RuntimeSessionBindingListOptions + ): Promise { + const page = resolveListOptions(options) + const rows = this.requireDatabase() + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE controller_id = ? AND binding_id > ? + ORDER BY binding_id + LIMIT ? + `) + .all( + controllerId, + page.afterBindingId, + page.limit + ) as PersistedBindingRow[] + return rows.map((row) => this.decode(row)) + } + + async pruneClosed(limit = maximumPruneBatch): Promise { + const boundedLimit = boundedPositiveInteger( + limit, + maximumPruneBatch, + 'limit' + ) + const database = this.requireDatabase() + let deleted = 0 + this.transaction(database, () => { + deleted = this.deleteClosed(database, boundedLimit) + }) + return deleted + } + + close(): void { + this.database?.close() + this.database = undefined + } + + dispose(): void { + this.close() + } + + private selectById(bindingId: string): PersistedBindingRow | undefined { + return this.requireDatabase() + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE binding_id = ? + `) + .get(bindingId) as PersistedBindingRow | undefined + } + + private decode(row: PersistedBindingRow): RuntimeSessionBinding { + try { + const parsed = runtimeSessionBindingSchema.parse( + JSON.parse(row.binding_json) + ) + if ( + parsed.bindingId !== row.binding_id || + parsed.controllerId !== row.controller_id || + parsed.conversationId !== row.conversation_id || + parsed.acpSessionId !== row.acp_session_id || + parsed.state !== row.state + ) { + throw new Error('Indexed binding fields do not match payload') + } + return structuredClone(parsed) + } catch (error) { + throw new RuntimeSessionBindingCorruptionError(row.binding_id, { + cause: error + }) + } + } + + private assertConversationAvailable( + conversationId: string, + bindingId: string + ): void { + const owner = this.requireDatabase() + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE conversation_id = ? AND binding_id <> ? + AND ${liveBindingPredicate} + `) + .get(conversationId, bindingId) as PersistedBindingRow | undefined + if (owner) { + this.decode(owner) + throw new RuntimeSessionBindingConflictError( + 'Conversation is already owned by another live Runtime binding' + ) + } + } + + private assertSessionAvailable( + acpSessionId: string, + bindingId: string + ): void { + const owner = this.requireDatabase() + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE acp_session_id = ? AND binding_id <> ? + AND ${liveBindingPredicate} + `) + .get(acpSessionId, bindingId) as PersistedBindingRow | undefined + if (owner) { + this.decode(owner) + throw new RuntimeSessionClaimConflictError(acpSessionId) + } + } + + private makeCapacityForInsert(database: DatabaseSync): void { + const row = database + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_session_bindings' + ) + .get() as { count: number } + const required = row.count - this.maximumRows + 1 + if (required <= 0) { + return + } + this.deleteClosed(database, required) + const remaining = database + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_session_bindings' + ) + .get() as { count: number } + if (remaining.count >= this.maximumRows) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding store capacity is exhausted by authoritative bindings' + ) + } + } + + private enforceExistingCapacity(database: DatabaseSync): void { + const row = database + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_session_bindings' + ) + .get() as { count: number } + const excess = row.count - this.maximumRows + if (excess <= 0) { + return + } + this.transaction(database, () => { + this.deleteClosed(database, excess) + const remaining = database + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_session_bindings' + ) + .get() as { count: number } + if (remaining.count > this.maximumRows) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding store exceeds capacity with authoritative bindings' + ) + } + }) + } + + private deleteClosed(database: DatabaseSync, limit: number): number { + const rows = database + .prepare(` + SELECT binding_id, controller_id, conversation_id, acp_session_id, + state, binding_json + FROM runtime_session_bindings + WHERE state = 'closed' + ORDER BY binding_id + LIMIT ? + `) + .all(limit) as PersistedBindingRow[] + for (const row of rows) { + const binding = this.decode(row) + if (binding.state !== 'closed') { + throw new RuntimeSessionBindingCorruptionError(binding.bindingId) + } + } + const remove = database.prepare( + 'DELETE FROM runtime_session_bindings WHERE binding_id = ?' + ) + let deleted = 0 + for (const row of rows) { + deleted += Number(remove.run(row.binding_id).changes) + } + return deleted + } + + private transaction(database: DatabaseSync, operation: () => void): void { + database.exec('BEGIN IMMEDIATE') + try { + operation() + database.exec('COMMIT') + } catch (error) { + try { + database.exec('ROLLBACK') + } catch { + // Preserve the original domain, validation, or SQLite failure. + } + throw error + } + } + + private requireDatabase(): DatabaseSync { + if (!this.database) { + throw new Error('Runtime session binding store is closed') + } + return this.database + } +} + +/** A strict store useful for tests and non-persistent composition roots. */ +export class MemoryRuntimeSessionBindingStore + implements RuntimeSessionBindingStore +{ + private readonly bindings = new Map() + private readonly maximumRows: number + + constructor(options?: RuntimeSessionBindingStoreOptions) { + this.maximumRows = resolveMaximumRows(options) + } + + async getByConversation( + conversationId: string + ): Promise { + const binding = [...this.bindings.values()].find( + (candidate) => + candidate.conversationId === conversationId && + candidate.state !== 'closed' + ) + return binding ? structuredClone(binding) : undefined + } + + async getById( + bindingId: string + ): Promise { + const binding = this.bindings.get(bindingId) + return binding ? structuredClone(binding) : undefined + } + + async put(binding: RuntimeSessionBinding): Promise { + const parsed = runtimeSessionBindingSchema.parse(binding) + const previous = this.bindings.get(parsed.bindingId) + if (previous) { + assertBindingUpdateAllowed(previous, parsed) + } else { + this.makeCapacityForInsert() + } + if (parsed.state !== 'closed') { + const conversationOwner = [...this.bindings.values()].find( + (candidate) => + candidate.bindingId !== parsed.bindingId && + candidate.state !== 'closed' && + candidate.conversationId === parsed.conversationId + ) + if (conversationOwner) { + throw new RuntimeSessionBindingConflictError( + 'Conversation is already owned by another live Runtime binding' + ) + } + this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId) + } + this.bindings.set(parsed.bindingId, structuredClone(parsed)) + } + + async claimAcpSession( + bindingId: string, + acpSessionId: string + ): Promise { + const current = this.bindings.get(bindingId) + if (!current) { + throw new RuntimeSessionBindingConflictError( + 'Cannot claim an ACP session for an unknown binding' + ) + } + if (current.state === 'closed') { + throw new RuntimeSessionBindingConflictError( + 'Cannot claim an ACP session for a closed binding' + ) + } + this.assertSessionAvailable(acpSessionId, bindingId) + const claimed = runtimeSessionBindingSchema.parse({ + ...current, + acpSessionId + }) + this.bindings.set(bindingId, structuredClone(claimed)) + return structuredClone(claimed) + } + + async rotateReadyTransport( + bindingId: string, + expected: RuntimeSessionTransportIdentity, + next: RuntimeSessionTransportIdentity + ): Promise { + const current = this.bindings.get(bindingId) + if (!current) { + throw new RuntimeSessionBindingConflictError( + 'Cannot rotate transport for an unknown Runtime binding' + ) + } + const rotated = rotateReadyBinding( + current, + bindingId, + expected, + next + ) + this.bindings.set(bindingId, structuredClone(rotated)) + return structuredClone(rotated) + } + + private assertSessionAvailable( + acpSessionId: string, + bindingId: string + ): void { + const owner = [...this.bindings.values()].find( + (candidate) => + candidate.bindingId !== bindingId && + candidate.state !== 'closed' && + candidate.acpSessionId === acpSessionId + ) + if (owner) { + throw new RuntimeSessionClaimConflictError(acpSessionId) + } + } + + async listByController( + controllerId: string, + options?: RuntimeSessionBindingListOptions + ): Promise { + const page = resolveListOptions(options) + return [...this.bindings.values()] + .filter( + (binding) => + binding.controllerId === controllerId && + binding.bindingId > page.afterBindingId + ) + .sort((left, right) => + left.bindingId < right.bindingId + ? -1 + : left.bindingId > right.bindingId + ? 1 + : 0 + ) + .slice(0, page.limit) + .map((binding) => structuredClone(binding)) + } + + async pruneClosed(limit = maximumPruneBatch): Promise { + const boundedLimit = boundedPositiveInteger( + limit, + maximumPruneBatch, + 'limit' + ) + return this.deleteClosed(boundedLimit) + } + + private makeCapacityForInsert(): void { + const required = this.bindings.size - this.maximumRows + 1 + if (required <= 0) { + return + } + this.deleteClosed(required) + if (this.bindings.size >= this.maximumRows) { + throw new RuntimeSessionBindingConflictError( + 'Runtime binding store capacity is exhausted by authoritative bindings' + ) + } + } + + private deleteClosed(limit: number): number { + const closedIds = [...this.bindings.values()] + .filter((binding) => binding.state === 'closed') + .map((binding) => binding.bindingId) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .slice(0, limit) + for (const bindingId of closedIds) { + this.bindings.delete(bindingId) + } + return closedIds.length + } +} diff --git a/src/main/agent/selected-runtime-manager.test.ts b/src/main/agent/selected-runtime-manager.test.ts index 92f71f01..32a8224a 100644 --- a/src/main/agent/selected-runtime-manager.test.ts +++ b/src/main/agent/selected-runtime-manager.test.ts @@ -5,6 +5,34 @@ import type { } from './runtime' import { SelectedRuntimeManager } from './selected-runtime-manager' import { describe, expect, it, vi } from 'vitest' +import type { ExecutionSpaceDescriptor } from '../execution-space' + +function executionSpace( + cacheIdentity: string +): ExecutionSpaceDescriptor { + return { + kind: 'local', + rootPath: cacheIdentity, + cacheIdentity, + routeIdentity: cacheIdentity, + workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess'] + } +} + +function remoteExecutionSpace( + hostId: string, + cacheIdentity: string +): ExecutionSpaceDescriptor { + return { + kind: 'ssh', + hostId, + remoteRootPath: '/srv/project', + cacheIdentity, + routeIdentity: cacheIdentity, + workspaceAccess: + {} as ExecutionSpaceDescriptor['workspaceAccess'] + } +} function runtime() { const releaseConversation = vi.fn(async () => undefined) @@ -111,30 +139,26 @@ describe('SelectedRuntimeManager', () => { const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } - const projectOne = await manager.getRuntime( - selection, - 'C:\\Projects\\One' - ) + const firstSpace = executionSpace('local:project-one') + const secondSpace = executionSpace('local:project-two') + const projectOne = await manager.getRuntime(selection, firstSpace) const projectOneAgain = await manager.getRuntime( selection, - 'C:\\Projects\\One' - ) - const projectTwo = await manager.getRuntime( - selection, - 'C:\\Projects\\Two' + executionSpace('local:project-one') ) + const projectTwo = await manager.getRuntime(selection, secondSpace) expect(projectOneAgain).toBe(projectOne) expect(projectTwo).not.toBe(projectOne) expect(create).toHaveBeenNthCalledWith( 1, selection, - 'C:\\Projects\\One' + firstSpace ) expect(create).toHaveBeenNthCalledWith( 2, selection, - 'C:\\Projects\\Two' + secondSpace ) await manager.dispose() }) @@ -162,6 +186,64 @@ describe('SelectedRuntimeManager', () => { expect(second.dispose).toHaveBeenCalledOnce() }) + it('retires only cached runtimes for an edited SSH Host', async () => { + const firstHost = runtime() + const secondHost = runtime() + const local = runtime() + const replacement = runtime() + const create = vi + .fn() + .mockResolvedValueOnce(firstHost.value) + .mockResolvedValueOnce(secondHost.value) + .mockResolvedValueOnce(local.value) + .mockResolvedValueOnce(replacement.value) + const manager = new SelectedRuntimeManager(create) + const selection = { provider: 'opencode' as const } + const firstSpace = remoteExecutionSpace( + 'host-one', + 'ssh:host-one:revision-one' + ) + const secondSpace = remoteExecutionSpace( + 'host-two', + 'ssh:host-two:revision-one' + ) + const localSpace = executionSpace('local:project') + + const firstController = await manager.getRuntime( + selection, + firstSpace + ) + const secondController = await manager.getRuntime( + selection, + secondSpace + ) + const localController = await manager.getRuntime( + selection, + localSpace + ) + + await manager.invalidateHost('host-one') + + expect(firstHost.dispose).toHaveBeenCalledOnce() + expect(secondHost.dispose).not.toHaveBeenCalled() + expect(local.dispose).not.toHaveBeenCalled() + expect( + await manager.getRuntime(selection, secondSpace) + ).toBe(secondController) + expect( + await manager.getRuntime(selection, localSpace) + ).toBe(localController) + expect( + await manager.getRuntime(selection, firstSpace) + ).not.toBe(firstController) + expect(create).toHaveBeenCalledTimes(4) + + await manager.dispose() + expect(secondHost.dispose).toHaveBeenCalledOnce() + expect(local.dispose).toHaveBeenCalledOnce() + expect(replacement.dispose).toHaveBeenCalledOnce() + }) + it('disposes a connection-test runtime without caching it', async () => { const tested = runtime() const cached = runtime() @@ -194,7 +276,10 @@ describe('SelectedRuntimeManager', () => { const selection = { provider: 'opencode' as const } await expect( - manager.getNativeSnapshot(selection, 'C:\\Projects\\One') + manager.getNativeSnapshot( + selection, + executionSpace('local:project-one') + ) ).resolves.toMatchObject({ provider: 'opencode', inventoryStatus: 'available' @@ -202,7 +287,10 @@ describe('SelectedRuntimeManager', () => { expect(inspected.getNativeSnapshot).toHaveBeenCalledOnce() expect(inspected.dispose).toHaveBeenCalledOnce() - await manager.getRuntime(selection, 'C:\\Projects\\One') + await manager.getRuntime( + selection, + executionSpace('local:project-one') + ) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() }) @@ -297,6 +385,152 @@ describe('SelectedRuntimeManager', () => { await manager.dispose() }) + it('retires a drainable Runtime without forcing it and keeps routing conversation release', async () => { + let finishDrain!: () => void + const drain = new Promise((resolve) => { + finishDrain = resolve + }) + const active = runtime() + const beginDrain = vi.fn(async () => undefined) + const waitForDrain = vi.fn(async () => await drain) + const forceShutdown = vi.fn(async () => undefined) + Object.assign(active.value, { + beginDrain, + waitForDrain, + forceShutdown + }) + const manager = new SelectedRuntimeManager( + vi.fn(async () => active.value) + ) + const controller = await manager.getRuntime({ + provider: 'opencode' + }) + const stream = controller.run( + { + requestId: '00000000-0000-4000-8000-000000000021', + conversationId: 'conversation-draining', + prompt: 'finish normally', + workMode: 'ask' + }, + new AbortController().signal + ) + await stream.next() + await stream.next() + + await manager.reset() + expect(beginDrain).toHaveBeenCalledOnce() + expect(forceShutdown).not.toHaveBeenCalled() + expect(active.dispose).not.toHaveBeenCalled() + + await manager.releaseConversation('conversation-draining') + expect(active.releaseConversation).toHaveBeenCalledWith( + 'conversation-draining' + ) + finishDrain() + await vi.waitFor(() => + expect(active.dispose).toHaveBeenCalledOnce() + ) + expect(forceShutdown).not.toHaveBeenCalled() + await manager.dispose() + }) + + it('evicts only the least-recently-used idle Runtime at the cache bound', async () => { + const first = runtime() + const second = runtime() + const third = runtime() + const create = vi + .fn() + .mockResolvedValueOnce(first.value) + .mockResolvedValueOnce(second.value) + .mockResolvedValueOnce(third.value) + const manager = new SelectedRuntimeManager(create, 2) + const selection = (suffix: string) => ({ + provider: 'model' as const, + profileId: `00000000-0000-4000-8000-0000000000${suffix}` + }) + + const firstController = await manager.getRuntime(selection('31')) + await manager.getRuntime(selection('32')) + await expect( + manager.getRuntime(selection('31')) + ).resolves.toBe(firstController) + await manager.getRuntime(selection('33')) + + await vi.waitFor(() => + expect(second.dispose).toHaveBeenCalledOnce() + ) + expect(first.dispose).not.toHaveBeenCalled() + expect(third.dispose).not.toHaveBeenCalled() + await manager.dispose() + }) + + it('bounds current and drain-retained Runtime controllers across resets', async () => { + const created = [runtime(), runtime(), runtime()] + for (const item of created) { + Object.assign(item.value, { + beginDrain: vi.fn(async () => undefined), + waitForDrain: vi.fn(async () => await new Promise(() => undefined)), + forceShutdown: vi.fn(async () => undefined) + }) + } + const create = vi + .fn() + .mockResolvedValueOnce(created[0]!.value) + .mockResolvedValueOnce(created[1]!.value) + .mockResolvedValueOnce(created[2]!.value) + const manager = new SelectedRuntimeManager(create, 1, 2) + const selection = (suffix: string) => ({ + provider: 'model' as const, + profileId: `00000000-0000-4000-8000-0000000000${suffix}` + }) + + await manager.getRuntime(selection('41')) + await manager.reset() + await manager.getRuntime(selection('42')) + await manager.reset() + await expect( + manager.getRuntime(selection('43')) + ).rejects.toThrow(/退役容量/iu) + expect(create).toHaveBeenCalledTimes(2) + await manager.dispose() + expect(created[0]!.dispose).toHaveBeenCalledOnce() + expect(created[1]!.dispose).toHaveBeenCalledOnce() + }) + + it('reserves retained capacity while an invalidated Runtime is still being created', async () => { + let finishCreate!: (runtime: AgentRuntime) => void + const pendingCreate = new Promise((resolve) => { + finishCreate = resolve + }) + const created = runtime() + const create = vi.fn(async () => await pendingCreate) + const manager = new SelectedRuntimeManager(create, 1, 1) + const selection = (suffix: string) => ({ + provider: 'model' as const, + profileId: `00000000-0000-4000-8000-0000000000${suffix}` + }) + + const firstRuntime = manager + .getRuntime(selection('51')) + .catch((error: unknown) => error) + const reset = manager.reset() + + await expect( + manager.getRuntime(selection('52')) + ).rejects.toThrow(/退役容量/iu) + expect(create).toHaveBeenCalledOnce() + + finishCreate(created.value) + await expect(firstRuntime).resolves.toEqual( + expect.objectContaining({ + message: expect.stringMatching(/设置已更改/iu) + }) + ) + await reset + expect(created.dispose).toHaveBeenCalledOnce() + await manager.dispose() + }) + it('releases a conversation from every selected runtime', async () => { const first = runtime() const second = runtime() @@ -321,4 +555,40 @@ describe('SelectedRuntimeManager', () => { ) await manager.dispose() }) + + it('uses only the resolver-owned execution-space cache identity', async () => { + const create = vi.fn(async () => runtime().value) + const manager = new SelectedRuntimeManager(create) + const selection = { provider: 'opencode' as const } + const remote = ( + cacheIdentity: string + ): ExecutionSpaceDescriptor => ({ + kind: 'ssh', + hostId: 'host-one', + remoteRootPath: '/srv/project', + cacheIdentity, + routeIdentity: 'remote-route', + workspaceAccess: + {} as ExecutionSpaceDescriptor['workspaceAccess'] + }) + + const baseline = await manager.getRuntime( + selection, + remote('resolver-identity-one') + ) + await expect( + manager.getRuntime( + selection, + remote('resolver-identity-one') + ) + ).resolves.toBe(baseline) + await expect( + manager.getRuntime( + selection, + remote('resolver-identity-two') + ) + ).resolves.not.toBe(baseline) + expect(create).toHaveBeenCalledTimes(2) + await manager.dispose() + }) }) diff --git a/src/main/agent/selected-runtime-manager.ts b/src/main/agent/selected-runtime-manager.ts index 51f86ab6..4b61ee9c 100644 --- a/src/main/agent/selected-runtime-manager.ts +++ b/src/main/agent/selected-runtime-manager.ts @@ -12,11 +12,12 @@ import type { RuntimeConversationCompactOutcome } from './runtime' import { AgentRuntimeController } from './runtime-controller' +import type { ExecutionSpaceDescriptor } from '../execution-space' export type SelectedRuntimeResolver = { getRuntime( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise getStatus( selection: AgentRuntimeSelection @@ -26,65 +27,124 @@ export type SelectedRuntimeResolver = { ): Promise getNativeSnapshot( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise compactConversation( request: RuntimeConversationCompactInput, - workspacePath: string | undefined, + executionSpace: ExecutionSpaceDescriptor | undefined, signal: AbortSignal ): Promise releaseConversation(conversationId: string): Promise reset?(): Promise } +type RuntimeEntry = { + operation: Promise + controller?: AgentRuntimeController + hostId?: string + lastUsed: number +} + +const DEFAULT_MAXIMUM_CACHED_RUNTIMES = 8 +const DEFAULT_MAXIMUM_RETAINED_RUNTIMES = 16 + export class SelectedRuntimeManager implements SelectedRuntimeResolver { - private readonly entries = new Map< - string, - Promise - >() + private readonly entries = new Map() private disposed = false private readonly retiring = new Set>() + private readonly retiringControllers = + new Set() + private pendingRetirementControllers = 0 private readonly tests = new Set>() private readonly snapshots = new Set< Promise >() + private useSequence = 0 constructor( private readonly createRuntime: ( selection: AgentRuntimeSelection, - workspacePath?: string - ) => Promise - ) {} + executionSpace?: ExecutionSpaceDescriptor + ) => Promise, + private readonly maximumCachedRuntimes = + DEFAULT_MAXIMUM_CACHED_RUNTIMES, + private readonly maximumRetainedRuntimes = + DEFAULT_MAXIMUM_RETAINED_RUNTIMES + ) { + if ( + !Number.isSafeInteger(maximumCachedRuntimes) || + maximumCachedRuntimes < 1 || + maximumCachedRuntimes > 64 + ) { + throw new RangeError('Invalid selected Runtime cache limit') + } + if ( + !Number.isSafeInteger(maximumRetainedRuntimes) || + maximumRetainedRuntimes < maximumCachedRuntimes || + maximumRetainedRuntimes > 64 + ) { + throw new RangeError('Invalid retained Runtime limit') + } + } async getRuntime( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise { if (this.disposed) { throw new Error('Agent Runtime 正在关闭') } const key = JSON.stringify([ agentRuntimeSelectionKey(selection), - workspacePath ?? '' + executionSpace?.cacheIdentity ?? null ]) const existing = this.entries.get(key) if (existing) { - return existing + existing.lastUsed = ++this.useSequence + return existing.operation } - const operation = this.createRuntime(selection, workspacePath).then( + this.evictIdleEntries(this.maximumCachedRuntimes - 1) + if (this.entries.size >= this.maximumCachedRuntimes) { + throw new Error( + 'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话' + ) + } + if ( + this.entries.size + + this.retiringControllers.size + + this.pendingRetirementControllers >= + this.maximumRetainedRuntimes + ) { + throw new Error( + 'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话' + ) + } + const operation = this.createRuntime(selection, executionSpace).then( async (runtime) => { - if (this.disposed || this.entries.get(key) !== operation) { + if ( + this.disposed || + this.entries.get(key)?.operation !== operation + ) { await runtime.dispose() throw new Error('Runtime 设置已更改,请重新选择') } - return new AgentRuntimeController(runtime) + const controller = new AgentRuntimeController(runtime) + entry.controller = controller + return controller } ) - this.entries.set(key, operation) + const entry: RuntimeEntry = { + operation, + ...(executionSpace?.kind === 'ssh' + ? { hostId: executionSpace.hostId } + : {}), + lastUsed: ++this.useSequence + } + this.entries.set(key, entry) try { return await operation } catch (error) { - if (this.entries.get(key) === operation) { + if (this.entries.get(key) === entry) { this.entries.delete(key) } throw error @@ -114,14 +174,14 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { async getNativeSnapshot( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise { if (this.disposed) { throw new Error('Agent Runtime 正在关闭') } const operation = this.runNativeSnapshot( selection, - workspacePath + executionSpace ) this.snapshots.add(operation) try { @@ -133,27 +193,31 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { async compactConversation( request: RuntimeConversationCompactInput, - workspacePath: string | undefined, + executionSpace: ExecutionSpaceDescriptor | undefined, signal: AbortSignal ): Promise { const runtime = await this.getRuntime( request.runtimeSelection, - workspacePath + executionSpace ) return runtime.compactConversation(request, signal) } async releaseConversation(conversationId: string): Promise { const controllers = await Promise.allSettled([ - ...this.entries.values() + ...[...this.entries.values()].map(({ operation }) => operation) ]) + const current = controllers.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] + ) await Promise.allSettled( - controllers.flatMap((result) => - result.status === 'fulfilled' - ? [result.value.releaseConversation(conversationId)] - : [] + [...new Set([...current, ...this.retiringControllers])].map( + async (controller) => { + await controller.releaseConversation(conversationId) + } ) ) + this.evictIdleEntries(this.maximumCachedRuntimes) } async reset(): Promise { @@ -164,6 +228,20 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { ) } + async invalidateHost(hostId: string): Promise { + const entries: RuntimeEntry[] = [] + for (const [key, entry] of this.entries) { + if (entry.hostId !== hostId) { + continue + } + this.entries.delete(key) + entries.push(entry) + } + await Promise.allSettled( + entries.map((entry) => this.startRetiring(entry, false)) + ) + } + async dispose(): Promise { this.disposed = true const entries = [...this.entries.values()] @@ -171,6 +249,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { await Promise.allSettled( entries.map((entry) => this.startRetiring(entry, true)) ) + await Promise.allSettled( + [...this.retiringControllers].map( + async (controller) => await controller.dispose() + ) + ) await Promise.allSettled([...this.tests]) await Promise.allSettled([...this.snapshots]) await Promise.allSettled([...this.retiring]) @@ -195,9 +278,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { private async runNativeSnapshot( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise { - const runtime = await this.createRuntime(selection, workspacePath) + const runtime = await this.createRuntime(selection, executionSpace) try { if (this.disposed) { throw new Error('Agent Runtime 正在关闭') @@ -212,22 +295,75 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { } private async startRetiring( - entry: Promise, + entry: RuntimeEntry, waitForDisposal: boolean ): Promise { + this.pendingRetirementControllers += 1 + let controllerPending = true try { - const controller = await entry - const disposal = controller.dispose() - this.retiring.add(disposal) - void disposal.then( - () => this.retiring.delete(disposal), - () => this.retiring.delete(disposal) + const controller = + entry.controller ?? await entry.operation + this.pendingRetirementControllers -= 1 + controllerPending = false + const disposal = this.trackRetiringController( + controller, + waitForDisposal ) if (waitForDisposal) { await disposal } } catch { return + } finally { + if (controllerPending) { + this.pendingRetirementControllers -= 1 + } + } + } + + private trackRetiringController( + controller: AgentRuntimeController, + force: boolean + ): Promise { + this.retiringControllers.add(controller) + const disposal = force + ? controller.dispose() + : controller.retire() + this.retiring.add(disposal) + void disposal.then( + () => { + this.retiring.delete(disposal) + this.retiringControllers.delete(controller) + }, + () => { + this.retiring.delete(disposal) + this.retiringControllers.delete(controller) + } + ) + return disposal + } + + private evictIdleEntries(targetSize: number): void { + if (this.entries.size <= targetSize) { + return + } + const candidates = [...this.entries.entries()] + .filter((entry): entry is [string, RuntimeEntry] => { + const controller = entry[1].controller + return controller !== undefined && controller.canRetire + }) + .sort( + (left, right) => left[1].lastUsed - right[1].lastUsed + ) + for (const [key, entry] of candidates) { + if (this.entries.size <= targetSize) { + break + } + if (this.entries.get(key) !== entry) { + continue + } + this.entries.delete(key) + void this.startRetiring(entry, false) } } } diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index a41cb7dd..c766fbf5 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -39,6 +39,67 @@ async function createDatabase( return database } +const validatedSshHostId = + '00000000-0000-4000-8000-000000000321' +const validatedSshAgentId = 'agent-installation-1' +const validatedSshDigest = `sha256:${'b'.repeat(64)}` + +function validatedSshProjectWrite( + overrides: { + rootPath?: string + hostId?: string + agentInstallationId?: string + runtimeSelectionKey?: string + assertSshHostCurrent?: Parameters< + AssistantDatabase['createValidatedSshProject'] + >[0]['assertSshHostCurrent'] + } = {} +): Parameters< + AssistantDatabase['createValidatedSshProject'] +>[0] { + const rootPath = overrides.rootPath ?? '/srv/goodbuddy' + const hostId = overrides.hostId ?? validatedSshHostId + const agentInstallationId = + overrides.agentInstallationId ?? validatedSshAgentId + return { + project: { + name: '远程项目', + description: '原子持久化', + rootPath, + defaultWorkMode: 'execute', + runtimeSelection: { provider: 'opencode' } + }, + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: rootPath, + validation: { + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + workspaceIdentity: 'workspace-identity', + agentProtocolMajor: 1, + agentInstallationIdAtValidation: agentInstallationId, + agentBinaryDigestAtValidation: validatedSshDigest, + agentVersionAtValidation: '0.11.0', + agentArchitectureAtValidation: 'x64', + validatedAt: '2026-08-21T00:00:00.000Z' + } + }, + runtimeValidation: { + runtimeSelectionKey: + overrides.runtimeSelectionKey ?? 'opencode:default', + runtimeBundleDigest: `sha256:${'c'.repeat(64)}`, + runtimeAdapterDigest: `sha256:${'d'.repeat(64)}`, + agentInstallationIdAtValidation: agentInstallationId, + validatedAt: '2026-08-21T00:01:00.000Z', + workMode: 'execute' + }, + assertSshHostCurrent: + overrides.assertSshHostCurrent ?? (() => {}) + } +} + function claimQueuedSchedules( database: AssistantDatabase, now: Date, @@ -169,7 +230,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 26', async () => { + it('migrates existing databases to schema version 30', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -198,7 +259,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(26) + ).toBe(30) expect( current .prepare( @@ -307,6 +368,385 @@ describe('AssistantDatabase', () => { current.close() }) + it('backfills every v26 project as a local execution space without changing project data', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-execution-space-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('') + const blankRootProject = initial.listProjects()[0]! + const project = initial.createProject({ + name: '迁移项目', + description: '保留全部项目字段', + rootPath: 'D:\\Migration', + defaultWorkMode: 'execute', + runtimeSelection: { provider: 'continue' } + }) + initial.close() + + const legacy = new DatabaseSync(databasePath) + const projectRowsBefore = legacy + .prepare('SELECT * FROM projects ORDER BY rowid') + .all() + legacy.exec(` + DROP TABLE project_runtime_validations; + DROP TABLE project_execution_spaces; + PRAGMA user_version = 26; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\MustNotReplaceBlank') + expect(migrated.getProject(blankRootProject.id)).toMatchObject({ + id: blankRootProject.id, + rootPath: '', + executionSpace: { kind: 'local', rootPath: '' } + }) + expect(migrated.getProject(project.id)).toMatchObject({ + id: project.id, + name: '迁移项目', + description: '保留全部项目字段', + rootPath: 'D:\\Migration', + defaultWorkMode: 'execute', + runtimeSelection: { provider: 'continue' }, + executionSpace: { + kind: 'local', + rootPath: 'D:\\Migration' + } + }) + migrated.close() + + const inspected = new DatabaseSync(databasePath, { + enableForeignKeyConstraints: true + }) + expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ + user_version: 30 + }) + expect( + inspected.prepare('SELECT * FROM projects ORDER BY rowid').all() + ).toEqual(projectRowsBefore) + expect( + inspected + .prepare( + `SELECT project_id, kind, root_path, ssh_host_id, + host_revision, host_key_generation, remote_username, + workspace_identity, + agent_installation_id_at_validation, + agent_binary_digest_at_validation, + agent_version_at_validation, + agent_architecture_at_validation, + agent_protocol_major, trust_attestation_revision, + validated_at + FROM project_execution_spaces + ORDER BY project_id` + ) + .all() + ).toEqual([ + { + project_id: blankRootProject.id, + kind: 'local', + root_path: '', + ssh_host_id: null, + host_revision: null, + host_key_generation: null, + remote_username: null, + workspace_identity: null, + agent_installation_id_at_validation: null, + agent_binary_digest_at_validation: null, + agent_version_at_validation: null, + agent_architecture_at_validation: null, + agent_protocol_major: null, + trust_attestation_revision: null, + validated_at: null + }, + { + project_id: project.id, + kind: 'local', + root_path: 'D:\\Migration', + ssh_host_id: null, + host_revision: null, + host_key_generation: null, + remote_username: null, + workspace_identity: null, + agent_installation_id_at_validation: null, + agent_binary_digest_at_validation: null, + agent_version_at_validation: null, + agent_architecture_at_validation: null, + agent_protocol_major: null, + trust_attestation_revision: null, + validated_at: null + } + ].sort((left, right) => + left.project_id.localeCompare(right.project_id) + )) + expect( + inspected + .prepare('PRAGMA foreign_key_list(project_execution_spaces)') + .all() + ).toEqual([ + expect.objectContaining({ + table: 'projects', + from: 'project_id', + to: 'id', + on_delete: 'CASCADE' + }) + ]) + expect( + inspected + .prepare('PRAGMA foreign_key_list(project_runtime_validations)') + .all() + ).toEqual([ + expect.objectContaining({ + table: 'projects', + from: 'project_id', + to: 'id', + on_delete: 'CASCADE' + }) + ]) + expect( + inspected + .prepare('PRAGMA index_list(project_runtime_validations)') + .all() + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ origin: 'pk', unique: 1 }) + ]) + ) + expect(() => + inspected + .prepare( + `UPDATE project_execution_spaces + SET ssh_host_id = ? + WHERE project_id = ?` + ) + .run( + '00000000-0000-4000-8000-000000000310', + blankRootProject.id + ) + ).toThrow() + const insertRuntimeValidation = inspected.prepare( + `INSERT INTO project_runtime_validations + (project_id, runtime_selection_key, runtime_bundle_digest, + runtime_adapter_digest, confinement_policy_digest, + approval_bridge_version, + agent_installation_id_at_validation, validated_at, work_mode, + trust_tier, trust_attestation_revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + insertRuntimeValidation.run( + project.id, + 'continue', + `sha256:${'c'.repeat(64)}`, + `sha256:${'d'.repeat(64)}`, + `sha256:${'d'.repeat(64)}`, + 'unused', + 'agent-installation', + '2026-08-21T00:00:00.000Z', + 'execute', + null, + null + ) + expect(() => + insertRuntimeValidation.run( + project.id, + 'opencode', + `sha256:${'f'.repeat(64)}`, + `sha256:${'1'.repeat(64)}`, + `sha256:${'1'.repeat(64)}`, + 'unused', + 'other-agent-installation', + '2026-08-21T00:01:00.000Z', + 'execute', + null, + null + ) + ).toThrow() + inspected.close() + }) + + it('preserves v27 SSH rows but fails closed until live Agent identity is revalidated', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-v27-ssh-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const project = initial.createProject({ + name: '旧远程项目', + description: '', + rootPath: '/srv/legacy', + defaultWorkMode: 'execute', + runtimeSelection: { provider: 'opencode' } + }) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP TRIGGER project_execution_spaces_validate_insert; + DROP TRIGGER project_execution_spaces_validate_update; + ALTER TABLE project_execution_spaces + DROP COLUMN agent_binary_digest_at_validation; + ALTER TABLE project_execution_spaces + DROP COLUMN agent_version_at_validation; + ALTER TABLE project_execution_spaces + DROP COLUMN agent_architecture_at_validation; + `) + legacy + .prepare( + `UPDATE project_execution_spaces + SET kind = 'ssh', ssh_host_id = ?, host_revision = 2, + host_key_generation = 3, remote_username = 'builder', + workspace_identity = 'workspace-legacy', + agent_installation_id_at_validation = 'agent-legacy', + agent_protocol_major = 1, + trust_attestation_revision = 4, + validated_at = '2026-08-20T00:00:00.000Z' + WHERE project_id = ?` + ) + .run(validatedSshHostId, project.id) + legacy.exec('PRAGMA user_version = 27;') + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect(() => migrated.getProject(project.id)).toThrow( + '执行空间配置无效' + ) + migrated.close() + + const inspected = new DatabaseSync(databasePath) + expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ + user_version: 30 + }) + expect( + inspected + .prepare( + `SELECT agent_binary_digest_at_validation, + agent_version_at_validation, + agent_architecture_at_validation + FROM project_execution_spaces + WHERE project_id = ?` + ) + .get(project.id) + ).toEqual({ + agent_binary_digest_at_validation: null, + agent_version_at_validation: null, + agent_architecture_at_validation: null + }) + inspected.close() + }) + + it('migrates v28 Runtime rows without inventing trust evidence', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-v28-runtime-migration-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const project = initial.createValidatedSshProject( + validatedSshProjectWrite() + ) + initial.close() + + const legacy = new DatabaseSync(databasePath) + legacy.exec(` + DROP TRIGGER project_runtime_validations_validate_insert; + DROP TRIGGER project_runtime_validations_validate_update; + ALTER TABLE project_runtime_validations DROP COLUMN work_mode; + ALTER TABLE project_runtime_validations DROP COLUMN trust_tier; + ALTER TABLE project_runtime_validations + DROP COLUMN trust_attestation_revision; + PRAGMA user_version = 28; + `) + legacy.close() + + const migrated = new AssistantDatabase(databasePath) + migrated.initialize('C:\\Workspace') + expect(migrated.getProject(project.id)).toMatchObject({ + id: project.id, + defaultWorkMode: 'execute' + }) + expect( + migrated.getProject(project.id).runtimeValidation + ).toBeUndefined() + + const inspected = new DatabaseSync(databasePath) + expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ + user_version: 30 + }) + expect( + inspected + .prepare( + `SELECT work_mode, confinement_policy_digest, + approval_bridge_version, runtime_adapter_digest, + trust_tier, trust_attestation_revision + FROM project_runtime_validations + WHERE project_id = ?` + ) + .get(project.id) + ).toEqual({ + work_mode: null, + confinement_policy_digest: `sha256:${'d'.repeat(64)}`, + approval_bridge_version: 'unused', + runtime_adapter_digest: `sha256:${'d'.repeat(64)}`, + trust_tier: null, + trust_attestation_revision: null + }) + inspected + .prepare( + `UPDATE project_runtime_validations + SET work_mode = 'execute' + WHERE project_id = ?` + ) + .run(project.id) + expect(() => + inspected + .prepare( + `UPDATE project_runtime_validations + SET work_mode = 'ask', trust_tier = 'T3', + trust_attestation_revision = 5 + WHERE project_id = ?` + ) + .run(project.id) + ).toThrow('invalid project runtime validation') + inspected.close() + + expect(migrated.getProject(project.id).runtimeValidation).toMatchObject({ + workMode: 'execute' + }) + migrated.close() + }) + + it('fails closed when a project execution-space row is missing', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-missing-execution-space-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const raw = new DatabaseSync(databasePath) + raw + .prepare( + 'DELETE FROM project_execution_spaces WHERE project_id = ?' + ) + .run(project.id) + raw.close() + + expect(() => database.getProject(project.id)).toThrow( + '缺少执行空间配置' + ) + expect(() => database.listProjects()).toThrow( + '缺少执行空间配置' + ) + database.close() + }) + it('backfills one exact legacy built-in default candidate', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-default-project-migration-') @@ -505,7 +945,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(26) + ).toBe(30) expect( current .prepare( @@ -643,7 +1083,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 26 }) + ).toEqual({ user_version: 30 }) expect( inspected .prepare( @@ -776,6 +1216,10 @@ describe('AssistantDatabase', () => { name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Workspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Workspace' + }, defaultWorkMode: 'ask', kind: 'user', builtInDefault: true, @@ -810,6 +1254,10 @@ describe('AssistantDatabase', () => { defaultWorkMode: 'ask' }) expect(project.builtInDefault).toBe(false) + expect(project.executionSpace).toEqual({ + kind: 'local', + rootPath: 'C:\\Release' + }) expect(database.listProjects()).toHaveLength(2) const updated = database.updateProject(project.id, { @@ -824,6 +1272,10 @@ describe('AssistantDatabase', () => { expect(updated).toMatchObject({ name: '产品发布 2', defaultWorkMode: 'execute', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Release' + }, runtimeSelection: { provider: 'continue' } @@ -841,6 +1293,605 @@ describe('AssistantDatabase', () => { database.close() }) + it('atomically creates and updates projects with local execution spaces', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-project-execution-rollback-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.createProject({ + name: '原始项目', + description: '原始说明', + rootPath: '', + defaultWorkMode: 'ask' + }) + expect(project).toMatchObject({ + rootPath: '', + executionSpace: { kind: 'local', rootPath: '' } + }) + + const raw = new DatabaseSync(databasePath) + raw.exec(` + CREATE TRIGGER reject_blocked_execution_space_insert + BEFORE INSERT ON project_execution_spaces + WHEN NEW.root_path = 'blocked-insert' + BEGIN + SELECT RAISE(ABORT, 'forced execution-space insert failure'); + END; + CREATE TRIGGER reject_blocked_execution_space_update + BEFORE UPDATE ON project_execution_spaces + WHEN NEW.root_path = 'blocked-update' + BEGIN + SELECT RAISE(ABORT, 'forced execution-space update failure'); + END; + `) + raw.close() + + expect(() => + database.createProject({ + name: '不能半创建', + description: '', + rootPath: 'blocked-insert', + defaultWorkMode: 'ask' + }) + ).toThrow('forced execution-space insert failure') + expect( + database + .listProjects(true) + .some((candidate) => candidate.name === '不能半创建') + ).toBe(false) + + expect(() => + database.updateProject(project.id, { + name: '不能半更新', + description: '不能保留', + rootPath: 'blocked-update', + defaultWorkMode: 'execute' + }) + ).toThrow('forced execution-space update failure') + expect(database.getProject(project.id)).toMatchObject({ + name: '原始项目', + description: '原始说明', + rootPath: '', + defaultWorkMode: 'ask', + executionSpace: { kind: 'local', rootPath: '' } + }) + database.close() + }) + + it('maps SSH execution spaces and lists projects referencing a host', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-ssh-project-reference-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.createProject({ + name: '远程项目', + description: '', + rootPath: 'C:\\LegacyProjection', + defaultWorkMode: 'ask' + }) + const hostId = '00000000-0000-4000-8000-000000000311' + const raw = new DatabaseSync(databasePath) + raw + .prepare( + `UPDATE project_execution_spaces + SET kind = 'ssh', root_path = ?, ssh_host_id = ?, + host_revision = ?, host_key_generation = ?, + remote_username = ?, workspace_identity = ?, + agent_installation_id_at_validation = ?, + agent_binary_digest_at_validation = ?, + agent_version_at_validation = ?, + agent_architecture_at_validation = ?, + agent_protocol_major = ?, + trust_attestation_revision = ?, validated_at = ? + WHERE project_id = ?` + ) + .run( + '/srv/goodbuddy', + hostId, + 2, + 3, + 'builder', + 'workspace-identity', + 'agent-installation', + `sha256:${'a'.repeat(64)}`, + '0.11.0', + 'x64', + 1, + 0, + '2026-08-21T00:00:00.000Z', + project.id + ) + raw + .prepare( + `INSERT INTO project_runtime_validations + (project_id, runtime_selection_key, runtime_bundle_digest, + runtime_adapter_digest, confinement_policy_digest, + approval_bridge_version, + agent_installation_id_at_validation, validated_at, work_mode, + trust_tier, trust_attestation_revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + project.id, + 'opencode:profile', + `sha256:${'b'.repeat(64)}`, + `sha256:${'c'.repeat(64)}`, + `sha256:${'c'.repeat(64)}`, + 'unused', + 'agent-installation', + '2026-08-21T00:01:00.000Z', + 'ask', + null, + null + ) + raw.close() + + expect(database.getProject(project.id)).toEqual({ + ...project, + rootPath: '/srv/goodbuddy', + id: project.id, + executionSpace: { + kind: 'ssh', + hostId, + remoteRootPath: '/srv/goodbuddy', + validation: { + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + workspaceIdentity: 'workspace-identity', + agentProtocolMajor: 1, + agentInstallationIdAtValidation: 'agent-installation', + agentBinaryDigestAtValidation: `sha256:${'a'.repeat(64)}`, + agentVersionAtValidation: '0.11.0', + agentArchitectureAtValidation: 'x64', + validatedAt: '2026-08-21T00:00:00.000Z' + } + }, + runtimeValidation: { + runtimeSelectionKey: 'opencode:profile', + runtimeBundleDigest: `sha256:${'b'.repeat(64)}`, + runtimeAdapterDigest: `sha256:${'c'.repeat(64)}`, + agentInstallationIdAtValidation: 'agent-installation', + validatedAt: '2026-08-21T00:01:00.000Z', + workMode: 'ask' + } + }) + expect(database.listProjectIdsReferencingSshHost(hostId)).toEqual([ + project.id + ]) + expect( + database.listProjectIdsReferencingSshHost( + '00000000-0000-4000-8000-000000000312' + ) + ).toEqual([]) + database.close() + }) + + it('fails closed on malformed persisted validation timestamps', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-invalid-execution-trust-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const raw = new DatabaseSync(databasePath) + raw + .prepare( + `UPDATE project_execution_spaces + SET kind = 'ssh', root_path = ?, ssh_host_id = ?, + host_revision = ?, host_key_generation = ?, + remote_username = ?, workspace_identity = ?, + agent_installation_id_at_validation = ?, + agent_binary_digest_at_validation = ?, + agent_version_at_validation = ?, + agent_architecture_at_validation = ?, + agent_protocol_major = ?, + trust_attestation_revision = ?, validated_at = ? + WHERE project_id = ?` + ) + .run( + '/srv/goodbuddy', + '00000000-0000-4000-8000-000000000313', + 1, + 1, + 'builder', + 'workspace-identity', + 'agent-installation', + `sha256:${'a'.repeat(64)}`, + '0.11.0', + 'arm64', + 1, + 0, + 'not-a-timestamp', + project.id + ) + + expect(() => database.getProject(project.id)).toThrow( + '执行空间配置无效' + ) + raw + .prepare( + `UPDATE project_execution_spaces + SET validated_at = ? + WHERE project_id = ?` + ) + .run('2026-08-21T00:00:00.000Z', project.id) + raw + .prepare( + `INSERT INTO project_runtime_validations + (project_id, runtime_selection_key, runtime_bundle_digest, + runtime_adapter_digest, confinement_policy_digest, + approval_bridge_version, + agent_installation_id_at_validation, validated_at, work_mode, + trust_tier, trust_attestation_revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + project.id, + 'opencode', + `sha256:${'b'.repeat(64)}`, + `sha256:${'c'.repeat(64)}`, + `sha256:${'c'.repeat(64)}`, + 'unused', + 'agent-installation', + 'not-a-timestamp', + 'ask', + null, + null + ) + raw.close() + + expect(() => database.getProject(project.id)).toThrow( + 'Runtime 验证配置无效' + ) + database.close() + }) + + it('atomically creates a fully validated SSH project', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-validated-ssh-create-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const precondition = vi.fn() + const project = database.createValidatedSshProject( + validatedSshProjectWrite({ + assertSshHostCurrent: precondition + }) + ) + + expect(precondition).toHaveBeenCalledWith({ + hostId: validatedSshHostId, + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + remoteRootPath: '/srv/goodbuddy', + workspaceIdentity: 'workspace-identity', + agentProtocolMajor: 1, + agentInstallationId: validatedSshAgentId, + agentBinaryDigest: validatedSshDigest, + agentVersion: '0.11.0', + agentArchitecture: 'x64' + }) + expect(project).toMatchObject({ + name: '远程项目', + rootPath: '/srv/goodbuddy', + runtimeSelection: { provider: 'opencode' }, + executionSpace: { + kind: 'ssh', + hostId: validatedSshHostId, + remoteRootPath: '/srv/goodbuddy', + validation: { + agentInstallationIdAtValidation: validatedSshAgentId, + agentBinaryDigestAtValidation: validatedSshDigest, + agentVersionAtValidation: '0.11.0', + agentArchitectureAtValidation: 'x64' + } + }, + runtimeValidation: { + runtimeSelectionKey: 'opencode:default', + agentInstallationIdAtValidation: validatedSshAgentId + } + }) + expect(project).toEqual(database.getProject(project.id)) + const raw = new DatabaseSync(databasePath) + expect( + raw + .prepare( + `SELECT + (SELECT COUNT(*) FROM projects WHERE id = ?) AS projects, + (SELECT COUNT(*) FROM project_execution_spaces + WHERE project_id = ?) AS execution_spaces, + (SELECT COUNT(*) FROM project_runtime_validations + WHERE project_id = ?) AS runtime_validations` + ) + .get(project.id, project.id, project.id) + ).toEqual({ + projects: 1, + execution_spaces: 1, + runtime_validations: 1 + }) + raw.close() + database.close() + }) + + it('rejects mismatched SSH candidates before persisting rows', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-invalid-ssh-candidate-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const baseline = database.listProjects().length + + const rootMismatch = validatedSshProjectWrite() + rootMismatch.executionSpace.remoteRootPath = '/srv/other' + expect(() => + database.createValidatedSshProject(rootMismatch) + ).toThrow('目录不匹配') + + expect(() => + database.createValidatedSshProject( + validatedSshProjectWrite({ + runtimeSelectionKey: 'continue:platform' + }) + ) + ).toThrow('Runtime 验证与项目 Runtime 选择不匹配') + + const modeMismatch = validatedSshProjectWrite() + modeMismatch.project.defaultWorkMode = 'ask' + expect(() => + database.createValidatedSshProject(modeMismatch) + ).toThrow('Runtime 验证与项目默认工作模式不匹配') + + const identityMismatch = validatedSshProjectWrite() + identityMismatch.runtimeValidation.agentInstallationIdAtValidation = + 'other-agent' + expect(() => + database.createValidatedSshProject(identityMismatch) + ).toThrow('Agent 身份不匹配') + + expect(() => + database.createValidatedSshProject( + validatedSshProjectWrite({ + assertSshHostCurrent: () => { + throw new Error('SSH Host 已变化') + } + }) + ) + ).toThrow('SSH Host 已变化') + + expect(database.listProjects()).toHaveLength(baseline) + const raw = new DatabaseSync(databasePath) + expect( + raw + .prepare('SELECT COUNT(*) AS count FROM project_runtime_validations') + .get() + ).toEqual({ count: 0 }) + raw.close() + database.close() + }) + + it('optimistically updates validated SSH projects without moving them', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-21T01:00:00.000Z')) + const database = await createDatabase() + const created = database.createValidatedSshProject( + validatedSshProjectWrite() + ) + const write = validatedSshProjectWrite() + write.project.name = '已重新验证' + write.project.description = '更新后的配置' + write.executionSpace.validation.validatedAt = + '2026-08-21T01:01:00.000Z' + write.runtimeValidation.validatedAt = + '2026-08-21T01:01:01.000Z' + + const updated = database.updateValidatedSshProject( + created.id, + created.updatedAt, + write + ) + expect(updated.name).toBe('已重新验证') + expect(updated.updatedAt).not.toBe(created.updatedAt) + expect( + updated.executionSpace.kind === 'ssh' && + updated.executionSpace.validation?.validatedAt + ).toBe('2026-08-21T01:01:00.000Z') + expect(updated).toEqual(database.getProject(created.id)) + + const beforeRejectedPrecondition = database.getProject(created.id) + const rejectedWrite = validatedSshProjectWrite({ + assertSshHostCurrent: () => { + throw new Error('SSH Host 已变化') + } + }) + rejectedWrite.project.name = '不应保存' + expect(() => + database.updateValidatedSshProject( + created.id, + updated.updatedAt, + rejectedWrite + ) + ).toThrow('SSH Host 已变化') + expect(database.getProject(created.id)).toEqual( + beforeRejectedPrecondition + ) + + expect(() => + database.updateValidatedSshProject( + created.id, + created.updatedAt, + write + ) + ).toThrow('项目已被其他操作更新') + + const moved = validatedSshProjectWrite({ + rootPath: '/srv/moved' + }) + expect(() => + database.updateValidatedSshProject( + created.id, + updated.updatedAt, + moved + ) + ).toThrow('执行位置不能通过此更新修改') + + const otherHost = validatedSshProjectWrite({ + hostId: '00000000-0000-4000-8000-000000000322' + }) + expect(() => + database.updateValidatedSshProject( + created.id, + updated.updatedAt, + otherHost + ) + ).toThrow('执行位置不能通过此更新修改') + + const localProject = database.createProject({ + name: '本地项目', + description: '', + rootPath: 'D:\\Local', + defaultWorkMode: 'ask' + }) + expect(() => + database.updateValidatedSshProject( + localProject.id, + localProject.updatedAt, + validatedSshProjectWrite() + ) + ).toThrow('仅支持更新已有远程项目') + const [channelProject] = database.ensureChannelProjects( + 'C:\\Channel', + channelDefaultProfileId + ) + expect(() => + database.updateValidatedSshProject( + channelProject!.id, + channelProject!.updatedAt, + validatedSshProjectWrite() + ) + ).toThrow('系统通道项目不支持远程执行空间') + database.close() + }) + + it('rolls back SSH writes when strict project mapping fails before commit', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-ssh-mapping-rollback-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const raw = new DatabaseSync(databasePath) + raw.exec(` + CREATE TRIGGER corrupt_created_runtime_validation + AFTER INSERT ON project_runtime_validations + BEGIN + UPDATE project_runtime_validations + SET validated_at = 'not-a-timestamp' + WHERE project_id = NEW.project_id; + END; + `) + raw.close() + const projectsBefore = database.listProjects().length + + expect(() => + database.createValidatedSshProject(validatedSshProjectWrite()) + ).toThrow('Runtime 验证配置无效') + expect(database.listProjects()).toHaveLength(projectsBefore) + + const setup = new DatabaseSync(databasePath) + setup.exec('DROP TRIGGER corrupt_created_runtime_validation') + setup.close() + const created = database.createValidatedSshProject( + validatedSshProjectWrite() + ) + const persistedBeforeUpdate = database.getProject(created.id) + const updateTrigger = new DatabaseSync(databasePath) + updateTrigger.exec(` + CREATE TRIGGER corrupt_updated_runtime_validation + AFTER UPDATE ON project_runtime_validations + BEGIN + UPDATE project_runtime_validations + SET validated_at = 'not-a-timestamp' + WHERE project_id = NEW.project_id; + END; + `) + updateTrigger.close() + const write = validatedSshProjectWrite() + write.project.name = '不应提交的项目名' + + expect(() => + database.updateValidatedSshProject( + created.id, + created.updatedAt, + write + ) + ).toThrow('Runtime 验证配置无效') + expect(database.getProject(created.id)).toEqual( + persistedBeforeUpdate + ) + + database.close() + }) + + it('rolls back every SSH project table when a trigger rejects the write', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-ssh-atomic-rollback-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const raw = new DatabaseSync(databasePath) + raw.exec(` + CREATE TRIGGER reject_runtime_validation + BEFORE INSERT ON project_runtime_validations + BEGIN + SELECT RAISE(ABORT, 'test runtime rejection'); + END; + `) + raw.close() + const projectsBefore = database.listProjects().length + + expect(() => + database.createValidatedSshProject(validatedSshProjectWrite()) + ).toThrow('test runtime rejection') + + const inspected = new DatabaseSync(databasePath) + expect( + inspected.prepare('SELECT COUNT(*) AS count FROM projects').get() + ).toEqual({ count: projectsBefore }) + expect( + inspected + .prepare( + `SELECT COUNT(*) AS count + FROM project_execution_spaces + WHERE kind = 'ssh'` + ) + .get() + ).toEqual({ count: 0 }) + expect( + inspected + .prepare('SELECT COUNT(*) AS count FROM project_runtime_validations') + .get() + ).toEqual({ count: 0 }) + inspected.close() + database.close() + }) + it('idempotently creates protected channel projects by channel identity', async () => { const database = await createDatabase() const sameName = database.createProject({ @@ -863,6 +1914,10 @@ describe('AssistantDatabase', () => { expect.objectContaining({ name: '微信 ClawBot', rootPath: 'C:\\Users\\test', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Users\\test' + }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', @@ -904,6 +1959,10 @@ describe('AssistantDatabase', () => { name: '微信 ClawBot', description: '更新后的通道说明', rootPath: 'C:\\Remote', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Remote' + }, defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode', diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 48db5d3e..9a8d489d 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -6,6 +6,9 @@ import { conversationSnapshotSchema, expertCreateSchema, normalizeInteractiveWorkMode, + persistedProjectExecutionSpaceSchema, + projectCreateSchema, + projectRuntimeValidationSchema, scheduleCreateSchema } from '../../shared/assistant-contracts' import type { @@ -33,7 +36,9 @@ import type { ModelUsageCallInput, ProjectChannel, ProjectCreateInput, + ProjectRuntimeValidation, ScheduleCreateInput, + SshExecutionValidation, TokenUsageRecord, TokenUsageSummary } from '../../shared/assistant-contracts' @@ -77,6 +82,10 @@ import { } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 30 + +const LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION = 'unused' + type ProjectRow = { id: string name: string @@ -90,8 +99,79 @@ type ProjectRow = { status: AssistantProject['status'] created_at: string updated_at: string + execution_space_project_id: string | null + execution_space_kind: string | null + execution_space_root_path: string | null + execution_space_ssh_host_id: string | null + execution_space_host_revision: number | null + execution_space_host_key_generation: number | null + execution_space_remote_username: string | null + execution_space_workspace_identity: string | null + execution_space_agent_installation_id_at_validation: string | null + execution_space_agent_binary_digest_at_validation: string | null + execution_space_agent_version_at_validation: string | null + execution_space_agent_architecture_at_validation: string | null + execution_space_agent_protocol_major: number | null + execution_space_validated_at: string | null + runtime_validation_project_id: string | null + runtime_validation_runtime_selection_key: string | null + runtime_validation_runtime_bundle_digest: string | null + runtime_validation_runtime_adapter_digest: string | null + runtime_validation_agent_installation_id_at_validation: string | null + runtime_validation_validated_at: string | null + runtime_validation_work_mode: string | null } +const projectSelect = ` + SELECT projects.*, + project_execution_spaces.project_id + AS execution_space_project_id, + project_execution_spaces.kind AS execution_space_kind, + project_execution_spaces.root_path + AS execution_space_root_path, + project_execution_spaces.ssh_host_id + AS execution_space_ssh_host_id, + project_execution_spaces.host_revision + AS execution_space_host_revision, + project_execution_spaces.host_key_generation + AS execution_space_host_key_generation, + project_execution_spaces.remote_username + AS execution_space_remote_username, + project_execution_spaces.workspace_identity + AS execution_space_workspace_identity, + project_execution_spaces.agent_installation_id_at_validation + AS execution_space_agent_installation_id_at_validation, + project_execution_spaces.agent_binary_digest_at_validation + AS execution_space_agent_binary_digest_at_validation, + project_execution_spaces.agent_version_at_validation + AS execution_space_agent_version_at_validation, + project_execution_spaces.agent_architecture_at_validation + AS execution_space_agent_architecture_at_validation, + project_execution_spaces.agent_protocol_major + AS execution_space_agent_protocol_major, + project_execution_spaces.validated_at + AS execution_space_validated_at, + project_runtime_validations.project_id + AS runtime_validation_project_id, + project_runtime_validations.runtime_selection_key + AS runtime_validation_runtime_selection_key, + project_runtime_validations.runtime_bundle_digest + AS runtime_validation_runtime_bundle_digest, + project_runtime_validations.runtime_adapter_digest + AS runtime_validation_runtime_adapter_digest, + project_runtime_validations.agent_installation_id_at_validation + AS runtime_validation_agent_installation_id_at_validation, + project_runtime_validations.validated_at + AS runtime_validation_validated_at, + project_runtime_validations.work_mode + AS runtime_validation_work_mode + FROM projects + LEFT JOIN project_execution_spaces + ON project_execution_spaces.project_id = projects.id + LEFT JOIN project_runtime_validations + ON project_runtime_validations.project_id = projects.id +` + type TaskRow = { id: string project_id: string | null @@ -387,6 +467,36 @@ export type ClaimedHeartbeatRun = { acquired: boolean } +export type ValidatedSshProjectExecutionSpace = { + kind: 'ssh' + hostId: string + remoteRootPath: string + validation: SshExecutionValidation +} + +export type ValidatedSshHostPrecondition = Readonly<{ + hostId: string + hostRevision: number + hostKeyGeneration: number + remoteUsername: string + remoteRootPath: string + workspaceIdentity: string + agentProtocolMajor: number + agentInstallationId: string + agentBinaryDigest: string + agentVersion: string + agentArchitecture: 'x64' | 'arm64' +}> + +export type ValidatedSshProjectWrite = { + project: ProjectCreateInput + executionSpace: ValidatedSshProjectExecutionSpace + runtimeValidation: ProjectRuntimeValidation + assertSshHostCurrent: ( + expected: ValidatedSshHostPrecondition + ) => void +} + export type HeartbeatInputSnapshot = { scope: AssistantHeartbeatConfig['scope'] conversations: Array<{ @@ -418,11 +528,112 @@ export type HeartbeatInputSnapshot = { } function toProject(row: ProjectRow): AssistantProject { + if (!row.execution_space_project_id) { + throw new Error(`项目 ${row.id} 缺少执行空间配置`) + } + const runtimeValidation = + row.runtime_validation_project_id && + row.runtime_validation_work_mode !== null + ? projectRuntimeValidationSchema.safeParse({ + runtimeSelectionKey: + row.runtime_validation_runtime_selection_key, + runtimeBundleDigest: + row.runtime_validation_runtime_bundle_digest, + runtimeAdapterDigest: + row.runtime_validation_runtime_adapter_digest, + agentInstallationIdAtValidation: + row.runtime_validation_agent_installation_id_at_validation, + validatedAt: row.runtime_validation_validated_at, + workMode: row.runtime_validation_work_mode + }) + : undefined + if (runtimeValidation && !runtimeValidation.success) { + throw new Error(`项目 ${row.id} 的 Runtime 验证配置无效`) + } + const hasExecutionValidation = [ + row.execution_space_host_revision, + row.execution_space_host_key_generation, + row.execution_space_remote_username, + row.execution_space_workspace_identity, + row.execution_space_agent_installation_id_at_validation, + row.execution_space_agent_binary_digest_at_validation, + row.execution_space_agent_version_at_validation, + row.execution_space_agent_architecture_at_validation, + row.execution_space_agent_protocol_major, + row.execution_space_validated_at + ].some((value) => value !== null) + if ( + row.execution_space_kind === 'ssh' && + !hasExecutionValidation + ) { + throw new Error(`项目 ${row.id} 的执行空间配置无效`) + } + const persistedExecutionValidation = hasExecutionValidation + ? { + validation: { + hostRevision: row.execution_space_host_revision, + hostKeyGeneration: + row.execution_space_host_key_generation, + remoteUsername: row.execution_space_remote_username, + workspaceIdentity: + row.execution_space_workspace_identity, + agentInstallationIdAtValidation: + row.execution_space_agent_installation_id_at_validation, + agentBinaryDigestAtValidation: + row.execution_space_agent_binary_digest_at_validation, + agentVersionAtValidation: + row.execution_space_agent_version_at_validation, + agentArchitectureAtValidation: + row.execution_space_agent_architecture_at_validation, + agentProtocolMajor: + row.execution_space_agent_protocol_major, + validatedAt: row.execution_space_validated_at + } + } + : {} + const executionSpace = + persistedProjectExecutionSpaceSchema.safeParse( + row.execution_space_kind === 'local' + ? { + kind: 'local', + rootPath: row.execution_space_root_path, + ...persistedExecutionValidation + } + : row.execution_space_kind === 'ssh' + ? { + kind: 'ssh', + hostId: row.execution_space_ssh_host_id, + remoteRootPath: row.execution_space_root_path, + ...persistedExecutionValidation + } + : { kind: row.execution_space_kind } + ) + if (!executionSpace.success) { + throw new Error(`项目 ${row.id} 的执行空间配置无效`) + } + if ( + runtimeValidation?.success && + executionSpace.data.kind === 'ssh' && + executionSpace.data.validation !== undefined && + runtimeValidation.data.agentInstallationIdAtValidation !== + executionSpace.data.validation + .agentInstallationIdAtValidation + ) { + throw new Error(`项目 ${row.id} 的执行空间与 Runtime 验证不匹配`) + } + const rootPath = + executionSpace.data.kind === 'local' + ? executionSpace.data.rootPath + : executionSpace.data.remoteRootPath return { id: row.id, name: row.name, description: row.description, - rootPath: row.root_path, + rootPath, + executionSpace: executionSpace.data, + ...(runtimeValidation?.success + ? { runtimeValidation: runtimeValidation.data } + : {}), defaultWorkMode: normalizeInteractiveWorkMode( row.default_work_mode ), @@ -441,6 +652,19 @@ function toProject(row: ProjectRow): AssistantProject { } } +function readProject( + database: DatabaseSync, + projectId: string +): AssistantProject { + const row = database + .prepare(`${projectSelect} WHERE projects.id = ?`) + .get(projectId) as ProjectRow | undefined + if (!row) { + throw new Error('项目不存在') + } + return toProject(row) +} + function toTask(row: TaskRow): AssistantTask { return { id: row.id, @@ -963,6 +1187,207 @@ function serializeConversationMessageMetadata( }) } +type NormalizedValidatedSshProjectWrite = Omit< + ValidatedSshProjectWrite, + 'project' | 'executionSpace' | 'runtimeValidation' +> & { + project: ProjectCreateInput + executionSpace: ValidatedSshProjectExecutionSpace + runtimeValidation: ProjectRuntimeValidation +} + +function normalizeValidatedSshProjectWrite( + write: ValidatedSshProjectWrite +): NormalizedValidatedSshProjectWrite { + const project = projectCreateSchema.parse(write.project) + const executionSpace = + persistedProjectExecutionSpaceSchema.parse(write.executionSpace) + if ( + executionSpace.kind !== 'ssh' || + executionSpace.validation === undefined + ) { + throw new Error('远程项目必须使用 SSH 执行空间') + } + const runtimeValidation = projectRuntimeValidationSchema.parse( + write.runtimeValidation + ) + if (project.defaultWorkMode !== runtimeValidation.workMode) { + throw new Error('Runtime 验证与项目默认工作模式不匹配') + } + if (project.rootPath !== executionSpace.remoteRootPath) { + throw new Error('项目目录与远程执行空间目录不匹配') + } + if ( + project.runtimeSelection && + runtimeValidation.runtimeSelectionKey !== + agentRuntimeSelectionKey(project.runtimeSelection) + ) { + throw new Error('Runtime 验证与项目 Runtime 选择不匹配') + } + if ( + runtimeValidation.agentInstallationIdAtValidation !== + executionSpace.validation.agentInstallationIdAtValidation + ) { + throw new Error('执行空间与 Runtime 验证的 Agent 身份不匹配') + } + if (typeof write.assertSshHostCurrent !== 'function') { + throw new TypeError('缺少 SSH Host 事务前置校验') + } + return { + project, + executionSpace: { + kind: 'ssh', + hostId: executionSpace.hostId, + remoteRootPath: executionSpace.remoteRootPath, + validation: executionSpace.validation + }, + runtimeValidation, + assertSshHostCurrent: write.assertSshHostCurrent + } +} + +function assertValidatedSshHostCurrent( + write: NormalizedValidatedSshProjectWrite +): void { + const { executionSpace } = write + write.assertSshHostCurrent({ + hostId: executionSpace.hostId, + hostRevision: executionSpace.validation.hostRevision, + hostKeyGeneration: + executionSpace.validation.hostKeyGeneration, + remoteUsername: executionSpace.validation.remoteUsername, + remoteRootPath: executionSpace.remoteRootPath, + workspaceIdentity: + executionSpace.validation.workspaceIdentity, + agentProtocolMajor: + executionSpace.validation.agentProtocolMajor, + agentInstallationId: + executionSpace.validation.agentInstallationIdAtValidation, + agentBinaryDigest: + executionSpace.validation.agentBinaryDigestAtValidation, + agentVersion: + executionSpace.validation.agentVersionAtValidation, + agentArchitecture: + executionSpace.validation.agentArchitectureAtValidation + }) +} + +function insertValidatedSshExecutionSpace( + database: DatabaseSync, + projectId: string, + executionSpace: ValidatedSshProjectExecutionSpace +): void { + const validation = executionSpace.validation + database + .prepare( + `INSERT INTO project_execution_spaces + (project_id, kind, root_path, ssh_host_id, host_revision, + host_key_generation, remote_username, workspace_identity, + agent_installation_id_at_validation, + agent_binary_digest_at_validation, + agent_version_at_validation, + agent_architecture_at_validation, agent_protocol_major, + trust_attestation_revision, validated_at) + VALUES (?, 'ssh', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + projectId, + executionSpace.remoteRootPath, + executionSpace.hostId, + validation.hostRevision, + validation.hostKeyGeneration, + validation.remoteUsername, + validation.workspaceIdentity, + validation.agentInstallationIdAtValidation, + validation.agentBinaryDigestAtValidation, + validation.agentVersionAtValidation, + validation.agentArchitectureAtValidation, + validation.agentProtocolMajor, + 0, + validation.validatedAt + ) +} + +function updateValidatedSshExecutionSpace( + database: DatabaseSync, + projectId: string, + executionSpace: ValidatedSshProjectExecutionSpace +): number { + const validation = executionSpace.validation + return Number( + database + .prepare( + `UPDATE project_execution_spaces + SET root_path = ?, ssh_host_id = ?, host_revision = ?, + host_key_generation = ?, remote_username = ?, + workspace_identity = ?, + agent_installation_id_at_validation = ?, + agent_binary_digest_at_validation = ?, + agent_version_at_validation = ?, + agent_architecture_at_validation = ?, + agent_protocol_major = ?, + trust_attestation_revision = ?, validated_at = ? + WHERE project_id = ? AND kind = 'ssh'` + ) + .run( + executionSpace.remoteRootPath, + executionSpace.hostId, + validation.hostRevision, + validation.hostKeyGeneration, + validation.remoteUsername, + validation.workspaceIdentity, + validation.agentInstallationIdAtValidation, + validation.agentBinaryDigestAtValidation, + validation.agentVersionAtValidation, + validation.agentArchitectureAtValidation, + validation.agentProtocolMajor, + 0, + validation.validatedAt, + projectId + ).changes + ) +} + +function insertProjectRuntimeValidation( + database: DatabaseSync, + projectId: string, + validation: ProjectRuntimeValidation +): void { + database + .prepare( + `INSERT INTO project_runtime_validations + (project_id, runtime_selection_key, runtime_bundle_digest, + runtime_adapter_digest, confinement_policy_digest, + approval_bridge_version, + agent_installation_id_at_validation, validated_at, work_mode, + trust_tier, trust_attestation_revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + projectId, + validation.runtimeSelectionKey, + validation.runtimeBundleDigest, + validation.runtimeAdapterDigest, + validation.runtimeAdapterDigest, + LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION, + validation.agentInstallationIdAtValidation, + validation.validatedAt, + validation.workMode, + null, + null + ) +} + +function nextProjectUpdatedAt(current: string): string { + const now = Date.now() + const currentTimestamp = Date.parse(current) + return new Date( + Number.isFinite(currentTimestamp) + ? Math.max(now, currentTimestamp + 1) + : now + ).toISOString() +} + export class AssistantDatabase { private database?: DatabaseSync private channelEventWrites = 0 @@ -1229,11 +1654,11 @@ export class AssistantDatabase { const rows = database .prepare( includeArchived - ? `SELECT * FROM projects - ORDER BY created_at ASC, rowid ASC` - : `SELECT * FROM projects - WHERE status = 'active' - ORDER BY created_at ASC, rowid ASC` + ? `${projectSelect} + ORDER BY projects.created_at ASC, projects.rowid ASC` + : `${projectSelect} + WHERE projects.status = 'active' + ORDER BY projects.created_at ASC, projects.rowid ASC` ) .all() as ProjectRow[] return rows.map(toProject) @@ -1266,7 +1691,7 @@ export class AssistantDatabase { } ] const find = database.prepare( - 'SELECT * FROM projects WHERE channel = ?' + 'SELECT id FROM projects WHERE channel = ?' ) const insert = database.prepare( `INSERT INTO projects @@ -1275,6 +1700,11 @@ export class AssistantDatabase { updated_at) VALUES (?, ?, ?, ?, 'ask', ?, 'channel', ?, 'active', ?, ?)` ) + const insertExecutionSpace = database.prepare( + `INSERT INTO project_execution_spaces + (project_id, kind, root_path) + VALUES (?, 'local', ?)` + ) database.exec('BEGIN IMMEDIATE') try { @@ -1283,8 +1713,9 @@ export class AssistantDatabase { continue } const now = new Date().toISOString() + const projectId = randomUUID() insert.run( - randomUUID(), + projectId, definition.name, definition.description, defaultRootPath, @@ -1296,6 +1727,7 @@ export class AssistantDatabase { now, now ) + insertExecutionSpace.run(projectId, defaultRootPath) } database.exec('COMMIT') } catch (error) { @@ -1304,11 +1736,13 @@ export class AssistantDatabase { } return definitions.map((definition) => { - const row = find.get(definition.channel) as ProjectRow | undefined + const row = find.get(definition.channel) as + | { id: string } + | undefined if (!row) { throw new Error(`未能创建${definition.name}通道项目`) } - return toProject(row) + return this.getProject(row.id) }) } @@ -1323,27 +1757,41 @@ export class AssistantDatabase { const database = this.requireDatabase() const id = randomUUID() const now = new Date().toISOString() - database - .prepare( - `INSERT INTO projects - (id, name, description, root_path, default_work_mode, - runtime_selection_json, kind, channel, built_in_default, - status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)` - ) - .run( - id, - input.name, - input.description, - input.rootPath, - input.defaultWorkMode, - input.runtimeSelection - ? JSON.stringify(input.runtimeSelection) - : null, - builtInDefault ? 1 : 0, - now, - now - ) + database.exec('BEGIN IMMEDIATE') + try { + database + .prepare( + `INSERT INTO projects + (id, name, description, root_path, default_work_mode, + runtime_selection_json, kind, channel, built_in_default, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)` + ) + .run( + id, + input.name, + input.description, + input.rootPath, + input.defaultWorkMode, + input.runtimeSelection + ? JSON.stringify(input.runtimeSelection) + : null, + builtInDefault ? 1 : 0, + now, + now + ) + database + .prepare( + `INSERT INTO project_execution_spaces + (project_id, kind, root_path) + VALUES (?, 'local', ?)` + ) + .run(id, input.rootPath) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } return this.getProject(id) } @@ -1359,33 +1807,261 @@ export class AssistantDatabase { ) { throw new Error('通道项目必须设置默认工作目录') } - const result = database - .prepare( - `UPDATE projects - SET name = ?, description = ?, root_path = ?, - default_work_mode = ?, runtime_selection_json = ?, - updated_at = ? - WHERE id = ?` - ) - .run( - current.kind === 'channel' ? current.name : input.name, - input.description, - input.rootPath, - input.defaultWorkMode, - input.runtimeSelection || current.runtimeSelection - ? JSON.stringify( - input.runtimeSelection ?? current.runtimeSelection - ) - : null, - new Date().toISOString(), - projectId - ) - if (result.changes !== 1) { - throw new Error('项目不存在') + if (current.executionSpace.kind !== 'local') { + throw new Error('远程项目必须通过远程执行空间验证流程更新') + } + database.exec('BEGIN IMMEDIATE') + try { + const result = database + .prepare( + `UPDATE projects + SET name = ?, description = ?, root_path = ?, + default_work_mode = ?, runtime_selection_json = ?, + updated_at = ? + WHERE id = ?` + ) + .run( + current.kind === 'channel' ? current.name : input.name, + input.description, + input.rootPath, + input.defaultWorkMode, + input.runtimeSelection || current.runtimeSelection + ? JSON.stringify( + input.runtimeSelection ?? current.runtimeSelection + ) + : null, + new Date().toISOString(), + projectId + ) + if (result.changes !== 1) { + throw new Error('项目不存在') + } + const executionSpaceResult = database + .prepare( + `UPDATE project_execution_spaces + SET root_path = ? + WHERE project_id = ? AND kind = 'local'` + ) + .run(input.rootPath, projectId) + if (executionSpaceResult.changes !== 1) { + throw new Error('项目执行空间配置无效') + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error } return this.getProject(projectId) } + createValidatedSshProject( + write: ValidatedSshProjectWrite + ): AssistantProject { + const normalized = normalizeValidatedSshProjectWrite(write) + const database = this.requireDatabase() + const id = randomUUID() + const now = new Date().toISOString() + database.exec('BEGIN IMMEDIATE') + let transactionActive = true + try { + assertValidatedSshHostCurrent(normalized) + database + .prepare( + `INSERT INTO projects + (id, name, description, root_path, default_work_mode, + runtime_selection_json, kind, channel, built_in_default, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, 0, 'active', ?, ?)` + ) + .run( + id, + normalized.project.name, + normalized.project.description, + normalized.executionSpace.remoteRootPath, + normalized.project.defaultWorkMode, + normalized.project.runtimeSelection + ? JSON.stringify(normalized.project.runtimeSelection) + : null, + now, + now + ) + insertValidatedSshExecutionSpace( + database, + id, + normalized.executionSpace + ) + insertProjectRuntimeValidation( + database, + id, + normalized.runtimeValidation + ) + const project = readProject(database, id) + database.exec('COMMIT') + transactionActive = false + return project + } catch (error) { + if (transactionActive && database.isTransaction) { + database.exec('ROLLBACK') + } + throw error + } + } + + updateValidatedSshProject( + projectId: string, + expectedUpdatedAt: string, + write: ValidatedSshProjectWrite + ): AssistantProject { + const normalized = normalizeValidatedSshProjectWrite(write) + const database = this.requireDatabase() + database.exec('BEGIN IMMEDIATE') + let transactionActive = true + try { + const current = database + .prepare( + `SELECT projects.kind, projects.channel, projects.updated_at, + project_execution_spaces.kind AS execution_space_kind, + project_execution_spaces.ssh_host_id, + project_execution_spaces.root_path + FROM projects + LEFT JOIN project_execution_spaces + ON project_execution_spaces.project_id = projects.id + WHERE projects.id = ?` + ) + .get(projectId) as + | { + kind: AssistantProject['kind'] + channel: ProjectChannel | null + updated_at: string + execution_space_kind: string | null + ssh_host_id: string | null + root_path: string | null + } + | undefined + if (!current) { + throw new Error('项目不存在') + } + if (current.kind === 'channel' || current.channel !== null) { + throw new Error('系统通道项目不支持远程执行空间') + } + if ( + current.execution_space_kind !== 'ssh' || + current.ssh_host_id === null || + current.root_path === null + ) { + throw new Error('仅支持更新已有远程项目') + } + if ( + current.ssh_host_id !== normalized.executionSpace.hostId || + current.root_path !== + normalized.executionSpace.remoteRootPath + ) { + throw new Error('远程项目执行位置不能通过此更新修改') + } + if (current.updated_at !== expectedUpdatedAt) { + throw new Error('项目已被其他操作更新,请重新加载后重试') + } + assertValidatedSshHostCurrent(normalized) + const updatedAt = nextProjectUpdatedAt(current.updated_at) + const projectResult = database + .prepare( + `UPDATE projects + SET name = ?, description = ?, root_path = ?, + default_work_mode = ?, runtime_selection_json = ?, + updated_at = ? + WHERE id = ? AND kind = 'user' AND channel IS NULL + AND updated_at = ?` + ) + .run( + normalized.project.name, + normalized.project.description, + normalized.executionSpace.remoteRootPath, + normalized.project.defaultWorkMode, + normalized.project.runtimeSelection + ? JSON.stringify(normalized.project.runtimeSelection) + : null, + updatedAt, + projectId, + expectedUpdatedAt + ) + if (projectResult.changes !== 1) { + throw new Error('项目已被其他操作更新,请重新加载后重试') + } + const executionResult = updateValidatedSshExecutionSpace( + database, + projectId, + normalized.executionSpace + ) + if (executionResult !== 1) { + throw new Error('项目执行空间配置无效') + } + database + .prepare( + `INSERT INTO project_runtime_validations + (project_id, runtime_selection_key, runtime_bundle_digest, + runtime_adapter_digest, confinement_policy_digest, + approval_bridge_version, + agent_installation_id_at_validation, validated_at, + work_mode, trust_tier, trust_attestation_revision) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + runtime_selection_key = excluded.runtime_selection_key, + runtime_bundle_digest = excluded.runtime_bundle_digest, + runtime_adapter_digest = excluded.runtime_adapter_digest, + confinement_policy_digest = + excluded.confinement_policy_digest, + approval_bridge_version = + excluded.approval_bridge_version, + agent_installation_id_at_validation = + excluded.agent_installation_id_at_validation, + validated_at = excluded.validated_at, + work_mode = excluded.work_mode, + trust_tier = excluded.trust_tier, + trust_attestation_revision = + excluded.trust_attestation_revision` + ) + .run( + projectId, + normalized.runtimeValidation.runtimeSelectionKey, + normalized.runtimeValidation.runtimeBundleDigest, + normalized.runtimeValidation.runtimeAdapterDigest, + normalized.runtimeValidation.runtimeAdapterDigest, + LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION, + normalized.runtimeValidation + .agentInstallationIdAtValidation, + normalized.runtimeValidation.validatedAt, + normalized.runtimeValidation.workMode, + null, + null + ) + const project = readProject(database, projectId) + database.exec('COMMIT') + transactionActive = false + return project + } catch (error) { + if (transactionActive && database.isTransaction) { + database.exec('ROLLBACK') + } + throw error + } + } + + listProjectIdsReferencingSshHost(sshHostId: string): string[] { + return ( + this.requireDatabase() + .prepare( + `SELECT project_execution_spaces.project_id + FROM project_execution_spaces + INNER JOIN projects + ON projects.id = project_execution_spaces.project_id + WHERE project_execution_spaces.kind = 'ssh' + AND project_execution_spaces.ssh_host_id = ? + ORDER BY projects.created_at ASC, projects.rowid ASC` + ) + .all(sshHostId) as Array<{ project_id: string }> + ).map((row) => row.project_id) + } + setProjectArchived(projectId: string, archived: boolean): void { const database = this.requireDatabase() if (this.getProject(projectId).kind === 'channel') { @@ -5748,13 +6424,7 @@ export class AssistantDatabase { } getProject(projectId: string): AssistantProject { - const row = this.requireDatabase() - .prepare('SELECT * FROM projects WHERE id = ?') - .get(projectId) as ProjectRow | undefined - if (!row) { - throw new Error('项目不存在') - } - return toProject(row) + return readProject(this.requireDatabase(), projectId) } private getTask(taskId: string): AssistantTask { @@ -5981,12 +6651,12 @@ export class AssistantDatabase { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } - if (version.user_version > 26) { + if (version.user_version > ASSISTANT_DATABASE_SCHEMA_VERSION) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } - if (version.user_version === 26) { + if (version.user_version === ASSISTANT_DATABASE_SCHEMA_VERSION) { return } if (version.user_version < 1) { @@ -7398,6 +8068,626 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 27) { + database.exec('BEGIN IMMEDIATE') + try { + database.exec(` + CREATE TABLE IF NOT EXISTS project_execution_spaces ( + project_id TEXT PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('local', 'ssh')), + root_path TEXT NOT NULL + CHECK(length(root_path) <= 4096), + ssh_host_id TEXT, + host_revision INTEGER, + host_key_generation INTEGER, + remote_username TEXT, + workspace_identity TEXT, + agent_installation_id_at_validation TEXT, + agent_protocol_major INTEGER, + trust_attestation_revision INTEGER, + validated_at TEXT, + CHECK( + ( + kind = 'local' AND + ssh_host_id IS NULL AND + host_revision IS NULL AND + host_key_generation IS NULL AND + remote_username IS NULL AND + workspace_identity IS NULL AND + agent_installation_id_at_validation IS NULL AND + agent_protocol_major IS NULL AND + trust_attestation_revision IS NULL AND + validated_at IS NULL + ) OR ( + kind = 'ssh' AND + ssh_host_id IS NOT NULL AND + length(ssh_host_id) = 36 AND + length(trim(root_path)) > 0 AND + ( + ( + host_revision IS NULL AND + host_key_generation IS NULL AND + remote_username IS NULL AND + workspace_identity IS NULL AND + agent_installation_id_at_validation IS NULL AND + agent_protocol_major IS NULL AND + trust_attestation_revision IS NULL AND + validated_at IS NULL + ) OR ( + host_revision IS NOT NULL AND + typeof(host_revision) = 'integer' AND + host_revision >= 0 AND + host_key_generation IS NOT NULL AND + typeof(host_key_generation) = 'integer' AND + host_key_generation > 0 AND + remote_username IS NOT NULL AND + length(trim(remote_username)) BETWEEN 1 AND 1000 AND + workspace_identity IS NOT NULL AND + length(trim(workspace_identity)) BETWEEN 1 AND 1000 AND + agent_installation_id_at_validation IS NOT NULL AND + length(agent_installation_id_at_validation) + BETWEEN 1 AND 1000 AND + agent_protocol_major IS NOT NULL AND + typeof(agent_protocol_major) = 'integer' AND + agent_protocol_major > 0 AND + trust_attestation_revision IS NOT NULL AND + typeof(trust_attestation_revision) = 'integer' AND + trust_attestation_revision >= 0 AND + validated_at IS NOT NULL + ) + ) + ) + ) + ); + CREATE INDEX IF NOT EXISTS + project_execution_spaces_ssh_host_idx + ON project_execution_spaces(ssh_host_id, project_id) + WHERE kind = 'ssh'; + CREATE TABLE IF NOT EXISTS project_runtime_validations ( + project_id TEXT PRIMARY KEY + REFERENCES projects(id) ON DELETE CASCADE, + runtime_selection_key TEXT NOT NULL + CHECK( + length(trim(runtime_selection_key)) BETWEEN 1 AND 1000 + ), + runtime_bundle_digest TEXT NOT NULL + CHECK(length(runtime_bundle_digest) BETWEEN 1 AND 1000), + runtime_adapter_digest TEXT NOT NULL + CHECK(length(runtime_adapter_digest) BETWEEN 1 AND 1000), + confinement_policy_digest TEXT NOT NULL + CHECK( + length(confinement_policy_digest) BETWEEN 1 AND 1000 + ), + approval_bridge_version TEXT NOT NULL + CHECK(length(approval_bridge_version) BETWEEN 1 AND 1000), + agent_installation_id_at_validation TEXT NOT NULL + CHECK( + length(agent_installation_id_at_validation) + BETWEEN 1 AND 1000 + ), + validated_at TEXT NOT NULL, + UNIQUE(project_id, runtime_selection_key) + ); + INSERT OR IGNORE INTO project_execution_spaces + (project_id, kind, root_path) + SELECT id, 'local', root_path + FROM projects; + PRAGMA user_version = 27; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + if (version.user_version < 29) { + const executionSpaceColumns = new Set( + ( + database + .prepare('PRAGMA table_info(project_execution_spaces)') + .all() as Array<{ name: string }> + ).map((column) => column.name) + ) + const runtimeValidationColumns = new Set( + ( + database + .prepare( + 'PRAGMA table_info(project_runtime_validations)' + ) + .all() as Array<{ name: string }> + ).map((column) => column.name) + ) + database.exec('BEGIN IMMEDIATE') + try { + if ( + !executionSpaceColumns.has( + 'agent_binary_digest_at_validation' + ) + ) { + database.exec(` + ALTER TABLE project_execution_spaces + ADD COLUMN agent_binary_digest_at_validation TEXT; + `) + } + if ( + !executionSpaceColumns.has( + 'agent_version_at_validation' + ) + ) { + database.exec(` + ALTER TABLE project_execution_spaces + ADD COLUMN agent_version_at_validation TEXT; + `) + } + if ( + !executionSpaceColumns.has( + 'agent_architecture_at_validation' + ) + ) { + database.exec(` + ALTER TABLE project_execution_spaces + ADD COLUMN agent_architecture_at_validation TEXT; + `) + } + if (!runtimeValidationColumns.has('work_mode')) { + database.exec(` + ALTER TABLE project_runtime_validations + ADD COLUMN work_mode TEXT; + `) + } + if (!runtimeValidationColumns.has('trust_tier')) { + database.exec(` + ALTER TABLE project_runtime_validations + ADD COLUMN trust_tier TEXT; + `) + } + if ( + !runtimeValidationColumns.has( + 'trust_attestation_revision' + ) + ) { + database.exec(` + ALTER TABLE project_runtime_validations + ADD COLUMN trust_attestation_revision INTEGER; + `) + } + database.exec(` + DROP TRIGGER IF EXISTS + project_execution_spaces_validate_insert; + DROP TRIGGER IF EXISTS + project_execution_spaces_validate_update; + CREATE TRIGGER project_execution_spaces_validate_insert + BEFORE INSERT ON project_execution_spaces + WHEN COALESCE(NOT ( + ( + NEW.kind = 'local' AND + length(NEW.root_path) <= 4096 AND + NEW.ssh_host_id IS NULL AND + NEW.host_revision IS NULL AND + NEW.host_key_generation IS NULL AND + NEW.remote_username IS NULL AND + NEW.workspace_identity IS NULL AND + NEW.agent_installation_id_at_validation IS NULL AND + NEW.agent_binary_digest_at_validation IS NULL AND + NEW.agent_version_at_validation IS NULL AND + NEW.agent_architecture_at_validation IS NULL AND + NEW.agent_protocol_major IS NULL AND + NEW.trust_attestation_revision IS NULL AND + NEW.validated_at IS NULL + ) OR ( + NEW.kind = 'ssh' AND + length(NEW.root_path) <= 4096 AND + length(trim(NEW.root_path)) > 0 AND + NEW.ssh_host_id IS NOT NULL AND + length(NEW.ssh_host_id) = 36 AND + typeof(NEW.host_revision) = 'integer' AND + NEW.host_revision > 0 AND + typeof(NEW.host_key_generation) = 'integer' AND + NEW.host_key_generation > 0 AND + length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND + length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.agent_binary_digest_at_validation) = 71 AND + substr(NEW.agent_binary_digest_at_validation, 1, 7) = + 'sha256:' AND + substr(NEW.agent_binary_digest_at_validation, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND + substr(NEW.agent_version_at_validation, 1, 1) + NOT GLOB '[^0-9A-Za-z]' AND + NEW.agent_version_at_validation + NOT GLOB '*[^0-9A-Za-z.+_-]*' AND + NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND + typeof(NEW.agent_protocol_major) = 'integer' AND + NEW.agent_protocol_major > 0 AND + typeof(NEW.trust_attestation_revision) = 'integer' AND + NEW.trust_attestation_revision > 0 AND + length(NEW.validated_at) > 0 + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project execution space validation' + ); + END; + CREATE TRIGGER project_execution_spaces_validate_update + BEFORE UPDATE ON project_execution_spaces + WHEN COALESCE(NOT ( + ( + NEW.kind = 'local' AND + length(NEW.root_path) <= 4096 AND + NEW.ssh_host_id IS NULL AND + NEW.host_revision IS NULL AND + NEW.host_key_generation IS NULL AND + NEW.remote_username IS NULL AND + NEW.workspace_identity IS NULL AND + NEW.agent_installation_id_at_validation IS NULL AND + NEW.agent_binary_digest_at_validation IS NULL AND + NEW.agent_version_at_validation IS NULL AND + NEW.agent_architecture_at_validation IS NULL AND + NEW.agent_protocol_major IS NULL AND + NEW.trust_attestation_revision IS NULL AND + NEW.validated_at IS NULL + ) OR ( + NEW.kind = 'ssh' AND + length(NEW.root_path) <= 4096 AND + length(trim(NEW.root_path)) > 0 AND + NEW.ssh_host_id IS NOT NULL AND + length(NEW.ssh_host_id) = 36 AND + typeof(NEW.host_revision) = 'integer' AND + NEW.host_revision > 0 AND + typeof(NEW.host_key_generation) = 'integer' AND + NEW.host_key_generation > 0 AND + length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND + length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.agent_binary_digest_at_validation) = 71 AND + substr(NEW.agent_binary_digest_at_validation, 1, 7) = + 'sha256:' AND + substr(NEW.agent_binary_digest_at_validation, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND + substr(NEW.agent_version_at_validation, 1, 1) + NOT GLOB '[^0-9A-Za-z]' AND + NEW.agent_version_at_validation + NOT GLOB '*[^0-9A-Za-z.+_-]*' AND + NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND + typeof(NEW.agent_protocol_major) = 'integer' AND + NEW.agent_protocol_major > 0 AND + typeof(NEW.trust_attestation_revision) = 'integer' AND + NEW.trust_attestation_revision > 0 AND + length(NEW.validated_at) > 0 + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project execution space validation' + ); + END; + DROP TRIGGER IF EXISTS + project_runtime_validations_validate_insert; + DROP TRIGGER IF EXISTS + project_runtime_validations_validate_update; + CREATE TRIGGER project_runtime_validations_validate_insert + BEFORE INSERT ON project_runtime_validations + WHEN COALESCE(NOT ( + length(trim(NEW.runtime_selection_key)) + BETWEEN 1 AND 1000 AND + length(NEW.runtime_bundle_digest) = 71 AND + substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_bundle_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.runtime_adapter_digest) = 71 AND + substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_adapter_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.confinement_policy_digest) = 71 AND + substr(NEW.confinement_policy_digest, 1, 7) = + 'sha256:' AND + substr(NEW.confinement_policy_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(trim(NEW.approval_bridge_version)) + BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.validated_at) > 0 AND + ( + (NEW.work_mode = 'ask' AND NEW.trust_tier = 'T2') OR + (NEW.work_mode = 'execute' AND NEW.trust_tier = 'T3') + ) AND + typeof(NEW.trust_attestation_revision) = 'integer' AND + NEW.trust_attestation_revision > 0 AND + EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = NEW.project_id + AND projects.default_work_mode = NEW.work_mode + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project runtime authorization' + ); + END; + CREATE TRIGGER project_runtime_validations_validate_update + BEFORE UPDATE ON project_runtime_validations + WHEN COALESCE(NOT ( + ( + OLD.work_mode IS NULL AND + OLD.trust_tier IS NULL AND + OLD.trust_attestation_revision IS NULL AND + NEW.work_mode IS NULL AND + NEW.trust_tier IS NULL AND + NEW.trust_attestation_revision IS NULL + ) OR ( + length(trim(NEW.runtime_selection_key)) + BETWEEN 1 AND 1000 AND + length(NEW.runtime_bundle_digest) = 71 AND + substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_bundle_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.runtime_adapter_digest) = 71 AND + substr(NEW.runtime_adapter_digest, 1, 7) = + 'sha256:' AND + substr(NEW.runtime_adapter_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.confinement_policy_digest) = 71 AND + substr(NEW.confinement_policy_digest, 1, 7) = + 'sha256:' AND + substr(NEW.confinement_policy_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(trim(NEW.approval_bridge_version)) + BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.validated_at) > 0 AND + ( + (NEW.work_mode = 'ask' AND NEW.trust_tier = 'T2') OR + (NEW.work_mode = 'execute' AND NEW.trust_tier = 'T3') + ) AND + typeof(NEW.trust_attestation_revision) = 'integer' AND + NEW.trust_attestation_revision > 0 AND + EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = NEW.project_id + AND projects.default_work_mode = NEW.work_mode + ) + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project runtime authorization' + ); + END; + PRAGMA user_version = 29; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + if (version.user_version < ASSISTANT_DATABASE_SCHEMA_VERSION) { + database.exec('BEGIN IMMEDIATE') + try { + database.exec(` + DROP TRIGGER IF EXISTS + project_execution_spaces_validate_insert; + DROP TRIGGER IF EXISTS + project_execution_spaces_validate_update; + DROP TRIGGER IF EXISTS + project_runtime_validations_validate_insert; + DROP TRIGGER IF EXISTS + project_runtime_validations_validate_update; + + UPDATE project_execution_spaces + SET trust_attestation_revision = 0 + WHERE kind = 'ssh' + AND host_revision IS NOT NULL; + + UPDATE project_runtime_validations + SET confinement_policy_digest = runtime_adapter_digest, + approval_bridge_version = + '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}', + trust_tier = NULL, + trust_attestation_revision = NULL; + + CREATE TRIGGER project_execution_spaces_validate_insert + BEFORE INSERT ON project_execution_spaces + WHEN COALESCE(NOT ( + ( + NEW.kind = 'local' AND + length(NEW.root_path) <= 4096 AND + NEW.ssh_host_id IS NULL AND + NEW.host_revision IS NULL AND + NEW.host_key_generation IS NULL AND + NEW.remote_username IS NULL AND + NEW.workspace_identity IS NULL AND + NEW.agent_installation_id_at_validation IS NULL AND + NEW.agent_binary_digest_at_validation IS NULL AND + NEW.agent_version_at_validation IS NULL AND + NEW.agent_architecture_at_validation IS NULL AND + NEW.agent_protocol_major IS NULL AND + NEW.trust_attestation_revision IS NULL AND + NEW.validated_at IS NULL + ) OR ( + NEW.kind = 'ssh' AND + length(NEW.root_path) <= 4096 AND + length(trim(NEW.root_path)) > 0 AND + length(NEW.ssh_host_id) = 36 AND + typeof(NEW.host_revision) = 'integer' AND + NEW.host_revision > 0 AND + typeof(NEW.host_key_generation) = 'integer' AND + NEW.host_key_generation > 0 AND + length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND + length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.agent_binary_digest_at_validation) = 71 AND + substr(NEW.agent_binary_digest_at_validation, 1, 7) = + 'sha256:' AND + substr(NEW.agent_binary_digest_at_validation, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND + substr(NEW.agent_version_at_validation, 1, 1) + NOT GLOB '[^0-9A-Za-z]' AND + NEW.agent_version_at_validation + NOT GLOB '*[^0-9A-Za-z.+_-]*' AND + NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND + typeof(NEW.agent_protocol_major) = 'integer' AND + NEW.agent_protocol_major > 0 AND + NEW.trust_attestation_revision = 0 AND + length(NEW.validated_at) > 0 + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project execution space validation' + ); + END; + + CREATE TRIGGER project_execution_spaces_validate_update + BEFORE UPDATE ON project_execution_spaces + WHEN COALESCE(NOT ( + ( + NEW.kind = 'local' AND + length(NEW.root_path) <= 4096 AND + NEW.ssh_host_id IS NULL AND + NEW.host_revision IS NULL AND + NEW.host_key_generation IS NULL AND + NEW.remote_username IS NULL AND + NEW.workspace_identity IS NULL AND + NEW.agent_installation_id_at_validation IS NULL AND + NEW.agent_binary_digest_at_validation IS NULL AND + NEW.agent_version_at_validation IS NULL AND + NEW.agent_architecture_at_validation IS NULL AND + NEW.agent_protocol_major IS NULL AND + NEW.trust_attestation_revision IS NULL AND + NEW.validated_at IS NULL + ) OR ( + NEW.kind = 'ssh' AND + length(NEW.root_path) <= 4096 AND + length(trim(NEW.root_path)) > 0 AND + length(NEW.ssh_host_id) = 36 AND + typeof(NEW.host_revision) = 'integer' AND + NEW.host_revision > 0 AND + typeof(NEW.host_key_generation) = 'integer' AND + NEW.host_key_generation > 0 AND + length(trim(NEW.remote_username)) BETWEEN 1 AND 1000 AND + length(trim(NEW.workspace_identity)) BETWEEN 1 AND 1000 AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.agent_binary_digest_at_validation) = 71 AND + substr(NEW.agent_binary_digest_at_validation, 1, 7) = + 'sha256:' AND + substr(NEW.agent_binary_digest_at_validation, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.agent_version_at_validation) BETWEEN 1 AND 128 AND + substr(NEW.agent_version_at_validation, 1, 1) + NOT GLOB '[^0-9A-Za-z]' AND + NEW.agent_version_at_validation + NOT GLOB '*[^0-9A-Za-z.+_-]*' AND + NEW.agent_architecture_at_validation IN ('x64', 'arm64') AND + typeof(NEW.agent_protocol_major) = 'integer' AND + NEW.agent_protocol_major > 0 AND + NEW.trust_attestation_revision = 0 AND + length(NEW.validated_at) > 0 + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project execution space validation' + ); + END; + + CREATE TRIGGER project_runtime_validations_validate_insert + BEFORE INSERT ON project_runtime_validations + WHEN COALESCE(NOT ( + length(trim(NEW.runtime_selection_key)) + BETWEEN 1 AND 1000 AND + length(NEW.runtime_bundle_digest) = 71 AND + substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_bundle_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.runtime_adapter_digest) = 71 AND + substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_adapter_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + NEW.confinement_policy_digest = + NEW.runtime_adapter_digest AND + NEW.approval_bridge_version = + '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}' AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.validated_at) > 0 AND + NEW.work_mode IN ('ask', 'execute') AND + NEW.trust_tier IS NULL AND + NEW.trust_attestation_revision IS NULL AND + EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = NEW.project_id + AND projects.default_work_mode = NEW.work_mode + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project runtime validation' + ); + END; + + CREATE TRIGGER project_runtime_validations_validate_update + BEFORE UPDATE ON project_runtime_validations + WHEN COALESCE(NOT ( + length(trim(NEW.runtime_selection_key)) + BETWEEN 1 AND 1000 AND + length(NEW.runtime_bundle_digest) = 71 AND + substr(NEW.runtime_bundle_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_bundle_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + length(NEW.runtime_adapter_digest) = 71 AND + substr(NEW.runtime_adapter_digest, 1, 7) = 'sha256:' AND + substr(NEW.runtime_adapter_digest, 8) + NOT GLOB '*[^0-9a-f]*' AND + NEW.confinement_policy_digest = + NEW.runtime_adapter_digest AND + NEW.approval_bridge_version = + '${LEGACY_UNUSED_APPROVAL_BRIDGE_VERSION}' AND + length(trim(NEW.agent_installation_id_at_validation)) + BETWEEN 1 AND 1000 AND + length(NEW.validated_at) > 0 AND + NEW.work_mode IN ('ask', 'execute') AND + NEW.trust_tier IS NULL AND + NEW.trust_attestation_revision IS NULL AND + EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = NEW.project_id + AND projects.default_work_mode = NEW.work_mode + ) + ), 1) + BEGIN + SELECT RAISE( + ABORT, + 'invalid project runtime validation' + ); + END; + + PRAGMA user_version = ${ASSISTANT_DATABASE_SCHEMA_VERSION}; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/heartbeat-database.test.ts b/src/main/assistant/heartbeat-database.test.ts index e5c12c31..34f74569 100644 --- a/src/main/assistant/heartbeat-database.test.ts +++ b/src/main/assistant/heartbeat-database.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { afterEach, describe, expect, it } from 'vitest' -import { AssistantDatabase } from './assistant-database' +import { + ASSISTANT_DATABASE_SCHEMA_VERSION, + AssistantDatabase +} from './assistant-database' const temporaryDirectories: string[] = [] @@ -102,7 +105,7 @@ describe('AssistantDatabase heartbeat persistence', () => { ).count check.close() migrated.close() - expect(version).toBe(26) + expect(version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect(heartbeatTableCount).toBe(4) }) diff --git a/src/main/assistant/workspace-changes-service.test.ts b/src/main/assistant/workspace-changes-service.test.ts index 9eacbb95..97972497 100644 --- a/src/main/assistant/workspace-changes-service.test.ts +++ b/src/main/assistant/workspace-changes-service.test.ts @@ -3,7 +3,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceAccess } from '../workspace' import { getWorkspaceChanges, listWorkspaceDirectory, @@ -75,6 +76,71 @@ describe('getWorkspaceChanges', () => { }) describe('workspace file browsing', () => { + it('delegates workspace operations through WorkspaceAccess', async () => { + const listDirectory = vi.fn(async () => ({ + path: '', + entries: [ + { name: 'remote.md', path: 'remote.md', type: 'file' as const } + ], + truncated: false + })) + const stat = vi.fn(async () => ({ + name: 'remote.md', + path: 'remote.md', + type: 'file' as const, + size: 7, + modifiedAt: '2026-08-21T00:00:00.000Z' + })) + const readText = vi.fn(async () => ({ + path: 'remote.md', + name: 'remote.md', + content: '# fake\n', + size: 7 + })) + const getChanges = vi.fn(async () => ({ + rootPath: '/remote', + available: false, + status: '', + patch: '', + files: [], + truncated: false + })) + const workspace = { + getIdentity: vi.fn(), + listDirectory, + stat, + readText, + writeTextAtomic: vi.fn(), + search: vi.fn(), + getChanges, + dispose: vi.fn() + } as unknown as WorkspaceAccess + + await expect( + listWorkspaceDirectory(workspace, '') + ).resolves.toMatchObject({ + entries: [{ name: 'remote.md', type: 'file' }] + }) + await expect( + readWorkspaceFile(workspace, 'remote.md') + ).resolves.toMatchObject({ + content: '# fake\n', + mimeType: 'text/markdown' + }) + await expect(getWorkspaceChanges(workspace)).resolves.toMatchObject({ + rootPath: '/remote' + }) + expect(listDirectory).toHaveBeenCalledWith( + expect.objectContaining({ path: '', maximumEntries: 500 }) + ) + expect(readText).toHaveBeenCalledWith( + expect.objectContaining({ + path: 'remote.md', + maximumBytes: 256 * 1024 + }) + ) + }) + it('lists directories and reads bounded Markdown previews', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-')) temporaryDirectories.push(directory) diff --git a/src/main/assistant/workspace-changes-service.ts b/src/main/assistant/workspace-changes-service.ts index ffddbed3..f35a62b9 100644 --- a/src/main/assistant/workspace-changes-service.ts +++ b/src/main/assistant/workspace-changes-service.ts @@ -1,23 +1,15 @@ -import spawn from 'cross-spawn' -import { basename, extname, join } from 'node:path' -import { stat } from 'node:fs/promises' +import { extname } from 'node:path' import type { - WorkspaceChangedFile, WorkspaceChanges, WorkspaceDirectoryListing, WorkspaceFilePreview } from '../../shared/assistant-contracts' import { - getCanonicalWorkspace, - listBoundedDirectoryEntries, - readBoundedUtf8File, - resolveExistingWorkspacePath -} from '../workspace-file-access' + LocalWorkspaceAccess, + type WorkspaceAccess +} from '../workspace' -const MAX_OUTPUT_BYTES = 512 * 1024 -const COMMAND_TIMEOUT_MS = 10_000 const MAX_DIRECTORY_ENTRIES = 500 -const MAX_CHANGED_FILES = 2_000 const MAX_PREVIEW_BYTES = 256 * 1024 const previewExtensions = new Set([ '.c', @@ -64,104 +56,89 @@ const previewFileNames = new Set([ 'readme' ]) -type CommandResult = { - code: number | null - stdout: string - stderr: string - truncated: boolean -} +export class WorkspaceChangesService { + constructor(private readonly workspace: WorkspaceAccess) {} -function runGit( - rootPath: string, - args: string[] -): Promise { - return new Promise((resolve, reject) => { - const child = spawn('git', args, { - cwd: rootPath, - shell: false, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'] + getChanges(signal?: AbortSignal): Promise { + return this.workspace.getChanges({ signal }) + } + + async listDirectory( + inputPath: string, + signal?: AbortSignal + ): Promise { + const listing = await this.workspace.listDirectory({ + path: inputPath, + maximumEntries: MAX_DIRECTORY_ENTRIES, + includeGit: false, + includeOther: false, + signal }) - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - let bytes = 0 - let truncated = false - const capture = (target: Buffer[], chunk: Buffer | string): void => { - const buffer = Buffer.from(chunk) - const remaining = MAX_OUTPUT_BYTES - bytes - if (remaining <= 0) { - truncated = true - return - } - target.push(buffer.subarray(0, remaining)) - bytes += Math.min(buffer.byteLength, remaining) - truncated ||= buffer.byteLength > remaining + return { + path: listing.path, + entries: listing.entries.map((entry) => { + if (entry.type === 'other') { + throw new Error('工作区目录返回了不支持的条目类型') + } + return { + name: entry.name, + path: entry.path, + type: entry.type + } + }), + truncated: listing.truncated } - child.stdout?.on('data', (chunk: Buffer | string) => - capture(stdout, chunk) - ) - child.stderr?.on('data', (chunk: Buffer | string) => - capture(stderr, chunk) - ) - const timeout = setTimeout(() => { - child.kill() - reject(new Error('读取文件更改超时')) - }, COMMAND_TIMEOUT_MS) - child.once('error', (error) => { - clearTimeout(timeout) - reject(error) + } + + async readFile( + inputPath: string, + signal?: AbortSignal + ): Promise { + const entry = await this.workspace.stat({ + path: inputPath, + signal }) - child.once('close', (code) => { - clearTimeout(timeout) - resolve({ - code, - stdout: Buffer.concat(stdout).toString('utf8'), - stderr: Buffer.concat(stderr).toString('utf8'), - truncated - }) + if (entry.type !== 'file') { + throw new Error('目标不是普通文件') + } + const extension = extname(entry.name).toLowerCase() + if ( + !previewExtensions.has(extension) && + !previewFileNames.has(entry.name.toLowerCase()) + ) { + throw new Error('当前文件类型不支持安全预览') + } + const preview = await this.workspace.readText({ + path: inputPath, + maximumBytes: MAX_PREVIEW_BYTES, + tooLargeMessage: '工作区文件超过 256KB 预览限制', + invalidUtf8Message: '工作区文件不是有效 UTF-8 文本', + signal }) - }) + return { + path: preview.path, + name: preview.name, + content: preview.content, + mimeType: + extension === '.md' || extension === '.markdown' + ? 'text/markdown' + : extension === '.json' + ? 'application/json' + : 'text/plain', + size: preview.size + } + } } -function pathSegments(inputPath: string, allowRoot: boolean): string[] { - const normalized = inputPath.replaceAll('\\', '/') - if (allowRoot && normalized === '') { - return [] - } - if ( - !normalized || - normalized.startsWith('/') || - /^[a-zA-Z]:\//u.test(normalized) - ) { - throw new Error('路径必须是工作区内的相对路径') - } - const segments = normalized.split('/') - if ( - segments.some( - (segment) => - !segment || segment === '.' || segment === '..' || segment.includes('\0') - ) - ) { - throw new Error('路径必须是工作区内的相对路径') - } - return segments -} - -async function resolveWorkspacePath( +async function withLocalWorkspace( rootPath: string, - inputPath: string, - expected: 'file' | 'directory' -): Promise<{ canonicalPath: string; path: string }> { - const canonicalRoot = await getCanonicalWorkspace(rootPath) - const segments = pathSegments(inputPath, expected === 'directory') - const canonicalPath = await resolveExistingWorkspacePath( - canonicalRoot, - segments, - expected - ) - return { - canonicalPath, - path: segments.join('/') + operation: (workspace: LocalWorkspaceAccess) => Promise +): Promise { + const workspace = new LocalWorkspaceAccess(rootPath) + try { + return await operation(workspace) + } finally { + await workspace.dispose() } } @@ -170,189 +147,42 @@ export async function resolveWorkspaceEntryPath( inputPath: string, expected: 'file' | 'directory' ): Promise { - return (await resolveWorkspacePath(rootPath, inputPath, expected)) - .canonicalPath -} - -function parseChangedFiles(status: string): { - files: WorkspaceChangedFile[] - truncated: boolean -} { - const records = status.split('\0') - const files: WorkspaceChangedFile[] = [] - let index = 0 - while (index < records.length && files.length < MAX_CHANGED_FILES) { - const record = records[index] - index += 1 - if (!record) { - continue - } - const statusCode = record.slice(0, 2) - const path = record.slice(3) - if (!path) { - continue - } - const renamed = statusCode.includes('R') || statusCode.includes('C') - const previousPath = renamed ? records[index] : undefined - if (renamed) { - index += 1 - } - files.push({ - path, - status: statusCode, - ...(previousPath ? { previousPath } : {}) - }) - } - return { - files, - truncated: index < records.length - 1 - } -} - -function formatChangedFiles(files: WorkspaceChangedFile[]): string { - return files - .map((file) => - file.previousPath - ? `${file.status} ${file.previousPath} -> ${file.path}` - : `${file.status} ${file.path}` - ) - .join('\n') + return withLocalWorkspace(rootPath, (workspace) => + workspace.resolveEntryPath(inputPath, expected) + ) } export async function getWorkspaceChanges( - rootPath: string + workspace: string | WorkspaceAccess ): Promise { - if (!rootPath.trim()) { - return { - rootPath, - available: false, - status: '', - patch: '', - files: [], - truncated: false, - error: '项目尚未配置工作区目录' - } + if (typeof workspace !== 'string') { + return new WorkspaceChangesService(workspace).getChanges() } - const gitMetadata = await stat(join(rootPath, '.git')).catch( - () => undefined + return withLocalWorkspace(workspace, (access) => + new WorkspaceChangesService(access).getChanges() ) - if (!gitMetadata) { - return { - rootPath, - available: false, - status: '', - patch: '', - files: [], - truncated: false - } - } - try { - const [status, patch] = await Promise.all([ - runGit(rootPath, [ - 'status', - '--porcelain=v1', - '-z', - '--untracked-files=normal' - ]), - runGit(rootPath, ['diff', '--no-ext-diff', '--no-color', 'HEAD']) - ]) - if (status.code !== 0 || patch.code !== 0) { - const detail = status.stderr || patch.stderr - return { - rootPath, - available: false, - status: '', - patch: '', - files: [], - truncated: status.truncated || patch.truncated, - error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区' - } - } - const changedFiles = parseChangedFiles(status.stdout) - return { - rootPath, - available: true, - status: formatChangedFiles(changedFiles.files), - patch: patch.stdout, - files: changedFiles.files, - truncated: - status.truncated || patch.truncated || changedFiles.truncated - } - } catch (error) { - return { - rootPath, - available: false, - status: '', - patch: '', - files: [], - truncated: false, - error: - error instanceof Error ? error.message : '无法读取 Git 工作区' - } - } } export async function listWorkspaceDirectory( - rootPath: string, + workspace: string | WorkspaceAccess, inputPath: string ): Promise { - const directory = await resolveWorkspacePath( - rootPath, - inputPath, - 'directory' - ) - const listing = await listBoundedDirectoryEntries( - directory.canonicalPath, - MAX_DIRECTORY_ENTRIES, - (entry) => - entry.name !== '.git' && (entry.isDirectory() || entry.isFile()) - ) - const entries = listing.entries.sort((left, right) => { - if (left.isDirectory() !== right.isDirectory()) { - return left.isDirectory() ? -1 : 1 - } - return left.name.localeCompare(right.name) - }) - return { - path: directory.path, - entries: entries.map((entry) => ({ - name: entry.name, - path: [directory.path, entry.name].filter(Boolean).join('/'), - type: entry.isDirectory() ? 'directory' : 'file' - })), - truncated: listing.truncated + if (typeof workspace !== 'string') { + return new WorkspaceChangesService(workspace).listDirectory(inputPath) } + return withLocalWorkspace(workspace, (access) => + new WorkspaceChangesService(access).listDirectory(inputPath) + ) } export async function readWorkspaceFile( - rootPath: string, + workspace: string | WorkspaceAccess, inputPath: string ): Promise { - const file = await resolveWorkspacePath(rootPath, inputPath, 'file') - const name = basename(file.canonicalPath) - const extension = extname(name).toLowerCase() - if ( - !previewExtensions.has(extension) && - !previewFileNames.has(name.toLowerCase()) - ) { - throw new Error('当前文件类型不支持安全预览') + if (typeof workspace !== 'string') { + return new WorkspaceChangesService(workspace).readFile(inputPath) } - const preview = await readBoundedUtf8File( - file.canonicalPath, - MAX_PREVIEW_BYTES, - '工作区文件超过 256KB 预览限制', - '工作区文件不是有效 UTF-8 文本' + return withLocalWorkspace(workspace, (access) => + new WorkspaceChangesService(access).readFile(inputPath) ) - return { - path: file.path, - name, - content: preview.content, - mimeType: - extension === '.md' || extension === '.markdown' - ? 'text/markdown' - : extension === '.json' - ? 'application/json' - : 'text/plain', - size: preview.size - } } diff --git a/src/main/execution-space/execution-space-resolver.test.ts b/src/main/execution-space/execution-space-resolver.test.ts new file mode 100644 index 00000000..f6a08f3e --- /dev/null +++ b/src/main/execution-space/execution-space-resolver.test.ts @@ -0,0 +1,372 @@ +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 +} from './execution-space-resolver' + +function project( + overrides: Partial = {} +): AssistantProject { + return { + id: '00000000-0000-4000-8000-000000000101', + name: 'Project', + description: '', + rootPath: 'C:\\Workspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Workspace' + }, + defaultWorkMode: 'ask', + kind: 'user', + status: 'active', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides + } +} + +describe('ExecutionSpaceResolver', () => { + it('uses a stable local cache identity without accessing the workspace', () => { + const resolver = new ExecutionSpaceResolver() + const first = resolver.resolveProject(project()) + const second = resolver.resolveProject(project()) + + expect(first.kind).toBe('local') + expect(second.cacheIdentity).toBe(first.cacheIdentity) + 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' + } + }) + ) + + 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 () => { + const workspaceAccess = { + getIdentity: vi.fn(), + listDirectory: vi.fn(), + stat: vi.fn(), + readText: vi.fn(), + writeTextAtomic: vi.fn(), + search: vi.fn(), + getChanges: vi.fn(), + dispose: vi.fn() + } + 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 + }) + ) + + 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(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' + }) + + const stale = resolver.resolveProject( + project({ + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh', + hostId: '00000000-0000-4000-8000-000000000201', + remoteRootPath: '/srv/project', + validation + }, + runtimeValidation: { + ...runtimeValidation, + agentInstallationIdAtValidation: 'agent-2' + } + }) + ) + 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 + }) + ) + + 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 }) + } + }) +}) diff --git a/src/main/execution-space/execution-space-resolver.ts b/src/main/execution-space/execution-space-resolver.ts new file mode 100644 index 00000000..acb91ea0 --- /dev/null +++ b/src/main/execution-space/execution-space-resolver.ts @@ -0,0 +1,167 @@ +import { isAbsolute, normalize, resolve } from 'node:path' +import type { + AssistantProject, + ProjectRuntimeValidation, + SshExecutionValidation +} from '../../shared/assistant-contracts' +import { + LocalWorkspaceAccess, + UnsupportedRemoteWorkspaceAccess, + type RemoteWorkspaceAccessFactory, + type RemoteWorkspaceProjectBinding, + type WorkspaceAccess +} from '../workspace' + +export const REMOTE_EXECUTION_SPACE_UNAVAILABLE = + '远程执行空间尚不可用' + +export type LocalExecutionSpaceDescriptor = { + kind: 'local' + rootPath: string + cacheIdentity: string + routeIdentity: string + workspaceAccess: WorkspaceAccess +} + +export type SshExecutionSpaceDescriptor = { + kind: 'ssh' + hostId: string + remoteRootPath: string + validation?: SshExecutionValidation + runtimeValidation?: ProjectRuntimeValidation + cacheIdentity: string + routeIdentity: string + workspaceAccess: WorkspaceAccess +} + +export type ExecutionSpaceDescriptor = + | LocalExecutionSpaceDescriptor + | SshExecutionSpaceDescriptor + +function normalizeLocalIdentity(rootPath: string): string { + const trimmed = rootPath.trim() + if (!trimmed) { + return 'local:unconfigured' + } + const normalized = normalize( + isAbsolute(trimmed) ? trimmed : resolve(trimmed) + ) + return `local:${ + process.platform === 'win32' + ? normalized.toLocaleLowerCase('en-US') + : normalized + }` +} + +export class ExecutionSpaceResolver { + constructor( + private readonly remoteWorkspaceAccessFactory?: + RemoteWorkspaceAccessFactory + ) {} + + resolveLocal(rootPath: string): LocalExecutionSpaceDescriptor { + const cacheIdentity = normalizeLocalIdentity(rootPath) + return { + kind: 'local', + rootPath, + cacheIdentity, + routeIdentity: cacheIdentity, + workspaceAccess: new LocalWorkspaceAccess(rootPath) + } + } + + resolveProject(project: AssistantProject): ExecutionSpaceDescriptor { + const executionSpace = project.executionSpace ?? { + kind: 'local' as const, + rootPath: project.rootPath + } + if (executionSpace.kind === 'local') { + return this.resolveLocal(executionSpace.rootPath) + } + + const sshValidation = executionSpace.validation + const runtimeValidation = project.runtimeValidation + const identity = { + hostId: executionSpace.hostId, + remoteRootPath: executionSpace.remoteRootPath, + validation: sshValidation, + runtimeValidation + } + 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, + 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 + ]), + routeIdentity: JSON.stringify([ + 'ssh', + project.id, + identity.hostId, + identity.remoteRootPath + ]), + workspaceAccess: + remoteBinding && this.remoteWorkspaceAccessFactory + ? this.remoteWorkspaceAccessFactory.create(remoteBinding) + : new UnsupportedRemoteWorkspaceAccess( + REMOTE_EXECUTION_SPACE_UNAVAILABLE + ) + } + } + + assertLocal( + descriptor: ExecutionSpaceDescriptor + ): asserts descriptor is LocalExecutionSpaceDescriptor { + if (descriptor.kind !== 'local') { + throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) + } + } +} diff --git a/src/main/execution-space/index.ts b/src/main/execution-space/index.ts new file mode 100644 index 00000000..87844b2f --- /dev/null +++ b/src/main/execution-space/index.ts @@ -0,0 +1,9 @@ +export { + ExecutionSpaceResolver, + REMOTE_EXECUTION_SPACE_UNAVAILABLE +} from './execution-space-resolver' +export type { + ExecutionSpaceDescriptor, + LocalExecutionSpaceDescriptor, + SshExecutionSpaceDescriptor +} from './execution-space-resolver' diff --git a/src/main/execution-space/remote-runtime-request-validation.test.ts b/src/main/execution-space/remote-runtime-request-validation.test.ts new file mode 100644 index 00000000..8ad706d9 --- /dev/null +++ b/src/main/execution-space/remote-runtime-request-validation.test.ts @@ -0,0 +1,91 @@ +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 the exact persisted Runtime, Agent, and work mode', () => { + expect(() => + assertRemoteRuntimeRequestValidated( + descriptor(), + { provider: 'opencode' }, + 'ask' + ) + ).not.toThrow() + }) + + it('rejects Runtime selection and work-mode reuse', () => { + expect(() => + assertRemoteRuntimeRequestValidated( + descriptor(), + { + provider: 'opencode', + profileId: '00000000-0000-4000-8000-000000000001' + }, + 'ask' + ) + ).toThrow('Runtime 选择已变化') + expect(() => + assertRemoteRuntimeRequestValidated( + descriptor(), + { provider: 'opencode' }, + 'execute' + ) + ).toThrow('仅验证了 Ask') + }) + + it('rejects missing or cross-installation evidence', () => { + expect(() => + assertRemoteRuntimeRequestValidated( + { ...descriptor(), runtimeValidation: undefined }, + { provider: 'opencode' }, + 'ask' + ) + ).toThrow('缺少完整') + expect(() => + assertRemoteRuntimeRequestValidated( + { + ...descriptor(), + runtimeValidation: { + ...descriptor().runtimeValidation!, + agentInstallationIdAtValidation: 'installation-2' + } + }, + { provider: 'opencode' }, + 'ask' + ) + ).toThrow('缺少完整') + }) +}) diff --git a/src/main/execution-space/remote-runtime-request-validation.ts b/src/main/execution-space/remote-runtime-request-validation.ts new file mode 100644 index 00000000..b0b4f0a6 --- /dev/null +++ b/src/main/execution-space/remote-runtime-request-validation.ts @@ -0,0 +1,38 @@ +import type { WorkMode } from '../../shared/assistant-contracts' +import { + agentRuntimeSelectionKey, + type AgentRuntimeSelection +} from '../../shared/runtime-selection-contracts' +import type { SshExecutionSpaceDescriptor } from './execution-space-resolver' + +/** + * Prevents a request from reusing persisted remote Runtime evidence under a + * different Runtime selection, work mode, or Agent install. + */ +export function assertRemoteRuntimeRequestValidated( + executionSpace: SshExecutionSpaceDescriptor, + selection: AgentRuntimeSelection, + workMode: WorkMode +): void { + const executionValidation = executionSpace.validation + const runtimeValidation = executionSpace.runtimeValidation + if ( + !executionValidation || + !runtimeValidation || + executionValidation.agentInstallationIdAtValidation !== + runtimeValidation.agentInstallationIdAtValidation + ) { + throw new Error('远程 Project 缺少完整的当前验证,请重新验证') + } + if ( + runtimeValidation.runtimeSelectionKey !== + agentRuntimeSelectionKey(selection) + ) { + throw new Error('远程 Project 的 Runtime 选择已变化,请重新验证') + } + if (runtimeValidation.workMode !== workMode) { + throw new Error( + `远程 Project 仅验证了 ${runtimeValidation.workMode === 'ask' ? 'Ask' : 'Execute'} 模式,请先重新验证工作模式` + ) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 1fd21a99..d3188175 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,11 +28,15 @@ import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway' import { applyRuntimeSelection, getConfiguredRuntimeTarget, + resolveConfiguredAgentRuntimeSelection, type SelectedRuntimeTarget } from './agent/runtime-selection' import { CapabilityService } from './capabilities/capability-service' import { ContextManager } from './context-manager' -import { registerIpcHandlers } from './ipc' +import { + registerIpcHandlers, + sendRemoteProjectSaveProgress +} from './ipc' import { KnowledgeService } from './knowledge/knowledge-service' import { AssistantDatabase } from './assistant/assistant-database' import { createModelGraphExtractor } from './knowledge/model-extractor' @@ -105,10 +109,26 @@ import { } from './windows-notification-identity' import { ShortcutSettingsStore } from './shortcut-settings-store' import { ShortcutSettingsService } from './shortcut-settings-service' +import { SshHostStore } from './ssh/ssh-host-store' +import { Ssh2Transport } from './ssh/ssh-transport' +import { SshHostService } from './ssh/ssh-host-service' +import { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser' +import { + SshHostRemoteEnvironmentInspector +} from './ssh/ssh-host-remote-environment' import { defaultGlobalShortcutSettings } from '../shared/shortcut' import { requestProcessTreeTermination } from './agent/child-process-termination' import { createContinueUtilityProcessChild } from './agent/continue-utility-process-adapter' -import type { RuntimeSettings, RuntimeSettingsInput } from '../shared/contracts' +import { + ExecutionSpaceResolver, + type ExecutionSpaceDescriptor +} from './execution-space' +import { RemoteAgentServices } from './remote-agent/remote-agent-services' +import { ManagedRemoteExecutionServices } from './remote-agent/managed-remote-execution-services' +import type { + RuntimeSettings, + RuntimeSettingsInput +} from '../shared/contracts' const legacyDefaultShortcut = defaultGlobalShortcutSettings.accelerator @@ -157,6 +177,10 @@ let embeddingModelManager: EmbeddingModelManager | undefined const embeddingBrokers = new Set() let stopRuntimeReconfiguration: (() => Promise) | undefined let dshExtensionInstaller: DshNpmExtensionInstaller | undefined +let remoteAgentServices: RemoteAgentServices | undefined +let managedRemoteExecutionServices: + | ManagedRemoteExecutionServices + | undefined type ManagedEmbeddingProvider = EmbeddingProvider & { dispose?: () => void | Promise @@ -597,6 +621,80 @@ if (hasSingleInstanceLock) { join(app.getPath('userData'), 'runtime-settings.json'), secureCipher ) + const sshHostStore = new SshHostStore( + join(app.getPath('userData'), 'ssh-hosts.json'), + secureCipher + ) + const startupRemoteAgentServices = new RemoteAgentServices({ + sshHostStore, + goodBuddyVersion: app.getVersion(), + userDataPath: app.getPath('userData'), + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + packaged: app.isPackaged + }) + remoteAgentServices = startupRemoteAgentServices + const startupManagedRemoteExecutionServices = + new ManagedRemoteExecutionServices({ + sshHostStore, + agentServices: startupRemoteAgentServices, + userDataPath: app.getPath('userData'), + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + packaged: app.isPackaged, + resolveRuntimeSelection: async (selection) => + resolveConfiguredAgentRuntimeSelection( + await settingsStore.getResolvedSettings(), + selection + ), + resolveModelProfile: async (selection) => + applyRuntimeSelection( + await settingsStore.getResolvedSettings(), + selection + ).settings.opencodeModelProfile + }) + await startupManagedRemoteExecutionServices.initialize() + managedRemoteExecutionServices = + startupManagedRemoteExecutionServices + const beginRemoteHostInvalidation = (hostId: string): void => { + const invalidations = [ + startupRemoteAgentServices.invalidateHost(hostId) + ] + if (selectedRuntimeManager) { + invalidations.push( + selectedRuntimeManager.invalidateHost(hostId) + ) + } + void Promise.allSettled(invalidations).then((results) => { + if (results.some((result) => result.status === 'rejected')) { + console.warn( + 'Failed to invalidate complete remote Host state' + ) + } + }) + } + const sshHostService = new SshHostService( + sshHostStore, + new Ssh2Transport(), + { + onHostEdited: beginRemoteHostInvalidation, + onHostRemoved: beginRemoteHostInvalidation + } + ) + const sshHostDirectoryBrowser = new SshHostDirectoryBrowser({ + sshHosts: sshHostStore, + sshPool: startupRemoteAgentServices.sshPool + }) + const sshHostRemoteEnvironmentInspector = + new SshHostRemoteEnvironmentInspector({ + sshHosts: sshHostStore, + sshPool: startupRemoteAgentServices.sshPool, + agentRuntimeLockPath: + startupRemoteAgentServices.resourcePaths.runtimeLockPath, + remoteRuntimeLockPath: + startupManagedRemoteExecutionServices.runtimeResourcePaths + .runtimeLockPath + }) const [initialRuntimeSettings, initialResolvedSettings] = await Promise.all([ settingsStore.getPublicSettings(), @@ -745,6 +843,9 @@ if (hasSingleInstanceLock) { } ) assistantDatabase = startupAssistantDatabase + const executionSpaceResolver = new ExecutionSpaceResolver( + startupManagedRemoteExecutionServices.workspaceAccessFactory + ) const goodbuddyConfigService = new GoodBuddyConfigService( applicationSettingsStore, capabilityService @@ -759,7 +860,8 @@ if (hasSingleInstanceLock) { knowledgeGateway = startupKnowledgeGateway const createRuntimeWithCapabilities = async ( settings: ResolvedRuntimeSettings, - target: SelectedRuntimeTarget + target: SelectedRuntimeTarget, + executionSpace?: ExecutionSpaceDescriptor ): Promise => { const [ skillContext, @@ -800,7 +902,9 @@ if (hasSingleInstanceLock) { ? browserService : undefined, knowledgeGateway: startupKnowledgeGateway, - webSearchEnabled: webSearchCapability?.enabled + webSearchEnabled: webSearchCapability?.enabled, + executionSpace, + workspaceAccess: executionSpace?.workspaceAccess }) } const createConfiguredRuntime = async ( @@ -815,17 +919,38 @@ if (hasSingleInstanceLock) { } const createSelectedRuntime = async ( selection: AgentRuntimeSelection, - workspacePath?: string + executionSpace?: ExecutionSpaceDescriptor ): Promise => { const resolved = applyRuntimeSelection( await settingsStore.getResolvedSettings(), selection ) + if (executionSpace?.kind === 'ssh') { + const profile = resolved.settings.opencodeModelProfile + if (profile === undefined) { + throw new Error( + '托管远程 OpenCode 需要选择一个可用的文本模型配置' + ) + } + return await startupManagedRemoteExecutionServices.createRuntime({ + executionSpace, + selection, + modelProfile: profile, + usage: { + provider: profile.protocol, + model: profile.modelName + } + }) + } return createRuntimeWithCapabilities( - workspacePath - ? { ...resolved.settings, workspacePath } + executionSpace?.kind === 'local' + ? { + ...resolved.settings, + workspacePath: executionSpace.rootPath + } : resolved.settings, - resolved.target + resolved.target, + executionSpace ) } const configuredRuntime = await runStartupPrerequisites({ @@ -1063,6 +1188,11 @@ if (hasSingleInstanceLock) { } } + const remoteProjectSaveService = + startupManagedRemoteExecutionServices.createProjectSaveService({ + database: startupAssistantDatabase, + notify: sendRemoteProjectSaveProgress + }) removeIpcHandlers = registerIpcHandlers( mainWindow, runtime, @@ -1096,6 +1226,11 @@ if (hasSingleInstanceLock) { goodbuddyConfigService, runtimeExtensionStore, shortcutSettingsService, + sshHostService, + executionSpaceResolver, + remoteProjectSaveService, + sshHostDirectoryBrowser, + sshHostRemoteEnvironmentInspector, startupEmbeddingModelManager, async (connectionId) => { const settings = await settingsStore.getResolvedSettings() @@ -1207,6 +1342,8 @@ app.on('before-quit', (event) => { () => documentOcrModelManager?.dispose(), () => documentOcrBroker?.dispose() ], + [() => managedRemoteExecutionServices?.dispose()], + [() => remoteAgentServices?.dispose()], [() => knowledgeGateway?.dispose()], [() => knowledgeService?.dispose()] ]) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 67d71acb..426c632d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -16,7 +16,10 @@ import type { import type { AgentEvent, BrowserLiveState } from '../shared/contracts' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' -import { registerIpcHandlers } from './ipc' +import { + registerIpcHandlers, + sendRemoteProjectSaveProgress +} from './ipc' describe('embedding IPC boundary', () => { it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => { @@ -928,6 +931,444 @@ describe('registerIpcHandlers DSH runtime extensions', () => { }) }) +describe('registerIpcHandlers SSH hosts', () => { + afterEach(() => { + electronMocks.handlers.clear() + vi.clearAllMocks() + }) + + it('validates narrow host requests and restricts them to the trusted renderer', async () => { + const webContents = { + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const hostId = '00000000-0000-4000-8000-000000000105' + const candidateId = '00000000-0000-4000-8000-000000000106' + const fingerprint = `SHA256:${'A'.repeat(43)}` + const host = { + id: hostId, + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent' as const, + credentialConfigured: true, + credentialSource: 'system-agent' as const, + hostKey: { state: 'unverified' as const, generation: 0 }, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z' + } + const snapshot = { + hosts: [host], + secureStorageAvailable: true + } + const sshHostService = { + getSnapshot: vi.fn(async () => snapshot), + remove: vi.fn(async () => undefined), + inspectDraftHostKey: vi.fn(async () => ({ + candidateId, + hostId, + state: 'unverified' as const, + algorithm: 'ssh-ed25519', + fingerprintSha256: fingerprint + })), + discardCandidate: vi.fn(), + validateAndSave: vi.fn(async () => ({ + host, + connection: { + hostId, + connected: true as const, + latencyMs: 12, + platform: 'linux' as const, + architecture: 'x64' as const, + shell: '/bin/bash', + homeDirectory: '/home/builder', + detail: 'SSH 已连接,远端系统为 linux/x64' + } + })) + } + const listProjectIdsReferencingSshHost = vi.fn((): string[] => []) + 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) + } + const directoryListing = { + path: '/srv', + homeDirectory: '/home/builder', + parentPath: '/', + entries: [ + { + name: 'project', + path: '/srv/project' + } + ], + truncated: false + } + const listDirectories = vi.fn< + ( + hostId: string, + path?: string, + signal?: AbortSignal + ) => Promise + >(async () => directoryListing) + const sshHostDirectoryBrowser = { listDirectories } + const remoteEnvironment = { + hostId, + checkedAt: '2030-01-01T00:00:00.000Z', + architecture: 'x64', + agent: { + state: 'current', + expected: { + version: '0.11.1', + architecture: 'x64' + }, + installed: { + version: '0.11.1', + architecture: 'x64' + } + }, + runtimes: [{ + runtimeId: 'opencode', + provider: 'opencode', + state: 'current', + expected: { + version: '1.18.9', + architecture: 'x64' + }, + installed: { + version: '1.18.9', + architecture: 'x64' + } + }] + } as const + const inspectRemoteEnvironment = vi.fn( + async () => remoteEnvironment + ) + const sshHostRemoteEnvironmentInspector = { + inspect: inspectRemoteEnvironment + } + const selectedRuntimes = { + reset: vi.fn(async () => undefined) + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + listProjectIdsReferencingSshHost + } as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => undefined), + undefined, // onBeforeClearLocalData + undefined, // browserControl + undefined, // subagentService + undefined, // channelSettingsStore + undefined, // applicationSettingsStore + undefined, // versionChecker + undefined, // speechModelManager + undefined, // embeddingIndexCoordinator + selectedRuntimes as never, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + sshHostService as never, + undefined, + remoteProjectSaveService as never, + sshHostDirectoryBrowser as never, + sshHostRemoteEnvironmentInspector as never + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + const createInput = { + name: ' Build host ', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + } + + await expect( + electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event) + ).resolves.toEqual(snapshot) + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsInspectDraftKey + )?.( + event, + { + hostId, + hostname: createInput.hostname, + port: createInput.port, + username: createInput.username + } + ) + ).resolves.toMatchObject({ fingerprintSha256: fingerprint }) + expect( + sshHostService.inspectDraftHostKey + ).toHaveBeenCalledWith({ + hostId, + hostname: createInput.hostname, + port: createInput.port, + username: createInput.username + }) + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsValidateAndSave + )?.( + event, + { + candidateId, + fingerprintSha256: fingerprint, + input: createInput + } + ) + ).resolves.toMatchObject({ + host, + connection: { connected: true } + }) + expect(sshHostService.validateAndSave).toHaveBeenCalledWith({ + candidateId, + fingerprintSha256: fingerprint, + input: { + ...createInput, + name: 'Build host' + } + }) + expect( + electronMocks.handlers.get( + ipcChannels.sshHostsDiscardCandidate + )?.(event, { candidateId }) + ).toBeUndefined() + expect(sshHostService.discardCandidate).toHaveBeenCalledWith( + candidateId + ) + await expect( + electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( + event, + { hostId } + ) + ).resolves.toBeUndefined() + listProjectIdsReferencingSshHost.mockReturnValue([ + '00000000-0000-4000-8000-000000000107' + ]) + expect(() => + electronMocks.handlers.get(ipcChannels.sshHostsRemove)?.( + event, + { hostId } + ) + ).toThrow('SSH 主机仍被项目引用,无法删除') + expect(sshHostService.remove).toHaveBeenCalledOnce() + + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsRemoteEnvironment + )?.(event, { hostId }) + ).resolves.toEqual(remoteEnvironment) + expect(inspectRemoteEnvironment).toHaveBeenCalledWith(hostId) + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsRemoteEnvironment + )?.(event, { hostId: 'not-a-uuid' }) + ).rejects.toThrow() + expect(inspectRemoteEnvironment).toHaveBeenCalledOnce() + + expect(() => + electronMocks.handlers.get( + ipcChannels.sshHostsValidateAndSave + )?.(event, { + candidateId: 'not-a-uuid', + fingerprintSha256: fingerprint, + input: { ...createInput, command: 'whoami' } + }) + ).toThrow() + expect(sshHostService.validateAndSave).toHaveBeenCalledOnce() + expect(() => + electronMocks.handlers.get(ipcChannels.sshHostsGet)?.({ + sender: {}, + senderFrame: webContents.mainFrame + }) + ).toThrow('拒绝来自未知窗口的 IPC 请求') + + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsBrowseDirectories + )?.(event, { hostId, path: '/srv' }) + ).resolves.toEqual(directoryListing) + expect( + sshHostDirectoryBrowser.listDirectories + ).toHaveBeenCalledWith( + hostId, + '/srv', + expect.any(AbortSignal) + ) + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsBrowseDirectories + )?.(event, { hostId, path: '../escape' }) + ).rejects.toThrow() + expect( + sshHostDirectoryBrowser.listDirectories + ).toHaveBeenCalledOnce() + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsBrowseDirectories + )?.( + { sender: {}, senderFrame: webContents.mainFrame }, + { hostId } + ) + ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') + + let browseSignal: AbortSignal | undefined + sshHostDirectoryBrowser.listDirectories.mockImplementationOnce( + async (...args): Promise => { + const signal = args[2] + if (!signal) { + throw new Error('Expected directory browse AbortSignal') + } + return new Promise((_resolve, reject) => { + browseSignal = signal + signal.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + } + ) + const activeBrowse = electronMocks.handlers.get( + ipcChannels.sshHostsBrowseDirectories + )?.(event, { hostId }) + await vi.waitFor(() => + expect(browseSignal).toBeInstanceOf(AbortSignal) + ) + expect( + electronMocks.handlers.get( + ipcChannels.sshHostsCancelDirectoryBrowse + )?.(event) + ).toBeUndefined() + await expect(activeBrowse).rejects.toMatchObject({ + name: 'AbortError' + }) + + const remoteDraft = { + intent: 'create', + draft: { + name: 'Remote project', + description: '', + runtimeSelection: { provider: 'opencode' }, + hostId, + remoteRootPath: '/srv/project', + 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 + )?.(event, remoteDraft) + ).resolves.toEqual(remoteProject) + expect(remoteProjectSaveService.save).toHaveBeenCalledWith( + webContents, + remoteDraft + ) + expect( + electronMocks.handlers.get( + ipcChannels.remoteProjectCancelCurrent + )?.(event) + ).toBeUndefined() + expect( + 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( + ipcChannels.remoteProjectSave + )?.( + { sender: {}, senderFrame: webContents.mainFrame }, + remoteDraft + ) + ).rejects.toThrow('拒绝来自未知窗口的 IPC 请求') + + await dispose() + expect(remoteProjectSaveService.dispose).toHaveBeenCalledOnce() + }) + + it('sends save progress only to a live owning WebContents', () => { + const owner = { + isDestroyed: vi.fn(() => false), + send: vi.fn(), + once: vi.fn(), + removeListener: vi.fn() + } + const progress = { phase: 'agent' as const } + + sendRemoteProjectSaveProgress(owner as never, progress) + expect(owner.send).toHaveBeenCalledWith( + ipcChannels.remoteProjectSaveProgress, + progress + ) + owner.isDestroyed.mockReturnValue(true) + sendRemoteProjectSaveProgress(owner as never, progress) + expect(owner.send).toHaveBeenCalledOnce() + }) +}) + function runtimeUpdateFixture( workspacePath: string, provider: 'model' | 'opencode' | 'continue' = 'model' @@ -2689,6 +3130,81 @@ describe('registerIpcHandlers workspace files', () => { await dispose() }) + + it('fails remote workspace requests before filesystem, Git, or shell access', async () => { + const projectId = '00000000-0000-4000-8000-000000000102' + const assistantDatabase = { + queueDueSchedules: vi.fn(() => []), + listConversationQueueItems: vi.fn(() => []), + listPendingConversationQueueIds: vi.fn(() => []), + getProject: vi.fn(() => ({ + id: projectId, + name: 'Remote', + description: '', + rootPath: '/srv/project', + executionSpace: { + kind: 'ssh', + hostId: '00000000-0000-4000-8000-000000000202', + remoteRootPath: '/srv/project' + }, + defaultWorkMode: 'ask', + kind: 'user', + status: 'active', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z' + })) + } + const webContents = { + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html') + } + const window = { + webContents, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + removeListener: vi.fn() + } + const dispose = registerIpcHandlers( + window as never, + { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', + {} as never, + {} as never, + { clear: vi.fn() } as never, + {} as never, + assistantDatabase as never, + { clear: vi.fn() } as never, + {} as never, + vi.fn(async () => {}) + ) + const event = { + sender: webContents, + senderFrame: webContents.mainFrame + } + + for (const [channel, input] of [ + [ipcChannels.workspaceChangesGet, projectId], + [ + ipcChannels.workspaceDirectoryList, + { projectId, path: '' } + ], + [ + ipcChannels.workspaceFileRead, + { projectId, path: 'README.md' } + ], + [ + ipcChannels.workspacePathOpen, + { projectId, path: 'README.md', type: 'file' } + ] + ] as const) { + await expect( + electronMocks.handlers.get(channel)?.(event, input) + ).rejects.toThrow('远程执行空间尚不可用') + } + expect(electronMocks.openPath).not.toHaveBeenCalled() + + await dispose() + }) }) describe('registerIpcHandlers token usage', () => { @@ -3234,7 +3750,10 @@ describe('registerIpcHandlers Runtime customization', () => { ).resolves.toEqual(snapshot) expect(selectedRuntimes.getNativeSnapshot).toHaveBeenCalledWith( { provider: 'opencode' }, - 'C:\\ProjectWorkspace' + expect.objectContaining({ + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }) ) const compactInput = { @@ -3268,7 +3787,10 @@ describe('registerIpcHandlers Runtime customization', () => { }) expect(selectedRuntimes.compactConversation).toHaveBeenCalledWith( expect.objectContaining(compactInput), - 'C:\\ProjectWorkspace', + expect.objectContaining({ + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }), expect.any(AbortSignal) ) expect(assistantDatabase.createTask).toHaveBeenCalledWith( @@ -3374,13 +3896,17 @@ describe('registerIpcHandlers agent terminal state', () => { name: '企业微信', description: '企业微信远程消息与受控任务', rootPath: 'C:\\ProjectWorkspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }, kind: 'channel', - channel: 'wecom', + channel: 'wecom', status: 'active', createdAt: '2026-08-04T00:00:00.000Z', updatedAt: '2026-08-04T00:00:00.000Z' @@ -5241,10 +5767,14 @@ describe('registerIpcHandlers agent terminal state', () => { expect(fallbackRuntime.run).not.toHaveBeenCalled() expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( firstSelection, - 'C:\\ProjectWorkspace' + expect.objectContaining({ + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }) ) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( - secondSelection + secondSelection, + undefined ) await harness.dispose() }) @@ -6197,6 +6727,10 @@ describe('registerIpcHandlers agent terminal state', () => { name: channelLabel, description: `${channelLabel}远程消息与受控任务`, rootPath: 'C:\\ProjectWorkspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }, defaultWorkMode: 'ask', runtimeSelection: { provider: 'model', @@ -6874,6 +7408,10 @@ describe('registerIpcHandlers agent terminal state', () => { name: '企业微信', description: '企业微信远程消息与受控任务', rootPath: 'C:\\ProjectWorkspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }, defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' }, kind: 'channel', @@ -6908,7 +7446,10 @@ describe('registerIpcHandlers agent terminal state', () => { }) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( { provider: 'continue', profileId: configuredProfileId }, - 'C:\\ProjectWorkspace' + expect.objectContaining({ + kind: 'local', + rootPath: 'C:\\ProjectWorkspace' + }) ) expect( harness.assistantDatabase.getOrCreateRemoteConversation diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d40e3d14..4a62d8e5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -212,6 +212,29 @@ import { } from '../shared/runtime-extension-contracts' import type { RuntimeExtensionStore } from './agent/runtime-extension-store' import type { ShortcutSettingsService } from './shortcut-settings-service' +import { + sshDirectoryBrowseRequestSchema, + sshDirectoryBrowseResultSchema, + sshHostCandidateRequestSchema, + sshHostDraftInspectionRequestSchema, + sshHostRequestSchema, + sshHostRemoteEnvironmentSchema, + sshHostValidationRequestSchema +} from '../shared/ssh-host-contracts' +import type { SshHostService } from './ssh/ssh-host-service' +import type { SshHostDirectoryBrowser } from './ssh/ssh-host-directory-browser' +import type { + SshHostRemoteEnvironmentInspector +} from './ssh/ssh-host-remote-environment' +import { + remoteProjectSaveProgressSchema, + remoteProjectSaveRequestSchema, + type RemoteProjectSaveProgress +} from '../shared/remote-project-candidate-contracts' +import type { + RemoteProjectSaveOwner, + RemoteProjectSaveService +} from './remote-agent/remote-project-save-service' import type { ContextManager } from './context-manager' import type { KnowledgeService } from './knowledge/knowledge-service' import { @@ -282,6 +305,11 @@ import { analyzeMagicTodo } from './magic-notes/magic-note-analyzer' import { AgentEventBuffer } from './agent-event-buffer' +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 @@ -660,6 +688,22 @@ function assertTrustedSender( } } +export function sendRemoteProjectSaveProgress( + owner: RemoteProjectSaveOwner, + progress: RemoteProjectSaveProgress +): void { + const webContents = owner as RemoteProjectSaveOwner & { + send?: (channel: string, payload: unknown) => void + } + if (owner.isDestroyed() || typeof webContents.send !== 'function') { + return + } + webContents.send( + ipcChannels.remoteProjectSaveProgress, + remoteProjectSaveProgressSchema.parse(progress) + ) +} + async function readArtifactImportFile( path: string, maximumBytes: number, @@ -921,6 +965,12 @@ export function registerIpcHandlers( goodbuddyConfigService?: GoodBuddyConfigService, runtimeExtensionStore?: RuntimeExtensionStore, shortcutSettingsService?: ShortcutSettingsService, + sshHostService?: SshHostService, + executionSpaceResolver?: ExecutionSpaceResolver, + remoteProjectSaveService?: RemoteProjectSaveService, + sshHostDirectoryBrowser?: SshHostDirectoryBrowser, + sshHostRemoteEnvironmentInspector?: + SshHostRemoteEnvironmentInspector, embeddingModelManager?: EmbeddingModelManager & { importArchive?( modelId: string, @@ -966,6 +1016,7 @@ export function registerIpcHandlers( { requestId: string; runtime: AgentRuntime } >() const heartbeatControllers = new Set() + let activeSshDirectoryBrowse: AbortController | undefined let shuttingDown = false let executionPaused = false let clearLocalDataOperation: Promise | undefined @@ -987,6 +1038,8 @@ export function registerIpcHandlers( const executionTracker = createPromiseTracker() const maintenanceTracker = createPromiseTracker() const trackExecution = executionTracker.track + const spaceResolver: ExecutionSpaceResolver = + executionSpaceResolver ?? new ExecutionSpaceResolver() const registerHandler = ( channel: Parameters[0], listener: Parameters[1], @@ -1002,17 +1055,28 @@ export function registerIpcHandlers( }) } const resolveRequestRuntime = async ( - request: Pick & { + request: Pick< + AgentRequest, + 'projectId' | 'runtimeSelection' | 'workMode' + > & { workspaceOverride?: string followConfiguredAgentRuntime?: boolean } ): Promise => { - const projectWorkspace = - request.workspaceOverride?.trim() ?? - (request.projectId - ? assistantDatabase.getProject(request.projectId).rootPath.trim() - : '') - if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) { + const executionSpace = request.projectId + ? spaceResolver.resolveProject( + assistantDatabase.getProject(request.projectId) + ) + : request.workspaceOverride?.trim() + ? spaceResolver.resolveLocal(request.workspaceOverride.trim()) + : undefined + if (executionSpace?.kind === 'ssh' && !selectedRuntimes) { + throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) + } + if ( + !selectedRuntimes || + (!request.runtimeSelection && !executionSpace) + ) { return runtime } let selection = @@ -1023,9 +1087,15 @@ export function registerIpcHandlers( selection ) } - return projectWorkspace - ? selectedRuntimes.getRuntime(selection, projectWorkspace) - : selectedRuntimes.getRuntime(selection) + if (executionSpace?.kind === 'ssh') { + const workMode = normalizeInteractiveWorkMode(request.workMode) + assertRemoteRuntimeRequestValidated( + executionSpace, + selection, + workMode + ) + } + return selectedRuntimes.getRuntime(selection, executionSpace) } const channels = Object.values(ipcChannels).filter( (channel) => @@ -1036,6 +1106,7 @@ export function registerIpcHandlers( channel !== ipcChannels.versionCheckResult && channel !== ipcChannels.weixinBindingChanged && channel !== ipcChannels.remoteChannelActivity && + channel !== ipcChannels.remoteProjectSaveProgress && channel !== ipcChannels.conversationsChanged && channel !== ipcChannels.windowMaximizedChanged ) @@ -1222,7 +1293,8 @@ export function registerIpcHandlers( { summarize: async (request) => { const requestRuntime = await resolveRequestRuntime({ - projectId: request.projectId + projectId: request.projectId, + workMode: 'ask' }) if (requestRuntime.capability === 'image-generation') { throw new Error('智能心跳需要文本模型,当前默认连接仅支持图像生成') @@ -1675,6 +1747,7 @@ export function registerIpcHandlers( remoteContext?.runtimeSelection ?? schedule.runtimeSelection, workspaceOverride: remoteContext?.rootPath, + workMode: schedule.workMode, followConfiguredAgentRuntime: remoteContext?.followConfiguredAgentRuntime })) @@ -2457,6 +2530,7 @@ export function registerIpcHandlers( projectId: project.id, runtimeSelection, workspaceOverride: project.rootPath, + workMode: parsed.workMode, followConfiguredAgentRuntime: true }) executionStatus = await executionRuntime.getStatus() @@ -2818,10 +2892,13 @@ export function registerIpcHandlers( throw new Error('请求包含不存在的知识库') } } - const selectedRuntime = await resolveRequestRuntime(parsedInput) const normalizedWorkMode = normalizeInteractiveWorkMode( parsedInput.workMode ) + const selectedRuntime = await resolveRequestRuntime({ + ...parsedInput, + workMode: normalizedWorkMode + }) const agentRuntimeSelected = isAgentRuntime(selectedRuntime) const parsedRequest = { ...parsedInput, @@ -2877,11 +2954,19 @@ export function registerIpcHandlers( applicationSettings?.magicNotesEnabled ?? false const webSearchEnabled = webSearchCapability?.enabled === true + const configProject = enrichedRequest.projectId + ? assistantDatabase.getProject(enrichedRequest.projectId) + : undefined + const configExecutionSpace = configProject + ? spaceResolver.resolveProject(configProject) + : undefined const configWorkspacePath = configAccess === 'none' ? undefined - : enrichedRequest.projectId - ? assistantDatabase.getProject(enrichedRequest.projectId).rootPath + : configExecutionSpace + ? configExecutionSpace.kind === 'local' + ? configExecutionSpace.rootPath + : undefined : resolvedRuntimeSettings?.workspacePath const controller = new AbortController() const scopedCapability = grantScopedDataCapability({ @@ -3563,9 +3648,15 @@ export function registerIpcHandlers( settings, request.runtimeSelection ) - const workspacePath = request.projectId - ? assistantDatabase.getProject(request.projectId).rootPath - : selected.settings.workspacePath + const executionSpace = request.projectId + ? spaceResolver.resolveProject( + assistantDatabase.getProject(request.projectId) + ) + : spaceResolver.resolveLocal(selected.settings.workspacePath) + if (executionSpace.kind === 'ssh') { + throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE) + } + const workspacePath = executionSpace.rootPath const controller = new AbortController() const timeout = setTimeout( () => @@ -3596,7 +3687,7 @@ export function registerIpcHandlers( } outcome = await selectedRuntimes.compactConversation( trustedRequest, - workspacePath, + executionSpace, controller.signal ) } else { @@ -3725,13 +3816,17 @@ export function registerIpcHandlers( ? { profileId: request.profileId } : {}) } - const workspacePath = request.projectId - ? assistantDatabase.getProject(request.projectId).rootPath - : (await settingsStore.getResolvedSettings()).workspacePath + const executionSpace = request.projectId + ? spaceResolver.resolveProject( + assistantDatabase.getProject(request.projectId) + ) + : spaceResolver.resolveLocal( + (await settingsStore.getResolvedSettings()).workspacePath + ) return runtimeNativeSnapshotSchema.parse( await selectedRuntimes.getNativeSnapshot( selection, - workspacePath + executionSpace ) ) } @@ -3952,6 +4047,130 @@ export function registerIpcHandlers( } ) + registerHandler(ipcChannels.sshHostsGet, (event) => { + assertTrustedSender(event, window) + if (!sshHostService) { + throw new Error('SSH 主机设置服务不可用') + } + return sshHostService.getSnapshot() + }) + + registerHandler( + ipcChannels.sshHostsRemove, + (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostService) { + throw new Error('SSH 主机设置服务不可用') + } + const hostId = sshHostRequestSchema.parse(input).hostId + const referencingProjectIds = + assistantDatabase.listProjectIdsReferencingSshHost(hostId) + if (referencingProjectIds.length > 0) { + throw new Error('SSH 主机仍被项目引用,无法删除') + } + return sshHostService.remove(hostId) + } + ) + + registerHandler( + ipcChannels.sshHostsInspectDraftKey, + (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostService) { + throw new Error('SSH 主机设置服务不可用') + } + return sshHostService.inspectDraftHostKey( + sshHostDraftInspectionRequestSchema.parse(input) + ) + } + ) + + registerHandler( + ipcChannels.sshHostsDiscardCandidate, + (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostService) { + throw new Error('SSH 主机设置服务不可用') + } + sshHostService.discardCandidate( + sshHostCandidateRequestSchema.parse(input).candidateId + ) + } + ) + + registerHandler( + ipcChannels.sshHostsValidateAndSave, + (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostService) { + throw new Error('SSH 主机设置服务不可用') + } + return sshHostService.validateAndSave( + sshHostValidationRequestSchema.parse(input) + ) + } + ) + + registerHandler( + ipcChannels.sshHostsRemoteEnvironment, + async (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostRemoteEnvironmentInspector) { + throw new Error('SSH 远端运行环境服务不可用') + } + const hostId = sshHostRequestSchema.parse(input).hostId + return sshHostRemoteEnvironmentSchema.parse( + await sshHostRemoteEnvironmentInspector.inspect(hostId) + ) + } + ) + + registerHandler( + ipcChannels.sshHostsBrowseDirectories, + async (event, input: unknown) => { + assertTrustedSender(event, window) + if (!sshHostDirectoryBrowser) { + throw new Error('SSH 远端目录浏览服务不可用') + } + const request = sshDirectoryBrowseRequestSchema.parse(input) + activeSshDirectoryBrowse?.abort( + new DOMException( + 'SSH directory browse replaced', + 'AbortError' + ) + ) + const controller = new AbortController() + activeSshDirectoryBrowse = controller + try { + return sshDirectoryBrowseResultSchema.parse( + await sshHostDirectoryBrowser.listDirectories( + request.hostId, + request.path, + controller.signal + ) + ) + } finally { + if (activeSshDirectoryBrowse === controller) { + activeSshDirectoryBrowse = undefined + } + } + } + ) + + registerHandler( + ipcChannels.sshHostsCancelDirectoryBrowse, + (event) => { + assertTrustedSender(event, window) + activeSshDirectoryBrowse?.abort( + new DOMException( + 'SSH directory browse cancelled', + 'AbortError' + ) + ) + activeSshDirectoryBrowse = undefined + } + ) + registerHandler(ipcChannels.channelSettingsGet, (event) => { assertTrustedSender(event, window) if (!channelManager) { @@ -4749,6 +4968,47 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.remoteProjectActivate, + async (event, input: unknown) => { + assertTrustedSender(event, window) + if (!remoteProjectSaveService) { + throw new Error('远程项目验证服务不可用') + } + const result = await remoteProjectSaveService.activate( + event.sender, + assistantIdSchema.parse(input) + ) + return result + } + ) + + registerHandler( + ipcChannels.remoteProjectSave, + async (event, input: unknown) => { + assertTrustedSender(event, window) + if (!remoteProjectSaveService) { + throw new Error('远程项目验证服务不可用') + } + const result = await remoteProjectSaveService.save( + event.sender, + remoteProjectSaveRequestSchema.parse(input) + ) + return result + } + ) + + registerHandler( + ipcChannels.remoteProjectCancelCurrent, + (event) => { + assertTrustedSender(event, window) + if (!remoteProjectSaveService) { + throw new Error('远程项目验证服务不可用') + } + remoteProjectSaveService.cancelCurrent(event.sender) + } + ) + registerHandler( ipcChannels.projectsUpdate, async (event, input: unknown) => { @@ -5021,7 +5281,14 @@ export function registerIpcHandlers( const project = assistantDatabase.getProject( assistantIdSchema.parse(input) ) - return getWorkspaceChanges(project.rootPath) + const executionSpace = spaceResolver.resolveProject(project) + try { + return await getWorkspaceChanges( + executionSpace.workspaceAccess + ) + } finally { + await executionSpace.workspaceAccess.dispose() + } } ) registerHandler( @@ -5030,7 +5297,15 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceDirectoryRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) - return listWorkspaceDirectory(project.rootPath, value.path) + const executionSpace = spaceResolver.resolveProject(project) + try { + return await listWorkspaceDirectory( + executionSpace.workspaceAccess, + value.path + ) + } finally { + await executionSpace.workspaceAccess.dispose() + } } ) registerHandler( @@ -5039,7 +5314,15 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceFileRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) - return readWorkspaceFile(project.rootPath, value.path) + const executionSpace = spaceResolver.resolveProject(project) + try { + return await readWorkspaceFile( + executionSpace.workspaceAccess, + value.path + ) + } finally { + await executionSpace.workspaceAccess.dispose() + } } ) registerHandler( @@ -5048,11 +5331,18 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const value = workspaceOpenPathRequestSchema.parse(input) const project = assistantDatabase.getProject(value.projectId) - const targetPath = await resolveWorkspaceEntryPath( - project.rootPath, - value.path, - value.type - ) + const executionSpace = spaceResolver.resolveProject(project) + spaceResolver.assertLocal(executionSpace) + let targetPath: string + try { + targetPath = await resolveWorkspaceEntryPath( + executionSpace.rootPath, + value.path, + value.type + ) + } finally { + await executionSpace.workspaceAccess.dispose() + } const error = await shell.openPath(targetPath) if (error) { throw new Error( @@ -6538,7 +6828,16 @@ export function registerIpcHandlers( controller.abort(new Error('应用正在退出')) } heartbeatControllers.clear() + activeSshDirectoryBrowse?.abort( + new DOMException( + 'SSH directory browse disposed', + 'AbortError' + ) + ) + activeSshDirectoryBrowse = undefined speechTranscriptionService?.dispose() + const remoteProjectSaveCleanup = + remoteProjectSaveService?.dispose() const speechModelCleanup = speechModelManager ?.getSnapshot() .then((snapshot) => { @@ -6567,6 +6866,7 @@ export function registerIpcHandlers( await Promise.allSettled([ channelCleanup, speechModelCleanup, + remoteProjectSaveCleanup, remoteDelegation?.stop(), wechatBindingController?.stop(), executionTracker.drain(), diff --git a/src/main/knowledge/knowledge-database.test.ts b/src/main/knowledge/knowledge-database.test.ts index 7c59e57f..bad3c2fc 100644 --- a/src/main/knowledge/knowledge-database.test.ts +++ b/src/main/knowledge/knowledge-database.test.ts @@ -778,7 +778,7 @@ describe('KnowledgeDatabase', () => { expect(database.listDocuments(library.id)).toHaveLength(500) expect(database.listSourcesForSnapshot(library.id)).toHaveLength(501) expect(database.listDocumentsForSnapshot(library.id)).toHaveLength(501) - }) + }, 10_000) it('edits graph records and merges entities while retaining evidence and locks', async () => { const { database } = await createDatabase() @@ -948,7 +948,7 @@ describe('KnowledgeDatabase', () => { 'RELATED_TO' )?.id ).toBe(expectedRelationId) - }) + }, 15_000) it('persists controlled ontology updates and marks graph rebuild state', async () => { const { database } = await createDatabase() diff --git a/src/main/remote-agent/agent-attach-transport.test.ts b/src/main/remote-agent/agent-attach-transport.test.ts new file mode 100644 index 00000000..dc4a9682 --- /dev/null +++ b/src/main/remote-agent/agent-attach-transport.test.ts @@ -0,0 +1,387 @@ +import { EventEmitter } from 'node:events' +import type { ClientChannel } from 'ssh2' +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_FAILURE_STDERR_PREFIX, + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + encodeAgentFrame, + type AgentFrame, + type AttachPreface, + type AttachWelcome +} from '../../shared/agent-protocol' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' +import { + AgentAttachTransport, + AgentAttachTransportError +} from './agent-attach-transport' + +class FakeChannel extends EventEmitter { + readonly stderr = new EventEmitter() + readonly writes: Buffer[] = [] + readonly callbacks: Array<(error?: Error | null) => void> = [] + destroyed = false + paused = false + autoComplete = true + onWrite?: (bytes: Buffer) => void + + write( + bytes: Uint8Array, + callback?: (error?: Error | null) => void + ): boolean { + const buffer = Buffer.from(bytes) + this.writes.push(buffer) + this.onWrite?.(buffer) + if (callback !== undefined) { + if (this.autoComplete) { + queueMicrotask(() => callback()) + } else { + this.callbacks.push(callback) + } + } + return true + } + + pause(): this { + this.paused = true + return this + } + + resume(): this { + this.paused = false + return this + } + + destroy(): this { + if (!this.destroyed) { + this.destroyed = true + this.emit('close') + } + return this + } + + completeWrite(error?: Error | null): void { + this.callbacks.shift()?.(error) + } + + remote(bytes: Uint8Array): void { + this.emit('data', Buffer.from(bytes)) + } +} + +const preface: AttachPreface = { + type: 'goodbuddy-agent-attach', + protocol: AGENT_PROTOCOL_VERSION, + goodBuddyVersion: '0.11.0', + controllerId: 'controller-1', + clientNonce: 'client-nonce-1', + hostRevision: 2, + hostKeyGeneration: 3 +} + +const welcome: AttachWelcome = { + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId: 'connection-1', + generation: 2, + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + serverNonce: 'server-nonce-1' +} + +function packet(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), 'utf8') + const bytes = Buffer.alloc(4 + body.byteLength) + bytes.writeUInt32BE(body.byteLength, 0) + body.copy(bytes, 4) + return bytes +} + +function frame( + overrides: Partial = {}, + payload = Buffer.from('payload') +): AgentFrame { + return { + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 2, + channelId: 'channel-1', + channelEpoch: '1', + direction: 'agent-to-main', + sequence: '1', + kind: 'acp', + payloadLength: payload.byteLength, + ...overrides + }, + payload + } +} + +function connect( + channel: FakeChannel, + options: { timeoutMs?: number } = {} +): Promise { + return AgentAttachTransport.connect({ + sshLease: { + openAgentAttach: vi.fn(async () => channel as unknown as ClientChannel) + }, + installationId: verifyAgentInstallationId('agent-v1'), + preface, + timeoutMs: options.timeoutMs + }) +} + +describe('AgentAttachTransport', () => { + it('handles a split welcome and a coalesced first binary frame', async () => { + const channel = new FakeChannel() + const encodedFrame = encodeAgentFrame(frame()) + channel.onWrite = () => { + const response = Buffer.concat([packet(welcome), encodedFrame]) + queueMicrotask(() => { + channel.remote(response.subarray(0, 2)) + channel.remote(response.subarray(2, 9)) + channel.remote(response.subarray(9)) + }) + } + const transport = await connect(channel) + expect(transport.welcome).toEqual(welcome) + expect((await transport.receive()).payload).toEqual( + Buffer.from('payload') + ) + transport.dispose() + }) + + it('rejects oversized welcome packets and attach timeouts', async () => { + const oversized = new FakeChannel() + oversized.onWrite = () => { + const length = Buffer.alloc(4) + length.writeUInt32BE(4_097) + queueMicrotask(() => oversized.remote(length)) + } + await expect(connect(oversized)).rejects.toMatchObject({ + code: 'malformed' + }) + + const stalled = new FakeChannel() + await expect(connect(stalled, { timeoutMs: 5 })).rejects.toMatchObject({ + code: 'timeout' + }) + expect(stalled.destroyed).toBe(true) + + let observedSignal: AbortSignal | undefined + const bootstrap = AgentAttachTransport.connect({ + sshLease: { + openAgentAttach: vi.fn( + async (_installationId, signal?: AbortSignal) => { + observedSignal = signal + await new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + throw new Error('unreachable') + } + ) + }, + installationId: verifyAgentInstallationId('agent-v1'), + preface, + timeoutMs: 5 + }) + await expect(bootstrap).rejects.toMatchObject({ + code: 'timeout' + }) + expect(observedSignal?.aborted).toBe(true) + }) + + it('fails closed on a stale generation frame', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + queueMicrotask(() => channel.remote(packet(welcome))) + } + const transport = await connect(channel) + channel.remote( + encodeAgentFrame(frame({ generation: 1 })) + ) + expect(() => transport.receive()).toThrow( + AgentAttachTransportError + ) + expect(transport.closed).toBe(true) + }) + + it('extracts only a bounded Agent protocol failure category', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + queueMicrotask(() => channel.remote(packet(welcome))) + } + const transport = await connect(channel) + const closed = new Promise((resolve) => { + transport.onClose(resolve) + }) + channel.stderr.emit( + 'data', + `${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n` + ) + channel.emit('close') + await expect(closed).resolves.toMatchObject({ + code: 'closed', + diagnostic: 'dispatch/process' + }) + }) + + it('does not copy remote stderr into attach diagnostics', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + queueMicrotask(() => { + channel.stderr.emit( + 'data', + 'secret-value host-00000000-0000-4000-8000-000000000001' + ) + channel.emit('close') + }) + } + + await expect(connect(channel)).rejects.toMatchObject({ + code: 'closed', + message: + 'Agent attach closed during handshake with diagnostic output' + }) + }) + + it('writes every frame in caller FIFO order', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + if (channel.writes.length === 1) { + queueMicrotask(() => channel.remote(packet(welcome))) + } + } + const transport = await connect(channel) + channel.autoComplete = false + const firstFrame = frame( + { direction: 'main-to-agent', kind: 'blob', channelId: 'one' }, + Buffer.from('one') + ) + const secondFrame = frame( + { direction: 'main-to-agent', kind: 'blob', channelId: 'two' }, + Buffer.from('two') + ) + const controlFrame = frame( + { direction: 'main-to-agent', kind: 'control', channelId: 'three' }, + Buffer.from('{}') + ) + const first = transport.send(firstFrame) + const second = transport.send(secondFrame) + const control = transport.send(controlFrame) + + expect(channel.writes).toHaveLength(2) + expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(firstFrame))) + channel.completeWrite() + await vi.waitFor(() => expect(channel.writes).toHaveLength(3)) + expect(channel.writes[2]).toEqual(Buffer.from(encodeAgentFrame(secondFrame))) + channel.completeWrite() + await vi.waitFor(() => expect(channel.writes).toHaveLength(4)) + expect(channel.writes[3]).toEqual(Buffer.from(encodeAgentFrame(controlFrame))) + channel.completeWrite() + await Promise.all([first, second, control]) + transport.dispose() + }) + + it('rejects an active write when the SSH transport fails', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + if (channel.writes.length === 1) { + queueMicrotask(() => channel.remote(packet(welcome))) + } + } + const transport = await connect(channel) + channel.autoComplete = false + const pending = transport.send( + frame( + { + direction: 'main-to-agent', + kind: 'blob' + }, + Buffer.of(1) + ) + ) + + channel.emit('error', new Error('SSH channel failed')) + + await expect(pending).rejects.toMatchObject({ code: 'closed' }) + expect(transport.closed).toBe(true) + channel.completeWrite() + }) + + it('normalizes an active SSH write callback failure', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + if (channel.writes.length === 1) { + queueMicrotask(() => channel.remote(packet(welcome))) + } + } + const transport = await connect(channel) + channel.autoComplete = false + const pending = transport.send( + frame( + { + direction: 'main-to-agent', + kind: 'blob' + }, + Buffer.of(1) + ) + ) + + channel.completeWrite(new Error('SSH write failed')) + + await expect(pending).rejects.toMatchObject({ + code: 'closed', + message: 'Failed to write an Agent frame' + }) + expect(transport.closed).toBe(true) + }) + + it('writes one maximum-sized blob frame in one SSH write', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + if (channel.writes.length === 1) { + queueMicrotask(() => channel.remote(packet(welcome))) + } + } + const transport = await connect(channel) + const blob = frame( + { direction: 'main-to-agent', kind: 'blob' }, + Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes) + ) + + await transport.send(blob) + + expect(channel.writes).toHaveLength(2) + expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(blob))) + transport.dispose() + }) + + it('rejects an oversized blob before writing to SSH', async () => { + const channel = new FakeChannel() + channel.onWrite = () => { + if (channel.writes.length === 1) { + queueMicrotask(() => channel.remote(packet(welcome))) + } + } + const transport = await connect(channel) + + await expect( + transport.send( + frame( + { direction: 'main-to-agent', kind: 'blob' }, + Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1) + ) + ) + ).rejects.toMatchObject({ code: 'malformed' }) + expect(channel.writes).toHaveLength(1) + transport.dispose() + }) +}) diff --git a/src/main/remote-agent/agent-attach-transport.ts b/src/main/remote-agent/agent-attach-transport.ts new file mode 100644 index 00000000..42e3179d --- /dev/null +++ b/src/main/remote-agent/agent-attach-transport.ts @@ -0,0 +1,643 @@ +import type { ClientChannel } from 'ssh2' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_FAILURE_STDERR_PREFIX, + agentProtocolFailureCategorySchema, + attachPrefaceSchema, + attachWelcomeSchema, + type AgentFrame, + type AttachPreface, + type AttachWelcome +} from '../../shared/agent-protocol' +import { + AGENT_FRAME_FIXED_HEADER_BYTES, + AgentFrameError, + MAXIMUM_ENCODED_AGENT_FRAME_BYTES, + decodeAgentFrame, + encodeAgentFrame, + inspectAgentFramePrefix +} from '../../shared/agent-protocol/frame' +import type { SshConnectionLease } from '../ssh/ssh-connection-pool' +import type { VerifiedAgentInstallationId } from '../ssh/ssh-agent-command' + +const HANDSHAKE_MAXIMUM_BYTES = 4 * 1024 +const MAXIMUM_INCOMING_ITEMS = 256 +const MAXIMUM_PENDING_READS = 256 +const MAXIMUM_OUTGOING_ITEMS = 512 +const MAXIMUM_BUFFERED_BYTES = + AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes +const MAXIMUM_STDERR_BYTES = 64 * 1024 +const DEFAULT_ATTACH_TIMEOUT_MS = 10_000 + +type OutgoingWrite = { + bytes: Uint8Array + resolve: () => void + reject: (error: unknown) => void +} + +type FrameWaiter = { + resolve: (frame: AgentFrame) => void + reject: (error: unknown) => void + signal?: AbortSignal + abort?: () => void +} + +export type AgentAttachTransportOptions = { + sshLease: Pick + installationId: VerifiedAgentInstallationId + preface: AttachPreface + signal?: AbortSignal + timeoutMs?: number +} + +export class AgentAttachTransportError extends Error { + constructor( + message: string, + readonly code: + | 'timeout' + | 'aborted' + | 'closed' + | 'stderr' + | 'malformed' + | 'overflow', + readonly cause?: unknown, + readonly diagnostic?: string + ) { + super(message) + this.name = 'AgentAttachTransportError' + } +} + +/** + * Owns one fixed SSH attach channel. The initial packet and every subsequent + * frame are length bounded before allocation. Ordering and backpressure are + * delegated to the SSH channel; this layer adds no scheduler or flow window. + */ +export class AgentAttachTransport { + readonly welcome: AttachWelcome + readonly #channel: ClientChannel + #input = Buffer.alloc(0) + #incoming: AgentFrame[] = [] + #incomingBytes = 0 + #waiters: FrameWaiter[] = [] + #writes: OutgoingWrite[] = [] + #outgoingBytes = 0 + #writing = false + #activeWrite?: OutgoingWrite + #closed = false + #closeError?: AgentAttachTransportError + #stderr = Buffer.alloc(0) + #closeListeners = new Set<(error: AgentAttachTransportError) => void>() + + private constructor(channel: ClientChannel, welcome: AttachWelcome) { + this.#channel = channel + this.welcome = welcome + this.#channel.on('data', this.#onData) + this.#channel.stderr.on('data', this.#onStderr) + this.#channel.once('error', this.#onError) + this.#channel.once('close', this.#onClose) + } + + static async connect( + options: AgentAttachTransportOptions + ): Promise { + const preface = attachPrefaceSchema.parse(options.preface) + const timeoutMs = options.timeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + throw new RangeError('Invalid Agent attach timeout') + } + options.signal?.throwIfAborted() + const controller = new AbortController() + const abort = (): void => controller.abort(options.signal?.reason) + options.signal?.addEventListener('abort', abort, { once: true }) + const timer = setTimeout(() => { + controller.abort( + new AgentAttachTransportError( + 'GoodBuddy Agent attach handshake timed out', + 'timeout' + ) + ) + }, timeoutMs) + try { + const channel = await options.sshLease.openAgentAttach( + options.installationId, + controller.signal + ) + const handshake = readWelcome(channel, preface, controller.signal) + const { welcome, trailingBytes, stderr } = await handshake + const transport = new AgentAttachTransport(channel, welcome) + transport.#stderr = Buffer.from(stderr) + if (trailingBytes.byteLength > 0) { + transport.#acceptBytes(trailingBytes) + } + return transport + } catch (error) { + if (controller.signal.aborted) { + const reason = controller.signal.reason + if (reason instanceof AgentAttachTransportError) { + throw reason + } + throw new AgentAttachTransportError( + 'GoodBuddy Agent attach was aborted', + 'aborted', + reason + ) + } + throw error + } finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } + } + + get closed(): boolean { + return this.#closed + } + + async send(frame: AgentFrame): Promise { + this.#assertOpen() + let bytes: Uint8Array + try { + bytes = encodeAgentFrame(frame) + } catch (error) { + throw new AgentAttachTransportError( + 'Refused malformed outgoing Agent frame', + 'malformed', + error + ) + } + await this.#enqueueWrite(bytes) + } + + receive(signal?: AbortSignal): Promise { + this.#assertOpen() + signal?.throwIfAborted() + const frame = this.#incoming.shift() + if (frame !== undefined) { + this.#incomingBytes -= encodedFrameLength(frame) + this.#resumeInputIfPossible() + return Promise.resolve(frame) + } + if (this.#waiters.length >= MAXIMUM_PENDING_READS) { + return Promise.reject( + new AgentAttachTransportError( + 'Too many pending Agent frame readers', + 'overflow' + ) + ) + } + return new Promise((resolve, reject) => { + const waiter: FrameWaiter = { resolve, reject, signal } + if (signal !== undefined) { + waiter.abort = (): void => { + const index = this.#waiters.indexOf(waiter) + if (index >= 0) { + this.#waiters.splice(index, 1) + } + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) + } + signal.addEventListener('abort', waiter.abort, { once: true }) + } + this.#waiters.push(waiter) + }) + } + + onClose( + listener: (error: AgentAttachTransportError) => void + ): () => void { + if (this.#closeError !== undefined) { + queueMicrotask(() => listener(this.#closeError!)) + return () => undefined + } + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + dispose(): void { + this.#fail( + new AgentAttachTransportError( + 'GoodBuddy Agent attach transport was disposed', + 'closed' + ) + ) + } + + readonly #onData = (chunk: Buffer | string): void => { + if (!this.#closed) { + this.#acceptBytes(Buffer.from(chunk)) + } + } + + readonly #onStderr = (chunk: Buffer | string): void => { + if (this.#closed) { + return + } + const bytes = Buffer.from(chunk) + if (this.#stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) { + this.#fail( + new AgentAttachTransportError( + 'GoodBuddy Agent attach stderr exceeded its limit', + 'stderr' + ) + ) + return + } + this.#stderr = Buffer.concat([this.#stderr, bytes]) + } + + readonly #onError = (error: Error): void => { + this.#fail( + new AgentAttachTransportError( + 'GoodBuddy Agent attach channel failed', + 'closed', + error + ) + ) + } + + readonly #onClose = (): void => { + const diagnostic = protocolFailureDiagnostic(this.#stderr) + this.#fail( + new AgentAttachTransportError( + this.#stderr.byteLength > 0 + ? 'GoodBuddy Agent attach closed with diagnostic output' + : 'GoodBuddy Agent attach channel closed', + 'closed', + undefined, + diagnostic + ) + ) + } + + #acceptBytes(chunk: Buffer): void { + if ( + this.#input.byteLength + chunk.byteLength > + MAXIMUM_BUFFERED_BYTES + MAXIMUM_ENCODED_AGENT_FRAME_BYTES + ) { + this.#fail( + new AgentAttachTransportError( + 'Agent attach input buffer exceeded its limit', + 'overflow' + ) + ) + return + } + this.#input = Buffer.concat([this.#input, chunk]) + try { + while ( + !this.#closed && + this.#input.byteLength >= AGENT_FRAME_FIXED_HEADER_BYTES + ) { + const frameLength = + inspectAgentFramePrefix(this.#input).encodedByteLength + if (this.#input.byteLength < frameLength) { + break + } + const encoded = this.#input.subarray(0, frameLength) + const frame = decodeAgentFrame(encoded, { + protocolMajor: this.welcome.protocol.major, + maximumProtocolMinor: this.welcome.protocol.minor, + connectionId: this.welcome.connectionId, + generation: this.welcome.generation + }) + if (frame.header.direction !== 'agent-to-main') { + throw new AgentFrameError( + 'Agent sent a frame in the wrong direction', + 'invalid' + ) + } + this.#input = this.#input.subarray(frameLength) + this.#enqueueIncoming(frame) + if ( + this.#incoming.length >= MAXIMUM_INCOMING_ITEMS || + this.#incomingBytes >= MAXIMUM_BUFFERED_BYTES + ) { + break + } + } + } catch (error) { + this.#fail( + new AgentAttachTransportError( + 'Received a malformed Agent frame', + 'malformed', + error + ) + ) + } + } + + #enqueueIncoming(frame: AgentFrame): void { + const waiter = this.#waiters.shift() + if (waiter !== undefined) { + if (waiter.abort !== undefined) { + waiter.signal?.removeEventListener('abort', waiter.abort) + } + waiter.resolve(frame) + return + } + const byteLength = encodedFrameLength(frame) + if ( + this.#incoming.length >= MAXIMUM_INCOMING_ITEMS || + this.#incomingBytes + byteLength > MAXIMUM_BUFFERED_BYTES + ) { + this.#channel.pause() + throw new AgentAttachTransportError( + 'Agent attach incoming queue is full', + 'overflow' + ) + } + this.#incoming.push(frame) + this.#incomingBytes += byteLength + if ( + this.#incoming.length === MAXIMUM_INCOMING_ITEMS || + this.#incomingBytes === MAXIMUM_BUFFERED_BYTES + ) { + this.#channel.pause() + } + } + + #resumeInputIfPossible(): void { + if ( + !this.#closed && + this.#incoming.length < MAXIMUM_INCOMING_ITEMS && + this.#incomingBytes < MAXIMUM_BUFFERED_BYTES + ) { + if (this.#input.byteLength > 0) { + this.#acceptBytes(Buffer.alloc(0)) + } + if ( + this.#incoming.length < MAXIMUM_INCOMING_ITEMS && + this.#incomingBytes < MAXIMUM_BUFFERED_BYTES + ) { + this.#channel.resume() + } + } + } + + #pumpWrites(): void { + if (this.#writing || this.#closed) { + return + } + const write = this.#writes.shift() + if (write === undefined) { + return + } + this.#writing = true + this.#activeWrite = write + this.#channel.write(write.bytes, (error?: Error | null) => { + if (this.#activeWrite !== write) { + return + } + this.#activeWrite = undefined + this.#writing = false + this.#outgoingBytes -= write.bytes.byteLength + if (error !== undefined && error !== null) { + const transportError = new AgentAttachTransportError( + 'Failed to write an Agent frame', + 'closed', + error + ) + write.reject(transportError) + this.#fail(transportError) + return + } + write.resolve() + this.#pumpWrites() + }) + } + + #enqueueWrite(bytes: Uint8Array): Promise { + if ( + this.#writes.length >= MAXIMUM_OUTGOING_ITEMS || + this.#outgoingBytes + bytes.byteLength > MAXIMUM_BUFFERED_BYTES + ) { + return Promise.reject( + new AgentAttachTransportError( + 'Agent attach outgoing queue is full', + 'overflow' + ) + ) + } + return new Promise((resolve, reject) => { + const write = { + bytes, + resolve, + reject + } + this.#writes.push(write) + this.#outgoingBytes += bytes.byteLength + this.#pumpWrites() + }) + } + + #assertOpen(): void { + if (this.#closed) { + throw ( + this.#closeError ?? + new AgentAttachTransportError( + 'GoodBuddy Agent attach transport is closed', + 'closed' + ) + ) + } + } + + #fail(error: AgentAttachTransportError): void { + if (this.#closed) { + return + } + this.#closed = true + this.#closeError = error + this.#channel.off('data', this.#onData) + this.#channel.stderr.off('data', this.#onStderr) + this.#channel.destroy() + const activeWrite = this.#activeWrite + this.#activeWrite = undefined + this.#writing = false + activeWrite?.reject(error) + for (const waiter of this.#waiters.splice(0)) { + if (waiter.abort !== undefined) { + waiter.signal?.removeEventListener('abort', waiter.abort) + } + waiter.reject(error) + } + for (const write of this.#writes.splice(0)) { + write.reject(error) + } + this.#incoming = [] + this.#input = Buffer.alloc(0) + this.#incomingBytes = 0 + this.#outgoingBytes = 0 + for (const listener of this.#closeListeners) { + listener(error) + } + this.#closeListeners.clear() + } +} + +function protocolFailureDiagnostic( + stderr: Uint8Array +): string | undefined { + for (const line of Buffer.from(stderr).toString('utf8').split(/\r?\n/u)) { + if (!line.startsWith(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX)) { + continue + } + const category = agentProtocolFailureCategorySchema.safeParse( + line.slice(AGENT_PROTOCOL_FAILURE_STDERR_PREFIX.length) + ) + if (category.success) { + return category.data + } + } + return undefined +} + +async function readWelcome( + channel: ClientChannel, + preface: AttachPreface, + signal: AbortSignal +): Promise<{ + welcome: AttachWelcome + trailingBytes: Buffer + stderr: Buffer +}> { + let input = Buffer.alloc(0) + let stderr = Buffer.alloc(0) + return await new Promise((resolve, reject) => { + let settled = false + const cleanup = (): void => { + channel.off('data', onData) + channel.stderr.off('data', onStderr) + channel.off('error', onError) + channel.off('close', onClose) + signal.removeEventListener('abort', onAbort) + } + const fail = (error: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + channel.destroy() + reject(error) + } + const onAbort = (): void => fail(signal.reason) + const onError = (error: Error): void => fail(error) + const onClose = (): void => + fail( + new AgentAttachTransportError( + stderr.byteLength > 0 + ? 'Agent attach closed during handshake with diagnostic output' + : 'Agent attach closed during handshake', + 'closed' + ) + ) + const onStderr = (chunk: Buffer | string): void => { + const bytes = Buffer.from(chunk) + if (stderr.byteLength + bytes.byteLength > MAXIMUM_STDERR_BYTES) { + fail( + new AgentAttachTransportError( + 'Agent attach handshake stderr exceeded its limit', + 'stderr' + ) + ) + } else { + stderr = Buffer.concat([stderr, bytes]) + } + } + const onData = (chunk: Buffer | string): void => { + const bytes = Buffer.from(chunk) + if ( + input.byteLength + bytes.byteLength > + 4 + + HANDSHAKE_MAXIMUM_BYTES + + MAXIMUM_BUFFERED_BYTES + + MAXIMUM_ENCODED_AGENT_FRAME_BYTES + ) { + fail( + new AgentAttachTransportError( + 'Agent attach handshake input exceeded its limit', + 'overflow' + ) + ) + return + } + input = Buffer.concat([input, bytes]) + if (input.byteLength < 4) { + return + } + const length = input.readUInt32BE(0) + if (length < 2 || length > HANDSHAKE_MAXIMUM_BYTES) { + fail( + new AgentAttachTransportError( + 'Agent attach welcome is oversized', + 'malformed' + ) + ) + return + } + if (input.byteLength < 4 + length) { + return + } + try { + const value: unknown = JSON.parse( + input.subarray(4, 4 + length).toString('utf8') + ) + const welcome = attachWelcomeSchema.parse(value) + if ( + welcome.protocol.major !== preface.protocol.major || + welcome.protocol.minor > preface.protocol.minor + ) { + throw new Error('Agent attach protocol version is incompatible') + } + settled = true + cleanup() + resolve({ + welcome, + trailingBytes: input.subarray(4 + length), + stderr + }) + } catch (error) { + fail( + new AgentAttachTransportError( + 'Agent attach welcome is malformed', + 'malformed', + error + ) + ) + } + } + channel.on('data', onData) + channel.stderr.on('data', onStderr) + channel.once('error', onError) + channel.once('close', onClose) + signal.addEventListener('abort', onAbort, { once: true }) + const contents = Buffer.from(JSON.stringify(preface), 'utf8') + if (contents.byteLength > HANDSHAKE_MAXIMUM_BYTES) { + fail( + new AgentAttachTransportError( + 'Agent attach preface is oversized', + 'malformed' + ) + ) + return + } + const packet = Buffer.allocUnsafe(contents.byteLength + 4) + packet.writeUInt32BE(contents.byteLength, 0) + contents.copy(packet, 4) + channel.write(packet, (error?: Error | null) => { + if (error !== undefined && error !== null) { + fail(error) + } + }) + }) +} + +function encodedFrameLength(frame: AgentFrame): number { + return ( + AGENT_FRAME_FIXED_HEADER_BYTES + + Buffer.byteLength(frame.header.connectionId) + + Buffer.byteLength(frame.header.channelId) + + frame.payload.byteLength + ) +} diff --git a/src/main/remote-agent/agent-bundle-verifier.test.ts b/src/main/remote-agent/agent-bundle-verifier.test.ts new file mode 100644 index 00000000..b9a428be --- /dev/null +++ b/src/main/remote-agent/agent-bundle-verifier.test.ts @@ -0,0 +1,439 @@ +import { + createHash, + generateKeyPairSync, + sign, + type KeyObject +} from 'node:crypto' +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest' +import type { + AgentBundleManifest, + AgentReleaseKeyRegistry, + AgentRuntimeLock +} from '../../shared/agent-installation-contracts' +import { + agentManifestSignaturePayload, + canonicalAgentManifestBytes, + verifyAgentBundleDirectory, + verifyAgentManifestSignature, + type VerifiedAgentBundle +} from './agent-bundle-verifier' + +type BuildAgentBundleModule = { + canonicalManifestBytes( + manifest: AgentBundleManifest + ): Buffer + createManifest( + directory: string, + metadata: { + agentVersion: string + nodeVersion: string + zodVersion: string + koffiVersion: string + koffiNativePackage: string + arch: 'x64' | 'arm64' + protocol: { major: number; minor: number } + signingKeyId: string + } + ): AgentBundleManifest + signaturePayload(manifestBytes: Buffer): Buffer +} + +const require = createRequire(import.meta.url) +const buildAgentBundle = require( + '../../../build/agent-bundle.cjs' +) as BuildAgentBundleModule + +let directory: string +let privateKey: KeyObject +let registry: AgentReleaseKeyRegistry +let runtimeLock: AgentRuntimeLock + +function elf(architecture: 'x64' | 'arm64'): Buffer { + const contents = Buffer.alloc(64) + contents.set([0x7f, 0x45, 0x4c, 0x46]) + contents[5] = 1 + contents.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18) + return contents +} + +function writeKoffiPayload( + bundleDirectory: string, + architecture: 'x64' | 'arm64' +): void { + const packageRoot = join( + bundleDirectory, + 'lib', + 'node_modules', + 'koffi' + ) + const nativePackage = `@koromix/koffi-linux-${architecture}` + const nativeRoot = join( + bundleDirectory, + 'lib', + 'node_modules', + ...nativePackage.split('/') + ) + mkdirSync(join(packageRoot, 'src', 'koffi', 'src'), { + recursive: true + }) + mkdirSync(join(nativeRoot, `linux_${architecture}`), { + recursive: true + }) + mkdirSync(join(nativeRoot, `musl_${architecture}`), { + recursive: true + }) + const files = new Map([ + [ + join(packageRoot, 'package.json'), + JSON.stringify({ + name: 'koffi', + version: '3.1.4', + type: 'module', + exports: { '.': { import: './index.js' } } + }) + ], + [ + join(packageRoot, 'index.js'), + 'export { default } from "./src/koffi/index.js";\n' + ], + [ + join(packageRoot, 'src', 'koffi', 'index.js'), + 'export default {};\n' + ], + [ + join(packageRoot, 'src', 'koffi', 'src', 'static.js'), + 'export function loadStatic() { return null }\n' + ], + [ + join(nativeRoot, 'package.json'), + JSON.stringify({ + name: nativePackage, + version: '3.1.4', + main: './index.js', + os: ['linux'], + cpu: [architecture] + }) + ], + [ + join(nativeRoot, 'index.js'), + `module.exports = require('./linux_${architecture}/koffi.node');\n` + ], + [ + join(nativeRoot, `linux_${architecture}`, 'koffi.node'), + elf(architecture) + ], + [ + join(nativeRoot, `musl_${architecture}`, 'koffi.node'), + elf(architecture) + ] + ]) + for (const [filePath, contents] of files) { + writeFileSync(filePath, contents) + chmodSync(filePath, 0o644) + } +} + +function writeBundle( + signingKeyId = 'generated-test-key' +): AgentBundleManifest { + mkdirSync(join(directory, 'lib'), { recursive: true }) + mkdirSync(join(directory, 'licenses'), { recursive: true }) + writeFileSync(join(directory, 'node'), elf('x64')) + writeFileSync(join(directory, 'goodbuddy-agent'), '#!/bin/sh\n') + writeFileSync( + join(directory, 'lib', 'agent.cjs'), + 'module.exports = {}\n' + ) + writeFileSync( + join(directory, 'licenses', 'GoodBuddy-0BSD.txt'), + '0BSD' + ) + writeFileSync( + join(directory, 'licenses', 'Node.js-MIT.txt'), + 'MIT' + ) + writeFileSync( + join(directory, 'licenses', 'zod-MIT.txt'), + 'MIT' + ) + writeFileSync( + join(directory, 'licenses', 'koffi-MIT.txt'), + 'MIT' + ) + writeFileSync( + join(directory, 'licenses', 'koffi-native-MIT.txt'), + 'MIT' + ) + writeKoffiPayload(directory, 'x64') + for (const filePath of ['node', 'goodbuddy-agent']) { + chmodSync(join(directory, filePath), 0o755) + } + for (const filePath of [ + 'lib/agent.cjs', + 'licenses/GoodBuddy-0BSD.txt', + 'licenses/Node.js-MIT.txt', + 'licenses/zod-MIT.txt', + 'licenses/koffi-MIT.txt', + 'licenses/koffi-native-MIT.txt' + ]) { + chmodSync(join(directory, ...filePath.split('/')), 0o644) + } + + const manifest = buildAgentBundle.createManifest(directory, { + agentVersion: '0.11.0', + nodeVersion: '24.19.0', + zodVersion: '4.4.3', + koffiVersion: '3.1.4', + koffiNativePackage: '@koromix/koffi-linux-x64', + arch: 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId + }) + const manifestBytes = + buildAgentBundle.canonicalManifestBytes(manifest) + writeFileSync(join(directory, 'manifest.json'), manifestBytes) + writeFileSync( + join(directory, 'manifest.sig'), + `${sign( + null, + buildAgentBundle.signaturePayload(manifestBytes), + privateKey + ).toString('base64')}\n` + ) + chmodSync(join(directory, 'manifest.json'), 0o644) + chmodSync(join(directory, 'manifest.sig'), 0o644) + return manifest +} + +async function verifyBundle(): Promise { + return verifyAgentBundleDirectory(directory, { + architecture: 'x64', + registry, + runtimeLock, + verificationEnvironment: 'test', + enforceFilesystemMode: process.platform !== 'win32' + }) +} + +beforeEach(() => { + directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-runtime-agent-bundle-') + ) + const generated = generateKeyPairSync('ed25519') + privateKey = generated.privateKey + registry = { + formatVersion: 1, + keys: [ + { + keyId: 'generated-test-key', + publicKeySpkiBase64: generated.publicKey + .export({ format: 'der', type: 'spki' }) + .toString('base64'), + environment: 'test' + } + ], + revocations: [] + } + const target = { + archive: 'node-v24.19.0-linux.tar.gz', + sha256: 'a'.repeat(64), + binaryPath: 'node/bin/node', + licensePath: 'node/LICENSE' + } + runtimeLock = { + formatVersion: 1, + agentVersion: '0.11.0', + protocol: { major: 1, minor: 0 }, + node: { + version: '24.19.0', + source: 'https://nodejs.org/dist/v24.19.0/', + targets: { + 'linux-x64': target, + 'linux-arm64': target + } + }, + koffi: { version: '3.1.4' } + } +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('runtime and build-time Agent verifier parity', () => { + it('uses the exact build-time canonical bytes and signature domain', async () => { + const manifest = writeBundle() + const manifestBytes = readFileSync( + join(directory, 'manifest.json') + ) + + expect(canonicalAgentManifestBytes(manifest)).toEqual( + buildAgentBundle.canonicalManifestBytes(manifest) + ) + expect(agentManifestSignaturePayload(manifestBytes)).toEqual( + buildAgentBundle.signaturePayload(manifestBytes) + ) + await expect(verifyBundle()).resolves.toMatchObject({ + manifest: { + arch: 'x64', + protocol: { major: 1, minor: 0 } + } + }) + }) + + it('rejects corrupt, undeclared, missing, and architecture-mismatched payloads', async () => { + writeBundle() + writeFileSync(join(directory, 'lib', 'agent.cjs'), 'corrupt') + await expect(verifyBundle()).rejects.toThrow( + /size mismatch|hash mismatch/u + ) + + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-runtime-agent-bundle-') + ) + writeBundle() + writeFileSync(join(directory, 'undeclared.txt'), 'no') + await expect(verifyBundle()).rejects.toThrow( + 'undeclared or missing files' + ) + + rmSync(join(directory, 'licenses', 'zod-MIT.txt')) + await expect(verifyBundle()).rejects.toThrow() + + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync( + join(tmpdir(), 'goodbuddy-runtime-agent-bundle-') + ) + writeBundle() + writeFileSync(join(directory, 'node'), elf('arm64')) + const manifest = JSON.parse( + readFileSync(join(directory, 'manifest.json'), 'utf8') + ) as AgentBundleManifest + const nodeFile = manifest.files.find((file) => file.path === 'node') + if (!nodeFile) { + throw new Error('Fixture Node entry is missing') + } + nodeFile.sha256 = createHash('sha256') + .update(elf('arm64')) + .digest('hex') + const bytes = canonicalAgentManifestBytes(manifest) + writeFileSync(join(directory, 'manifest.json'), bytes) + writeFileSync( + join(directory, 'manifest.sig'), + `${sign( + null, + agentManifestSignaturePayload(bytes), + privateKey + ).toString('base64')}\n` + ) + await expect(verifyBundle()).rejects.toThrow( + 'architecture mismatch' + ) + }) + + it('refuses symlink payloads', async () => { + writeBundle() + const targetDirectory = join(directory, '.lib-target') + renameSync( + join(directory, 'lib'), + targetDirectory + ) + symlinkSync( + targetDirectory, + join(directory, 'lib'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + await expect(verifyBundle()).rejects.toThrow( + /symlink|regular file/u + ) + }) + + it('rejects non-canonical manifests and signature corruption', () => { + const manifest = writeBundle() + const canonical = canonicalAgentManifestBytes(manifest) + const nonCanonical = Buffer.from(JSON.stringify(manifest), 'utf8') + const nonCanonicalSignature = sign( + null, + agentManifestSignaturePayload(nonCanonical), + privateKey + ) + expect(() => + verifyAgentManifestSignature( + nonCanonical, + nonCanonicalSignature, + registry, + 'test' + ) + ).toThrow('not in canonical deterministic form') + + const signature = sign( + null, + agentManifestSignaturePayload(canonical), + privateKey + ) + signature[0] = (signature[0] ?? 0) ^ 1 + expect(() => + verifyAgentManifestSignature( + canonical, + signature, + registry, + 'test' + ) + ).toThrow('signature verification failed') + }) + + it('enforces key revocation and production keys', () => { + const manifest = writeBundle() + const manifestBytes = canonicalAgentManifestBytes(manifest) + const signature = sign( + null, + agentManifestSignaturePayload(manifestBytes), + privateKey + ) + + expect(() => + verifyAgentManifestSignature( + manifestBytes, + signature, + { ...registry, keys: [] } + ) + ).toThrow('production key registry is empty') + expect(() => + verifyAgentManifestSignature( + manifestBytes, + signature, + { + ...registry, + revocations: [ + { + keyId: 'generated-test-key' + } + ] + }, + 'test' + ) + ).toThrow('is revoked') + + }) +}) diff --git a/src/main/remote-agent/agent-bundle-verifier.ts b/src/main/remote-agent/agent-bundle-verifier.ts new file mode 100644 index 00000000..6d780250 --- /dev/null +++ b/src/main/remote-agent/agent-bundle-verifier.ts @@ -0,0 +1,639 @@ +import { + createHash, + createPublicKey, + verify +} from 'node:crypto' +import { createReadStream } from 'node:fs' +import { + lstat, + open, + readFile, + readdir +} from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import { + agentBundleManifestSchema, + agentReleaseKeyRegistrySchema, + agentRuntimeLockSchema, + type AgentArchitecture, + type AgentBundleManifest, + type AgentReleaseKey, + type AgentReleaseKeyRegistry, + type AgentRuntimeLock +} from '../../shared/agent-installation-contracts' +import { + getBundledAgentDirectory, + type BundledAgentResourcePaths +} from './bundled-agent-resources' + +const AGENT_MANIFEST_SIGNATURE_DOMAIN = Buffer.from( + 'GoodBuddy Agent Bundle Manifest Signature v1\0', + 'utf8' +) +const MANIFEST_FILE_NAME = 'manifest.json' +const SIGNATURE_FILE_NAME = 'manifest.sig' +const MAXIMUM_METADATA_BYTES = 1024 * 1024 + +export type AgentVerificationEnvironment = 'production' | 'test' + +export type VerifiedAgentBundle = { + bundleDirectory: string + manifest: AgentBundleManifest + manifestSha256: string +} + +export type VerifyAgentBundleOptions = { + architecture: AgentArchitecture + registry: AgentReleaseKeyRegistry + runtimeLock: AgentRuntimeLock + verificationEnvironment?: AgentVerificationEnvironment + enforceFilesystemMode?: boolean + filesystemPlatform?: NodeJS.Platform +} + +export type VerifyBundledAgentOptions = { + verificationEnvironment?: AgentVerificationEnvironment + enforceFilesystemMode?: boolean + filesystemPlatform?: NodeJS.Platform +} + +export async function readAgentReleaseKeyRegistry( + filePath: string +): Promise { + const value = await readJsonMetadata( + filePath, + 'Agent trusted key registry' + ) + return parseAgentReleaseKeyRegistry(value) +} + +export async function readAgentRuntimeLock( + filePath: string +): Promise { + const value = await readJsonMetadata(filePath, 'Agent runtime lock') + return parseContract( + agentRuntimeLockSchema, + value, + 'Agent runtime lock contract is invalid' + ) +} + +export async function verifyBundledAgentResources( + paths: BundledAgentResourcePaths, + architecture: AgentArchitecture, + options: VerifyBundledAgentOptions = {} +): Promise { + const [registry, runtimeLock] = await Promise.all([ + readAgentReleaseKeyRegistry(paths.keyRegistryPath), + readAgentRuntimeLock(paths.runtimeLockPath) + ]) + return verifyAgentBundleDirectory( + getBundledAgentDirectory(paths, architecture), + { + ...options, + architecture, + registry, + runtimeLock + } + ) +} + +export async function verifyAgentBundleDirectory( + bundleDirectory: string, + options: VerifyAgentBundleOptions +): Promise { + const registry = parseAgentReleaseKeyRegistry(options.registry) + const runtimeLock = parseContract( + agentRuntimeLockSchema, + options.runtimeLock, + 'Agent runtime lock contract is invalid' + ) + const verificationEnvironment = + options.verificationEnvironment ?? 'production' + assertProductionRegistryAvailable(registry, verificationEnvironment) + await assertDirectoryWithoutSymlink( + bundleDirectory, + 'Agent bundle directory' + ) + + const manifestBytes = await readBoundedFile( + join(bundleDirectory, MANIFEST_FILE_NAME), + MAXIMUM_METADATA_BYTES, + 'Agent manifest' + ) + const signatureBytes = await readDetachedSignature( + join(bundleDirectory, SIGNATURE_FILE_NAME) + ) + const manifest = verifyAgentManifestSignature( + manifestBytes, + signatureBytes, + registry, + verificationEnvironment + ) + assertAgentManifestMatchesRuntimeLock( + manifest, + runtimeLock, + options.architecture + ) + + const declaredPaths = new Set( + manifest.files.map((file) => file.path) + ) + for (const file of manifest.files) { + const absolutePath = join( + bundleDirectory, + ...file.path.split('/') + ) + const fileStat = await lstat(absolutePath) + if (!fileStat.isFile() || fileStat.isSymbolicLink()) { + throw new Error( + `Agent payload is not a regular file: ${file.path}` + ) + } + if (fileStat.size !== file.size) { + throw new Error(`Agent payload size mismatch: ${file.path}`) + } + if ((await sha256File(absolutePath)) !== file.sha256) { + throw new Error(`Agent payload hash mismatch: ${file.path}`) + } + if ( + shouldEnforceFilesystemMode(options) && + modeString(fileStat.mode) !== file.mode + ) { + throw new Error(`Agent payload mode mismatch: ${file.path}`) + } + } + + const actualPaths = new Set( + await listRegularBundleFiles(bundleDirectory) + ) + 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 bundle contains undeclared or missing files') + } + + if (shouldEnforceFilesystemMode(options)) { + for (const metadataName of [ + MANIFEST_FILE_NAME, + SIGNATURE_FILE_NAME + ]) { + const metadataStat = await lstat( + join(bundleDirectory, metadataName) + ) + if ( + !metadataStat.isFile() || + metadataStat.isSymbolicLink() || + modeString(metadataStat.mode) !== '0644' + ) { + throw new Error( + `Agent payload mode mismatch: ${metadataName}` + ) + } + } + } + + await assertElfArchitecture( + join(bundleDirectory, manifest.entrypoint.runtimePath), + manifest.arch, + 'Agent Node runtime' + ) + const koffiPaths = requiredKoffiPayloadPaths(manifest.arch) + assertKoffiManifest( + manifest, + runtimeLock.koffi.version, + koffiPaths.required + ) + for (const nativePath of koffiPaths.native) { + await assertElfArchitecture( + join(bundleDirectory, ...nativePath.split('/')), + manifest.arch, + 'Agent Koffi native binding' + ) + } + return { + bundleDirectory, + manifest, + manifestSha256: sha256Bytes(manifestBytes) + } +} + +export function canonicalAgentManifestBytes( + manifest: AgentBundleManifest +): Buffer { + return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') +} + +export function agentManifestSignaturePayload( + manifestBytes: Uint8Array +): Buffer { + return Buffer.concat([ + AGENT_MANIFEST_SIGNATURE_DOMAIN, + Buffer.from(manifestBytes) + ]) +} + +export function verifyAgentManifestSignature( + manifestBytes: Buffer, + signatureBytes: Buffer, + registryInput: AgentReleaseKeyRegistry, + verificationEnvironment: AgentVerificationEnvironment = 'production' +): AgentBundleManifest { + const registry = parseAgentReleaseKeyRegistry(registryInput) + assertProductionRegistryAvailable(registry, verificationEnvironment) + + let untrustedManifest: unknown + try { + untrustedManifest = JSON.parse(manifestBytes.toString('utf8')) + } catch (error) { + throw new Error('Agent manifest JSON is invalid', { cause: error }) + } + const manifest = parseContract( + agentBundleManifestSchema, + untrustedManifest, + 'Agent manifest contract is invalid' + ) + if (!canonicalAgentManifestBytes(manifest).equals(manifestBytes)) { + throw new Error( + 'Agent manifest is not in canonical deterministic form' + ) + } + const trustedKey = trustedKeyForManifest( + manifest, + registry, + verificationEnvironment + ) + const publicKey = importEd25519PublicKey(trustedKey) + if ( + signatureBytes.length !== 64 || + !verify( + null, + agentManifestSignaturePayload(manifestBytes), + publicKey, + signatureBytes + ) + ) { + throw new Error('Agent manifest signature verification failed') + } + return manifest +} + +export function assertAgentManifestMatchesRuntimeLock( + manifest: AgentBundleManifest, + lock: AgentRuntimeLock, + architecture: AgentArchitecture +): void { + if ( + manifest.agentVersion !== lock.agentVersion + ) { + throw new Error( + 'Agent manifest version does not match the runtime lock' + ) + } + if (manifest.arch !== architecture) { + throw new Error( + 'Agent manifest architecture does not match the selected bundle' + ) + } + if ( + manifest.protocol.major !== lock.protocol.major || + manifest.protocol.minor !== lock.protocol.minor + ) { + throw new Error( + 'Agent manifest protocol does not match the runtime lock' + ) + } +} + +function requiredKoffiPayloadPaths( + architecture: AgentArchitecture +): { + required: string[] + native: string[] +} { + const packageRoot = 'lib/node_modules/koffi' + const nativePackage = + `@koromix/koffi-linux-${architecture}` + const nativeRoot = `lib/node_modules/${nativePackage}` + return { + required: [ + `${packageRoot}/package.json`, + `${packageRoot}/index.js`, + `${packageRoot}/src/koffi/index.js`, + `${packageRoot}/src/koffi/src/static.js`, + `${nativeRoot}/package.json`, + `${nativeRoot}/index.js`, + `${nativeRoot}/linux_${architecture}/koffi.node`, + `${nativeRoot}/musl_${architecture}/koffi.node` + ], + native: [ + `${nativeRoot}/linux_${architecture}/koffi.node`, + `${nativeRoot}/musl_${architecture}/koffi.node` + ] + } +} + +function assertKoffiManifest( + manifest: AgentBundleManifest, + version: string, + requiredPaths: readonly string[] +): void { + const declaredPaths = new Set( + manifest.files.map((file) => file.path) + ) + for (const path of requiredPaths) { + if (!declaredPaths.has(path)) { + throw new Error(`Agent Koffi payload is missing: ${path}`) + } + } + const nativePackage = + `@koromix/koffi-linux-${manifest.arch}` + const koffiLicense = manifest.licenses.find( + (license) => license.package === 'koffi' + ) + const nativeLicense = manifest.licenses.find( + (license) => license.package === nativePackage + ) + if ( + koffiLicense?.version !== version || + koffiLicense.spdx !== 'MIT' || + koffiLicense.path !== 'licenses/koffi-MIT.txt' || + nativeLicense?.version !== version || + nativeLicense.spdx !== 'MIT' || + nativeLicense.path !== 'licenses/koffi-native-MIT.txt' + ) { + throw new Error( + 'Agent Koffi dependencies do not match the runtime lock' + ) + } +} + +function trustedKeyForManifest( + manifest: AgentBundleManifest, + registry: AgentReleaseKeyRegistry, + verificationEnvironment: AgentVerificationEnvironment +): AgentReleaseKey { + const key = registry.keys.find( + (candidate) => candidate.keyId === manifest.signingKeyId + ) + if (!key) { + throw new Error( + `Agent manifest uses an unknown signing key: ${manifest.signingKeyId}` + ) + } + if ( + verificationEnvironment === 'production' && + key.environment !== 'production' + ) { + throw new Error( + `Production Agent verification rejects non-production key: ${key.keyId}` + ) + } + if ( + registry.revocations.some( + (revocation) => revocation.keyId === key.keyId + ) + ) { + throw new Error(`Agent signing key is revoked: ${key.keyId}`) + } + return key +} + +function assertProductionRegistryAvailable( + registry: AgentReleaseKeyRegistry, + verificationEnvironment: AgentVerificationEnvironment +): void { + if ( + verificationEnvironment === 'production' && + !registry.keys.some((key) => key.environment === 'production') + ) { + throw new Error( + 'Agent production key registry is empty; bundled Agent verification is unavailable' + ) + } +} + +function importEd25519PublicKey(key: AgentReleaseKey) { + let publicKey + try { + publicKey = createPublicKey({ + key: Buffer.from(key.publicKeySpkiBase64, 'base64'), + format: 'der', + type: 'spki' + }) + } catch (error) { + throw new Error( + `Agent trusted public key is invalid: ${key.keyId}`, + { cause: error } + ) + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error( + `Agent trusted public key is not Ed25519: ${key.keyId}` + ) + } + return publicKey +} + +function parseAgentReleaseKeyRegistry( + value: unknown +): AgentReleaseKeyRegistry { + const registry = parseContract( + agentReleaseKeyRegistrySchema, + value, + 'Agent trusted key registry contract is invalid' + ) + for (const key of registry.keys) { + importEd25519PublicKey(key) + } + return registry +} + +async function readDetachedSignature(filePath: string): Promise { + const signatureText = ( + await readBoundedFile( + filePath, + 128, + 'Agent detached signature' + ) + ).toString('utf8') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(signatureText)) { + throw new Error('Agent detached signature encoding is invalid') + } + return Buffer.from(signatureText.trim(), 'base64') +} + +async function readJsonMetadata( + filePath: string, + description: string +): Promise { + const contents = await readBoundedFile( + filePath, + MAXIMUM_METADATA_BYTES, + description + ) + try { + return JSON.parse(contents.toString('utf8')) + } catch (error) { + throw new Error(`${description} is invalid JSON`, { cause: error }) + } +} + +async function readBoundedFile( + filePath: string, + maximumBytes: number, + description: string +): Promise { + const fileStat = await lstat(filePath) + if (!fileStat.isFile() || fileStat.isSymbolicLink()) { + throw new Error(`${description} is not a regular file`) + } + if (fileStat.size > maximumBytes) { + throw new Error(`${description} exceeds its size limit`) + } + return readFile(filePath) +} + +async function listRegularBundleFiles( + bundleDirectory: string +): Promise { + const files: string[] = [] + const pending = [bundleDirectory] + while (pending.length > 0) { + const current = pending.pop() + if (current === undefined) { + continue + } + const entries = await readdir(current, { withFileTypes: true }) + for (const entry of entries) { + const absolutePath = join(current, entry.name) + if (entry.isSymbolicLink()) { + throw new Error( + `Agent bundle cannot contain a symlink: ${absolutePath}` + ) + } + if (entry.isDirectory()) { + pending.push(absolutePath) + } else if (entry.isFile()) { + files.push( + relative(bundleDirectory, absolutePath) + .split(sep) + .join('/') + ) + } else { + throw new Error( + `Agent bundle contains an unsupported file: ${absolutePath}` + ) + } + } + } + return files +} + +async function assertDirectoryWithoutSymlink( + directory: string, + description: string +): Promise { + const directoryStat = await lstat(directory) + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error(`${description} is not a regular directory`) + } +} + +async function sha256File(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk as Buffer) + } + return hash.digest('hex') +} + +function sha256Bytes(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} + +function shouldEnforceFilesystemMode( + options: Pick< + VerifyAgentBundleOptions, + 'enforceFilesystemMode' | 'filesystemPlatform' + > +): boolean { + return ( + options.enforceFilesystemMode !== false && + (options.filesystemPlatform ?? process.platform) !== 'win32' + ) +} + +function modeString(mode: number): string { + return (mode & 0o777).toString(8).padStart(4, '0') +} + +async function assertElfArchitecture( + filePath: string, + expectedArchitecture: AgentArchitecture, + description: string +): Promise { + const handle = await open(filePath, 'r') + try { + const header = Buffer.alloc(64) + const { bytesRead } = await handle.read(header, 0, header.length, 0) + const actualArchitecture = detectElfArchitecture( + header.subarray(0, bytesRead) + ) + if (actualArchitecture !== expectedArchitecture) { + throw new Error( + `${description} architecture mismatch: expected ${expectedArchitecture}, received ${actualArchitecture ?? 'unknown'}` + ) + } + } finally { + await handle.close() + } +} + +function detectElfArchitecture( + buffer: Buffer +): AgentArchitecture | undefined { + if ( + buffer.length < 20 || + buffer[0] !== 0x7f || + buffer.toString('ascii', 1, 4) !== 'ELF' || + (buffer[5] !== 1 && buffer[5] !== 2) + ) { + return undefined + } + const machine = + buffer[5] === 2 + ? buffer.readUInt16BE(18) + : buffer.readUInt16LE(18) + if (machine === 62) { + return 'x64' + } + if (machine === 183) { + return 'arm64' + } + return undefined +} + +function parseContract( + schema: { safeParse(value: unknown): { + success: true + data: T + } | { + success: false + error: { message: string } + } }, + value: unknown, + message: string +): T { + const result = schema.safeParse(value) + if (!result.success) { + throw new Error(`${message}: ${result.error.message}`) + } + return result.data +} diff --git a/src/main/remote-agent/agent-installation-manager.test.ts b/src/main/remote-agent/agent-installation-manager.test.ts new file mode 100644 index 00000000..6cea2d4b --- /dev/null +++ b/src/main/remote-agent/agent-installation-manager.test.ts @@ -0,0 +1,1549 @@ +import { + createHash, + generateKeyPairSync, + sign +} from 'node:crypto' +import { + mkdtemp, + mkdir, + readFile, + writeFile +} from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import type { + AgentBundleManifest, + AgentReleaseKeyRegistry +} from '../../shared/agent-installation-contracts' +import type { + SftpEntryMetadata, + StagedSftp +} from '../ssh/bounded-sftp' +import type { + SshConnectionLease, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { + agentManifestSignaturePayload, + type VerifiedAgentBundle +} from './agent-bundle-verifier' +import { + AgentInstallationError, + AgentInstallationManager +} from './agent-installation-manager' + +type RemoteEntry = { + type: 'file' | 'directory' | 'symbolic-link' + mode: number + uid: number + contents?: Buffer +} + +const home = '/home/tester' +const uid = 1000 + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} + +function missing(): Error & { code: number } { + return Object.assign(new Error('missing'), { code: 2 }) +} + +class MemorySftp implements StagedSftp { + readonly stagingDirectory = home + readonly operations: string[] = [] + readonly entries = new Map([ + [home, { type: 'directory', mode: 0o700, uid }] + ]) + corruptReadPath?: string + abortWritePath?: string + abortController?: AbortController + closed = false + + private absolute(path: string): string { + return `${home}/${path}` + } + + private metadata(entry: RemoteEntry): SftpEntryMetadata { + return { + type: entry.type, + size: entry.contents?.byteLength ?? 0, + mode: entry.mode, + uid: entry.uid, + gid: entry.uid, + atime: 1, + mtime: 1 + } + } + + async mkdir(path: string): Promise { + const absolute = this.absolute(path) + this.operations.push(`mkdir:${path}`) + if (this.entries.has(absolute)) { + throw Object.assign(new Error('exists'), { code: 4 }) + } + this.entries.set(absolute, { + type: 'directory', + mode: 0o700, + uid + }) + } + + async writeFile(path: string, contents: Buffer): Promise { + const absolute = this.absolute(path) + this.operations.push(`write:${path}`) + if (this.entries.has(absolute)) { + throw Object.assign(new Error('exists'), { code: 4 }) + } + this.entries.set(absolute, { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(contents) + }) + if (this.abortWritePath === path) { + this.abortController?.abort() + throw this.abortController?.signal.reason + } + } + + async readFile(path: string): Promise { + this.operations.push(`read:${path}`) + const contents = this.entry(path).contents ?? Buffer.alloc(0) + return path === this.corruptReadPath + ? Buffer.from('corrupt') + : Buffer.from(contents) + } + + async lstat(path: string): Promise { + return this.metadata(this.entry(path)) + } + + async stat(path: string): Promise { + const entry = this.entry(path) + if (entry.type === 'symbolic-link') { + throw new Error('symbolic link') + } + return this.metadata(entry) + } + + async chmod(path: string, mode: 0o600 | 0o644 | 0o700 | 0o755): Promise { + this.operations.push(`chmod:${path}:${mode.toString(8)}`) + this.entry(path).mode = mode + } + + async setExecutable(path: string): Promise { + await this.chmod(path, 0o700) + } + + async rename(source: string, destination: string): Promise { + this.operations.push(`rename:${source}:${destination}`) + if (this.entries.has(this.absolute(destination))) { + throw new Error('destination exists') + } + this.moveTree(source, destination) + } + + async replaceFile(source: string, destination: string): Promise { + this.operations.push(`replace:${source}:${destination}`) + const sourceEntry = this.entry(source) + const destinationEntry = this.entries.get(this.absolute(destination)) + if ( + sourceEntry.type !== 'file' || + (destinationEntry && destinationEntry.type !== 'file') + ) { + throw new Error('unsafe replacement') + } + this.entries.delete(this.absolute(destination)) + this.entries.delete(this.absolute(source)) + this.entries.set(this.absolute(destination), sourceEntry) + } + + async hardLink(source: string, destination: string): Promise { + this.operations.push(`link:${source}:${destination}`) + const sourceEntry = this.entry(source) + if (sourceEntry.type !== 'file') { + throw new Error('not file') + } + if (this.entries.has(this.absolute(destination))) { + throw new Error('destination exists') + } + this.entries.set(this.absolute(destination), sourceEntry) + } + + async unlink(path: string): Promise { + this.operations.push(`unlink:${path}`) + const entry = this.entry(path) + if (entry.type !== 'file') { + throw new Error('not file') + } + this.entries.delete(this.absolute(path)) + } + + async rmdir(path: string): Promise { + this.operations.push(`rmdir:${path}`) + const absolute = this.absolute(path) + const entry = this.entry(path) + if (entry.type !== 'directory') { + throw new Error('not directory') + } + if ( + [...this.entries.keys()].some( + (candidate) => candidate.startsWith(`${absolute}/`) + ) + ) { + throw new Error('not empty') + } + this.entries.delete(absolute) + } + + close(): void { + this.closed = true + } + + add(path: string, entry: RemoteEntry): void { + this.entries.set(this.absolute(path), entry) + } + + private entry(path: string): RemoteEntry { + const entry = this.entries.get(this.absolute(path)) + if (!entry) { + throw missing() + } + return entry + } + + private moveTree(source: string, destination: string): void { + const sourceAbsolute = this.absolute(source) + const destinationAbsolute = this.absolute(destination) + const matches = [...this.entries.entries()].filter( + ([path]) => + path === sourceAbsolute || + path.startsWith(`${sourceAbsolute}/`) + ) + if (matches.length === 0) { + throw missing() + } + for (const [path] of matches) { + this.entries.delete(path) + } + for (const [path, entry] of matches) { + this.entries.set( + `${destinationAbsolute}${path.slice(sourceAbsolute.length)}`, + entry + ) + } + } +} + +const registry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [{ + keyId: 'test-key', + publicKeySpkiBase64: 'AAAA', + environment: 'test' + }], + revocations: [] +} + +function target(revision = 1): SshConnectionPoolTarget { + return { + host: { + id: 'host-1', + name: 'Host', + hostname: 'host.example', + port: 22, + username: 'tester', + authentication: 'password', + password: 'secret', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'AAAA', + fingerprintSha256: `SHA256:${'a'.repeat(43)}`, + generation: revision + } + }, + hostRevision: revision, + hostKeyGeneration: revision + } +} + +async function bundleFixture(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'agent-manager-')) + const files = [ + { path: 'node', contents: Buffer.from('node'), mode: '0755' as const }, + { + path: 'goodbuddy-agent', + contents: Buffer.from('agent'), + mode: '0755' as const + }, + { + path: 'lib/agent.cjs', + contents: Buffer.from('script'), + mode: '0644' as const + }, + { + path: 'licenses/LICENSE.txt', + contents: Buffer.from('license'), + mode: '0644' as const + } + ] + const manifest: AgentBundleManifest = { + formatVersion: 1, + product: 'GoodBuddy', + agentVersion: '1.0.0', + platform: 'linux', + arch: 'x64', + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + entrypoint: { + path: 'goodbuddy-agent', + runtimePath: 'node', + scriptPath: 'lib/agent.cjs' + }, + files: files.map((file) => ({ + path: file.path, + size: file.contents.byteLength, + sha256: sha256(file.contents), + mode: file.mode + })), + licenses: [{ + package: 'fixture', + version: '1.0.0', + spdx: 'MIT', + path: 'licenses/LICENSE.txt' + }] + } + for (const file of files) { + const destination = join(directory, ...file.path.split('/')) + await mkdir(join(destination, '..'), { recursive: true }) + await writeFile(destination, file.contents) + } + const manifestBytes = Buffer.from( + `${JSON.stringify(manifest, null, 2)}\n` + ) + await writeFile(join(directory, 'manifest.json'), manifestBytes) + await writeFile(join(directory, 'manifest.sig'), 'fixture-signature\n') + return { + bundleDirectory: directory, + manifest, + manifestSha256: sha256(manifestBytes) + } +} + +async function harness(options: { + sftp?: MemorySftp + lifecycleFailure?: 'bootstrap' | 'health' + lifecycleCancellation?: 'health' + emulateInstallationRegistry?: boolean + resolverTargets?: SshConnectionPoolTarget[] + bootstrapGate?: Promise +} = {}) { + const bundle = await bundleFixture() + const sftp = options.sftp ?? new MemorySftp() + const release = vi.fn() + const lifecycleActions: string[] = [] + const installationId = + `agent-${bundle.manifestSha256}` + const lease = { + identity: { + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + authenticationIdentity: 'b'.repeat(64) + }, + isUsable: () => true, + runAgentBootstrapProbe: vi.fn(async (signal?: AbortSignal) => { + if (options.bootstrapGate) { + await waitForGate(options.bootstrapGate, signal) + } + return { + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: home, + uid, + shell: '/bin/bash', + procfs: 'ready' + } as const + }), + openStagedSftp: vi.fn(async () => sftp), + runAgentLifecycleAction: vi.fn( + async ( + _installationId, + action: string, + signal?: AbortSignal + ) => { + lifecycleActions.push(action) + if ( + options.emulateInstallationRegistry && + action === 'bootstrap' + ) { + stageEmulatedCandidate( + sftp, + bundle, + installationId + ) + } + if (options.lifecycleCancellation === action) { + await waitForGate( + new Promise(() => undefined), + signal + ) + } + const failed = options.lifecycleFailure === action + if ( + options.emulateInstallationRegistry && + action === 'health' && + !failed + ) { + promoteEmulatedCandidate( + sftp, + bundle, + installationId + ) + } + return { + exitCode: failed ? 1 : 0, + stdout: '', + stderr: '' + } + } + ), + release + } as unknown as SshConnectionLease + let resolveCount = 0 + const targets = options.resolverTargets ?? [target()] + const resolver = { + resolve: vi.fn(async () => + targets[Math.min(resolveCount++, targets.length - 1)]! + ) + } + const sshPool = { + acquire: vi.fn(async () => lease) + } + const manager = new AgentInstallationManager({ + resolver, + sshPool, + resourcePaths: { + keyRegistryPath: 'unused', + runtimeLockPath: 'unused', + bundleDirectories: { x64: 'unused', arm64: 'unused' } + }, + verificationEnvironment: 'test', + loadVerifiedBundle: async () => ({ bundle, registry }) + }) + return { + manager, + bundle, + sftp, + release, + lifecycleActions, + resolver, + sshPool, + installationId + } +} + +function stageEmulatedCandidate( + sftp: MemorySftp, + bundle: VerifiedAgentBundle, + installationId: string +): void { + const path = '.goodbuddy/agent/registry.json' + const persisted = JSON.parse( + sftp.entries.get(`${home}/${path}`)?.contents?.toString('utf8') ?? + '{"formatVersion":1}' + ) as { + current?: Record + } + const current = persisted.current + sftp.add(path, { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(`${JSON.stringify({ + formatVersion: 1, + ...(current === undefined + ? {} + : { + current: { + installationId: current.installationId, + agentVersion: current.agentVersion, + manifestSha256: current.manifestSha256, + arch: current.arch + } + }), + candidate: registryEntry(bundle, installationId) + }, null, 2)}\n`) + }) +} + +function promoteEmulatedCandidate( + sftp: MemorySftp, + bundle: VerifiedAgentBundle, + installationId: string +): void { + sftp.add('.goodbuddy/agent/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(`${JSON.stringify({ + formatVersion: 1, + current: registryEntry(bundle, installationId) + }, null, 2)}\n`) + }) +} + +function registryEntry( + bundle: VerifiedAgentBundle, + installationId: string +): Record { + return { + installationId, + agentVersion: bundle.manifest.agentVersion, + manifestSha256: bundle.manifestSha256, + arch: bundle.manifest.arch + } +} + +function legacyRegistryBytes(): Buffer { + return Buffer.from(`{ + "formatVersion": 1, + "minimumTrustedReleaseSequence": 7, + "current": { + "installationId": "old-current", + "productVersion": "0.9.0", + "agentVersion": "0.9.0", + "releaseSequence": 7, + "manifestSha256": "${'d'.repeat(64)}", + "binaryDigest": "sha256:${'d'.repeat(64)}", + "arch": "x64", + "protocol": { "major": 1, "minor": 0 }, + "signingKeyId": "old-key", + "previouslyVerified": true + }, + "draining": [] +} +`) +} + +function populateManagedRegistries( + fixture: Awaited>, + installationRegistry: Buffer, + releaseKeyRegistry: Buffer +): void { + for (const path of [ + '.goodbuddy', + '.goodbuddy/agent', + '.goodbuddy/agent/staging', + '.goodbuddy/agent/installations' + ]) { + fixture.sftp.add(path, { + type: 'directory', + mode: 0o700, + uid + }) + } + fixture.sftp.add('.goodbuddy/agent/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(installationRegistry) + }) + fixture.sftp.add('.goodbuddy/agent/release-keys.json', { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(releaseKeyRegistry) + }) + fixture.sftp.add( + '.goodbuddy/agent/installations/old-current', + { type: 'directory', mode: 0o700, uid } + ) +} + +function managedBytes( + fixture: Awaited>, + path: string +): Buffer | undefined { + return fixture.sftp.entries.get(`${home}/${path}`)?.contents +} + +function bundledRegistryBytes(): Buffer { + return Buffer.from(`${JSON.stringify(registry, null, 2)}\n`) +} + +async function waitForGate( + gate: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + await gate + return + } + signal.throwIfAborted() + await new Promise((resolve, reject) => { + const abort = (): void => { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + signal.addEventListener('abort', abort, { once: true }) + gate.then( + () => { + signal.removeEventListener('abort', abort) + resolve() + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} + +function populateInstallation( + fixture: Awaited> +): void { + const root = + `.goodbuddy/agent/installations/${fixture.installationId}` + fixture.sftp.add(root, { + type: 'directory', + mode: 0o700, + uid + }) + const directories = new Set() + for (const file of fixture.bundle.manifest.files) { + const parts = file.path.split('/') + let directory = root + for (const part of parts.slice(0, -1)) { + directory = `${directory}/${part}` + if (!directories.has(directory)) { + fixture.sftp.add(directory, { + type: 'directory', + mode: 0o700, + uid + }) + directories.add(directory) + } + } + fixture.sftp.add(`${root}/${file.path}`, { + type: 'file', + mode: file.mode === '0755' ? 0o755 : 0o644, + uid, + contents: Buffer.from( + file.path === 'node' + ? 'node' + : file.path === 'goodbuddy-agent' + ? 'agent' + : file.path === 'lib/agent.cjs' + ? 'script' + : 'license' + ) + }) + } + const manifestBytes = Buffer.from( + `${JSON.stringify(fixture.bundle.manifest, null, 2)}\n` + ) + fixture.sftp.add(`${root}/manifest.json`, { + type: 'file', + mode: 0o644, + uid, + contents: manifestBytes + }) + fixture.sftp.add(`${root}/manifest.sig`, { + type: 'file', + mode: 0o644, + uid, + contents: Buffer.from('fixture-signature\n') + }) +} + +async function metadataOnlyHarness(options: { + remoteRegistryBytes?: Buffer + remoteReleaseKeyBytes?: Buffer + corruptPath?: string + resolverTargets?: SshConnectionPoolTarget[] + candidateResources?: boolean + currentAgentVersion?: string + currentNodeContents?: Buffer + trustCurrentSignature?: boolean +} = {}) { + const bundle = await bundleFixture() + const keyPair = generateKeyPairSync('ed25519') + const signedRegistry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [{ + keyId: 'test-key', + publicKeySpkiBase64: keyPair.publicKey.export({ + format: 'der', + type: 'spki' + }).toString('base64'), + environment: 'test' + }], + revocations: [] + } + const manifestBytes = await readFile( + join(bundle.bundleDirectory, 'manifest.json') + ) + const candidateSignatureBytes = Buffer.from( + `${sign( + null, + agentManifestSignaturePayload(manifestBytes), + keyPair.privateKey + ).toString('base64')}\n` + ) + await writeFile( + join(bundle.bundleDirectory, 'manifest.sig'), + candidateSignatureBytes + ) + const resourceRoot = await mkdtemp( + join(tmpdir(), 'agent-metadata-only-') + ) + const registryBytes = Buffer.from( + `${JSON.stringify(signedRegistry, null, 2)}\n` + ) + const rootLock = JSON.parse( + await readFile( + join(process.cwd(), 'agent-runtime-lock.json'), + 'utf8' + ) + ) as Record + const runtimeLock = { + ...rootLock, + agentVersion: bundle.manifest.agentVersion, + protocol: bundle.manifest.protocol + } + const runtimeLockBytes = Buffer.from( + `${JSON.stringify(runtimeLock, null, 2)}\n` + ) + const keyRegistryPath = join( + resourceRoot, + 'agent-release-keys.json' + ) + const runtimeLockPath = join( + resourceRoot, + 'agent-runtime-lock.json' + ) + await Promise.all([ + writeFile(keyRegistryPath, registryBytes), + writeFile(runtimeLockPath, runtimeLockBytes) + ]) + + const sftp = new MemorySftp() + const currentManifest: AgentBundleManifest = { + ...bundle.manifest, + agentVersion: + options.currentAgentVersion ?? + bundle.manifest.agentVersion, + files: bundle.manifest.files.map((file) => + file.path === bundle.manifest.entrypoint.runtimePath && + options.currentNodeContents !== undefined + ? { + ...file, + size: options.currentNodeContents.byteLength, + sha256: sha256(options.currentNodeContents) + } + : { ...file } + ) + } + const currentManifestBytes = Buffer.from( + `${JSON.stringify(currentManifest, null, 2)}\n` + ) + const currentManifestSha256 = sha256(currentManifestBytes) + const currentInstallationId = + `agent-${currentManifestSha256}` + const currentSigningKey = options.trustCurrentSignature === false + ? generateKeyPairSync('ed25519').privateKey + : keyPair.privateKey + const currentSignatureBytes = Buffer.from( + `${sign( + null, + agentManifestSignaturePayload(currentManifestBytes), + currentSigningKey + ).toString('base64')}\n` + ) + const installationId = `agent-${bundle.manifestSha256}` + const installationRoot = + `.goodbuddy/agent/installations/${currentInstallationId}` + for (const path of [ + '.goodbuddy', + '.goodbuddy/agent', + '.goodbuddy/agent/installations', + installationRoot, + `${installationRoot}/lib`, + `${installationRoot}/licenses` + ]) { + sftp.add(path, { + type: 'directory', + mode: 0o700, + uid + }) + } + const persistedRegistry = + options.remoteRegistryBytes ?? + Buffer.from(`${JSON.stringify({ + formatVersion: 1, + current: { + installationId: currentInstallationId, + agentVersion: currentManifest.agentVersion, + manifestSha256: currentManifestSha256, + arch: currentManifest.arch + } + }, null, 2)}\n`) + sftp.add('.goodbuddy/agent/registry.json', { + type: 'file', + mode: 0o600, + uid, + contents: persistedRegistry + }) + sftp.add('.goodbuddy/agent/release-keys.json', { + type: 'file', + mode: 0o600, + uid, + contents: + options.remoteReleaseKeyBytes ?? registryBytes + }) + for (const file of bundle.manifest.files) { + const contents = await readFile( + join(bundle.bundleDirectory, ...file.path.split('/')) + ) + sftp.add(`${installationRoot}/${file.path}`, { + type: 'file', + mode: file.mode === '0755' ? 0o755 : 0o644, + uid, + contents: + options.corruptPath === file.path + ? Buffer.alloc(contents.byteLength, 0x78) + : file.path === bundle.manifest.entrypoint.runtimePath && + options.currentNodeContents !== undefined + ? options.currentNodeContents + : contents + }) + } + sftp.add(`${installationRoot}/manifest.json`, { + type: 'file', + mode: 0o644, + uid, + contents: currentManifestBytes + }) + sftp.add(`${installationRoot}/manifest.sig`, { + type: 'file', + mode: 0o644, + uid, + contents: + options.corruptPath === 'manifest.sig' + ? Buffer.from(`${'A'.repeat(86)}==\n`) + : currentSignatureBytes + }) + + const lifecycleActions: string[] = [] + const release = vi.fn() + const lease = { + identity: { + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + authenticationIdentity: 'b'.repeat(64) + }, + isUsable: () => true, + runAgentBootstrapProbe: vi.fn(async () => ({ + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: home, + uid, + shell: '/bin/bash', + procfs: 'ready' + }) as const), + openStagedSftp: vi.fn(async () => sftp), + runAgentLifecycleAction: vi.fn( + async (_installationId, action: string) => { + lifecycleActions.push(action) + return { + exitCode: + options.corruptPath === 'node' && + !options.candidateResources && + action === 'health' + ? 1 + : 0, + stdout: '', + stderr: '' + } + } + ), + release + } as unknown as SshConnectionLease + let resolveCount = 0 + const targets = options.resolverTargets ?? [target()] + const resolver = { + resolve: vi.fn(async () => + targets[Math.min(resolveCount++, targets.length - 1)]! + ) + } + const manager = new AgentInstallationManager({ + resolver, + sshPool: { acquire: vi.fn(async () => lease) }, + resourcePaths: { + keyRegistryPath, + runtimeLockPath, + bundleDirectories: { + x64: join(resourceRoot, 'missing-x64'), + arm64: join(resourceRoot, 'missing-arm64') + } + }, + verificationEnvironment: 'test', + ...(options.candidateResources + ? { + loadVerifiedBundle: async () => ({ + bundle, + registry: signedRegistry + }) + } + : {}) + }) + return { + manager, + bundle, + installationId, + currentInstallationId, + lifecycleActions, + resolver, + sftp, + release + } +} + +describe('AgentInstallationManager', () => { + it('cryptographically reuses an exact current Host Agent when bundle resources are absent', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.installationId, + agentVersion: fixture.bundle.manifest.agentVersion, + architecture: 'x64' + }) + expect(fixture.lifecycleActions).toEqual(['health']) + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('write:') + ) + ).toBe(false) + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('rejects metadata-only Agent reuse when trust metadata differs', async () => { + const fixture = await metadataOnlyHarness({ + remoteReleaseKeyBytes: Buffer.from( + '{"formatVersion":1,"keys":[],"revocations":[]}\n' + ) + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'corrupt' }) + expect(fixture.lifecycleActions).toEqual([]) + }) + + it.each(['manifest.sig', 'lib/agent.cjs'])( + 'rejects metadata-only Agent reuse when %s is corrupt', + async (corruptPath) => { + const fixture = await metadataOnlyHarness({ corruptPath }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'corrupt' }) + expect(fixture.lifecycleActions).toEqual([]) + } + ) + + it('delegates the large Node runtime hash check to Agent health', async () => { + const fixture = await metadataOnlyHarness({ + corruptPath: 'node' + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'lifecycle' }) + expect(fixture.lifecycleActions).toEqual([ + 'health', + 'bootstrap', + 'health' + ]) + }) + + it('rejects metadata-only Agent reuse after Host identity changes', async () => { + const fixture = await metadataOnlyHarness({ + resolverTargets: [target(), target(2)] + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + expect(fixture.lifecycleActions).toEqual([]) + }) + + it('installs in manifest order with exact modes and verified readback', async () => { + const fixture = await harness() + const phases: string[] = [] + const identity = await fixture.manager.ensureInstalled( + 'host-1', + { onProgress: (phase) => phases.push(phase) } + ) + + expect(identity).toMatchObject({ + installationId: fixture.installationId, + binaryDigest: `sha256:${fixture.bundle.manifestSha256}`, + architecture: 'x64', + supervisor: 'detached-on-demand' + }) + expect(fixture.lifecycleActions).toEqual(['bootstrap', 'health']) + const payloadWrites = fixture.sftp.operations + .filter((operation) => operation.startsWith('write:') && + !operation.includes('.release-keys-')) + .map((operation) => + operation.slice(operation.lastIndexOf('/') + 1) + ) + expect(payloadWrites).toEqual([ + 'node', + 'goodbuddy-agent', + 'agent.cjs', + 'LICENSE.txt', + 'manifest.json', + 'manifest.sig' + ]) + expect( + fixture.sftp.operations.some((operation) => + /chmod:.*goodbuddy-agent:755/u.test(operation) + ) + ).toBe(true) + expect(phases.at(-1)).toBe('complete') + expect(fixture.release).toHaveBeenCalledOnce() + expect(fixture.sftp.closed).toBe(true) + }) + + it('upgrades an unverifiable current Agent when candidate resources exist', async () => { + const fixture = await metadataOnlyHarness({ + candidateResources: true, + currentAgentVersion: '0.9.0', + trustCurrentSignature: false + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.installationId, + agentVersion: fixture.bundle.manifest.agentVersion + }) + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('write:') && + operation.endsWith('/node') + ) + ).toBe(true) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/installations/${fixture.currentInstallationId}` + ) + ).toBe(true) + }) + + it('reuses an exactly verified current Node without uploading it', async () => { + const fixture = await metadataOnlyHarness({ + candidateResources: true, + currentAgentVersion: '0.9.0' + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.installationId + }) + + expect( + fixture.sftp.operations.some((operation) => + /^link:\.goodbuddy\/agent\/installations\/agent-[a-f0-9]{64}\/node:\.goodbuddy\/agent\/staging\/op-[^/]+\/node$/u + .test(operation) + ) + ).toBe(true) + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('write:') && + operation.endsWith('/node') + ) + ).toBe(false) + }) + + it.each([ + { + condition: 'untrusted current manifest', + options: { trustCurrentSignature: false } + }, + { + condition: 'corrupt current Node', + options: { corruptPath: 'node' } + }, + { + condition: 'different verified current Node', + options: { currentNodeContents: Buffer.from('old-node') } + } + ])( + 'uploads candidate Node for $condition', + async ({ options }) => { + const fixture = await metadataOnlyHarness({ + candidateResources: true, + currentAgentVersion: '0.9.0', + ...options + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.installationId + }) + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('link:') + ) + ).toBe(false) + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('write:') && + operation.endsWith('/node') + ) + ).toBe(true) + } + ) + + it('bootstraps a stopped exact installation and reuses it without upload', async () => { + const fixture = await harness() + populateInstallation(fixture) + await fixture.manager.ensureInstalled('host-1') + + expect(fixture.lifecycleActions).toEqual(['bootstrap', 'health']) + expect( + fixture.sftp.operations.some((entry) => + entry.startsWith('rename:.goodbuddy/agent/staging/') + ) + ).toBe(false) + }) + + it('reports lifecycle recovery failure without marking a valid bundle corrupt', async () => { + const fixture = await harness({ lifecycleFailure: 'health' }) + populateInstallation(fixture) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'lifecycle' }) + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health', + 'stop' + ]) + expect( + fixture.sftp.operations.some((entry) => + entry.startsWith('rename:.goodbuddy/agent/staging/') + ) + ).toBe(false) + }) + + it('rejects corrupt readback and removes operation-owned staging', async () => { + const sftp = new MemorySftp() + const fixture = await harness({ sftp }) + const originalRead = sftp.readFile.bind(sftp) + sftp.readFile = vi.fn(async (path: string) => { + const value = await originalRead(path) + if (path.endsWith('/node')) { + return Buffer.from('bad') + } + return value + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toThrow('readback mismatch') + expect( + [...sftp.entries.keys()].some((path) => + path.includes('/staging/op-') + ) + ).toBe(false) + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('atomically replaces the managed release-key registry', async () => { + const fixture = await harness() + fixture.sftp.add('.goodbuddy', { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add('.goodbuddy/agent', { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add('.goodbuddy/agent/staging', { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add('.goodbuddy/agent/installations', { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add('.goodbuddy/agent/release-keys.json', { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from('old') + }) + await fixture.manager.ensureInstalled('host-1') + + expect( + fixture.sftp.operations.some((operation) => + /^replace:\.goodbuddy\/agent\/\.release-keys-.*\.tmp:\.goodbuddy\/agent\/release-keys\.json$/u + .test(operation) + ) + ).toBe(true) + }) + + it('deduplicates concurrent ensures for the same full host identity', async () => { + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const fixture = await harness({ bootstrapGate: gate }) + const first = fixture.manager.ensureInstalled('host-1') + const second = fixture.manager.ensureInstalled('host-1') + await vi.waitFor(() => + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + ) + releaseGate() + const [left, right] = await Promise.all([first, second]) + expect(left).toEqual(right) + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + }) + + it('reuses a successful installation for the same Host identity', async () => { + const fixture = await harness() + const first = await fixture.manager.ensureInstalled('host-1') + const phases: string[] = [] + const second = await fixture.manager.ensureInstalled('host-1', { + onProgress: (phase) => phases.push(phase) + }) + + expect(second).toEqual(first) + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(phases).toEqual(['inspecting-host', 'complete']) + }) + + it('keeps a shared install running when the first waiter cancels', async () => { + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const fixture = await harness({ bootstrapGate: gate }) + const controller = new AbortController() + const canceledPhases: string[] = [] + const first = fixture.manager.ensureInstalled('host-1', { + signal: controller.signal, + onProgress: (phase) => canceledPhases.push(phase) + }) + const survivor = fixture.manager.ensureInstalled('host-1') + await vi.waitFor(() => + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + ) + + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + releaseGate() + + await expect(survivor).resolves.toMatchObject({ + installationId: fixture.installationId + }) + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(canceledPhases).toEqual(['inspecting-host']) + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('aborts a shared install after its last waiter cancels', async () => { + const gate = new Promise(() => undefined) + const fixture = await harness({ bootstrapGate: gate }) + const controller = new AbortController() + const installation = fixture.manager.ensureInstalled('host-1', { + signal: controller.signal + }) + await vi.waitFor(() => + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + ) + + controller.abort() + + await expect(installation).rejects.toMatchObject({ + name: 'AbortError' + }) + await vi.waitFor(() => + expect(fixture.release).toHaveBeenCalledOnce() + ) + }) + + it('dispose aborts active installs, cleans up, and rejects new calls', async () => { + const sftp = new MemorySftp() + let startedNodeWrite!: () => void + const nodeWriteStarted = new Promise((resolve) => { + startedNodeWrite = resolve + }) + const originalWrite = sftp.writeFile.bind(sftp) + sftp.writeFile = vi.fn(async ( + path: string, + contents: Buffer, + signal?: AbortSignal + ) => { + await originalWrite(path, contents) + if (path.endsWith('/node')) { + startedNodeWrite() + await waitForGate( + new Promise(() => undefined), + signal + ) + } + }) + const fixture = await harness({ sftp }) + const installation = fixture.manager.ensureInstalled('host-1') + await nodeWriteStarted + + const firstDispose = fixture.manager.dispose() + const secondDispose = fixture.manager.dispose() + expect(secondDispose).toBe(firstDispose) + await expect(installation).rejects.toMatchObject({ + name: 'AbortError' + }) + await firstDispose + + expect(fixture.release).toHaveBeenCalledOnce() + expect(fixture.sftp.closed).toBe(true) + expect( + [...sftp.entries.keys()].some((path) => + path.includes('/staging/op-') + ) + ).toBe(false) + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toThrow('Agent installation manager is disposed') + expect(fixture.resolver.resolve).toHaveBeenCalledOnce() + }) + + it('cleans tracked staging and releases the lease after abort', async () => { + const controller = new AbortController() + const sftp = new MemorySftp() + sftp.abortController = controller + const fixture = await harness({ sftp }) + const originalWrite = sftp.writeFile.bind(sftp) + sftp.writeFile = vi.fn(async (path: string, contents: Buffer) => { + await originalWrite(path, contents) + if (path.endsWith('/node')) { + controller.abort() + throw controller.signal.reason + } + }) + + await expect( + fixture.manager.ensureInstalled('host-1', { + signal: controller.signal + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => + expect(fixture.release).toHaveBeenCalledOnce() + ) + expect( + [...sftp.entries.keys()].some((path) => + path.includes('/staging/op-') + ) + ).toBe(false) + }) + + it('stops before activation when the host identity changes', async () => { + const fixture = await harness({ + resolverTargets: [target(1), target(2)] + }) + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + expect(fixture.lifecycleActions).toEqual([]) + }) + + it('reports an existing bad destination as corrupt without replacing it', async () => { + const fixture = await harness() + const destination = + `.goodbuddy/agent/installations/${fixture.installationId}` + fixture.sftp.add(destination, { + type: 'directory', + mode: 0o700, + uid + }) + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'corrupt' + }) + expect(fixture.sftp.entries.has(`${home}/${destination}`)).toBe(true) + expect( + fixture.sftp.operations.some((entry) => + entry.startsWith('rename:.goodbuddy/agent/staging/') + ) + ).toBe(false) + }) + + it('leaves an old installation untouched when candidate bootstrap fails', async () => { + const fixture = await harness({ lifecycleFailure: 'bootstrap' }) + fixture.sftp.add( + '.goodbuddy/agent/installations/old-current', + { type: 'directory', mode: 0o700, uid } + ) + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toBeInstanceOf(AgentInstallationError) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/installations/old-current` + ) + ).toBe(true) + expect(fixture.lifecycleActions).toEqual(['bootstrap', 'stop']) + }) + + it('restores legacy registry and release-key bytes after upgrade health failure', async () => { + const oldRegistry = legacyRegistryBytes() + const oldReleaseKeys = Buffer.from( + '{ "legacy": true, "spacing": "preserved" }\n' + ) + const fixture = await harness({ + lifecycleFailure: 'health', + emulateInstallationRegistry: true + }) + populateManagedRegistries( + fixture, + oldRegistry, + oldReleaseKeys + ) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'lifecycle' }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health', + 'stop' + ]) + expect( + managedBytes(fixture, '.goodbuddy/agent/registry.json') + ).toEqual(oldRegistry) + expect( + managedBytes( + fixture, + '.goodbuddy/agent/release-keys.json' + ) + ).toEqual(oldReleaseKeys) + expect( + fixture.sftp.entries.has( + `${home}/.goodbuddy/agent/installations/old-current` + ) + ).toBe(true) + }) + + it('stops the candidate and restores exact registry bytes after cancellation', async () => { + const oldRegistry = legacyRegistryBytes() + const oldReleaseKeys = Buffer.from('old release key bytes\n') + const fixture = await harness({ + lifecycleCancellation: 'health', + emulateInstallationRegistry: true + }) + populateManagedRegistries( + fixture, + oldRegistry, + oldReleaseKeys + ) + const controller = new AbortController() + const installation = fixture.manager.ensureInstalled('host-1', { + signal: controller.signal + }) + await vi.waitFor(() => + expect(fixture.lifecycleActions).toContain('health') + ) + + controller.abort() + + await expect(installation).rejects.toMatchObject({ + name: 'AbortError' + }) + await vi.waitFor(() => + expect(fixture.release).toHaveBeenCalledOnce() + ) + expect(fixture.lifecycleActions).toContain('stop') + expect( + managedBytes(fixture, '.goodbuddy/agent/registry.json') + ).toEqual(oldRegistry) + expect( + managedBytes( + fixture, + '.goodbuddy/agent/release-keys.json' + ) + ).toEqual(oldReleaseKeys) + }) + + it('keeps only the promoted current registry after successful upgrade health', async () => { + const oldRegistry = legacyRegistryBytes() + const fixture = await harness({ + emulateInstallationRegistry: true + }) + populateManagedRegistries( + fixture, + oldRegistry, + Buffer.from('old release keys\n') + ) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + installationId: fixture.installationId + }) + + expect(fixture.lifecycleActions).toEqual([ + 'bootstrap', + 'health' + ]) + expect(JSON.parse( + managedBytes( + fixture, + '.goodbuddy/agent/registry.json' + )!.toString('utf8') + )).toEqual({ + formatVersion: 1, + current: registryEntry( + fixture.bundle, + fixture.installationId + ) + }) + expect( + managedBytes( + fixture, + '.goodbuddy/agent/release-keys.json' + ) + ).toEqual(bundledRegistryBytes()) + }) +}) diff --git a/src/main/remote-agent/agent-installation-manager.ts b/src/main/remote-agent/agent-installation-manager.ts new file mode 100644 index 00000000..eb400a38 --- /dev/null +++ b/src/main/remote-agent/agent-installation-manager.ts @@ -0,0 +1,1749 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, readFile } from 'node:fs/promises' +import { + agentReleaseKeyRegistrySchema, + type AgentArchitecture, + type AgentBundleManifest, + type AgentReleaseKeyRegistry +} from '../../shared/agent-installation-contracts' +import type { AgentBootstrapIncompatibleReason } from '../../shared/ssh-host-contracts' +import type { + BoundedSftpLimits, + SftpEntryMetadata, + StagedSftp +} from '../ssh/bounded-sftp' +import { + verifyAgentInstallationId, + type VerifiedAgentInstallationId +} from '../ssh/ssh-agent-command' +import type { + SshConnectionLease, + SshConnectionPool, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { + parseInstallationRegistryState +} from '../../shared/remote-environment-registry-contracts' +import { + getBundledAgentDirectory, + type BundledAgentResourcePaths +} from './bundled-agent-resources' +import { + assertAgentManifestMatchesRuntimeLock, + readAgentReleaseKeyRegistry, + readAgentRuntimeLock, + verifyAgentManifestSignature, + verifyAgentBundleDirectory, + type AgentVerificationEnvironment, + type VerifiedAgentBundle +} from './agent-bundle-verifier' + +const MAXIMUM_INSTALLATION_FILES = 300 +const MAXIMUM_INSTALLATION_BYTES = 512 * 1024 * 1024 +const MAXIMUM_AGENT_FILE_BYTES = 256 * 1024 * 1024 +const METADATA_MODE = 0o644 +const PRIVATE_FILE_MODE = 0o600 +const PRIVATE_DIRECTORY_MODE = 0o700 +const DISPOSE_WAIT_TIMEOUT_MS = 5_000 +const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json' +const RELEASE_KEYS_PATH = '.goodbuddy/agent/release-keys.json' +const MAXIMUM_METADATA_BYTES = 1024 * 1024 + +export type AgentInstallationPhase = + | 'inspecting-host' + | 'verifying-bundle' + | 'preparing-installation' + | 'uploading-bundle' + | 'starting-agent' + | 'checking-health' + | 'complete' + +export type AgentInstallationIdentity = { + installationId: VerifiedAgentInstallationId + binaryDigest: string + agentVersion: string + protocol: { + major: number + minor: number + } + platform: 'linux' + architecture: AgentArchitecture + supervisor: 'detached-on-demand' +} + +export interface AgentInstallationTargetResolver { + resolve(hostId: string): Promise +} + +export type AgentInstallationRequestOptions = { + signal?: AbortSignal + onProgress?: (phase: AgentInstallationPhase) => void +} + +export class AgentInstallationError extends Error { + constructor( + message: string, + readonly reason: + | 'incompatible' + | 'corrupt' + | 'host-identity-changed' + | 'lifecycle' + | 'capacity', + readonly detail?: AgentBootstrapIncompatibleReason | unknown + ) { + super(message) + this.name = 'AgentInstallationError' + } +} + +type ActiveInstallation = { + promise: Promise + controller: AbortController + progressCallbacks: Set<(phase: AgentInstallationPhase) => void> + waiters: number + settled: boolean +} + +type CleanupEntry = { + path: string + type: 'file' | 'directory' +} + +type ManagedFileSnapshot = { + path: string + contents: Buffer | undefined + uid: number +} + +export type AgentInstallationBundleLoader = ( + architecture: AgentArchitecture +) => Promise<{ + bundle: VerifiedAgentBundle + registry: AgentReleaseKeyRegistry +}> + +export class AgentInstallationManager { + readonly #resolver: AgentInstallationTargetResolver + readonly #sshPool: Pick + readonly #resourcePaths: BundledAgentResourcePaths + readonly #verificationEnvironment: AgentVerificationEnvironment + readonly #sftpLimits?: BoundedSftpLimits + readonly #maximumConcurrentHosts: number + readonly #loadBundle?: AgentInstallationBundleLoader + readonly #active = new Map() + readonly #installed = new Map() + #closed = false + #disposePromise: Promise | undefined + + constructor(options: { + resolver: AgentInstallationTargetResolver + sshPool: Pick + resourcePaths: BundledAgentResourcePaths + verificationEnvironment?: AgentVerificationEnvironment + sftpLimits?: BoundedSftpLimits + maximumConcurrentHosts?: number + loadVerifiedBundle?: AgentInstallationBundleLoader + }) { + this.#resolver = options.resolver + this.#sshPool = options.sshPool + this.#resourcePaths = options.resourcePaths + this.#verificationEnvironment = + options.verificationEnvironment ?? 'production' + this.#sftpLimits = options.sftpLimits + this.#maximumConcurrentHosts = + options.maximumConcurrentHosts ?? 8 + this.#loadBundle = options.loadVerifiedBundle + if ( + this.#loadBundle && + this.#verificationEnvironment !== 'test' + ) { + throw new Error( + 'Injected Agent bundles are allowed only in test verification' + ) + } + if ( + !Number.isSafeInteger(this.#maximumConcurrentHosts) || + this.#maximumConcurrentHosts <= 0 || + this.#maximumConcurrentHosts > 32 + ) { + throw new Error('Agent installation concurrency limit is invalid') + } + } + + async ensureInstalled( + hostId: string, + options: AgentInstallationRequestOptions = {} + ): Promise { + this.#throwIfClosed() + options.signal?.throwIfAborted() + if (options.onProgress) { + try { + options.onProgress('inspecting-host') + } catch { + // Progress observers cannot alter the installation outcome. + } + } + const target = await this.#resolver.resolve(hostId) + this.#throwIfClosed() + options.signal?.throwIfAborted() + if (target.host.id !== hostId) { + throw new Error('Resolved SSH host does not match the request') + } + const operationKey = targetIdentityKey(target) + const installed = this.#installed.get(operationKey) + if (installed !== undefined) { + try { + options.onProgress?.('complete') + } catch { + // Progress observers cannot alter the installation outcome. + } + return installed + } + const existing = this.#active.get(operationKey) + if (existing) { + return this.#waitForActive(existing, options) + } + if (this.#active.size >= this.#maximumConcurrentHosts) { + throw new AgentInstallationError( + 'Too many Agent installations are already running', + 'capacity' + ) + } + const progressCallbacks = new Set< + (phase: AgentInstallationPhase) => void + >() + if (options.onProgress) { + progressCallbacks.add(options.onProgress) + } + const emitProgress = (phase: AgentInstallationPhase): void => { + for (const callback of progressCallbacks) { + try { + callback(phase) + } catch { + // Progress observers cannot alter the installation outcome. + } + } + } + const controller = new AbortController() + const promise = this.#run( + target, + controller.signal, + emitProgress + ).then((identity) => { + this.#installed.set(operationKey, identity) + return identity + }) + const active = { + 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) + } + + dispose(): Promise { + if (this.#disposePromise) { + return this.#disposePromise + } + this.#closed = true + const operations = [...this.#active.values()] + const reason = new DOMException( + 'Agent installation manager was disposed', + 'AbortError' + ) + for (const operation of operations) { + operation.controller.abort(reason) + } + this.#disposePromise = settleBoundedly( + operations.map((operation) => operation.promise), + DISPOSE_WAIT_TIMEOUT_MS + ) + this.#installed.clear() + return this.#disposePromise + } + + #throwIfClosed(): void { + if (this.#closed) { + throw new Error('Agent installation manager is disposed') + } + } + + async #waitForActive( + active: ActiveInstallation, + options: AgentInstallationRequestOptions + ): Promise { + 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( + 'Agent installation has no remaining waiters', + 'AbortError' + ) + ) + } + } + } + + async #run( + target: SshConnectionPoolTarget, + signal: AbortSignal, + progress: (phase: AgentInstallationPhase) => void + ): Promise { + let lease: SshConnectionLease | undefined + let sftp: StagedSftp | undefined + let cleanupHomeDirectory: string | undefined + const cleanup: CleanupEntry[] = [] + let stagingPublished = false + let activationStarted = false + let installationId: VerifiedAgentInstallationId | undefined + let registrySnapshots: readonly ManagedFileSnapshot[] | undefined + try { + lease = await this.#sshPool.acquire(target, signal) + assertLeaseMatchesTarget(lease, target) + const probe = await lease.runAgentBootstrapProbe(signal) + if (!probe.ready) { + throw new AgentInstallationError( + `Remote host cannot run the GoodBuddy Agent: ${probe.reason}`, + 'incompatible', + probe.reason + ) + } + + progress('verifying-bundle') + if ( + this.#loadBundle === undefined && + !(await bundledAgentDirectoryAvailable( + getBundledAgentDirectory( + this.#resourcePaths, + probe.architecture + ) + )) + ) { + const reused = await this.#reuseInstalledBundle( + target, + lease, + probe, + signal + ) + progress('complete') + return reused + } + const { bundle, registry, registryBytes } = + await this.#loadVerifiedBundle(probe.architecture) + assertBundleCapacity(bundle.manifest, registryBytes.byteLength) + const candidateInstallationId = installationIdFor(bundle) + installationId = candidateInstallationId + const identity = publicIdentity( + candidateInstallationId, + bundle + ) + + progress('preparing-installation') + sftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + installationSftpLimits( + bundle.manifest, + registryBytes.byteLength, + this.#sftpLimits + ), + signal + ) + cleanupHomeDirectory = probe.canonicalHomeDirectory + await ensureManagedHierarchy(sftp, probe.uid, signal) + registrySnapshots = await snapshotManagedRegistries( + sftp, + probe.uid, + signal + ) + await installReleaseKeyRegistry( + sftp, + registryBytes, + probe.uid, + cleanup, + signal + ) + + const destination = + `.goodbuddy/agent/installations/${candidateInstallationId}` + const existingDestination = await pathMetadata(sftp, destination) + if (existingDestination !== undefined) { + await verifyRemoteInstallation( + sftp, + destination, + bundle, + probe.uid, + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw corruptInstallationError( + candidateInstallationId, + error + ) + }) + progress('checking-health') + activationStarted = true + await expectLifecycleSuccess( + lease, + candidateInstallationId, + 'bootstrap', + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw new AgentInstallationError( + 'Existing Agent installation could not be bootstrapped', + 'lifecycle' + ) + }) + await expectLifecycleSuccess( + lease, + candidateInstallationId, + 'health', + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw new AgentInstallationError( + 'Existing Agent installation is not healthy', + 'lifecycle' + ) + }) + progress('complete') + return identity + } + + progress('uploading-bundle') + const staging = + `.goodbuddy/agent/staging/op-${randomUUID()}` + await sftp.mkdir(staging, signal) + cleanup.push({ path: staging, type: 'directory' }) + await assertRemoteMetadata( + sftp, + staging, + 'directory', + probe.uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + const reusedFiles = new Set() + if ( + await tryReuseVerifiedCurrentNode( + sftp, + staging, + bundle, + registry, + this.#verificationEnvironment, + probe.uid, + signal + ) + ) { + reusedFiles.add(bundle.manifest.entrypoint.runtimePath) + cleanup.push({ + path: + `${staging}/${bundle.manifest.entrypoint.runtimePath}`, + type: 'file' + }) + } + await uploadAndVerifyBundle( + sftp, + staging, + bundle, + probe.uid, + cleanup, + reusedFiles, + signal + ) + + const currentTarget = await this.#resolver.resolve( + target.host.id + ) + if ( + targetIdentityKey(currentTarget) !== targetIdentityKey(target) + ) { + throw new AgentInstallationError( + 'SSH host identity changed before Agent activation', + 'host-identity-changed' + ) + } + signal?.throwIfAborted() + await sftp.rename(staging, destination, signal) + stagingPublished = true + + progress('starting-agent') + activationStarted = true + await expectLifecycleSuccess( + lease, + candidateInstallationId, + 'bootstrap', + signal + ) + progress('checking-health') + await expectLifecycleSuccess( + lease, + candidateInstallationId, + 'health', + signal + ) + progress('complete') + return identity + } catch (error) { + if (sftp && registrySnapshots) { + if (activationStarted && lease && installationId) { + await stopFailedCandidate(lease, installationId) + } + await restoreManagedRegistries( + sftp, + registrySnapshots + ).catch((rollbackError: unknown) => { + throw new AggregateError( + [error, rollbackError], + 'Agent installation failed and its previous registry state could not be restored' + ) + }) + } + throw error + } finally { + let cleanupIncomplete = false + if (sftp && !stagingPublished) { + cleanupIncomplete = !(await cleanupOwnedStaging(sftp, cleanup)) + } + sftp?.close() + if ( + cleanupIncomplete && + lease?.isUsable() && + cleanupHomeDirectory + ) { + let cleanupSftp: StagedSftp | undefined + try { + cleanupSftp = await lease.openStagedSftp( + cleanupHomeDirectory, + this.#sftpLimits + ) + await cleanupOwnedStaging(cleanupSftp, cleanup) + } catch { + // The lease may already be unavailable after cancellation. + } finally { + cleanupSftp?.close() + } + } + lease?.release() + } + } + + async #loadVerifiedBundle( + architecture: AgentArchitecture + ): Promise<{ + bundle: VerifiedAgentBundle + registry: AgentReleaseKeyRegistry + registryBytes: Buffer + }> { + if (this.#loadBundle) { + const loaded = await this.#loadBundle(architecture) + if (loaded.bundle.manifest.arch !== architecture) { + throw new Error( + 'Verified Agent bundle architecture does not match the host' + ) + } + const canonicalRegistry = agentReleaseKeyRegistrySchema.parse( + loaded.registry + ) + return { + bundle: loaded.bundle, + registry: canonicalRegistry, + registryBytes: Buffer.from( + `${JSON.stringify(canonicalRegistry, null, 2)}\n`, + 'utf8' + ) + } + } + const [registry, runtimeLock] = await Promise.all([ + readAgentReleaseKeyRegistry( + this.#resourcePaths.keyRegistryPath + ), + readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath) + ]) + const bundle = await verifyAgentBundleDirectory( + getBundledAgentDirectory(this.#resourcePaths, architecture), + { + architecture, + registry, + runtimeLock, + verificationEnvironment: this.#verificationEnvironment + } + ) + const canonicalRegistry = agentReleaseKeyRegistrySchema.parse( + registry + ) + return { + bundle, + registry: canonicalRegistry, + registryBytes: Buffer.from( + `${JSON.stringify(canonicalRegistry, null, 2)}\n`, + 'utf8' + ) + } + } + + async #reuseInstalledBundle( + target: SshConnectionPoolTarget, + lease: SshConnectionLease, + probe: { + architecture: AgentArchitecture + canonicalHomeDirectory: string + uid: number + }, + signal: AbortSignal + ): Promise { + const [registry, runtimeLock, registryBytes] = + await Promise.all([ + readAgentReleaseKeyRegistry( + this.#resourcePaths.keyRegistryPath + ), + readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath), + readFile(this.#resourcePaths.keyRegistryPath) + ]) + const canonicalRegistryBytes = Buffer.from( + `${JSON.stringify( + agentReleaseKeyRegistrySchema.parse(registry), + null, + 2 + )}\n`, + 'utf8' + ) + if (!canonicalRegistryBytes.equals(registryBytes)) { + throw new Error('Agent trusted key registry is not canonical') + } + + let metadataSftp: StagedSftp | undefined + let installationSftp: StagedSftp | undefined + try { + metadataSftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + { + maximumFileBytes: MAXIMUM_METADATA_BYTES, + maximumTotalBytes: MAXIMUM_METADATA_BYTES * 4, + maximumOperations: 32, + operationTimeoutMs: 15_000 + }, + signal + ) + const remoteRegistryBytes = await metadataSftp.readFile( + AGENT_REGISTRY_PATH, + signal + ) + const remoteReleaseKeyBytes = await metadataSftp.readFile( + RELEASE_KEYS_PATH, + signal + ) + if (!remoteReleaseKeyBytes.equals(canonicalRegistryBytes)) { + throw new Error( + 'Installed Agent release-key registry does not match this GoodBuddy build' + ) + } + const remoteRegistry = parseInstallationRegistryState( + parseJsonBytes( + remoteRegistryBytes, + 'Installed Agent registry' + ) + ) + const current = remoteRegistry.current + if ( + current === undefined || + current.agentVersion !== runtimeLock.agentVersion || + current.arch !== probe.architecture || + current.installationId !== + `agent-${current.manifestSha256}` + ) { + throw new AgentInstallationError( + 'This package does not include Agent installation resources and the Host has no matching current Agent', + 'incompatible' + ) + } + const destination = + `.goodbuddy/agent/installations/${current.installationId}` + const manifestBytes = await metadataSftp.readFile( + `${destination}/manifest.json`, + signal + ) + const signatureFile = await metadataSftp.readFile( + `${destination}/manifest.sig`, + signal + ) + const signatureBytes = decodeDetachedSignature( + signatureFile, + 'Agent detached signature' + ) + const manifest = verifyAgentManifestSignature( + manifestBytes, + signatureBytes, + registry, + this.#verificationEnvironment + ) + assertAgentManifestMatchesRuntimeLock( + manifest, + runtimeLock, + probe.architecture + ) + const manifestSha256 = sha256(manifestBytes) + if ( + manifestSha256 !== current.manifestSha256 || + manifest.signingKeyId.length < 1 + ) { + throw new Error( + 'Installed Agent manifest identity does not match its registry' + ) + } + const installationId = verifyAgentInstallationId( + current.installationId + ) + const bundle = { + bundleDirectory: destination, + manifest, + manifestSha256 + } + assertBundleCapacity( + manifest, + canonicalRegistryBytes.byteLength + ) + metadataSftp.close() + metadataSftp = undefined + + installationSftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + installationSftpLimits( + manifest, + canonicalRegistryBytes.byteLength, + this.#sftpLimits + ), + signal + ) + await verifyRemoteInstallationFromMetadata( + installationSftp, + destination, + bundle, + manifestBytes, + signatureFile, + probe.uid, + 'critical-contents', + signal + ) + const currentTarget = await this.#resolver.resolve( + target.host.id + ) + if ( + targetIdentityKey(currentTarget) !== + targetIdentityKey(target) + ) { + throw new AgentInstallationError( + 'SSH host identity changed before Agent activation', + 'host-identity-changed' + ) + } + await ensureCurrentAgentHealthy( + lease, + installationId, + signal + ) + return publicIdentity(installationId, bundle) + } catch (error) { + if (error instanceof AgentInstallationError) { + throw error + } + if (isMissingPathError(error)) { + throw new AgentInstallationError( + 'This package does not include Agent installation resources and the Host has no matching current Agent', + 'incompatible', + error + ) + } + throw new AgentInstallationError( + 'The existing Host Agent could not be verified', + 'corrupt', + error + ) + } finally { + metadataSftp?.close() + installationSftp?.close() + } + } +} + +async function bundledAgentDirectoryAvailable( + directory: string +): Promise { + try { + return (await lstat(directory)).isDirectory() + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +function parseJsonBytes( + value: Buffer, + description: string +): unknown { + try { + return JSON.parse(value.toString('utf8')) as unknown + } catch (error) { + throw new Error(`${description} is not valid JSON`, { + cause: error + }) + } +} + +function decodeDetachedSignature( + value: Buffer, + description: string +): Buffer { + const text = value.toString('ascii') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) { + throw new Error(`${description} is not canonical Base64`) + } + const signature = Buffer.from(text.slice(0, -1), 'base64') + if (signature.byteLength !== 64) { + throw new Error(`${description} is not an Ed25519 signature`) + } + return signature +} + +function targetIdentityKey(target: SshConnectionPoolTarget): string { + const hostKey = target.host.hostKey + return createHash('sha256') + .update(JSON.stringify([ + 'goodbuddy-agent-installation-target-v1', + target.host.id, + target.host.name, + target.host.hostname, + target.host.port, + target.host.username, + target.host.authentication, + target.host.password ?? null, + target.hostRevision, + target.hostKeyGeneration, + hostKey?.algorithm ?? null, + hostKey?.publicKeyBase64 ?? null, + hostKey?.fingerprintSha256 ?? null, + hostKey?.acceptedAt ?? null, + hostKey?.generation ?? null + ])) + .digest('hex') +} + +function assertLeaseMatchesTarget( + lease: SshConnectionLease, + target: SshConnectionPoolTarget +): void { + if ( + lease.identity.hostId !== target.host.id || + lease.identity.hostRevision !== target.hostRevision || + lease.identity.hostKeyGeneration !== target.hostKeyGeneration || + !/^[a-f0-9]{64}$/u.test( + lease.identity.authenticationIdentity + ) + ) { + throw new Error('SSH lease identity does not match the resolved host') + } +} + +function installationIdFor( + bundle: VerifiedAgentBundle +): VerifiedAgentInstallationId { + return verifyAgentInstallationId( + `agent-${bundle.manifestSha256}` + ) +} + +function publicIdentity( + installationId: VerifiedAgentInstallationId, + bundle: VerifiedAgentBundle +): AgentInstallationIdentity { + return { + installationId, + binaryDigest: `sha256:${bundle.manifestSha256}`, + agentVersion: bundle.manifest.agentVersion, + protocol: { ...bundle.manifest.protocol }, + platform: 'linux', + architecture: bundle.manifest.arch, + supervisor: 'detached-on-demand' + } +} + +function assertBundleCapacity( + manifest: AgentBundleManifest, + registryBytes: number +): void { + const total = + manifest.files.reduce((sum, file) => sum + file.size, 0) + + registryBytes + if ( + manifest.files.length > MAXIMUM_INSTALLATION_FILES || + manifest.files.some( + (file) => file.size > MAXIMUM_AGENT_FILE_BYTES + ) || + !Number.isSafeInteger(total) || + total > MAXIMUM_INSTALLATION_BYTES + ) { + throw new Error('Agent bundle exceeds installation safety limits') + } +} + +function installationSftpLimits( + manifest: AgentBundleManifest, + registryBytes: number, + configured?: BoundedSftpLimits +): BoundedSftpLimits { + const largest = Math.max( + registryBytes, + ...manifest.files.map((file) => file.size), + 1024 * 1024 + ) + const payload = + manifest.files.reduce((sum, file) => sum + file.size, 0) + + registryBytes + + 2 * 1024 * 1024 + const reusableNodeBytes = + manifest.files.find( + (file) => + file.path === manifest.entrypoint.runtimePath + )?.size ?? 0 + return { + maximumFileBytes: Math.max( + largest, + configured?.maximumFileBytes ?? 0 + ), + maximumTotalBytes: Math.max( + payload * 2 + reusableNodeBytes, + configured?.maximumTotalBytes ?? 0 + ), + maximumOperations: + configured?.maximumOperations ?? 2_048, + operationTimeoutMs: configured?.operationTimeoutMs + } +} + +async function ensureManagedHierarchy( + sftp: StagedSftp, + uid: number, + signal?: AbortSignal +): Promise { + for (const path of [ + '.goodbuddy', + '.goodbuddy/agent', + '.goodbuddy/agent/staging', + '.goodbuddy/agent/installations' + ]) { + const existing = await pathMetadata(sftp, path, signal) + if (existing === undefined) { + await sftp.mkdir(path, signal) + } else { + assertMetadata( + existing, + path, + 'directory', + uid, + undefined, + undefined + ) + if (existing.mode !== PRIVATE_DIRECTORY_MODE) { + await sftp.chmod(path, PRIVATE_DIRECTORY_MODE, signal) + } + } + await assertRemoteMetadata( + sftp, + path, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } +} + +async function installReleaseKeyRegistry( + sftp: StagedSftp, + contents: Buffer, + uid: number, + cleanup: CleanupEntry[], + signal?: AbortSignal +): Promise { + const destination = '.goodbuddy/agent/release-keys.json' + const existing = await pathMetadata(sftp, destination, signal) + if (existing !== undefined) { + assertMetadata( + existing, + destination, + 'file', + uid, + PRIVATE_FILE_MODE, + undefined + ) + if ( + existing.size === contents.byteLength && + (await sftp.readFile(destination, signal)).equals(contents) + ) { + return + } + } + const temporary = + `.goodbuddy/agent/.release-keys-${randomUUID()}.tmp` + cleanup.push({ path: temporary, type: 'file' }) + await sftp.writeFile(temporary, contents, signal) + await sftp.chmod(temporary, PRIVATE_FILE_MODE, signal) + await verifyRemoteFile( + sftp, + temporary, + contents.byteLength, + sha256(contents), + uid, + PRIVATE_FILE_MODE, + signal + ) + await sftp.replaceFile(temporary, destination, signal) + cleanup.pop() + await verifyRemoteFile( + sftp, + destination, + contents.byteLength, + sha256(contents), + uid, + PRIVATE_FILE_MODE, + signal + ) +} + +async function snapshotManagedRegistries( + sftp: StagedSftp, + uid: number, + signal?: AbortSignal +): Promise { + const snapshots: ManagedFileSnapshot[] = [] + for (const path of [ + '.goodbuddy/agent/release-keys.json', + '.goodbuddy/agent/registry.json' + ]) { + const metadata = await pathMetadata(sftp, path, signal) + if (metadata === undefined) { + snapshots.push({ path, contents: undefined, uid }) + continue + } + assertMetadata( + metadata, + path, + 'file', + uid, + PRIVATE_FILE_MODE, + undefined + ) + snapshots.push({ + path, + contents: await sftp.readFile(path, signal), + uid + }) + } + return snapshots +} + +async function restoreManagedRegistries( + sftp: StagedSftp, + snapshots: readonly ManagedFileSnapshot[] +): Promise { + for (const snapshot of snapshots) { + const existing = await pathMetadata(sftp, snapshot.path) + if (snapshot.contents === undefined) { + if (existing !== undefined) { + assertMetadata( + existing, + snapshot.path, + 'file', + snapshot.uid, + PRIVATE_FILE_MODE, + undefined + ) + await sftp.unlink(snapshot.path) + } + continue + } + if ( + existing !== undefined && + existing.type === 'file' && + existing.mode === PRIVATE_FILE_MODE && + existing.size === snapshot.contents.byteLength && + (await sftp.readFile(snapshot.path)).equals(snapshot.contents) + ) { + continue + } + if (existing !== undefined) { + assertMetadata( + existing, + snapshot.path, + 'file', + snapshot.uid, + undefined, + undefined + ) + } + const temporary = + `.goodbuddy/agent/.registry-rollback-${randomUUID()}.tmp` + try { + await sftp.writeFile(temporary, snapshot.contents) + await sftp.chmod(temporary, PRIVATE_FILE_MODE) + await sftp.replaceFile(temporary, snapshot.path) + await verifyRemoteFile( + sftp, + snapshot.path, + snapshot.contents.byteLength, + sha256(snapshot.contents), + snapshot.uid, + PRIVATE_FILE_MODE + ) + } catch (error) { + await sftp.unlink(temporary).catch(() => undefined) + throw error + } + } +} + +async function stopFailedCandidate( + lease: SshConnectionLease, + installationId: VerifiedAgentInstallationId +): Promise { + if (!lease.isUsable()) { + return + } + try { + await lease.runAgentLifecycleAction( + installationId, + 'stop' + ) + } catch { + // The failed candidate is stopped best-effort before registry rollback. + } +} + +async function uploadAndVerifyBundle( + sftp: StagedSftp, + staging: string, + bundle: VerifiedAgentBundle, + uid: number, + cleanup: CleanupEntry[], + reusedFiles: ReadonlySet, + signal?: AbortSignal +): Promise { + const createdDirectories = new Set() + for (const file of bundle.manifest.files) { + const parts = file.path.split('/') + let relativeDirectory = '' + for (const part of parts.slice(0, -1)) { + relativeDirectory = relativeDirectory + ? `${relativeDirectory}/${part}` + : part + if (!createdDirectories.has(relativeDirectory)) { + const directory = `${staging}/${relativeDirectory}` + await sftp.mkdir(directory, signal) + cleanup.push({ path: directory, type: 'directory' }) + createdDirectories.add(relativeDirectory) + await assertRemoteMetadata( + sftp, + directory, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } + } + if (reusedFiles.has(file.path)) { + continue + } + const contents = await readFile( + `${bundle.bundleDirectory}/${file.path}` + ) + if ( + contents.byteLength !== file.size || + sha256(contents) !== file.sha256 + ) { + throw new Error( + `Agent bundle changed after local verification: ${file.path}` + ) + } + const destination = `${staging}/${file.path}` + cleanup.push({ path: destination, type: 'file' }) + await sftp.writeFile(destination, contents, signal) + await sftp.chmod( + destination, + file.mode === '0755' ? 0o755 : 0o644, + signal + ) + await verifyRemoteFile( + sftp, + destination, + file.size, + file.sha256, + uid, + file.mode === '0755' ? 0o755 : 0o644, + signal + ) + } + + for (const metadataName of ['manifest.json', 'manifest.sig']) { + const contents = await readFile( + `${bundle.bundleDirectory}/${metadataName}` + ) + if ( + metadataName === 'manifest.json' && + sha256(contents) !== bundle.manifestSha256 + ) { + throw new Error('Agent manifest changed after local verification') + } + const destination = `${staging}/${metadataName}` + cleanup.push({ path: destination, type: 'file' }) + await sftp.writeFile(destination, contents, signal) + await sftp.chmod(destination, METADATA_MODE, signal) + await verifyRemoteFile( + sftp, + destination, + contents.byteLength, + sha256(contents), + uid, + METADATA_MODE, + signal + ) + } +} + +async function tryReuseVerifiedCurrentNode( + sftp: StagedSftp, + staging: string, + candidate: VerifiedAgentBundle, + registry: AgentReleaseKeyRegistry, + verificationEnvironment: AgentVerificationEnvironment, + uid: number, + signal?: AbortSignal +): Promise { + const runtimePath = candidate.manifest.entrypoint.runtimePath + const candidateNode = candidate.manifest.files.find( + (file) => file.path === runtimePath + ) + if ( + candidateNode === undefined || + sftp.hardLink === undefined + ) { + return false + } + const destination = `${staging}/${runtimePath}` + try { + const registryBytes = await sftp.readFile( + AGENT_REGISTRY_PATH, + signal + ) + const current = parseInstallationRegistryState( + parseJsonBytes(registryBytes, 'Installed Agent registry') + ).current + if ( + current === undefined || + current.arch !== candidate.manifest.arch || + current.installationId !== + `agent-${current.manifestSha256}` + ) { + return false + } + const installationId = verifyAgentInstallationId( + current.installationId + ) + const root = + `.goodbuddy/agent/installations/${installationId}` + await assertRemoteMetadata( + sftp, + root, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + const manifestPath = `${root}/manifest.json` + const signaturePath = `${root}/manifest.sig` + const manifestBytes = await sftp.readFile( + manifestPath, + signal + ) + const signatureFile = await sftp.readFile( + signaturePath, + signal + ) + const manifest = verifyAgentManifestSignature( + manifestBytes, + decodeDetachedSignature( + signatureFile, + 'Agent detached signature' + ), + registry, + verificationEnvironment + ) + if ( + sha256(manifestBytes) !== current.manifestSha256 || + manifest.agentVersion !== current.agentVersion || + manifest.arch !== current.arch + ) { + return false + } + await verifyRemoteFile( + sftp, + manifestPath, + manifestBytes.byteLength, + sha256(manifestBytes), + uid, + METADATA_MODE, + signal + ) + await verifyRemoteFile( + sftp, + signaturePath, + signatureFile.byteLength, + sha256(signatureFile), + uid, + METADATA_MODE, + signal + ) + const currentNode = manifest.files.find( + (file) => file.path === manifest.entrypoint.runtimePath + ) + if ( + currentNode === undefined || + currentNode.path !== candidateNode.path || + currentNode.size !== candidateNode.size || + currentNode.sha256 !== candidateNode.sha256 || + currentNode.mode !== candidateNode.mode + ) { + return false + } + const source = `${root}/${currentNode.path}` + const mode = currentNode.mode === '0755' ? 0o755 : 0o644 + await verifyRemoteFile( + sftp, + source, + currentNode.size, + currentNode.sha256, + uid, + mode, + signal + ) + await sftp.hardLink(source, destination, signal) + await assertRemoteMetadata( + sftp, + destination, + 'file', + uid, + mode, + candidateNode.size, + signal + ) + return true + } catch (error) { + rethrowAbort(error) + try { + const linked = await pathMetadata(sftp, destination, signal) + if (linked !== undefined) { + assertMetadata( + linked, + destination, + 'file', + uid, + undefined, + undefined + ) + await sftp.unlink(destination, signal) + } + } catch (cleanupError) { + rethrowAbort(cleanupError) + } + return false + } +} + +async function verifyRemoteInstallation( + sftp: StagedSftp, + destination: string, + bundle: VerifiedAgentBundle, + uid: number, + signal?: AbortSignal +): Promise { + const [manifestBytes, signatureBytes] = await Promise.all([ + readFile(`${bundle.bundleDirectory}/manifest.json`), + readFile(`${bundle.bundleDirectory}/manifest.sig`) + ]) + await verifyRemoteInstallationFromMetadata( + sftp, + destination, + bundle, + manifestBytes, + signatureBytes, + uid, + 'contents', + signal + ) +} + +async function verifyRemoteInstallationFromMetadata( + sftp: StagedSftp, + destination: string, + bundle: VerifiedAgentBundle, + manifestBytes: Buffer, + signatureBytes: Buffer, + uid: number, + verification: 'contents' | 'critical-contents', + signal?: AbortSignal +): Promise { + await assertRemoteMetadata( + sftp, + destination, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + const checkedDirectories = new Set() + for (const file of bundle.manifest.files) { + const parts = file.path.split('/') + let directory = destination + for (const part of parts.slice(0, -1)) { + directory = `${directory}/${part}` + if (!checkedDirectories.has(directory)) { + checkedDirectories.add(directory) + await assertRemoteMetadata( + sftp, + directory, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } + } + const path = `${destination}/${file.path}` + const mode = file.mode === '0755' ? 0o755 : 0o644 + if ( + verification === 'contents' || + file.path !== bundle.manifest.entrypoint.runtimePath + ) { + await verifyRemoteFile( + sftp, + path, + file.size, + file.sha256, + uid, + mode, + signal + ) + } else { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + file.size, + signal + ) + } + } + for (const [metadataName, contents] of [ + ['manifest.json', manifestBytes], + ['manifest.sig', signatureBytes] + ] as const) { + await verifyRemoteFile( + sftp, + `${destination}/${metadataName}`, + contents.byteLength, + sha256(contents), + uid, + METADATA_MODE, + signal + ) + } +} + +async function verifyRemoteFile( + sftp: StagedSftp, + path: string, + size: number, + digest: string, + uid: number, + mode: number, + signal?: AbortSignal +): Promise { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + size, + signal + ) + const contents = await sftp.readFile(path, signal) + if (contents.byteLength !== size || sha256(contents) !== digest) { + throw new Error(`Remote Agent file readback mismatch: ${path}`) + } + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + size, + signal + ) +} + +async function assertRemoteMetadata( + sftp: StagedSftp, + path: string, + type: 'file' | 'directory', + uid: number, + mode: number, + size: number | undefined, + signal?: AbortSignal +): Promise { + assertMetadata( + await sftp.stat(path, signal), + path, + type, + uid, + mode, + size + ) +} + +function assertMetadata( + metadata: SftpEntryMetadata, + path: string, + type: 'file' | 'directory', + uid: number, + mode: number | undefined, + size: number | undefined +): void { + if ( + metadata.type !== type || + metadata.uid !== uid || + (mode !== undefined && metadata.mode !== mode) || + (size !== undefined && metadata.size !== size) + ) { + throw new Error(`Remote Agent metadata mismatch: ${path}`) + } +} + +async function pathMetadata( + sftp: StagedSftp, + path: string, + signal?: AbortSignal +): Promise { + try { + return await sftp.lstat(path, signal) + } catch (error) { + if (isMissingPathError(error)) { + return undefined + } + throw error + } +} + +async function expectLifecycleSuccess( + lease: SshConnectionLease, + installationId: VerifiedAgentInstallationId, + action: 'bootstrap' | 'health', + signal?: AbortSignal +): Promise { + const result = await lease.runAgentLifecycleAction( + installationId, + action, + signal + ) + if (result.exitCode !== 0) { + throw new AgentInstallationError( + `GoodBuddy Agent ${action} failed`, + 'lifecycle' + ) + } +} + +async function ensureCurrentAgentHealthy( + lease: SshConnectionLease, + installationId: VerifiedAgentInstallationId, + signal?: AbortSignal +): Promise { + const initial = await lease.runAgentLifecycleAction( + installationId, + 'health', + signal + ) + if (initial.exitCode === 0) { + return + } + await expectLifecycleSuccess( + lease, + installationId, + 'bootstrap', + signal + ) + await expectLifecycleSuccess( + lease, + installationId, + 'health', + signal + ) +} + +function corruptInstallationError( + installationId: VerifiedAgentInstallationId, + cause: unknown +): AgentInstallationError { + return new AgentInstallationError( + `Existing Agent installation is corrupt: ${installationId}`, + 'corrupt', + cause + ) +} + +async function cleanupOwnedStaging( + sftp: StagedSftp, + entries: readonly CleanupEntry[] +): Promise { + let complete = true + for (const entry of [...entries].reverse()) { + try { + if (entry.type === 'file') { + await sftp.unlink(entry.path) + } else { + await sftp.rmdir(entry.path) + } + } catch (error) { + if (!isMissingPathError(error)) { + complete = false + // Cleanup is best-effort and never widens beyond operation-owned paths. + } + } + } + return complete +} + +function waitForOperation( + promise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return promise + } + if (signal.aborted) { + return Promise.reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + return new Promise((resolve, reject) => { + const abort = (): void => { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + signal.addEventListener('abort', abort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', abort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} + +async function settleBoundedly( + promises: readonly Promise[], + timeoutMs: number +): Promise { + if (promises.length === 0) { + return + } + let timeout: ReturnType | undefined + try { + await Promise.race([ + Promise.allSettled(promises), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + ( + (error as { code?: unknown }).code === 2 || + (error as { code?: unknown }).code === 'ENOENT' + ) + ) +} + +function rethrowAbort(error: unknown): void { + if ( + ( + error instanceof DOMException && + error.name === 'AbortError' + ) || + ( + typeof error === 'object' && + error !== null && + 'name' in error && + (error as { name?: unknown }).name === 'AbortError' + ) + ) { + throw error + } +} + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} diff --git a/src/main/remote-agent/agent-protocol-client.test.ts b/src/main/remote-agent/agent-protocol-client.test.ts new file mode 100644 index 00000000..c0af45f7 --- /dev/null +++ b/src/main/remote-agent/agent-protocol-client.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + type AgentFrame, + type AttachWelcome +} from '../../shared/agent-protocol' +import type { AgentAttachTransport } from './agent-attach-transport' +import { + AgentProtocolClient, + AgentProtocolClientError +} from './agent-protocol-client' + +class FakeTransport { + readonly welcome: AttachWelcome = { + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId: 'connection-1', + generation: 3, + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + serverNonce: 'server-nonce' + } + readonly sent: AgentFrame[] = [] + #frames: AgentFrame[] = [] + #readers: Array<{ + resolve: (frame: AgentFrame) => void + reject: (error: unknown) => void + }> = [] + #close?: (error: Error) => void + + send(frame: AgentFrame): Promise { + this.sent.push(frame) + return Promise.resolve() + } + + receive(): Promise { + const frame = this.#frames.shift() + if (frame !== undefined) { + return Promise.resolve(frame) + } + return new Promise((resolve, reject) => { + this.#readers.push({ resolve, reject }) + }) + } + + remote(frame: AgentFrame): void { + const reader = this.#readers.shift() + if (reader !== undefined) { + reader.resolve(frame) + } else { + this.#frames.push(frame) + } + } + + onClose(listener: (error: Error) => void): () => void { + this.#close = listener + return () => { + this.#close = undefined + } + } + + dispose(): void { + const error = new Error('disposed') + this.#close?.(error) + for (const reader of this.#readers.splice(0)) { + reader.reject(error) + } + } +} + +function inbound( + outbound: AgentFrame, + sequence: string, + kind: AgentFrame['header']['kind'], + value: unknown +): AgentFrame { + const payload = + value instanceof Uint8Array + ? value + : Buffer.from(JSON.stringify(value), 'utf8') + return { + header: { + ...outbound.header, + direction: 'agent-to-main', + sequence, + kind, + payloadLength: payload.byteLength + }, + payload + } +} + +async function waitForSent( + transport: FakeTransport, + count: number +): Promise { + await vi.waitFor(() => expect(transport.sent.length).toBeGreaterThanOrEqual(count)) +} + +describe('AgentProtocolClient', () => { + it('strictly resolves a typed response only after matching channel close', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const result = client.request('agent/status', {}) + await waitForSent(transport, 1) + const requestFrame = transport.sent[0]! + const request = JSON.parse( + Buffer.from(requestFrame.payload).toString('utf8') + ) as { id: string } + transport.remote( + inbound(requestFrame, '1', 'control', { + jsonrpc: '2.0', + id: request.id, + result: { + state: 'ready', + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + agentVersion: '1.0.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'user-1', + draining: false + } + }) + ) + let settled = false + void result.finally(() => { + settled = true + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(settled).toBe(false) + transport.remote( + inbound(requestFrame, '2', 'control', { + jsonrpc: '2.0', + method: 'channel/close', + params: { + channelId: requestFrame.header.channelId, + channelEpoch: requestFrame.header.channelEpoch + } + }) + ) + await expect(result).resolves.toMatchObject({ state: 'ready' }) + client.dispose() + }) + + it('bounds abandoned waiters within request capacity', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + for (let index = 0; index < 256; index += 1) { + const controller = new AbortController() + const request = client.request('agent/status', {}, { + signal: controller.signal + }) + controller.abort(new Error('cancelled waiter')) + await expect(request).rejects.toThrow('cancelled waiter') + } + await expect( + client.request('agent/status', {}) + ).rejects.toMatchObject({ + code: 'capacity' + }) + client.dispose() + }) + + it('retains response sequence state when cancellation races channel close', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const closed = vi.fn() + client.onClose(closed) + const controller = new AbortController() + const result = client.request('agent/status', {}, { + signal: controller.signal + }) + await waitForSent(transport, 1) + const requestFrame = transport.sent[0]! + const request = JSON.parse( + Buffer.from(requestFrame.payload).toString('utf8') + ) as { id: string } + transport.remote( + inbound(requestFrame, '1', 'control', { + jsonrpc: '2.0', + id: request.id, + result: { + state: 'ready', + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + agentVersion: '1.0.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'user-1', + draining: false + } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + controller.abort(new Error('cancelled after response')) + await expect(result).rejects.toThrow('cancelled after response') + + transport.remote( + inbound(requestFrame, '2', 'control', { + jsonrpc: '2.0', + method: 'channel/close', + params: { + channelId: requestFrame.header.channelId, + channelEpoch: requestFrame.header.channelEpoch + } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(closed).not.toHaveBeenCalled() + const nextRequest = client.request('agent/status', {}) + void nextRequest.catch(() => undefined) + client.dispose() + }) + + it('acknowledges durable ACP output only after consumption', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.registerBinaryChannel({ + channelId: 'binding-1', + channelEpoch: '9', + kind: 'acp' + }) + const payload = Buffer.from('runtime event') + transport.remote({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 3, + channelId: 'binding-1', + channelEpoch: '9', + direction: 'agent-to-main', + sequence: '1', + kind: 'acp', + payloadLength: payload.byteLength + }, + payload + }) + const received = await channel.receive() + expect(transport.sent).toHaveLength(0) + await received.consume() + expect(transport.sent).toHaveLength(1) + expect(transport.sent[0]!.header.kind).toBe('ack') + expect(transport.sent[0]!.header.sequence).toBe('1') + expect( + JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8')) + ).toEqual({ + acknowledgedSequence: '1' + }) + + await channel.send(Buffer.from('next input')) + expect(transport.sent[1]!.header.kind).toBe('acp') + expect(transport.sent[1]!.header.sequence).toBe('1') + + const nextPayload = Buffer.from('next runtime event') + transport.remote({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 3, + channelId: 'binding-1', + channelEpoch: '9', + direction: 'agent-to-main', + sequence: '2', + kind: 'acp', + payloadLength: nextPayload.byteLength + }, + payload: nextPayload + }) + await expect(channel.receive()).resolves.toMatchObject({ + sequence: '2', + payload: nextPayload + }) + client.dispose() + }) + + it('seeds resumed ACP data sequences from authoritative cursors', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.registerBinaryChannel({ + channelId: 'binding-resumed', + channelEpoch: '12', + kind: 'acp', + nextInboundSequence: '7', + nextOutboundSequence: '5' + }) + await channel.send(Buffer.from('new-only-input')) + expect(transport.sent[0]!.header.sequence).toBe('5') + + const payload = Buffer.from('replayed-output') + transport.remote({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 3, + channelId: 'binding-resumed', + channelEpoch: '12', + direction: 'agent-to-main', + sequence: '7', + kind: 'acp', + payloadLength: payload.byteLength + }, + payload + }) + await expect(channel.receive()).resolves.toMatchObject({ + sequence: '7', + payload + }) + client.dispose() + }) + + it('notifies binary channel close listeners exactly once', () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.registerBinaryChannel({ + channelId: 'binding-close', + channelEpoch: '11', + kind: 'acp' + }) + const closed = vi.fn() + channel.onClose(closed) + channel.close() + channel.close() + expect(closed).toHaveBeenCalledOnce() + client.dispose() + }) + + it('allocates unique Main-owned blob channels and closes with a matching notification', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const first = client.allocateBinaryChannel({ kind: 'blob' }) + const second = client.allocateBinaryChannel({ kind: 'blob' }) + expect(first.channelId).not.toBe(second.channelId) + expect(first.channelEpoch).not.toBe(second.channelEpoch) + + await first.closeWithNotification() + expect(transport.sent).toHaveLength(1) + expect(transport.sent[0]!.header).toMatchObject({ + channelId: first.channelId, + channelEpoch: first.channelEpoch, + kind: 'control', + direction: 'main-to-agent' + }) + expect( + JSON.parse(Buffer.from(transport.sent[0]!.payload).toString('utf8')) + ).toEqual({ + jsonrpc: '2.0', + method: 'channel/close', + params: { + channelId: first.channelId, + channelEpoch: first.channelEpoch + } + }) + expect(() => + client.registerBinaryChannel({ + channelId: first.channelId, + channelEpoch: first.channelEpoch, + kind: 'blob' + }) + ).toThrow() + second.close() + client.dispose() + }) + + it('sends one bounded blob message as one protocol frame', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.allocateBinaryChannel({ kind: 'blob' }) + const payload = Buffer.alloc( + AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + ) + + await channel.send(payload) + + expect(transport.sent).toHaveLength(1) + expect(transport.sent[0]!.header).toMatchObject({ + kind: 'blob', + sequence: '1' + }) + expect(transport.sent[0]!.payload.byteLength).toBe( + payload.byteLength + ) + await expect( + channel.send( + Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + 1) + ) + ).rejects.toMatchObject({ code: 'protocol' }) + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect( + channel.send(Buffer.of(1), controller.signal) + ).rejects.toThrow(/cancelled/iu) + expect(transport.sent).toHaveLength(1) + channel.close() + client.dispose() + }) + + it('receives sequential blob messages without transport ACK traffic', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.allocateBinaryChannel({ kind: 'blob' }) + for (let index = 1; index <= 3; index += 1) { + const payload = Buffer.alloc(128 * 1024, index) + transport.remote({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: 3, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch, + direction: 'agent-to-main', + sequence: String(index), + kind: 'blob', + payloadLength: payload.byteLength + }, + payload + }) + const frame = await channel.receive() + expect(frame.payload[0]).toBe(index) + await frame.consume() + } + expect(transport.sent).toHaveLength(0) + channel.close() + client.dispose() + }) + + it.each([ + { + label: 'unknown Agent-first channel', + frame: { + channelId: 'agent-first', + channelEpoch: '90', + generation: 3, + kind: 'blob' as const + } + }, + { + label: 'cross-kind frame', + frame: { + channelId: 'allocated', + channelEpoch: 'allocated', + generation: 3, + kind: 'acp' as const + } + }, + { + label: 'stale epoch', + frame: { + channelId: 'allocated', + channelEpoch: '91', + generation: 3, + kind: 'blob' as const + } + }, + { + label: 'stale generation', + frame: { + channelId: 'allocated', + channelEpoch: 'allocated', + generation: 2, + kind: 'blob' as const + } + } + ])('rejects a $label', async ({ frame }) => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const closed = new Promise((resolve) => { + client.onClose(resolve) + }) + const allocated = client.allocateBinaryChannel({ kind: 'blob' }) + const payload = Buffer.from('invalid') + transport.remote({ + header: { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: 'connection-1', + generation: frame.generation, + channelId: + frame.channelId === 'allocated' + ? allocated.channelId + : frame.channelId, + channelEpoch: + frame.channelEpoch === 'allocated' + ? allocated.channelEpoch + : frame.channelEpoch, + direction: 'agent-to-main', + sequence: '1', + kind: frame.kind, + payloadLength: payload.byteLength + }, + payload + }) + await expect(closed).resolves.toBeInstanceOf( + AgentProtocolClientError + ) + expect(() => client.request('agent/status', {})).toThrow( + AgentProtocolClientError + ) + }) + + it('relies on SSH transport backpressure instead of peer credit', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const channel = client.registerBinaryChannel({ + channelId: 'binding-2', + channelEpoch: '10', + kind: 'acp' + }) + await channel.send(Buffer.alloc(300 * 1024)) + expect(transport.sent[0]!.payload).toHaveLength(300 * 1024) + client.dispose() + }) + + it('fails closed on a mismatched response ID', async () => { + const transport = new FakeTransport() + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const request = client.request('agent/status', {}) + await waitForSent(transport, 1) + transport.remote( + inbound(transport.sent[0]!, '1', 'control', { + jsonrpc: '2.0', + id: 'wrong-request', + result: null + }) + ) + await expect(request).rejects.toBeInstanceOf( + AgentProtocolClientError + ) + expect(() => client.request('agent/status', {})).toThrow( + AgentProtocolClientError + ) + }) +}) diff --git a/src/main/remote-agent/agent-protocol-client.ts b/src/main/remote-agent/agent-protocol-client.ts new file mode 100644 index 00000000..c25a634e --- /dev/null +++ b/src/main/remote-agent/agent-protocol-client.ts @@ -0,0 +1,1079 @@ +import { randomBytes } from 'node:crypto' +import { z } from 'zod' +import { + AGENT_PROTOCOL_LIMITS, + AGENT_PROTOCOL_VERSION, + AgentConnectionChannels, + acpBindingCursorsSchema, + acpCompletePromptRequestSchema, + acpCompletePromptResultSchema, + acpEscalateCancellationRequestSchema, + acpEscalateCancellationResultSchema, + acpGetCursorsRequestSchema, + acpCloseChannelRequestSchema, + acpCloseChannelResultSchema, + acpOpenChannelRequestSchema, + acpOpenChannelResultSchema, + acpReplayChannelRequestSchema, + acpReplayChannelResultSchema, + acpReconcilePromptRequestSchema, + acpReconcilePromptResultSchema, + acpResumeChannelRequestSchema, + acpResumeChannelResultSchema, + channelCloseRequestSchema, + controllerResumeRequestSchema, + controllerResumeResultSchema, + daemonCapabilitiesSchema, + daemonStatusSchema, + jsonRpcMessageSchema, + jsonRpcNotificationSchema, + frameAcknowledgmentSchema, + maximumPayloadLength, + type AgentChannelState, + type AgentFrame, + type AgentFrameKind +} from '../../shared/agent-protocol' +import { + remotePromptOperationAcceptanceSchema, + remotePromptOperationPreparationSchema, + remoteGitDiffRequestSchema, + remoteGitDiffResultSchema, + remoteGitStatusRequestSchema, + remoteGitStatusResultSchema, + remoteWorkspaceApplyChangeSetRequestSchema, + remoteWorkspaceApplyChangeSetResultSchema, + remoteWorkspaceCloseRequestSchema, + remoteWorkspaceCloseResultSchema, + remoteWorkspaceListRequestSchema, + remoteWorkspaceListResultSchema, + remoteWorkspaceOpenRequestSchema, + remoteWorkspaceOpenResultSchema, + remoteWorkspaceReadTextRequestSchema, + remoteWorkspaceReadTextResultSchema, + remoteWorkspaceResumeRequestSchema, + remoteWorkspaceResumeResultSchema, + remoteWorkspaceSearchRequestSchema, + remoteWorkspaceSearchResultSchema, + remoteWorkspaceStatRequestSchema, + remoteWorkspaceStatResultSchema, + remoteWorkspaceValidateRequestSchema, + remoteWorkspaceValidateResultSchema, + remoteWorkspaceWriteResultSchema, + remoteWorkspaceWriteTextAtomicRequestSchema +} from '../../shared/remote-agent-contracts' +import type { AgentAttachTransport } from './agent-attach-transport' + +const MAXIMUM_PENDING_REQUESTS = 256 +const MAXIMUM_RETIRED_CHANNELS = 4_096 +const MAXIMUM_CHANNEL_QUEUE_ITEMS = 256 +const MAXIMUM_CHANNEL_QUEUE_BYTES = + AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes +const CHANNEL_CLOSE_METHOD = 'channel/close' + +const emptyParamsSchema = z.object({}).strict() + +type MethodDefinition = { + params: z.ZodType + result: z.ZodType +} + +/** + * Deliberately closed method registry. Adding a wire method requires adding + * shared schemas here; callers cannot send arbitrary JSON-RPC method names. + */ +export const AGENT_PROTOCOL_METHODS = { + 'agent/status': { + params: emptyParamsSchema, + result: daemonStatusSchema + }, + 'agent/doctor': { + params: emptyParamsSchema, + result: daemonStatusSchema + }, + 'agent/capabilities': { + params: emptyParamsSchema, + result: daemonCapabilitiesSchema + }, + 'controller/resume': { + params: controllerResumeRequestSchema, + result: controllerResumeResultSchema + }, + 'workspace/validate': { + params: remoteWorkspaceValidateRequestSchema, + result: remoteWorkspaceValidateResultSchema + }, + 'workspace/open': { + params: remoteWorkspaceOpenRequestSchema, + result: remoteWorkspaceOpenResultSchema + }, + 'workspace/resume': { + params: remoteWorkspaceResumeRequestSchema, + result: remoteWorkspaceResumeResultSchema + }, + 'workspace/close': { + params: remoteWorkspaceCloseRequestSchema, + result: remoteWorkspaceCloseResultSchema + }, + 'workspace/list': { + params: remoteWorkspaceListRequestSchema, + result: remoteWorkspaceListResultSchema + }, + 'workspace/stat': { + params: remoteWorkspaceStatRequestSchema, + result: remoteWorkspaceStatResultSchema + }, + 'workspace/readText': { + params: remoteWorkspaceReadTextRequestSchema, + result: remoteWorkspaceReadTextResultSchema + }, + 'workspace/search': { + params: remoteWorkspaceSearchRequestSchema, + result: remoteWorkspaceSearchResultSchema + }, + 'workspace/writeTextAtomic': { + params: remoteWorkspaceWriteTextAtomicRequestSchema, + result: remoteWorkspaceWriteResultSchema + }, + 'workspace/applyChangeSet': { + params: remoteWorkspaceApplyChangeSetRequestSchema, + result: remoteWorkspaceApplyChangeSetResultSchema + }, + 'git/status': { + params: remoteGitStatusRequestSchema, + result: remoteGitStatusResultSchema + }, + 'git/diff': { + params: remoteGitDiffRequestSchema, + result: remoteGitDiffResultSchema + }, + 'runtime/openAcpChannel': { + params: acpOpenChannelRequestSchema, + result: acpOpenChannelResultSchema + }, + 'runtime/closeAcpChannel': { + params: acpCloseChannelRequestSchema, + result: acpCloseChannelResultSchema + }, + 'runtime/resumeAcpChannel': { + params: acpResumeChannelRequestSchema, + result: acpResumeChannelResultSchema + }, + 'runtime/replayAcpChannel': { + params: acpReplayChannelRequestSchema, + result: acpReplayChannelResultSchema + }, + 'runtime/preparePrompt': { + params: remotePromptOperationPreparationSchema, + result: remotePromptOperationAcceptanceSchema + }, + 'runtime/completePrompt': { + params: acpCompletePromptRequestSchema, + result: acpCompletePromptResultSchema + }, + 'runtime/getAcpCursors': { + params: acpGetCursorsRequestSchema, + result: acpBindingCursorsSchema + }, + 'runtime/escalateCancellation': { + params: acpEscalateCancellationRequestSchema, + result: acpEscalateCancellationResultSchema + }, + 'runtime/reconcilePrompt': { + params: acpReconcilePromptRequestSchema, + result: acpReconcilePromptResultSchema + } +} as const satisfies Record + +export type AgentProtocolMethod = keyof typeof AGENT_PROTOCOL_METHODS +export type AgentProtocolParams = z.infer< + (typeof AGENT_PROTOCOL_METHODS)[M]['params'] +> +export type AgentProtocolResult = z.infer< + (typeof AGENT_PROTOCOL_METHODS)[M]['result'] +> + +export type AgentProtocolRequestOptions = { + signal?: AbortSignal +} + +export class AgentRpcError extends Error { + constructor( + readonly rpcCode: number, + message: string, + readonly data?: unknown + ) { + super(message) + this.name = 'AgentRpcError' + } +} + +export class AgentProtocolClientError extends Error { + constructor( + message: string, + readonly code: + | 'closed' + | 'protocol' + | 'capacity' + | 'aborted', + readonly cause?: unknown + ) { + super(message) + this.name = 'AgentProtocolClientError' + } +} + +type PendingRequest = { + channel: AgentChannelState + requestId: string + method: AgentProtocolMethod + resultSchema: z.ZodType + response?: { result?: unknown; error?: AgentRpcError } + resolve: (value: unknown) => void + reject: (error: unknown) => void + signal?: AbortSignal + abort?: () => void + canceled: boolean + nextDataSequence: bigint + nextAckSequence: bigint +} + +type AbandonedRequest = { + channelId: string + channelEpoch: string + requestId: string + nextDataSequence: bigint + nextAckSequence: bigint + responseSeen: boolean +} + +type QueuedBinaryFrame = { + frame: AgentFrame + consumed: boolean +} + +type BinaryWaiter = { + resolve: (frame: AgentConsumedFrame) => void + reject: (error: unknown) => void + signal?: AbortSignal + abort?: () => void +} + +export type AgentConsumedFrame = { + payload: Uint8Array + sequence: string + consume(): Promise +} + +export class AgentProtocolBinaryChannel { + readonly #client: AgentProtocolClient + readonly state: AgentChannelState + readonly kind: Extract + #queue: QueuedBinaryFrame[] = [] + #queueBytes = 0 + #waiters: BinaryWaiter[] = [] + #closeListeners = new Set<(error?: unknown) => void>() + #closed = false + + constructor( + client: AgentProtocolClient, + state: AgentChannelState, + kind: Extract + ) { + this.#client = client + this.state = state + this.kind = kind + } + + get channelId(): string { + return this.state.channelId + } + + get channelEpoch(): string { + return this.state.channelEpoch + } + + async send(payload: Uint8Array, signal?: AbortSignal): Promise { + this.#assertOpen() + signal?.throwIfAborted() + if (payload.byteLength > maximumPayloadLength(this.kind)) { + throw new AgentProtocolClientError( + 'Binary Agent frame exceeds its protocol limit', + 'protocol' + ) + } + await this.#client.sendOnChannel(this.state, this.kind, payload) + } + + /** + * Main-owned binary channels announce their terminal state to the Agent + * before releasing the local channel identity. The notification is never + * replayed. + */ + async closeWithNotification(): Promise { + await this.#client.closeBinaryChannel(this, true) + } + + receive(signal?: AbortSignal): Promise { + this.#assertOpen() + signal?.throwIfAborted() + const queued = this.#queue.shift() + if (queued !== undefined) { + this.#queueBytes -= queued.frame.payload.byteLength + return Promise.resolve(this.#consumable(queued)) + } + if (this.#waiters.length >= MAXIMUM_CHANNEL_QUEUE_ITEMS) { + return Promise.reject( + new AgentProtocolClientError( + 'Too many binary Agent frame readers', + 'capacity' + ) + ) + } + return new Promise((resolve, reject) => { + const waiter: BinaryWaiter = { resolve, reject, signal } + if (signal !== undefined) { + waiter.abort = (): void => { + const index = this.#waiters.indexOf(waiter) + if (index >= 0) { + this.#waiters.splice(index, 1) + } + reject(signal.reason) + } + signal.addEventListener('abort', waiter.abort, { once: true }) + } + this.#waiters.push(waiter) + }) + } + + onClose(listener: (error?: unknown) => void): () => void { + if (this.#closed) { + queueMicrotask(listener) + return () => undefined + } + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + accept(frame: AgentFrame): void { + this.#assertOpen() + const queued: QueuedBinaryFrame = { frame, consumed: false } + const waiter = this.#waiters.shift() + if (waiter !== undefined) { + if (waiter.abort !== undefined) { + waiter.signal?.removeEventListener('abort', waiter.abort) + } + waiter.resolve(this.#consumable(queued)) + return + } + if ( + this.#queue.length >= MAXIMUM_CHANNEL_QUEUE_ITEMS || + this.#queueBytes + frame.payload.byteLength > + MAXIMUM_CHANNEL_QUEUE_BYTES + ) { + throw new AgentProtocolClientError( + 'Binary Agent receive queue is full', + 'capacity' + ) + } + this.#queue.push(queued) + this.#queueBytes += frame.payload.byteLength + } + + close(error?: unknown): void { + if (this.#closed) { + return + } + this.#closed = true + this.state.close() + for (const waiter of this.#waiters.splice(0)) { + if (waiter.abort !== undefined) { + waiter.signal?.removeEventListener('abort', waiter.abort) + } + waiter.reject( + error ?? + new AgentProtocolClientError( + 'Binary Agent channel closed', + 'closed' + ) + ) + } + this.#queue = [] + this.#queueBytes = 0 + for (const listener of this.#closeListeners) { + listener(error) + } + this.#closeListeners.clear() + this.#client.releaseBinaryChannel(this) + } + + #consumable(queued: QueuedBinaryFrame): AgentConsumedFrame { + return { + payload: queued.frame.payload, + sequence: queued.frame.header.sequence, + consume: async () => { + if (queued.consumed) { + return + } + queued.consumed = true + if (this.kind === 'acp') { + await this.#client.sendFrameAcknowledgment( + this.state, + queued.frame.header.sequence + ) + } + } + } + } + + #assertOpen(): void { + if (this.#closed) { + throw new AgentProtocolClientError( + 'Binary Agent channel is closed', + 'closed' + ) + } + } +} + +export class AgentProtocolClient { + readonly #transport: AgentAttachTransport + readonly #channels: AgentConnectionChannels + readonly #pending = new Map() + readonly #abandoned = new Map() + readonly #binaryChannels = new Map() + readonly #retired = new Set() + readonly #allocatedEpochs = new Set() + #nextChannelEpoch = 1n + #nextChannelNumber = 1 + #closed = false + #closeError?: AgentProtocolClientError + #unsubscribeClose: () => void + #closeListeners = new Set<(error: AgentProtocolClientError) => void>() + + constructor(transport: AgentAttachTransport) { + this.#transport = transport + this.#channels = new AgentConnectionChannels({ + connectionId: transport.welcome.connectionId, + generation: transport.welcome.generation + }) + this.#unsubscribeClose = transport.onClose((error) => { + this.#close( + new AgentProtocolClientError( + 'Agent protocol transport closed', + 'closed', + error + ) + ) + }) + void this.#readLoop() + } + + get connectionId(): string { + return this.#transport.welcome.connectionId + } + + get generation(): number { + return this.#transport.welcome.generation + } + + get welcome(): AgentAttachTransport['welcome'] { + return this.#transport.welcome + } + + onClose( + listener: (error: AgentProtocolClientError) => void + ): () => void { + if (this.#closeError !== undefined) { + queueMicrotask(() => listener(this.#closeError!)) + return () => undefined + } + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + request( + method: M, + params: AgentProtocolParams, + options: AgentProtocolRequestOptions = {} + ): Promise> { + this.#assertOpen() + options.signal?.throwIfAborted() + if ( + this.#pending.size + this.#abandoned.size >= + MAXIMUM_PENDING_REQUESTS + ) { + return Promise.reject( + new AgentProtocolClientError( + 'Agent protocol request tracking limit reached', + 'capacity' + ) + ) + } + const definition = AGENT_PROTOCOL_METHODS[method] + const parsedParams = definition.params.parse(params) + const { state, requestId } = this.#allocateChannel('control') + return new Promise>((resolve, reject) => { + const pending: PendingRequest = { + channel: state, + requestId, + method, + resultSchema: definition.result, + resolve: (value) => resolve(value as AgentProtocolResult), + reject, + signal: options.signal, + canceled: false, + nextDataSequence: 1n, + nextAckSequence: 1n + } + if (options.signal !== undefined) { + pending.abort = (): void => { + if (pending.canceled) { + return + } + pending.canceled = true + this.#abandonPending(pending) + reject( + options.signal?.reason ?? + new DOMException('Aborted', 'AbortError') + ) + } + options.signal.addEventListener('abort', pending.abort, { + once: true + }) + } + this.#pending.set(state.channelId, pending) + const request = { + jsonrpc: '2.0' as const, + id: requestId, + method, + params: parsedParams + } + void this.sendOnChannel( + state, + 'control', + Buffer.from(JSON.stringify(request), 'utf8') + ).catch((error: unknown) => { + this.#retirePending(pending, error) + }) + }) + } + + registerBinaryChannel(input: { + channelId: string + channelEpoch: string + kind: Extract + nextInboundSequence?: string + nextOutboundSequence?: string + }): AgentProtocolBinaryChannel { + this.#assertOpen() + if (this.#allocatedEpochs.has(input.channelEpoch)) { + throw new AgentProtocolClientError( + 'Agent channel epoch cannot be reused in a connection', + 'protocol' + ) + } + const state = this.#channels.open({ + channelId: input.channelId, + channelEpoch: input.channelEpoch, + inboundDirection: 'agent-to-main', + ...(input.nextInboundSequence === undefined + ? {} + : { nextInboundSequence: input.nextInboundSequence }), + ...(input.nextOutboundSequence === undefined + ? {} + : { nextOutboundSequence: input.nextOutboundSequence }) + }) + const channel = new AgentProtocolBinaryChannel( + this, + state, + input.kind + ) + this.#allocatedEpochs.add(input.channelEpoch) + this.#binaryChannels.set(input.channelId, channel) + return channel + } + + /** + * Allocates and registers a Main-authoritative binary channel before the + * Agent has any authority for that channel identity. + */ + allocateBinaryChannel(input: { + kind: Extract + }): AgentProtocolBinaryChannel { + this.#assertOpen() + const { state } = this.#allocateChannel(input.kind) + const channel = new AgentProtocolBinaryChannel( + this, + state, + input.kind + ) + this.#binaryChannels.set(state.channelId, channel) + return channel + } + + async closeBinaryChannel( + channel: AgentProtocolBinaryChannel, + notifyPeer: boolean + ): Promise { + if (this.#binaryChannels.get(channel.channelId) !== channel) { + channel.close() + return + } + try { + if (notifyPeer && !this.#closed) { + const notification = jsonRpcNotificationSchema.parse({ + jsonrpc: '2.0', + method: CHANNEL_CLOSE_METHOD, + params: channelCloseRequestSchema.parse({ + channelId: channel.channelId, + channelEpoch: channel.channelEpoch + }) + }) + await this.sendOnChannel( + channel.state, + 'control', + Buffer.from(JSON.stringify(notification), 'utf8') + ) + } + } finally { + channel.close() + } + } + + releaseBinaryChannel(channel: AgentProtocolBinaryChannel): void { + if (this.#binaryChannels.get(channel.channelId) !== channel) { + return + } + this.#binaryChannels.delete(channel.channelId) + this.#channels.close(channel.channelId) + this.#retireId(channel.channelId) + } + + async sendOnChannel( + channel: AgentChannelState, + kind: AgentFrameKind, + payload: Uint8Array + ): Promise { + this.#assertOpen() + await this.#transport.send( + this.#reserveOutboundFrame(channel, kind, payload) + ) + } + + #reserveOutboundFrame( + channel: AgentChannelState, + kind: AgentFrameKind, + payload: Uint8Array + ): AgentFrame { + return { + header: channel.reserveOutbound({ + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.connectionId, + generation: this.generation, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch, + direction: 'main-to-agent', + kind, + payloadLength: payload.byteLength + }), + payload + } + } + + async sendFrameAcknowledgment( + channel: AgentChannelState, + acknowledgedSequence: string + ): Promise { + const value = frameAcknowledgmentSchema.parse({ + acknowledgedSequence + }) + await this.sendOnChannel( + channel, + 'ack', + Buffer.from(JSON.stringify(value), 'utf8') + ) + } + + dispose(): void { + this.#close( + new AgentProtocolClientError( + 'Agent protocol client was disposed', + 'closed' + ) + ) + this.#transport.dispose() + } + + async #readLoop(): Promise { + try { + while (!this.#closed) { + const frame = await this.#transport.receive() + await this.#acceptFrame(frame) + } + } catch (error) { + if (!this.#closed) { + this.#close( + new AgentProtocolClientError( + 'Agent protocol reader failed', + 'protocol', + error + ) + ) + } + } + } + + async #acceptFrame(frame: AgentFrame): Promise { + const binary = this.#binaryChannels.get(frame.header.channelId) + if (binary !== undefined) { + binary.state.acceptInbound(frame.header) + if (frame.header.kind === 'ack') { + parseFrameAcknowledgment(frame) + } else if (frame.header.kind === binary.kind) { + binary.accept(frame) + } else if (frame.header.kind === 'control') { + await this.#acceptBinaryClose(binary, frame) + } else { + throw new AgentProtocolClientError( + 'Unexpected frame kind on binary Agent channel', + 'protocol' + ) + } + return + } + const abandoned = this.#abandoned.get(frame.header.channelId) + if (abandoned !== undefined) { + this.#acceptAbandonedFrame(abandoned, frame) + return + } + const pending = this.#pending.get(frame.header.channelId) + if (pending === undefined) { + if (this.#retired.has(frame.header.channelId)) { + throw new AgentProtocolClientError( + 'Received a stale frame for a retired Agent channel', + 'protocol' + ) + } + throw new AgentProtocolClientError( + 'Received a frame for an unknown Agent channel', + 'protocol' + ) + } + pending.channel.acceptInbound(frame.header) + if (frame.header.kind === 'ack') { + parseFrameAcknowledgment(frame) + pending.nextAckSequence += 1n + return + } + if (frame.header.kind !== 'control') { + throw new AgentProtocolClientError( + 'Control request channel received a non-control frame', + 'protocol' + ) + } + pending.nextDataSequence += 1n + const message = parseControl(frame) + if ('method' in message) { + if (message.method !== CHANNEL_CLOSE_METHOD) { + throw new AgentProtocolClientError( + 'Agent sent an unsupported control notification', + 'protocol' + ) + } + const close = channelCloseRequestSchema.parse(message.params) + if ( + close.channelId !== pending.channel.channelId || + close.channelEpoch !== pending.channel.channelEpoch || + pending.response === undefined + ) { + throw new AgentProtocolClientError( + 'Agent channel close did not match a completed response', + 'protocol' + ) + } + this.#settlePending(pending) + return + } + if ( + message.id !== pending.requestId || + pending.response !== undefined + ) { + throw new AgentProtocolClientError( + 'Agent response identity is invalid', + 'protocol' + ) + } + if ('error' in message) { + pending.response = { + error: new AgentRpcError( + message.error.code, + message.error.message, + message.error.data + ) + } + } else { + pending.response = { + result: pending.resultSchema.parse(message.result) + } + } + } + + #acceptAbandonedFrame( + abandoned: AbandonedRequest, + frame: AgentFrame + ): void { + if ( + frame.header.channelEpoch !== abandoned.channelEpoch || + frame.header.direction !== 'agent-to-main' + ) { + throw new AgentProtocolClientError( + 'Abandoned Agent channel identity is invalid', + 'protocol' + ) + } + const expected = + frame.header.kind === 'ack' + ? abandoned.nextAckSequence + : abandoned.nextDataSequence + if (BigInt(frame.header.sequence) !== expected) { + throw new AgentProtocolClientError( + 'Abandoned Agent channel sequence is invalid', + 'protocol' + ) + } + if (frame.header.kind === 'ack') { + parseFrameAcknowledgment(frame) + abandoned.nextAckSequence += 1n + return + } + if (frame.header.kind !== 'control') { + throw new AgentProtocolClientError( + 'Abandoned request received an invalid frame kind', + 'protocol' + ) + } + abandoned.nextDataSequence += 1n + const message = parseControl(frame) + if ('method' in message) { + if (message.method !== CHANNEL_CLOSE_METHOD) { + throw new AgentProtocolClientError( + 'Abandoned request received an invalid notification', + 'protocol' + ) + } + const close = channelCloseRequestSchema.parse(message.params) + if ( + close.channelId !== abandoned.channelId || + close.channelEpoch !== abandoned.channelEpoch || + !abandoned.responseSeen + ) { + throw new AgentProtocolClientError( + 'Abandoned Agent channel close identity is invalid', + 'protocol' + ) + } + this.#abandoned.delete(abandoned.channelId) + this.#retireId(abandoned.channelId) + return + } + if ( + message.id !== abandoned.requestId || + abandoned.responseSeen + ) { + throw new AgentProtocolClientError( + 'Abandoned Agent response identity is invalid', + 'protocol' + ) + } + abandoned.responseSeen = true + } + + async #acceptBinaryClose( + binary: AgentProtocolBinaryChannel, + frame: AgentFrame + ): Promise { + const message = parseControl(frame) + const notification = jsonRpcNotificationSchema.parse(message) + if (notification.method !== CHANNEL_CLOSE_METHOD) { + throw new AgentProtocolClientError( + 'Unexpected binary channel control message', + 'protocol' + ) + } + const close = channelCloseRequestSchema.parse(notification.params) + if ( + close.channelId !== binary.channelId || + close.channelEpoch !== binary.channelEpoch + ) { + throw new AgentProtocolClientError( + 'Binary channel close identity mismatch', + 'protocol' + ) + } + binary.close() + } + + #allocateChannel(kind: AgentFrameKind): { + state: AgentChannelState + requestId: string + } { + const entropy = randomBytes(8).toString('hex') + const channelId = `main-${this.#nextChannelNumber}-${entropy}` + this.#nextChannelNumber += 1 + while ( + this.#allocatedEpochs.has(this.#nextChannelEpoch.toString()) + ) { + this.#nextChannelEpoch += 1n + } + const channelEpoch = this.#nextChannelEpoch.toString() + this.#nextChannelEpoch += 1n + this.#allocatedEpochs.add(channelEpoch) + return { + state: this.#channels.open({ + channelId, + channelEpoch, + inboundDirection: 'agent-to-main' + }), + requestId: `${kind}-${entropy}` + } + } + + #settlePending(pending: PendingRequest): void { + const response = pending.response + if (response === undefined) { + throw new AgentProtocolClientError( + 'Agent channel closed before its response', + 'protocol' + ) + } + this.#pending.delete(pending.channel.channelId) + this.#channels.close(pending.channel.channelId) + this.#retireId(pending.channel.channelId) + if (pending.abort !== undefined) { + pending.signal?.removeEventListener('abort', pending.abort) + } + if (pending.canceled) { + return + } + if (response.error !== undefined) { + pending.reject(response.error) + } else { + pending.resolve(response.result) + } + } + + #retirePending(pending: PendingRequest, error: unknown): void { + if (!this.#pending.delete(pending.channel.channelId)) { + if (this.#abandoned.delete(pending.channel.channelId)) { + this.#retireId(pending.channel.channelId) + } + return + } + this.#channels.close(pending.channel.channelId) + this.#retireId(pending.channel.channelId) + if (pending.abort !== undefined) { + pending.signal?.removeEventListener('abort', pending.abort) + } + if (!pending.canceled) { + pending.reject(error) + } + } + + #abandonPending(pending: PendingRequest): void { + if (!this.#pending.delete(pending.channel.channelId)) { + return + } + if (pending.abort !== undefined) { + pending.signal?.removeEventListener('abort', pending.abort) + } + this.#channels.close(pending.channel.channelId) + this.#abandoned.set(pending.channel.channelId, { + channelId: pending.channel.channelId, + channelEpoch: pending.channel.channelEpoch, + requestId: pending.requestId, + nextDataSequence: pending.nextDataSequence, + nextAckSequence: pending.nextAckSequence, + responseSeen: pending.response !== undefined + }) + } + + #retireId(channelId: string): void { + this.#retired.add(channelId) + if (this.#retired.size > MAXIMUM_RETIRED_CHANNELS) { + this.#close( + new AgentProtocolClientError( + 'Agent channel identity lifetime exhausted', + 'capacity' + ) + ) + } + } + + #assertOpen(): void { + if (this.#closed) { + throw ( + this.#closeError ?? + new AgentProtocolClientError( + 'Agent protocol client is closed', + 'closed' + ) + ) + } + } + + #close(error: AgentProtocolClientError): void { + if (this.#closed) { + return + } + this.#closed = true + this.#closeError = error + this.#unsubscribeClose() + for (const pending of this.#pending.values()) { + if (pending.abort !== undefined) { + pending.signal?.removeEventListener('abort', pending.abort) + } + if (!pending.canceled) { + pending.reject(error) + } + } + this.#pending.clear() + this.#abandoned.clear() + for (const channel of this.#binaryChannels.values()) { + channel.close(error) + } + this.#binaryChannels.clear() + for (const listener of this.#closeListeners) { + listener(error) + } + this.#closeListeners.clear() + } +} + +function parseControl(frame: AgentFrame) { + let value: unknown + try { + value = JSON.parse(Buffer.from(frame.payload).toString('utf8')) + } catch (error) { + throw new AgentProtocolClientError( + 'Agent sent invalid JSON control payload', + 'protocol', + error + ) + } + return jsonRpcMessageSchema.parse(value) +} + +function parseFrameAcknowledgment(frame: AgentFrame): void { + let value: unknown + try { + value = JSON.parse(Buffer.from(frame.payload).toString('utf8')) + } catch (error) { + throw new AgentProtocolClientError( + 'Agent sent invalid frame ACK JSON', + 'protocol', + error + ) + } + frameAcknowledgmentSchema.parse(value) +} diff --git a/src/main/remote-agent/bundled-agent-resources.test.ts b/src/main/remote-agent/bundled-agent-resources.test.ts new file mode 100644 index 00000000..25d55d90 --- /dev/null +++ b/src/main/remote-agent/bundled-agent-resources.test.ts @@ -0,0 +1,65 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + getBundledAgentDirectory, + resolveBundledAgentResourcePaths +} from './bundled-agent-resources' + +describe('bundled Agent resource paths', () => { + it('resolves development resources from injected application paths', () => { + const paths = resolveBundledAgentResourcePaths({ + appPath: join('workspace', 'goodbuddy'), + resourcesPath: join('electron', 'resources'), + packaged: false + }) + + expect(paths).toEqual({ + keyRegistryPath: join( + 'workspace', + 'goodbuddy', + 'resources', + 'agent-release-keys.json' + ), + runtimeLockPath: join( + 'workspace', + 'goodbuddy', + 'agent-runtime-lock.json' + ), + bundleDirectories: { + x64: join( + 'workspace', + 'goodbuddy', + '.agent-resources', + 'linux-x64' + ), + arm64: join( + 'workspace', + 'goodbuddy', + '.agent-resources', + 'linux-arm64' + ) + } + }) + }) + + it('resolves packaged metadata and both Agent bundles outside ASAR', () => { + const paths = resolveBundledAgentResourcePaths({ + appPath: join('installed', 'resources', 'app.asar'), + resourcesPath: join('installed', 'resources'), + packaged: true + }) + + expect(paths.keyRegistryPath).toBe( + join('installed', 'resources', 'agent-release-keys.json') + ) + expect(paths.runtimeLockPath).toBe( + join('installed', 'resources', 'agent-runtime-lock.json') + ) + expect(getBundledAgentDirectory(paths, 'x64')).toBe( + join('installed', 'resources', 'agents', 'linux-x64') + ) + expect(getBundledAgentDirectory(paths, 'arm64')).toBe( + join('installed', 'resources', 'agents', 'linux-arm64') + ) + }) +}) diff --git a/src/main/remote-agent/bundled-agent-resources.ts b/src/main/remote-agent/bundled-agent-resources.ts new file mode 100644 index 00000000..b3cdc321 --- /dev/null +++ b/src/main/remote-agent/bundled-agent-resources.ts @@ -0,0 +1,43 @@ +import { join } from 'node:path' +import type { AgentArchitecture } from '../../shared/agent-installation-contracts' + +export type BundledAgentResourcePaths = { + keyRegistryPath: string + runtimeLockPath: string + bundleDirectories: Record +} + +export type BundledAgentResourceEnvironment = { + appPath: string + resourcesPath: string + packaged: boolean +} + +export function resolveBundledAgentResourcePaths( + environment: BundledAgentResourceEnvironment +): BundledAgentResourcePaths { + const metadataRoot = environment.packaged + ? environment.resourcesPath + : environment.appPath + const bundleRoot = environment.packaged + ? join(environment.resourcesPath, 'agents') + : join(environment.appPath, '.agent-resources') + + return { + keyRegistryPath: environment.packaged + ? join(metadataRoot, 'agent-release-keys.json') + : join(metadataRoot, 'resources', 'agent-release-keys.json'), + runtimeLockPath: join(metadataRoot, 'agent-runtime-lock.json'), + bundleDirectories: { + x64: join(bundleRoot, 'linux-x64'), + arm64: join(bundleRoot, 'linux-arm64') + } + } +} + +export function getBundledAgentDirectory( + paths: BundledAgentResourcePaths, + architecture: AgentArchitecture +): string { + return paths.bundleDirectories[architecture] +} diff --git a/src/main/remote-agent/bundled-remote-runtime-resources.test.ts b/src/main/remote-agent/bundled-remote-runtime-resources.test.ts new file mode 100644 index 00000000..4a84c067 --- /dev/null +++ b/src/main/remote-agent/bundled-remote-runtime-resources.test.ts @@ -0,0 +1,79 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + getBundledRemoteRuntimeRoot, + resolveBundledRemoteRuntimeResourcePaths +} from './bundled-remote-runtime-resources' + +describe('bundled remote Runtime resource paths', () => { + it('resolves development metadata and architecture roots', () => { + const paths = resolveBundledRemoteRuntimeResourcePaths({ + appPath: join('workspace', 'goodbuddy'), + resourcesPath: join('electron', 'resources'), + packaged: false + }) + + expect(paths).toEqual({ + keyRegistryPath: join( + 'workspace', + 'goodbuddy', + 'resources', + 'agent-release-keys.json' + ), + runtimeLockPath: join( + 'workspace', + 'goodbuddy', + 'remote-runtime-lock.json' + ), + runtimeRoots: { + x64: join( + 'workspace', + 'goodbuddy', + '.remote-runtime-resources', + 'linux-x64', + 'opencode' + ), + arm64: join( + 'workspace', + 'goodbuddy', + '.remote-runtime-resources', + 'linux-arm64', + 'opencode' + ) + } + }) + }) + + it('resolves packaged metadata and Runtime roots outside ASAR', () => { + const paths = resolveBundledRemoteRuntimeResourcePaths({ + appPath: join('installed', 'resources', 'app.asar'), + resourcesPath: join('installed', 'resources'), + packaged: true + }) + + expect(paths.keyRegistryPath).toBe( + join('installed', 'resources', 'agent-release-keys.json') + ) + expect(paths.runtimeLockPath).toBe( + join('installed', 'resources', 'remote-runtime-lock.json') + ) + expect(getBundledRemoteRuntimeRoot(paths, 'x64')).toBe( + join( + 'installed', + 'resources', + 'remote-runtimes', + 'linux-x64', + 'opencode' + ) + ) + expect(getBundledRemoteRuntimeRoot(paths, 'arm64')).toBe( + join( + 'installed', + 'resources', + 'remote-runtimes', + 'linux-arm64', + 'opencode' + ) + ) + }) +}) diff --git a/src/main/remote-agent/bundled-remote-runtime-resources.ts b/src/main/remote-agent/bundled-remote-runtime-resources.ts new file mode 100644 index 00000000..1658e99a --- /dev/null +++ b/src/main/remote-agent/bundled-remote-runtime-resources.ts @@ -0,0 +1,78 @@ +import { join } from 'node:path' +import type { AgentArchitecture } from '../../shared/agent-installation-contracts' + +export type BundledRemoteRuntimeResourcePaths = { + keyRegistryPath: string + runtimeLockPath: string + runtimeRoots: Record +} + +export type BundledRemoteRuntimeResourceEnvironment = { + appPath: string + resourcesPath: string + packaged: boolean +} + +export function resolveBundledRemoteRuntimeResourcePaths( + environment: BundledRemoteRuntimeResourceEnvironment +): BundledRemoteRuntimeResourcePaths { + if (environment.packaged) { + return { + keyRegistryPath: join( + environment.resourcesPath, + 'agent-release-keys.json' + ), + runtimeLockPath: join( + environment.resourcesPath, + 'remote-runtime-lock.json' + ), + runtimeRoots: { + x64: join( + environment.resourcesPath, + 'remote-runtimes', + 'linux-x64', + 'opencode' + ), + arm64: join( + environment.resourcesPath, + 'remote-runtimes', + 'linux-arm64', + 'opencode' + ) + } + } + } + + return { + keyRegistryPath: join( + environment.appPath, + 'resources', + 'agent-release-keys.json' + ), + runtimeLockPath: join( + environment.appPath, + 'remote-runtime-lock.json' + ), + runtimeRoots: { + x64: join( + environment.appPath, + '.remote-runtime-resources', + 'linux-x64', + 'opencode' + ), + arm64: join( + environment.appPath, + '.remote-runtime-resources', + 'linux-arm64', + 'opencode' + ) + } + } +} + +export function getBundledRemoteRuntimeRoot( + paths: BundledRemoteRuntimeResourcePaths, + architecture: AgentArchitecture +): string { + return paths.runtimeRoots[architecture] +} diff --git a/src/main/remote-agent/controller-state-store.test.ts b/src/main/remote-agent/controller-state-store.test.ts new file mode 100644 index 00000000..55ee6d0a --- /dev/null +++ b/src/main/remote-agent/controller-state-store.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import { + ControllerStateStore, + type ControllerConnectionState, + type ControllerStateFile, + type PersistedControllerState +} from './controller-state-store' + +class MemoryStateFile implements ControllerStateFile { + value?: unknown + writes = 0 + + async read(): Promise { + return structuredClone(this.value) + } + + async write(value: PersistedControllerState): Promise { + this.value = structuredClone(value) + this.writes += 1 + } +} + +const connection: ControllerConnectionState = { + cacheKey: 'a'.repeat(64), + hostId: 'host-1', + installationId: 'agent-v1', + protocolMajor: 1, + previousConnectionId: 'connection-1', + previousGeneration: 2, + daemonBootId: 'boot-1', + capabilityGeneration: 3, + acpBindings: [ + { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '7', + cursors: { + lastOutboundJournaledSequence: '4', + lastOutboundDeliveredSequence: '4', + lastInboundJournaledSequence: '6', + lastMainAckSequence: '5' + } + } + ] +} + +describe('ControllerStateStore', () => { + it('creates one stable controller ID and persists only active recovery state', async () => { + const file = new MemoryStateFile() + const first = new ControllerStateStore(file) + const controllerId = await first.getControllerId() + await first.updateConnection(connection) + first.dispose() + + const second = new ControllerStateStore(file) + expect(await second.getControllerId()).toBe(controllerId) + expect(await second.getConnection(connection.cacheKey)).toEqual( + connection + ) + const serialized = JSON.stringify(file.value) + expect(serialized).not.toMatch( + /password|secret|token|payload|result|hostname/iu + ) + }) + + it('isolates host invalidation from other cached identities', async () => { + const file = new MemoryStateFile() + const store = new ControllerStateStore(file) + await store.updateConnection(connection) + await store.updateConnection({ + ...connection, + cacheKey: 'b'.repeat(64), + hostId: 'host-2', + previousConnectionId: 'connection-2' + }) + await store.invalidateHost('host-1') + expect(await store.getConnection('a'.repeat(64))).toBeUndefined() + expect(await store.getConnection('b'.repeat(64))).toMatchObject({ + hostId: 'host-2' + }) + }) + + it('reads legacy synchronization fields but omits them from new writes', async () => { + const file = new MemoryStateFile() + file.value = { + version: 1, + controllerId: 'controller-1', + connections: [ + { + ...connection, + eventCursors: [ + { + streamId: 'run-1', + streamEpoch: '1', + acknowledgedSequence: '8' + } + ], + nonterminalOperationIds: ['operation-1'], + acpBindingIds: ['binding-1'] + } + ] + } + const store = new ControllerStateStore(file) + await expect(store.getConnection(connection.cacheKey)).resolves.toEqual( + connection + ) + await store.updateConnection(connection) + expect(file.value).toEqual({ + version: 1, + controllerId: 'controller-1', + connections: [connection] + }) + }) + + it('rejects duplicate ACP bindings and unknown persisted fields', async () => { + const file = new MemoryStateFile() + const store = new ControllerStateStore(file) + expect(() => + store.updateConnection({ + ...connection, + acpBindings: [ + connection.acpBindings[0]!, + connection.acpBindings[0]! + ] + }) + ).toThrow(/unique/iu) + file.value = { + version: 1, + controllerId: 'controller-1', + connections: [], + password: 'must-not-load' + } + const malformed = new ControllerStateStore(file) + await expect(malformed.getControllerId()).rejects.toThrow() + }) + + it('atomically advances and removes one ACP recovery binding', async () => { + const file = new MemoryStateFile() + const store = new ControllerStateStore(file) + await store.updateConnection(connection) + await store.updateAcpBinding( + connection.cacheKey, + 'binding-1', + { + ...connection.acpBindings[0]!, + cursors: { + lastOutboundJournaledSequence: '8', + lastOutboundDeliveredSequence: '8', + lastInboundJournaledSequence: '9', + lastMainAckSequence: '9' + } + } + ) + await expect( + store.getConnection(connection.cacheKey) + ).resolves.toMatchObject({ + acpBindings: [ + { + bindingId: 'binding-1', + cursors: { + lastOutboundJournaledSequence: '8', + lastMainAckSequence: '9' + } + } + ] + }) + + await store.updateAcpBinding( + connection.cacheKey, + 'binding-1', + undefined + ) + await expect( + store.getConnection(connection.cacheKey) + ).resolves.toMatchObject({ + acpBindings: [] + }) + }) + + it('coalesces many ACP cursor advances into one bounded write', async () => { + const file = new MemoryStateFile() + const store = new ControllerStateStore(file) + await store.updateConnection(connection) + const writesBeforeCursors = file.writes + + for (let sequence = 10; sequence <= 100; sequence += 1) { + const value = String(sequence) + await store.updateAcpBinding(connection.cacheKey, 'binding-1', { + ...connection.acpBindings[0]!, + cursors: { + lastOutboundJournaledSequence: value, + lastOutboundDeliveredSequence: value, + lastInboundJournaledSequence: value, + lastMainAckSequence: value + } + }) + } + + expect(file.writes).toBe(writesBeforeCursors) + await store.flush() + expect(file.writes).toBe(writesBeforeCursors + 1) + expect( + ( + await store.getConnection(connection.cacheKey) + )?.acpBindings[0]?.cursors.lastMainAckSequence + ).toBe('100') + }) + + it('enforces cursor monotonicity against pending in-memory state', async () => { + const file = new MemoryStateFile() + const store = new ControllerStateStore(file) + await store.updateConnection(connection) + await store.updateAcpBinding(connection.cacheKey, 'binding-1', { + ...connection.acpBindings[0]!, + cursors: { + lastOutboundJournaledSequence: '12', + lastOutboundDeliveredSequence: '12', + lastInboundJournaledSequence: '12', + lastMainAckSequence: '12' + } + }) + + await expect( + store.updateAcpBinding(connection.cacheKey, 'binding-1', { + ...connection.acpBindings[0]!, + cursors: { + lastOutboundJournaledSequence: '11', + lastOutboundDeliveredSequence: '11', + lastInboundJournaledSequence: '12', + lastMainAckSequence: '12' + } + }) + ).rejects.toThrow(/cannot regress/iu) + await store.flush() + expect( + ( + await store.getConnection(connection.cacheKey) + )?.acpBindings[0]?.cursors.lastOutboundJournaledSequence + ).toBe('12') + }) +}) diff --git a/src/main/remote-agent/controller-state-store.ts b/src/main/remote-agent/controller-state-store.ts new file mode 100644 index 00000000..c3ccd031 --- /dev/null +++ b/src/main/remote-agent/controller-state-store.ts @@ -0,0 +1,432 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { + acpBindingCursorsSchema, + agentIdentifierSchema, + agentSequenceSchema, + positiveAgentSequenceSchema +} from '../../shared/agent-protocol' +import { + isMissingFileError, + writeJsonFileAtomically +} from '../settings-file-utils' + +const MAXIMUM_CONNECTION_RECORDS = 128 +const MAXIMUM_EVENT_CURSORS = 1_024 +const MAXIMUM_NONTERMINAL_OPERATIONS = 4_096 +const MAXIMUM_ACP_BINDINGS = 128 +const ACP_BINDING_WRITE_DELAY_MS = 25 + +const eventCursorSchema = z + .object({ + streamId: agentIdentifierSchema, + streamEpoch: positiveAgentSequenceSchema, + acknowledgedSequence: agentSequenceSchema + }) + .strict() + +const acpRecoveryBindingSchema = z + .object({ + bindingId: agentIdentifierSchema, + channelId: agentIdentifierSchema, + channelEpoch: positiveAgentSequenceSchema, + cursors: acpBindingCursorsSchema + }) + .strict() + +const connectionStateSchema = z + .object({ + cacheKey: z.string().regex(/^[a-f0-9]{64}$/u), + hostId: agentIdentifierSchema, + installationId: agentIdentifierSchema, + protocolMajor: z.number().int().min(0).max(65_535), + previousConnectionId: agentIdentifierSchema, + previousGeneration: z.number().int().min(1).max(0xffff_ffff), + daemonBootId: agentIdentifierSchema, + capabilityGeneration: z.number().int().min(1).max(0xffff_ffff), + acpBindings: z + .array(acpRecoveryBindingSchema) + .max(MAXIMUM_ACP_BINDINGS) + .default([]) + }) + .strict() + .superRefine((state, context) => { + const bindingIds = new Set() + for (let index = 0; index < state.acpBindings.length; index += 1) { + const bindingId = state.acpBindings[index]!.bindingId + if (bindingIds.has(bindingId)) { + context.addIssue({ + code: 'custom', + path: ['acpBindings', index, 'bindingId'], + message: 'ACP recovery binding identities must be unique' + }) + } + bindingIds.add(bindingId) + } + }) + +const compatibleConnectionStateSchema = z + .object({ + eventCursors: z + .array(eventCursorSchema) + .max(MAXIMUM_EVENT_CURSORS) + .optional(), + nonterminalOperationIds: z + .array(agentIdentifierSchema) + .max(MAXIMUM_NONTERMINAL_OPERATIONS) + .optional(), + acpBindingIds: z + .array(agentIdentifierSchema) + .max(MAXIMUM_ACP_BINDINGS) + .optional() + }) + .passthrough() + .transform((state) => { + const migrated = { ...state } + delete migrated.eventCursors + delete migrated.nonterminalOperationIds + delete migrated.acpBindingIds + return connectionStateSchema.parse(migrated) + }) + +const persistedControllerStateSchema = z + .object({ + version: z.literal(1), + controllerId: agentIdentifierSchema, + connections: z + .array(connectionStateSchema) + .max(MAXIMUM_CONNECTION_RECORDS) + }) + .strict() + .superRefine((state, context) => { + const keys = new Set() + for (let index = 0; index < state.connections.length; index += 1) { + const key = state.connections[index]!.cacheKey + if (keys.has(key)) { + context.addIssue({ + code: 'custom', + path: ['connections', index, 'cacheKey'], + message: 'Controller connection cache keys must be unique' + }) + } + keys.add(key) + } + }) + +const persistedControllerStateReadSchema = z + .object({ + version: z.literal(1), + controllerId: agentIdentifierSchema, + connections: z + .array(compatibleConnectionStateSchema) + .max(MAXIMUM_CONNECTION_RECORDS) + }) + .strict() + .transform((state) => persistedControllerStateSchema.parse(state)) + +export type ControllerAcpRecoveryBinding = z.infer< + typeof acpRecoveryBindingSchema +> +export type ControllerConnectionState = z.infer< + typeof connectionStateSchema +> +export type PersistedControllerState = z.infer< + typeof persistedControllerStateSchema +> + +export interface ControllerStateFile { + read(): Promise + write(value: PersistedControllerState): Promise +} + +export class JsonControllerStateFile implements ControllerStateFile { + constructor(readonly filePath: string) {} + + async read(): Promise { + try { + return JSON.parse(await readFile(this.filePath, 'utf8')) as unknown + } catch (error) { + if (isMissingFileError(error)) { + return undefined + } + throw error + } + } + + async write(value: PersistedControllerState): Promise { + await writeJsonFileAtomically(this.filePath, value) + } +} + +/** + * The persisted shape deliberately excludes SSH targets, credentials, + * operation payloads/results, event payloads, capabilities, and tokens. + */ +export class ControllerStateStore { + readonly #file: ControllerStateFile + #state?: PersistedControllerState + #load?: Promise + #updates: Promise = Promise.resolve() + #acpBindingWriteTimer?: ReturnType + #acpBindingsDirty = false + #disposed = false + + constructor(file: string | ControllerStateFile) { + this.#file = + typeof file === 'string' ? new JsonControllerStateFile(file) : file + } + + async getControllerId(): Promise { + return (await this.#ensureLoaded()).controllerId + } + + async getConnection( + cacheKey: string + ): Promise { + assertCacheKey(cacheKey) + const state = await this.#ensureLoaded() + const connection = state.connections.find( + (candidate) => candidate.cacheKey === cacheKey + ) + return connection === undefined + ? undefined + : structuredClone(connection) + } + + updateConnection(connection: ControllerConnectionState): Promise { + const parsed = connectionStateSchema.parse(connection) + return this.#queue(async () => { + const state = structuredClone(await this.#ensureLoaded()) + const index = state.connections.findIndex( + (candidate) => candidate.cacheKey === parsed.cacheKey + ) + if (index >= 0) { + state.connections[index] = parsed + } else { + if (state.connections.length >= MAXIMUM_CONNECTION_RECORDS) { + throw new Error('Controller connection state limit reached') + } + state.connections.push(parsed) + } + await this.#writeState(state) + }) + } + + updateConnectionPreservingRecovery( + connection: ControllerConnectionState + ): Promise { + const parsed = connectionStateSchema.parse(connection) + return this.#queue(async () => { + const state = structuredClone(await this.#ensureLoaded()) + const index = state.connections.findIndex( + (candidate) => candidate.cacheKey === parsed.cacheKey + ) + if (index < 0) { + throw new Error('Controller connection state is unavailable') + } + const current = state.connections[index]! + state.connections[index] = connectionStateSchema.parse({ + ...parsed, + acpBindings: current.acpBindings + }) + await this.#writeState(state) + }) + } + + updateAcpBinding( + cacheKey: string, + bindingId: string, + binding: ControllerAcpRecoveryBinding | undefined + ): Promise { + assertCacheKey(cacheKey) + const parsedBindingId = agentIdentifierSchema.parse(bindingId) + const parsed = + binding === undefined + ? undefined + : acpRecoveryBindingSchema.parse(binding) + if (parsed !== undefined && parsed.bindingId !== parsedBindingId) { + throw new Error('Controller ACP recovery binding identity mismatch') + } + return this.#queue(async () => { + const state = await this.#ensureLoaded() + const connection = state.connections.find( + (candidate) => candidate.cacheKey === cacheKey + ) + if (connection === undefined) { + throw new Error('Controller connection state is unavailable') + } + const previous = connection.acpBindings.find( + (candidate) => candidate.bindingId === parsedBindingId + ) + if ( + parsed !== undefined && + previous !== undefined && + ( + parsed.channelId !== previous.channelId || + parsed.channelEpoch !== previous.channelEpoch || + Object.keys(parsed.cursors).some( + (key) => + BigInt( + parsed.cursors[ + key as keyof typeof parsed.cursors + ] + ) < + BigInt( + previous.cursors[ + key as keyof typeof previous.cursors + ] + ) + ) + ) + ) { + throw new Error( + 'Controller ACP recovery identity or cursors cannot regress' + ) + } + connection.acpBindings = connection.acpBindings.filter( + (candidate) => candidate.bindingId !== parsedBindingId + ) + if (parsed !== undefined) { + if (connection.acpBindings.length >= MAXIMUM_ACP_BINDINGS) { + throw new Error('Controller ACP recovery state limit reached') + } + connection.acpBindings.push(parsed) + } + this.#acpBindingsDirty = true + this.#scheduleAcpBindingWrite() + }) + } + + removeConnection(cacheKey: string): Promise { + assertCacheKey(cacheKey) + return this.#queue(async () => { + const state = structuredClone(await this.#ensureLoaded()) + const next = state.connections.filter( + (connection) => connection.cacheKey !== cacheKey + ) + if (next.length === state.connections.length) { + return + } + state.connections = next + await this.#writeState(state) + }) + } + + invalidateHost(hostId: string): Promise { + const parsedHostId = agentIdentifierSchema.parse(hostId) + return this.#queue(async () => { + const state = structuredClone(await this.#ensureLoaded()) + const next = state.connections.filter( + (connection) => connection.hostId !== parsedHostId + ) + if (next.length === state.connections.length) { + return + } + state.connections = next + await this.#writeState(state) + }) + } + + async flush(): Promise { + await this.#enqueue(async () => { + await this.#writePendingAcpBindings() + }) + } + + dispose(): void { + if (this.#acpBindingsDirty) { + clearTimeout(this.#acpBindingWriteTimer) + this.#acpBindingWriteTimer = undefined + void this.#enqueue(async () => { + await this.#writePendingAcpBindings() + }).catch(() => undefined) + } + this.#disposed = true + } + + async #ensureLoaded(): Promise { + if (this.#state !== undefined) { + return this.#state + } + if (this.#load !== undefined) { + return await this.#load + } + this.#load = this.#loadState() + try { + return await this.#load + } finally { + this.#load = undefined + } + } + + async #loadState(): Promise { + const value = await this.#file.read() + const state = + value === undefined + ? { + version: 1 as const, + controllerId: randomUUID(), + connections: [] + } + : persistedControllerStateReadSchema.parse(value) + if (value === undefined) { + await this.#file.write(state) + } + this.#state = state + return state + } + + #queue(operation: () => Promise): Promise { + if (this.#disposed) { + return Promise.reject(new Error('Controller state store is closed')) + } + return this.#enqueue(operation) + } + + #enqueue(operation: () => Promise): Promise { + const result = this.#updates.then(operation, operation) + this.#updates = result.then( + () => undefined, + () => undefined + ) + return result + } + + #scheduleAcpBindingWrite(): void { + if (this.#acpBindingWriteTimer !== undefined) { + return + } + this.#acpBindingWriteTimer = setTimeout(() => { + this.#acpBindingWriteTimer = undefined + void this.#queue(async () => { + await this.#writePendingAcpBindings() + }).catch(() => undefined) + }, ACP_BINDING_WRITE_DELAY_MS) + } + + async #writePendingAcpBindings(): Promise { + if (!this.#acpBindingsDirty) { + return + } + const state = await this.#ensureLoaded() + await this.#file.write(state) + this.#acpBindingsDirty = false + } + + async #writeState(state: PersistedControllerState): Promise { + await this.#file.write(state) + this.#state = state + this.#acpBindingsDirty = false + if (this.#acpBindingWriteTimer !== undefined) { + clearTimeout(this.#acpBindingWriteTimer) + this.#acpBindingWriteTimer = undefined + } + } +} + +function assertCacheKey(value: string): void { + if (!/^[a-f0-9]{64}$/u.test(value)) { + throw new Error('Invalid controller state cache key') + } +} diff --git a/src/main/remote-agent/index.ts b/src/main/remote-agent/index.ts new file mode 100644 index 00000000..fd1a6c1b --- /dev/null +++ b/src/main/remote-agent/index.ts @@ -0,0 +1,97 @@ +export { + agentManifestSignaturePayload, + canonicalAgentManifestBytes, + readAgentReleaseKeyRegistry, + readAgentRuntimeLock, + verifyAgentBundleDirectory, + verifyAgentManifestSignature, + verifyBundledAgentResources +} from './agent-bundle-verifier' +export type { + AgentVerificationEnvironment, + VerifiedAgentBundle, + VerifyAgentBundleOptions, + VerifyBundledAgentOptions +} from './agent-bundle-verifier' +export { + getBundledAgentDirectory, + resolveBundledAgentResourcePaths +} from './bundled-agent-resources' +export type { + BundledAgentResourceEnvironment, + BundledAgentResourcePaths +} from './bundled-agent-resources' +export { + AgentAttachTransport, + AgentAttachTransportError +} from './agent-attach-transport' +export type { AgentAttachTransportOptions } from './agent-attach-transport' +export { + AGENT_PROTOCOL_METHODS, + AgentProtocolBinaryChannel, + AgentProtocolClient, + AgentProtocolClientError, + AgentRpcError +} from './agent-protocol-client' +export type { + AgentConsumedFrame, + AgentProtocolMethod, + AgentProtocolParams, + AgentProtocolRequestOptions, + AgentProtocolResult +} from './agent-protocol-client' +export { + ControllerStateStore, + JsonControllerStateFile +} from './controller-state-store' +export type { + ControllerConnectionState, + ControllerStateFile, + PersistedControllerState +} from './controller-state-store' +export { + RemoteAgentConnectionError, + RemoteAgentConnectionManager +} from './remote-agent-connection-manager' +export type { + RemoteAgentConnection, + RemoteAgentConnectionIdentity, + RemoteAgentConnectionState, + RemoteAgentInstallationIdentity, + RemoteAgentSshPool, + RemoteAgentTargetResolver +} from './remote-agent-connection-manager' +export { + ProtocolRemoteWorkspaceTransport, + ProtocolRemoteWorkspaceTransportError +} from './protocol-remote-workspace-transport' +export type { + DurableRemoteWorkspaceValidation, + RemoteWorkspaceInstallationIdentityResolver, + RemoteWorkspaceInstallationResolution +} from './protocol-remote-workspace-transport' +export { + AgentInstallationError, + AgentInstallationManager +} from './agent-installation-manager' +export type { + AgentInstallationBundleLoader, + AgentInstallationIdentity, + AgentInstallationPhase, + AgentInstallationRequestOptions, + AgentInstallationTargetResolver +} from './agent-installation-manager' +export { RemoteAgentServices } from './remote-agent-services' +export type { RemoteAgentServicesOptions } from './remote-agent-services' +export { + RemoteProjectSaveService, + UnavailableRemoteProjectRuntimeValidator +} from './remote-project-save-service' +export type { + RemoteProjectSaveOwner, + RemoteProjectSaveServiceOptions, + RemoteProjectRuntimeValidationEvidence, + RemoteProjectRuntimeValidationInput, + RemoteProjectRuntimeValidationLease, + RemoteProjectRuntimeValidator +} from './remote-project-save-service' diff --git a/src/main/remote-agent/main-model-bridge-dispatcher.test.ts b/src/main/remote-agent/main-model-bridge-dispatcher.test.ts new file mode 100644 index 00000000..615199d5 --- /dev/null +++ b/src/main/remote-agent/main-model-bridge-dispatcher.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + MainModelBridgeDispatchError, + dispatchFailureCode, + dispatchFailureIsUncertain, + validateMainModelBridgeDispatchResponse +} from './main-model-bridge-dispatcher' + +describe('Main model bridge dispatcher boundary', () => { + it('treats unclassified dispatch failures as outcome-unknown', () => { + expect(dispatchFailureIsUncertain(new Error('secret'))).toBe(true) + expect(dispatchFailureCode(new Error('secret'))).toBe( + 'dispatch-failed' + ) + }) + + it('permits only an explicit pre-dispatch failure to stay definitive', () => { + const error = new MainModelBridgeDispatchError( + 'policy-rejected', + { outcomeUnknown: false, postDispatch: false } + ) + expect(dispatchFailureIsUncertain(error)).toBe(false) + expect(dispatchFailureCode(error)).toBe('policy-rejected') + expect( + dispatchFailureIsUncertain( + new MainModelBridgeDispatchError('network-failed', { + outcomeUnknown: true, + postDispatch: true + }) + ) + ).toBe(true) + }) + + it('strictly validates gateway responses', () => { + expect( + validateMainModelBridgeDispatchResponse({ + status: 200, + headers: {}, + bodyBase64: '' + }) + ).toEqual({ + status: 200, + headers: {}, + bodyBase64: '' + }) + expect(() => + validateMainModelBridgeDispatchResponse({ + status: 99, + headers: {}, + bodyBase64: '' + }) + ).toThrow() + }) +}) diff --git a/src/main/remote-agent/main-model-bridge-dispatcher.ts b/src/main/remote-agent/main-model-bridge-dispatcher.ts new file mode 100644 index 00000000..e083875d --- /dev/null +++ b/src/main/remote-agent/main-model-bridge-dispatcher.ts @@ -0,0 +1,85 @@ +import type { + ModelBridgeIdentity, + ModelBridgePolicy +} from '../../shared/model-bridge-contracts' +import { + remoteModelGatewayResponseSchema, + type RemoteModelGatewayRequest, + type RemoteModelGatewayResponse +} from '../../shared/remote-model-gateway-contracts' + +export type MainModelBridgeDispatchInput = { + identity: ModelBridgeIdentity + policy: ModelBridgePolicy + request: RemoteModelGatewayRequest + requestDigest: string +} + +export type MainModelBridgeDispatch = ( + input: MainModelBridgeDispatchInput, + signal: AbortSignal +) => Promise + +export type MainModelBridgeDelivered = ( + identity: ModelBridgeIdentity, + requestDigest: string +) => void | Promise + +export type MainModelBridgeFinalizePrompt = ( + identity: Pick< + ModelBridgeIdentity, + 'bindingId' | 'promptOperationId' + > +) => void | Promise + +export type MainModelBridgePoison = ( + bindingId: string, + promptOperationId: string, + reason: string +) => void | Promise + +/** + * Dispatch implementations may explicitly identify a failure that happened + * before any provider side effect. All unclassified dispatch failures are + * conservatively treated as outcome-unknown by the session. + */ +export class MainModelBridgeDispatchError extends Error { + constructor( + readonly code: string, + options: { + outcomeUnknown: boolean + postDispatch: boolean + cause?: unknown + } + ) { + super('Remote model dispatch failed', { + cause: options.cause + }) + this.name = 'MainModelBridgeDispatchError' + this.outcomeUnknown = options.outcomeUnknown + this.postDispatch = options.postDispatch + } + + readonly outcomeUnknown: boolean + readonly postDispatch: boolean +} + +export function validateMainModelBridgeDispatchResponse( + value: unknown +): RemoteModelGatewayResponse { + return remoteModelGatewayResponseSchema.parse(value) +} + +export function dispatchFailureIsUncertain(error: unknown): boolean { + return !( + error instanceof MainModelBridgeDispatchError && + !error.outcomeUnknown && + !error.postDispatch + ) +} + +export function dispatchFailureCode(error: unknown): string { + return error instanceof MainModelBridgeDispatchError + ? error.code + : 'dispatch-failed' +} diff --git a/src/main/remote-agent/main-model-bridge-session.test.ts b/src/main/remote-agent/main-model-bridge-session.test.ts new file mode 100644 index 00000000..a278d1e4 --- /dev/null +++ b/src/main/remote-agent/main-model-bridge-session.test.ts @@ -0,0 +1,572 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { AGENT_PROTOCOL_LIMITS } from '../../shared/agent-protocol' +import { + REMOTE_MODEL_GATEWAY_LIMITS +} from '../../shared/remote-model-gateway-contracts' +import { + createModelBridgeRequestMessage, + decodeModelBridgeMessage, + encodeModelBridgeMessage, + modelBridgeDeliveryAckMessageSchema, + type ModelBridgeIdentity, + type ModelBridgePolicy +} from '../../shared/model-bridge-contracts' +import type { RuntimeProtocolBinaryFrame } from './protocol-remote-runtime-channel' +import { + MainModelBridgeDispatchError +} from './main-model-bridge-dispatcher' +import { + MainModelBridgeSession +} from './main-model-bridge-session' + +const profileDigest = `sha256:${'a'.repeat(64)}` +const policy: ModelBridgePolicy = { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: profileDigest, + supportsImageInput: false +} + +class FakeBlobChannel { + readonly channelId = `model-${randomUUID()}` + readonly channelEpoch = '19' + readonly sent: Uint8Array[] = [] + consumed = 0 + closed = false + closeNotifications = 0 + #frames: RuntimeProtocolBinaryFrame[] = [] + #waiters: Array<{ + resolve: (frame: RuntimeProtocolBinaryFrame) => void + reject: (error: unknown) => void + signal?: AbortSignal + abort?: () => void + }> = [] + #listeners = new Set<() => void>() + + send(payload: Uint8Array, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + this.sent.push(new Uint8Array(payload)) + return Promise.resolve() + } + + receive(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const frame = this.#frames.shift() + if (frame !== undefined) { + return Promise.resolve(frame) + } + return new Promise((resolve, reject) => { + const waiter = { resolve, reject, signal } + if (signal !== undefined) { + const abort = (): void => { + const index = this.#waiters.indexOf(waiter) + if (index >= 0) { + this.#waiters.splice(index, 1) + } + reject(signal.reason) + } + Object.assign(waiter, { abort }) + signal.addEventListener('abort', abort, { once: true }) + } + this.#waiters.push(waiter) + }) + } + + push(payload: Uint8Array): void { + let consumed = false + const frame: RuntimeProtocolBinaryFrame = { + payload: new Uint8Array(payload), + sequence: String(this.consumed + this.#frames.length + 1), + consume: async () => { + if (!consumed) { + consumed = true + this.consumed += 1 + } + } + } + const waiter = this.#waiters.shift() + if (waiter === undefined) { + this.#frames.push(frame) + return + } + if (waiter.abort !== undefined) { + waiter.signal?.removeEventListener('abort', waiter.abort) + } + waiter.resolve(frame) + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + for (const waiter of this.#waiters.splice(0)) { + waiter.reject(new Error('transport closed')) + } + for (const listener of this.#listeners) { + listener() + } + } + + async closeWithNotification(): Promise { + this.closeNotifications += 1 + this.close() + } + + onClose(listener: () => void): () => void { + this.#listeners.add(listener) + return () => this.#listeners.delete(listener) + } +} + +function identity(roundIndex: number): ModelBridgeIdentity { + return { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: `request-${roundIndex}`, + roundIndex, + modelProfileDigest: profileDigest, + messageId: `message-${roundIndex}` + } +} + +function request(bodyBytes = 1) { + return { + method: 'POST' as const, + path: '/v1/responses' as const, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.alloc(bodyBytes, 7).toString('base64') + } +} + +const response = { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from('{"ok":true}').toString('base64') +} + +async function pushRequest( + channel: FakeBlobChannel, + roundIndex: number, + options: { + bodyBytes?: number + requestPolicy?: ModelBridgePolicy + } = {} +) { + const message = await createModelBridgeRequestMessage({ + identity: { + ...identity(roundIndex), + modelProfileDigest: + options.requestPolicy?.modelProfileDigest ?? profileDigest + }, + policy: options.requestPolicy ?? policy, + request: request(options.bodyBytes) + }) + const payload = await encodeModelBridgeMessage(message) + channel.push(payload) + return { message, payload } +} + +async function sentMessage( + channel: FakeBlobChannel, + offset = 0 +) { + await vi.waitFor(() => + expect(channel.sent.length).toBeGreaterThan(offset) + ) + return { + message: await decodeModelBridgeMessage(channel.sent[offset]!), + nextOffset: offset + 1 + } +} + +async function acknowledge( + channel: FakeBlobChannel, + requestIdentity: ModelBridgeIdentity, + requestDigest: string +): Promise { + const acknowledgment = modelBridgeDeliveryAckMessageSchema.parse({ + protocol: 'goodbuddy-model-bridge-v1', + kind: 'response-delivered', + identity: requestIdentity, + requestDigest + }) + channel.push(await encodeModelBridgeMessage(acknowledgment)) +} + +function fixture( + overrides: Partial< + ConstructorParameters[0] + > & { channel?: FakeBlobChannel } = {} +) { + const channel = overrides.channel ?? new FakeBlobChannel() + const dispatch = vi.fn(async () => response) + const onDelivered = vi.fn(async () => undefined) + const finalizePrompt = vi.fn(async () => undefined) + const poison = vi.fn(async () => undefined) + const session = new MainModelBridgeSession({ + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1' + }, + policy, + channel, + isCurrentGeneration: () => true, + dispatch, + onDelivered, + finalizePrompt, + poison, + requestTimeoutMs: 1_000, + closeTimeoutMs: 100, + ...overrides + }) + return { + channel, + dispatch, + onDelivered, + finalizePrompt, + poison, + session + } +} + +describe('MainModelBridgeSession', () => { + it('reports provider uncertainty only after a model request begins', async () => { + const dispatch = vi.fn( + () => new Promise(() => undefined) + ) + const bridge = fixture({ dispatch }) + expect( + bridge.session.providerDeliveryMayBeUncertain + ).toBe(false) + + await pushRequest(bridge.channel, 0) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect( + bridge.session.providerDeliveryMayBeUncertain + ).toBe(true) + + await bridge.session.close('prompt-cancelled') + }) + + it('accepts one bounded request message and records delivery', async () => { + const bridge = fixture() + const sent = await pushRequest(bridge.channel, 0, { + bodyBytes: 600 * 1024 + }) + expect(sent.payload.byteLength).toBeGreaterThan(600 * 1024) + expect(sent.payload.byteLength).toBeLessThanOrEqual( + AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + ) + const outgoing = await sentMessage(bridge.channel) + expect(outgoing.message).toMatchObject({ + kind: 'response', + identity: sent.message.identity, + requestDigest: sent.message.requestDigest, + response + }) + expect(bridge.channel.consumed).toBe(1) + + await acknowledge( + bridge.channel, + sent.message.identity, + sent.message.requestDigest + ) + await vi.waitFor(() => + expect(bridge.onDelivered).toHaveBeenCalledOnce() + ) + await expect(bridge.session.close()).resolves.toEqual({ + clean: true, + poisoned: false + }) + expect(bridge.finalizePrompt).toHaveBeenCalledOnce() + expect(bridge.poison).not.toHaveBeenCalled() + expect(bridge.channel.closeNotifications).toBe(1) + }) + + it('sends one bounded response message and records its delivery ACK', async () => { + const largeResponse = { + ...response, + bodyBase64: Buffer.alloc(600 * 1024, 3).toString('base64') + } + const bridge = fixture({ + dispatch: vi.fn(async () => largeResponse) + }) + const sent = await pushRequest(bridge.channel, 0) + const outgoing = await sentMessage(bridge.channel) + expect(outgoing.nextOffset).toBe(1) + expect(outgoing.message).toMatchObject({ + kind: 'response', + response: largeResponse + }) + + await acknowledge( + bridge.channel, + sent.message.identity, + sent.message.requestDigest + ) + await vi.waitFor(() => + expect(bridge.onDelivered).toHaveBeenCalledOnce() + ) + await expect(bridge.session.close()).resolves.toEqual({ + clean: true, + poisoned: false + }) + }) + + it('delivers a maximum-sized response in one bounded message', async () => { + const channel = new FakeBlobChannel() + const largeResponse = { + ...response, + bodyBase64: Buffer.alloc( + REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes, + 3 + ).toString('base64') + } + const bridge = fixture({ + channel, + dispatch: vi.fn(async () => largeResponse), + requestTimeoutMs: 10_000 + }) + const requestMessage = await pushRequest(channel, 0) + const outgoing = await sentMessage(channel) + expect(outgoing.nextOffset).toBe(1) + expect(channel.sent).toHaveLength(1) + expect(channel.sent[0]!.byteLength).toBeLessThanOrEqual( + AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes + ) + expect(outgoing.message).toMatchObject({ + kind: 'response', + response: largeResponse + }) + await acknowledge( + channel, + requestMessage.message.identity, + requestMessage.message.requestDigest + ) + await vi.waitFor(() => + expect(bridge.onDelivered).toHaveBeenCalledOnce() + ) + expect(bridge.poison).not.toHaveBeenCalled() + await expect(bridge.session.close()).resolves.toEqual({ + clean: true, + poisoned: false + }) + }) + + it('reports prompt authority finalization failure as poisoned', async () => { + const bridge = fixture({ + finalizePrompt: async () => { + throw new Error('SQLite unavailable') + } + }) + + await expect(bridge.session.close()).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(bridge.poison).toHaveBeenCalledWith( + 'binding-1', + 'prompt-1', + 'model-prompt-finalization-failed' + ) + }) + + it('reports prompt authority finalization timeout as poisoned', async () => { + const bridge = fixture({ + finalizePrompt: async () => await new Promise(() => undefined), + closeTimeoutMs: 10 + }) + + await expect(bridge.session.close()).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(bridge.poison).toHaveBeenCalledWith( + 'binding-1', + 'prompt-1', + 'model-prompt-finalization-failed' + ) + }) + + it('enforces exact policy before dispatch with a definitive error', async () => { + const bridge = fixture() + const mismatched = { + ...policy, + model: 'another-model', + modelProfileDigest: `sha256:${'b'.repeat(64)}` + } + await pushRequest(bridge.channel, 0, { + requestPolicy: mismatched + }) + const outgoing = await sentMessage(bridge.channel) + expect(outgoing.message).toMatchObject({ + kind: 'error', + error: { + poisoned: false, + outcomeUnknown: false, + retryable: false + } + }) + expect(bridge.dispatch).not.toHaveBeenCalled() + expect(bridge.poison).not.toHaveBeenCalled() + await bridge.session.close() + }) + + it('rejects an exact binding or prompt identity mismatch before dispatch', async () => { + const bridge = fixture() + const message = await createModelBridgeRequestMessage({ + identity: { + ...identity(0), + bindingId: 'different-binding' + }, + policy, + request: request() + }) + bridge.channel.push(await encodeModelBridgeMessage(message)) + const outgoing = await sentMessage(bridge.channel) + expect(outgoing.message).toMatchObject({ + kind: 'error', + error: { + code: 'request-identity-mismatch', + poisoned: false + } + }) + expect(bridge.dispatch).not.toHaveBeenCalled() + expect(bridge.poison).not.toHaveBeenCalled() + await bridge.session.close() + }) + + it('supports sequential rounds only after each delivery ACK', async () => { + const bridge = fixture() + let offset = 0 + for (let round = 0; round < 2; round += 1) { + const sent = await pushRequest(bridge.channel, round) + const outgoing = await sentMessage(bridge.channel, offset) + offset = outgoing.nextOffset + await acknowledge( + bridge.channel, + sent.message.identity, + sent.message.requestDigest + ) + } + await vi.waitFor(() => + expect(bridge.onDelivered).toHaveBeenCalledTimes(2) + ) + expect(bridge.dispatch).toHaveBeenCalledTimes(2) + await bridge.session.close() + }) + + it('poisons once when another request arrives before delivery ACK', async () => { + const bridge = fixture() + await pushRequest(bridge.channel, 0) + await sentMessage(bridge.channel) + await pushRequest(bridge.channel, 1) + await expect(bridge.session.closed).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(bridge.dispatch).toHaveBeenCalledOnce() + expect(bridge.poison).toHaveBeenCalledOnce() + }) + + it('poisons once when transport closes after response and before ACK', async () => { + const bridge = fixture() + await pushRequest(bridge.channel, 0) + await sentMessage(bridge.channel) + bridge.channel.close() + await expect(bridge.session.closed).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(bridge.poison).toHaveBeenCalledOnce() + }) + + it('allows an in-flight delivery ACK to finish during clean prompt close', async () => { + const bridge = fixture() + const sent = await pushRequest(bridge.channel, 0) + await sentMessage(bridge.channel) + const closing = bridge.session.close('prompt-completed') + await acknowledge( + bridge.channel, + sent.message.identity, + sent.message.requestDigest + ) + await expect(closing).resolves.toEqual({ + clean: true, + poisoned: false + }) + expect(bridge.onDelivered).toHaveBeenCalledOnce() + expect(bridge.poison).not.toHaveBeenCalled() + }) + + it('keeps an explicitly pre-dispatch failure definitive without consuming the round', async () => { + const definitive = new MainModelBridgeDispatchError( + 'policy-rejected', + { outcomeUnknown: false, postDispatch: false } + ) + const dispatch = vi + .fn() + .mockRejectedValueOnce(definitive) + .mockResolvedValueOnce(response) + const bridge = fixture({ dispatch }) + await pushRequest(bridge.channel, 0) + const first = await sentMessage(bridge.channel) + expect(first.message).toMatchObject({ + kind: 'error', + error: { poisoned: false, outcomeUnknown: false } + }) + const secondRequest = await pushRequest(bridge.channel, 0) + const second = await sentMessage( + bridge.channel, + first.nextOffset + ) + expect(second.message.kind).toBe('response') + await acknowledge( + bridge.channel, + secondRequest.message.identity, + secondRequest.message.requestDigest + ) + await vi.waitFor(() => + expect(bridge.onDelivered).toHaveBeenCalledOnce() + ) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(bridge.poison).not.toHaveBeenCalled() + await bridge.session.close() + }) + + it('times out an uncooperative dispatch, poisons, and never retries', async () => { + const dispatch = vi.fn( + () => new Promise(() => undefined) + ) + const bridge = fixture({ + dispatch, + requestTimeoutMs: 10 + }) + await pushRequest(bridge.channel, 0) + await expect(bridge.session.closed).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(bridge.poison).toHaveBeenCalledOnce() + }) + + it('cancels an active dispatch by poisoning once without retry', async () => { + const dispatch = vi.fn( + () => new Promise(() => undefined) + ) + const bridge = fixture({ dispatch }) + await pushRequest(bridge.channel, 0) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await expect( + bridge.session.close('prompt-cancelled') + ).resolves.toEqual({ + clean: false, + poisoned: true + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(bridge.poison).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/remote-agent/main-model-bridge-session.ts b/src/main/remote-agent/main-model-bridge-session.ts new file mode 100644 index 00000000..519d2f5e --- /dev/null +++ b/src/main/remote-agent/main-model-bridge-session.ts @@ -0,0 +1,582 @@ +import { isDeepStrictEqual } from 'node:util' +import { + MODEL_BRIDGE_LIMITS, + MODEL_BRIDGE_PROTOCOL, + decodeModelBridgeMessage, + encodeModelBridgeMessage, + modelBridgeErrorMessageSchema, + modelBridgePolicySchema, + modelBridgeResponseMessageSchema, + type ModelBridgeIdentity, + type ModelBridgePolicy, + type ModelBridgeRequestMessage +} from '../../shared/model-bridge-contracts' +import type { RuntimeProtocolBinaryChannel } from './protocol-remote-runtime-channel' +import { + dispatchFailureCode, + dispatchFailureIsUncertain, + validateMainModelBridgeDispatchResponse, + type MainModelBridgeDelivered, + type MainModelBridgeDispatch, + type MainModelBridgeFinalizePrompt, + type MainModelBridgePoison +} from './main-model-bridge-dispatcher' + +const DEFAULT_REQUEST_TIMEOUT_MS = 180_000 +const DEFAULT_CLOSE_TIMEOUT_MS = 2_000 + +export type MainModelBridgeSessionIdentity = { + bindingId: string + promptOperationId: string +} + +export type MainModelBridgeSessionOptions = { + identity: MainModelBridgeSessionIdentity + policy: ModelBridgePolicy + channel: RuntimeProtocolBinaryChannel + isCurrentGeneration(): boolean + dispatch: MainModelBridgeDispatch + onDelivered: MainModelBridgeDelivered + finalizePrompt: MainModelBridgeFinalizePrompt + poison: MainModelBridgePoison + requestTimeoutMs?: number + closeTimeoutMs?: number +} + +export type MainModelBridgeSessionCloseResult = { + clean: boolean + poisoned: boolean +} + +type RequestPhase = + | 'receiving' + | 'dispatching' + | 'sending-response' + | 'awaiting-delivery' + +type ActiveExchange = { + request: ModelBridgeRequestMessage + phase: RequestPhase +} + +export class MainModelBridgeSession { + readonly version = MODEL_BRIDGE_PROTOCOL + readonly channelId: string + readonly channelEpoch: string + readonly policy: ModelBridgePolicy + readonly closed: Promise + + readonly #identity: MainModelBridgeSessionIdentity + readonly #channel: RuntimeProtocolBinaryChannel + readonly #isCurrentGeneration: () => boolean + readonly #dispatch: MainModelBridgeDispatch + readonly #onDelivered: MainModelBridgeDelivered + readonly #finalizePrompt: MainModelBridgeFinalizePrompt + readonly #poisonCallback: MainModelBridgePoison + readonly #requestTimeoutMs: number + readonly #closeTimeoutMs: number + readonly #lifetime = new AbortController() + readonly #closedResolve: ( + result: MainModelBridgeSessionCloseResult + ) => void + #nextRoundIndex = 0 + #active?: ActiveExchange + #receivingRequest = false + #poisoned = false + #closing = false + #closePromise?: Promise + #poisonPromise?: Promise + #idleWaiters = new Set<() => void>() + #unsubscribeClose: () => void = () => undefined + + constructor(options: MainModelBridgeSessionOptions) { + this.#identity = options.identity + this.policy = modelBridgePolicySchema.parse(options.policy) + this.#channel = options.channel + this.channelId = options.channel.channelId + this.channelEpoch = options.channel.channelEpoch + this.#isCurrentGeneration = options.isCurrentGeneration + this.#dispatch = options.dispatch + this.#onDelivered = options.onDelivered + this.#finalizePrompt = options.finalizePrompt + this.#poisonCallback = options.poison + this.#requestTimeoutMs = boundedTimeout( + options.requestTimeoutMs, + DEFAULT_REQUEST_TIMEOUT_MS + ) + this.#closeTimeoutMs = boundedTimeout( + options.closeTimeoutMs, + DEFAULT_CLOSE_TIMEOUT_MS + ) + let resolveClosed: ( + result: MainModelBridgeSessionCloseResult + ) => void = () => undefined + this.closed = new Promise((resolve) => { + resolveClosed = resolve + }) + this.#closedResolve = resolveClosed + this.#unsubscribeClose = this.#channel.onClose(() => { + void this.#closeFromTransport() + }) + void this.#run() + } + + get preparation(): { + version: typeof MODEL_BRIDGE_PROTOCOL + channelId: string + channelEpoch: string + policy: ModelBridgePolicy + } { + return { + version: this.version, + channelId: this.channelId, + channelEpoch: this.channelEpoch, + policy: this.policy + } + } + + get providerDeliveryMayBeUncertain(): boolean { + return this.#active !== undefined || this.#receivingRequest + } + + async close(reason = 'prompt-completed'): Promise { + if ( + reason === 'prompt-completed' && + this.#active?.phase === 'awaiting-delivery' + ) { + await this.#waitForIdle() + } + return await this.#beginClose(true, reason) + } + + async #run(): Promise { + try { + while (!this.#closing) { + this.#assertCurrent() + const message = await this.#receiveMessage() + if (message.kind !== 'request') { + throw new Error( + 'Model bridge received a message without an active request' + ) + } + const request = message + const preDispatchError = this.#validateRequest(request) + if (preDispatchError !== undefined) { + await this.#sendError( + request.identity, + request.requestDigest, + preDispatchError, + false + ) + continue + } + + this.#active = { request, phase: 'dispatching' } + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort( + new DOMException( + 'Remote model bridge request timed out', + 'TimeoutError' + ) + ) + }, this.#requestTimeoutMs) + timeout.unref?.() + const abort = (): void => controller.abort(this.#lifetime.signal.reason) + this.#lifetime.signal.addEventListener('abort', abort, { + once: true + }) + try { + let response: unknown + try { + response = await raceWithAbort( + this.#dispatch( + { + identity: request.identity, + policy: request.policy, + request: request.request, + requestDigest: request.requestDigest + }, + controller.signal + ), + controller.signal + ) + } catch (error) { + const uncertain = + controller.signal.aborted || + dispatchFailureIsUncertain(error) + if (uncertain) { + await this.#poisonAndWait( + 'model-dispatch-outcome-unknown' + ) + } + await this.#sendError( + request.identity, + request.requestDigest, + dispatchFailureCode(error), + uncertain + ) + this.#clearActive() + if (uncertain) { + await this.#beginClose(true, 'model-dispatch-outcome-unknown') + return + } + continue + } + + let parsedResponse + try { + parsedResponse = + validateMainModelBridgeDispatchResponse(response) + } catch { + await this.#poisonAndWait('model-response-invalid') + await this.#sendError( + request.identity, + request.requestDigest, + 'response-invalid', + true + ) + await this.#beginClose(true, 'model-response-invalid') + return + } + this.#active.phase = 'sending-response' + const responseMessage = modelBridgeResponseMessageSchema.parse({ + protocol: MODEL_BRIDGE_PROTOCOL, + kind: 'response', + identity: request.identity, + requestDigest: request.requestDigest, + response: parsedResponse + }) + try { + await this.#sendMessage(responseMessage, controller.signal) + } catch { + await this.#poisonAndWait( + 'model-response-send-failed' + ) + await this.#beginClose(false, 'model-response-send-failed') + return + } + this.#active.phase = 'awaiting-delivery' + const acknowledgment = await this.#receiveMessage( + request.identity, + request.requestDigest + ) + if (acknowledgment.kind !== 'response-delivered') { + await this.#poisonAndWait( + 'model-delivery-ack-invalid' + ) + await this.#beginClose(true, 'model-delivery-ack-invalid') + return + } + await raceWithAbort( + Promise.resolve( + this.#onDelivered( + request.identity, + request.requestDigest + ) + ), + controller.signal + ) + this.#nextRoundIndex += 1 + this.#clearActive() + } finally { + clearTimeout(timeout) + this.#lifetime.signal.removeEventListener('abort', abort) + } + } + } catch { + if (!this.#closing) { + if (this.#active !== undefined) { + await this.#poisonAndWait( + this.#active.phase === 'awaiting-delivery' + ? 'model-delivery-ack-missing' + : 'model-bridge-transport-lost' + ) + } + await this.#beginClose(false, 'model-bridge-transport-lost') + } + } + } + + async #receiveMessage( + expectedIdentity?: ModelBridgeIdentity, + expectedRequestDigest?: string + ) { + const frame = await this.#channel.receive(this.#lifetime.signal) + if (expectedIdentity === undefined) { + this.#receivingRequest = true + } + try { + this.#assertCurrent() + const message = await decodeModelBridgeMessage(frame.payload, { + expectedIdentity, + expectedRequestDigest, + maximumMessageBytes: MODEL_BRIDGE_LIMITS.maximumMessageBytes + }) + await frame.consume() + if (expectedIdentity === undefined) { + this.#receivingRequest = false + } + return message + } catch (error) { + await frame.consume().catch(() => undefined) + throw error + } + } + + #validateRequest(request: ModelBridgeRequestMessage): string | undefined { + if ( + request.identity.bindingId !== this.#identity.bindingId || + request.identity.promptOperationId !== + this.#identity.promptOperationId || + request.identity.roundIndex !== this.#nextRoundIndex || + request.identity.modelProfileDigest !== + this.policy.modelProfileDigest || + !isDeepStrictEqual(request.policy, this.policy) + ) { + return 'request-identity-mismatch' + } + return undefined + } + + async #sendError( + identity: ModelBridgeIdentity, + requestDigest: string, + code: string, + uncertain: boolean + ): Promise { + const message = modelBridgeErrorMessageSchema.parse({ + protocol: MODEL_BRIDGE_PROTOCOL, + kind: 'error', + identity, + requestDigest, + error: { + code: safeErrorCode(code), + message: uncertain + ? 'Remote model request outcome is unknown' + : 'Remote model request was rejected', + retryable: false, + poisoned: uncertain, + outcomeUnknown: uncertain + } + }) + await this.#sendMessage(message, this.#lifetime.signal) + } + + async #sendMessage( + message: unknown, + signal: AbortSignal + ): Promise { + const payload = await encodeModelBridgeMessage(message) + this.#assertCurrent() + await this.#channel.send(payload, signal) + } + + #assertCurrent(): void { + if ( + this.#closing || + this.#lifetime.signal.aborted || + !this.#isCurrentGeneration() + ) { + throw new Error('Model bridge transport generation is stale') + } + } + + #poison(reason: string): Promise { + if (this.#poisonPromise !== undefined) { + return this.#poisonPromise + } + this.#poisoned = true + this.#poisonPromise = Promise.resolve() + .then(() => + this.#poisonCallback( + this.#identity.bindingId, + this.#identity.promptOperationId, + reason + ) + ) + .then(() => undefined) + .catch(() => undefined) + return this.#poisonPromise + } + + async #poisonAndWait(reason: string): Promise { + await settleWithin( + this.#poison(reason), + this.#closeTimeoutMs + ).catch(() => undefined) + } + + #closeFromTransport(): Promise { + return this.#beginClose(false, 'model-bridge-transport-lost') + } + + #beginClose( + notifyPeer: boolean, + reason: string + ): Promise { + if (this.#closePromise !== undefined) { + return this.#closePromise + } + this.#closing = true + if (this.#active !== undefined || this.#receivingRequest) { + void this.#poison(reason) + } + this.#lifetime.abort( + new DOMException('Model bridge closed', 'AbortError') + ) + this.#closePromise = Promise.resolve().then(async () => { + try { + if ( + !this.#poisoned && + this.#active === undefined && + !this.#receivingRequest + ) { + try { + await settleWithin( + Promise.resolve( + this.#finalizePrompt(this.#identity) + ), + this.#closeTimeoutMs + ) + } catch { + await this.#poisonAndWait( + 'model-prompt-finalization-failed' + ) + } + } + if (notifyPeer) { + const closable = this.#channel as RuntimeProtocolBinaryChannel & { + closeWithNotification?: () => Promise + } + if (closable.closeWithNotification !== undefined) { + await settleWithin( + closable.closeWithNotification(), + this.#closeTimeoutMs + ) + } else { + this.#channel.close() + } + } else { + this.#channel.close() + } + } catch { + this.#channel.close() + if (this.#active !== undefined) { + await this.#poisonAndWait('model-bridge-close-failed') + } + } + if (this.#poisonPromise !== undefined) { + await settleWithin( + this.#poisonPromise, + this.#closeTimeoutMs + ).catch(() => undefined) + } + this.#unsubscribeClose() + const result = { + clean: !this.#poisoned, + poisoned: this.#poisoned + } + this.#closedResolve(result) + return result + }) + return this.#closePromise + } + + #clearActive(): void { + this.#active = undefined + for (const resolve of this.#idleWaiters) { + resolve() + } + this.#idleWaiters.clear() + } + + async #waitForIdle(): Promise { + if (this.#active === undefined) { + return + } + let timer: ReturnType | undefined + try { + await Promise.race([ + new Promise((resolve) => { + this.#idleWaiters.add(resolve) + }), + new Promise((resolve) => { + timer = setTimeout(resolve, this.#closeTimeoutMs) + timer.unref?.() + }) + ]) + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } + this.#idleWaiters.clear() + } + } +} + +function safeErrorCode(value: string): string { + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) + ? value + : 'dispatch-failed' +} + +function boundedTimeout( + value: number | undefined, + fallback: number +): number { + const timeout = value ?? fallback + if ( + !Number.isSafeInteger(timeout) || + timeout < 1 || + timeout > 300_000 + ) { + throw new RangeError('Model bridge timeout is invalid') + } + return timeout +} + +async function settleWithin( + promise: Promise, + timeoutMs: number +): Promise { + let timer: ReturnType | undefined + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Model bridge close timed out')), + timeoutMs + ) + timer.unref?.() + }) + ]) + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } + } +} + +async function raceWithAbort( + promise: Promise, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + let abort = (): void => undefined + const aborted = new Promise((_, reject) => { + abort = (): void => { + reject( + signal.reason ?? + new DOMException('Model bridge aborted', 'AbortError') + ) + } + signal.addEventListener('abort', abort, { once: true }) + }) + try { + return await Promise.race([promise, aborted]) + } finally { + signal.removeEventListener('abort', abort) + } +} diff --git a/src/main/remote-agent/managed-model-bridge.test.ts b/src/main/remote-agent/managed-model-bridge.test.ts new file mode 100644 index 00000000..837dd6a6 --- /dev/null +++ b/src/main/remote-agent/managed-model-bridge.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it, vi } from 'vitest' +import { + runtimeSessionBindingSchema, + type RuntimeSessionBinding +} from '../../shared/remote-agent-contracts' +import type { RemoteModelGateway } from '../agent/remote-model-gateway' +import { RemoteModelGatewayError } from '../agent/remote-model-gateway' +import { ModelCallCapacityError } from '../agent/model-call-operation-store' +import { MemoryRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store' +import type { ResolvedModelProfile } from '../runtime-settings-store' +import { + createManagedModelBridge, + reconcileStartupModelCalls +} from './managed-model-bridge' +import { MainModelBridgeDispatchError } from './main-model-bridge-dispatcher' + +const digest = (character: string): string => + `sha256:${character.repeat(64)}` + +const profile: ResolvedModelProfile = { + id: '00000000-0000-4000-8000-000000000001', + name: 'Private profile', + baseUrl: 'https://provider.example/v1', + modelName: 'private-model', + protocol: 'openai-responses', + authentication: 'api-key', + supportsImageInput: false, + apiKey: 'main-only-secret' +} + +const response = { + status: 200, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from('{}').toString('base64') +} + +function bridgeFixture(options?: { + bindingStore?: MemoryRuntimeSessionBindingStore +}) { + const bindingStore = + options?.bindingStore ?? + new MemoryRuntimeSessionBindingStore() + const dispatch = vi.fn(async () => response) + const markResponseDelivered = vi.fn() + const finalizePrompt = vi.fn() + const gateway = { + dispatch, + markResponseDelivered, + finalizePrompt + } as unknown as RemoteModelGateway + const bridge = createManagedModelBridge({ + profile, + gateway, + bindingStore + }) + return { + bindingStore, + dispatch, + markResponseDelivered, + finalizePrompt, + bridge + } +} + +function binding( + policy: ReturnType['bridge']['policy'], + state: RuntimeSessionBinding['state'] = 'prompt-running' +): RuntimeSessionBinding { + return runtimeSessionBindingSchema.parse({ + bindingId: 'binding-1', + controllerId: 'controller-1', + controllerGeneration: 2, + conversationId: 'conversation-1', + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + workspaceIdentity: 'workspace-1', + agentInstallationId: 'agent-1', + daemonBootIdAtOpen: 'boot-1', + runtimeId: 'opencode', + runtimeBundleDigest: digest('a'), + runtimeAdapterDigest: digest('b'), + modelBridgeVersion: 'goodbuddy-model-bridge-v1', + modelBridgePolicy: policy, + acpSessionId: 'session-1', + acpCapabilitiesDigest: digest('c'), + state, + activePromptOperationId: + state === 'prompt-running' || state === 'outcome-unknown' + ? 'prompt-1' + : undefined, + channelEpoch: '1', + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + }) +} + +function dispatchInput( + policy: ReturnType['bridge']['policy'] +) { + return { + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'local-model-request-1', + roundIndex: 0, + modelProfileDigest: policy.modelProfileDigest, + messageId: 'message-1' + }, + policy, + request: { + method: 'POST' as const, + path: '/v1/responses' as const, + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'private-model' }) + ).toString('base64') + }, + requestDigest: digest('e') + } +} + +describe('managed model bridge composition', () => { + it('does not impose call-count or token quotas', () => { + const fixture = bridgeFixture() + + expect(fixture.bridge.policy).toEqual({ + protocol: 'openai-responses', + model: 'private-model', + modelProfileDigest: expect.stringMatching( + /^sha256:[a-f0-9]{64}$/u + ), + supportsImageInput: false + }) + }) + + it('keeps credentials in Main and maps exact dispatch plus delivery identity', async () => { + const fixture = bridgeFixture() + await fixture.bindingStore.put(binding(fixture.bridge.policy)) + const input = dispatchInput(fixture.bridge.policy) + + await expect( + fixture.bridge.channel.dispatch( + input, + new AbortController().signal + ) + ).resolves.toEqual(response) + expect(fixture.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'local-model-request-1', + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + promptSequence: 0, + roundIndex: 0, + modelProfile: expect.objectContaining({ + apiKey: 'main-only-secret' + }) + }), + input.request, + expect.any(AbortSignal) + ) + expect(JSON.stringify(fixture.bridge.policy)).not.toContain( + 'main-only-secret' + ) + expect(JSON.stringify(fixture.bridge.policy)).not.toContain( + 'provider.example' + ) + + await fixture.bridge.channel.onDelivered( + input.identity, + input.requestDigest + ) + expect(fixture.markResponseDelivered).toHaveBeenCalledWith({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + roundIndex: 0 + }) + await fixture.bridge.channel.finalizePrompt(input.identity) + expect(fixture.finalizePrompt).toHaveBeenCalledWith({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + promptSequence: 0 + }) + }) + + it('classifies only known pre-dispatch gateway failures as definitive', async () => { + const fixture = bridgeFixture() + await fixture.bindingStore.put(binding(fixture.bridge.policy)) + vi.mocked(fixture.dispatch).mockRejectedValueOnce( + new RemoteModelGatewayError( + 'request-policy-mismatch', + 'safe failure' + ) + ) + + await expect( + fixture.bridge.channel.dispatch( + dispatchInput(fixture.bridge.policy), + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'request-policy-mismatch', + outcomeUnknown: false, + postDispatch: false + }) + + vi.mocked(fixture.dispatch).mockRejectedValueOnce( + new ModelCallCapacityError() + ) + await expect( + fixture.bridge.channel.dispatch( + dispatchInput(fixture.bridge.policy), + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'model-ledger-capacity', + outcomeUnknown: false, + postDispatch: false + }) + + vi.mocked(fixture.dispatch).mockRejectedValueOnce( + new RemoteModelGatewayError( + 'network-error', + 'must not escape' + ) + ) + await expect( + fixture.bridge.channel.dispatch( + dispatchInput(fixture.bridge.policy), + new AbortController().signal + ) + ).rejects.toBeInstanceOf(MainModelBridgeDispatchError) + await expect( + fixture.bridge.channel.dispatch( + { + ...dispatchInput(fixture.bridge.policy), + policy: { + ...fixture.bridge.policy, + model: 'different-model' + } + }, + new AbortController().signal + ) + ).rejects.toMatchObject({ + code: 'model-policy-mismatch', + outcomeUnknown: false + }) + }) + + it('durably poisons the exact active binding', async () => { + const fixture = bridgeFixture() + await fixture.bindingStore.put(binding(fixture.bridge.policy)) + + await fixture.bridge.channel.poison( + 'binding-1', + 'prompt-1', + 'delivery-unknown' + ) + await expect( + fixture.bindingStore.getById('binding-1') + ).resolves.toMatchObject({ state: 'outcome-unknown' }) + }) + + it('paginates startup uncertainty and poisons only matching live prompts', async () => { + const fixture = bridgeFixture() + await fixture.bindingStore.put(binding(fixture.bridge.policy)) + const firstCursor = { + updatedAt: 10, + callOperationId: 'call-1' + } + const listStartupRecords = vi + .fn() + .mockReturnValueOnce({ + records: [ + { + status: 'completed', + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1' + } + } + ], + nextCursor: firstCursor + }) + .mockReturnValueOnce({ records: [] }) + + await reconcileStartupModelCalls({ + gatewayStore: { listStartupRecords }, + bindingStore: fixture.bindingStore + }) + expect(listStartupRecords).toHaveBeenNthCalledWith(2, { + cursor: firstCursor, + limit: 100 + }) + await expect( + fixture.bindingStore.getById('binding-1') + ).resolves.toMatchObject({ state: 'outcome-unknown' }) + }) + + it('marks a prepared but undispatched startup call as interrupted', async () => { + const fixture = bridgeFixture() + await fixture.bindingStore.put(binding(fixture.bridge.policy)) + + await reconcileStartupModelCalls({ + gatewayStore: { + listStartupRecords: () => ({ + records: [ + { + status: 'prepared', + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1' + } + } + ] + }) + }, + bindingStore: fixture.bindingStore + }) + + await expect( + fixture.bindingStore.getById('binding-1') + ).resolves.toMatchObject({ + state: 'interrupted', + activePromptOperationId: undefined + }) + }) +}) diff --git a/src/main/remote-agent/managed-model-bridge.ts b/src/main/remote-agent/managed-model-bridge.ts new file mode 100644 index 00000000..8699586c --- /dev/null +++ b/src/main/remote-agent/managed-model-bridge.ts @@ -0,0 +1,247 @@ +import { isDeepStrictEqual } from 'node:util' +import { + modelBridgePolicySchema, + type ModelBridgePolicy +} from '../../shared/model-bridge-contracts' +import { runtimeSessionBindingSchema } from '../../shared/remote-agent-contracts' +import { + RemoteModelGateway, + RemoteModelGatewayError, + createResolvedModelProfileDigest +} from '../agent/remote-model-gateway' +import { ModelCallCapacityError } from '../agent/model-call-operation-store' +import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store' +import type { ResolvedModelProfile } from '../runtime-settings-store' +import { + MainModelBridgeDispatchError, + type MainModelBridgeDispatchInput +} from './main-model-bridge-dispatcher' +import type { ProtocolRemoteRuntimeChannelOptions } from './protocol-remote-runtime-channel' + +const PRE_DISPATCH_GATEWAY_ERRORS = new Set([ + 'invalid-provider-endpoint', + 'missing-provider-credential', + 'model-profile-digest-mismatch', + 'protocol-path-mismatch', + 'request-policy-mismatch' +]) + +export type ManagedModelBridge = { + policy: ModelBridgePolicy + channel: NonNullable +} + +export function createManagedModelBridge(options: { + profile: ResolvedModelProfile + gateway: RemoteModelGateway + bindingStore: RuntimeSessionBindingStore +}): ManagedModelBridge { + const profile = structuredClone(options.profile) + if ( + profile.protocol === 'openai-images-generations' || + (profile.authentication === 'api-key' && !profile.apiKey) + ) { + throw new Error( + 'Managed remote OpenCode requires a usable text model profile' + ) + } + const policy = modelBridgePolicySchema.parse({ + protocol: profile.protocol, + model: profile.modelName, + modelProfileDigest: createResolvedModelProfileDigest(profile), + supportsImageInput: profile.supportsImageInput === true + }) + + const assertActiveBinding = async ( + input: Pick< + MainModelBridgeDispatchInput['identity'], + 'bindingId' | 'promptOperationId' + > + ) => { + const binding = await options.bindingStore.getById(input.bindingId) + if ( + binding === undefined || + binding.state !== 'prompt-running' || + binding.activePromptOperationId !== input.promptOperationId || + binding.modelBridgeVersion !== 'goodbuddy-model-bridge-v1' || + !isDeepStrictEqual(binding.modelBridgePolicy, policy) + ) { + throw new MainModelBridgeDispatchError( + 'binding-policy-mismatch', + { outcomeUnknown: false, postDispatch: false } + ) + } + return binding + } + + return { + policy, + channel: { + dispatch: async (input, signal) => { + const binding = await assertActiveBinding(input.identity) + if (!isDeepStrictEqual(input.policy, policy)) { + throw new MainModelBridgeDispatchError( + 'model-policy-mismatch', + { outcomeUnknown: false, postDispatch: false } + ) + } + try { + return await options.gateway.dispatch( + { + requestId: input.identity.requestId, + bindingId: input.identity.bindingId, + promptOperationId: + input.identity.promptOperationId, + promptSequence: binding.promptSequence, + roundIndex: input.identity.roundIndex, + modelProfileDigest: policy.modelProfileDigest, + modelProfile: profile + }, + input.request, + signal + ) + } catch (error) { + if ( + error instanceof MainModelBridgeDispatchError + ) { + throw error + } + if ( + error instanceof RemoteModelGatewayError && + PRE_DISPATCH_GATEWAY_ERRORS.has(error.code) + ) { + throw new MainModelBridgeDispatchError(error.code, { + outcomeUnknown: false, + postDispatch: false, + cause: error + }) + } + if (error instanceof ModelCallCapacityError) { + throw new MainModelBridgeDispatchError( + 'model-ledger-capacity', + { + outcomeUnknown: false, + postDispatch: false, + cause: error + } + ) + } + throw new MainModelBridgeDispatchError( + error instanceof RemoteModelGatewayError + ? error.code + : 'dispatch-failed', + { + outcomeUnknown: true, + postDispatch: true, + cause: error + } + ) + } + }, + onDelivered: async (identity) => { + await assertActiveBinding(identity) + options.gateway.markResponseDelivered({ + bindingId: identity.bindingId, + promptOperationId: identity.promptOperationId, + roundIndex: identity.roundIndex + }) + }, + finalizePrompt: async (identity) => { + const binding = await assertActiveBinding(identity) + options.gateway.finalizePrompt({ + bindingId: identity.bindingId, + promptOperationId: identity.promptOperationId, + promptSequence: binding.promptSequence + }) + }, + poison: async ( + bindingId, + promptOperationId + ) => { + const binding = + await options.bindingStore.getById(bindingId) + if ( + binding === undefined || + binding.state === 'closed' || + binding.state === 'outcome-unknown' || + binding.activePromptOperationId !== promptOperationId + ) { + return + } + await options.bindingStore.put( + runtimeSessionBindingSchema.parse({ + ...binding, + state: 'outcome-unknown' + }) + ) + } + } + } +} + +export async function reconcileStartupModelCalls(options: { + gatewayStore: { + listStartupRecords(input?: { + cursor?: { + updatedAt: number + callOperationId: string + } + limit?: number + }): { + records: Array<{ + status: + | 'prepared' + | 'dispatched' + | 'completed' + | 'failed-definitive' + | 'outcome-unknown' + identity: { + bindingId: string + promptOperationId: string + } + }> + nextCursor?: { + updatedAt: number + callOperationId: string + } + } + } + bindingStore: RuntimeSessionBindingStore +}): Promise { + let cursor: + | { updatedAt: number; callOperationId: string } + | undefined + do { + const page = options.gatewayStore.listStartupRecords({ + ...(cursor === undefined ? {} : { cursor }), + limit: 100 + }) + for (const record of page.records) { + const binding = await options.bindingStore.getById( + record.identity.bindingId + ) + if ( + binding === undefined || + binding.state === 'closed' || + binding.state === 'outcome-unknown' || + binding.activePromptOperationId !== + record.identity.promptOperationId + ) { + continue + } + await options.bindingStore.put( + runtimeSessionBindingSchema.parse({ + ...binding, + state: + record.status === 'prepared' + ? 'interrupted' + : 'outcome-unknown', + ...(record.status === 'prepared' + ? { activePromptOperationId: undefined } + : {}) + }) + ) + } + cursor = page.nextCursor + } while (cursor !== undefined) +} diff --git a/src/main/remote-agent/managed-remote-acp-runtime.test.ts b/src/main/remote-agent/managed-remote-acp-runtime.test.ts new file mode 100644 index 00000000..8e044c43 --- /dev/null +++ b/src/main/remote-agent/managed-remote-acp-runtime.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest' + +const remoteHarness = vi.hoisted(() => ({ + instances: [] as Array<{ + options: { + identity: { + controllerGeneration: number + daemonBootIdAtOpen: string + } + channelFactory: (bindingId: string) => Promise + } + dispose: ReturnType + runCalls: number + }>, + runGate: undefined as Promise | undefined +})) + +vi.mock('../agent/acp-remote-runtime', () => ({ + AcpRemoteRuntime: class { + readonly requiresToolApproval = false + readonly supportsScopedDataTools = false + readonly capability = 'chat' + readonly runtimeId = 'opencode' + readonly supportsToolExecution = true + readonly dispose = vi.fn(async () => undefined) + runCalls = 0 + + constructor( + readonly options: { + identity: { + controllerGeneration: number + daemonBootIdAtOpen: string + } + channelFactory: (bindingId: string) => Promise + } + ) { + remoteHarness.instances.push(this) + } + + async getStatus() { + return { + id: 'opencode', + label: 'OpenCode(远程托管)', + available: true + } + } + + async *run(request: { requestId: string }) { + this.runCalls += 1 + yield { + requestId: request.requestId, + type: 'status', + message: 'running' + } + await remoteHarness.runGate + yield { + requestId: request.requestId, + type: 'done', + sessionId: 'session-1' + } + } + + async releaseConversation() {} + async beginDrain() {} + async waitForDrain() {} + async forceShutdown() { + await this.dispose() + } + } +})) + +import { + MemoryRuntimeSessionBindingStore +} from '../agent/runtime-session-binding-store' +import type { SshExecutionSpaceDescriptor } from '../execution-space' +import { + createManagedRemoteAcpRuntime, + type ManagedRemoteAcpRuntimeOptions +} from './managed-remote-acp-runtime' + +const digest = (value: string): string => + `sha256:${value.repeat(64)}` + +function descriptor(): SshExecutionSpaceDescriptor { + return { + 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: { + getIdentity: vi.fn(), + listDirectory: vi.fn(), + stat: vi.fn(), + readText: vi.fn(), + writeTextAtomic: vi.fn(), + search: vi.fn(), + getChanges: vi.fn(), + dispose: vi.fn() + } + } +} + +function harness( + overrides: Partial = {} +) { + remoteHarness.instances.length = 0 + remoteHarness.runGate = undefined + const releaseConnection = vi.fn() + const workspaceAccesses: Array<{ + getIdentity: ReturnType + dispose: ReturnType + }> = [] + const connection = { + identity: { + cacheKey: 'connection-cache', + hostId: 'host-1', + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'alice', + installationId: 'agent-1', + binaryDigest: digest('a'), + protocolMajor: 1, + protocolMinor: 0 + }, + status: { + state: 'ready', + draining: false, + installationId: 'agent-1', + binaryDigest: digest('a'), + agentVersion: '1.0.0', + protocol: { major: 1, minor: 0 }, + daemonBootId: 'boot-1', + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand' + }, + capabilities: capabilities(), + client: { generation: 5 }, + state: 'ready', + refreshCapabilities: vi.fn(async () => capabilities()), + reconnect: vi.fn(async () => undefined), + release: releaseConnection + } + const options = { + 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') + } + }, + runtimeInstallationManager: { + ensureInstalled: vi.fn(async () => ({ + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + bundleDigest: digest('b'), + manifestDigest: digest('e'), + runtimeAdapterDigest: digest('c'), + acpCapabilitiesDigest: digest('f'), + platform: 'linux' as const, + architecture: 'x64' as const + })) + }, + 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: { + protocol: 'openai-responses' as const, + model: 'private-model', + modelProfileDigest: digest('9'), + supportsImageInput: false + }, + channel: { + dispatch: vi.fn(), + onDelivered: vi.fn(), + finalizePrompt: vi.fn(), + poison: vi.fn() + } + }, + ...overrides + } as unknown as ManagedRemoteAcpRuntimeOptions + return { + options, + connection, + releaseConnection, + workspaceAccesses + } +} + +describe('createManagedRemoteAcpRuntime', () => { + it('binds exact persisted evidence and owns its trust and connection leases', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + + await expect(runtime.getStatus()).resolves.toMatchObject({ + id: 'opencode', + available: true + }) + expect( + fixture.options.workspaceAccessFactory.create + ).not.toHaveBeenCalled() + expect( + fixture.options.agentServices.connectionManager.acquire + ).toHaveBeenCalledWith('host-1', expect.objectContaining({ + requiredCapabilities: [ + { name: 'runtime/acp', exactVersion: 3, critical: true }, + { + name: 'runtime/model-bridge', + exactVersion: 1, + critical: true + } + ] + })) + + await runtime.dispose() + await runtime.dispose() + expect(fixture.releaseConnection).toHaveBeenCalledOnce() + }) + + it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + + await collect(runtime.run(request('request-1'), new AbortController().signal)) + expect(remoteHarness.instances).toHaveLength(1) + expect(remoteHarness.instances[0]!.options.identity).toMatchObject({ + controllerGeneration: 5, + daemonBootIdAtOpen: 'boot-1' + }) + expect(fixture.workspaceAccesses).toHaveLength(1) + + fixture.connection.state = 'offline' + vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => { + fixture.connection.client.generation = 6 + fixture.connection.status.daemonBootId = 'boot-2' + fixture.connection.state = 'ready' + }) + + await collect(runtime.run(request('request-2'), new AbortController().signal)) + + expect(fixture.connection.reconnect).toHaveBeenCalledOnce() + expect(remoteHarness.instances).toHaveLength(2) + expect(remoteHarness.instances[0]!.dispose).toHaveBeenCalledOnce() + expect(remoteHarness.instances[1]!.options.identity).toMatchObject({ + controllerGeneration: 6, + daemonBootIdAtOpen: 'boot-2' + }) + expect(fixture.workspaceAccesses).toHaveLength(2) + expect( + fixture.workspaceAccesses[0]!.dispose + ).toHaveBeenCalledOnce() + + await runtime.dispose() + expect(fixture.workspaceAccesses[1]!.dispose).toHaveBeenCalledOnce() + expect(fixture.releaseConnection).toHaveBeenCalledOnce() + }) + + it('rejects a stale channel factory lease after a transport change', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + const staleFactory = + remoteHarness.instances[0]!.options.channelFactory + + fixture.connection.client.generation = 6 + fixture.connection.status.daemonBootId = 'boot-2' + + await expect(staleFactory('binding-1')).rejects.toThrow( + /transport lease is stale/iu + ) + expect(fixture.workspaceAccesses).toHaveLength(1) + await runtime.dispose() + }) + + it('recreates the Runtime when only the daemon boot identity changes', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + + await collect(runtime.run(request('request-1'), new AbortController().signal)) + fixture.connection.status.daemonBootId = 'boot-2' + await collect(runtime.run(request('request-2'), new AbortController().signal)) + + expect(remoteHarness.instances).toHaveLength(2) + expect(remoteHarness.instances[1]!.options.identity).toMatchObject({ + controllerGeneration: 5, + daemonBootIdAtOpen: 'boot-2' + }) + expect(fixture.workspaceAccesses).toHaveLength(2) + await runtime.dispose() + }) + + it('fails closed on reconnect during an active request without replaying it', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime( + fixture.options + ) + let releaseGate = (): void => undefined + remoteHarness.runGate = new Promise((resolve) => { + releaseGate = resolve + }) + const first = runtime.run( + request('request-1'), + new AbortController().signal + ) + + await expect(first.next()).resolves.toMatchObject({ + value: { type: 'status' }, + done: false + }) + fixture.connection.client.generation = 6 + fixture.connection.status.daemonBootId = 'boot-2' + + await expect( + runtime + .run(request('request-2'), new AbortController().signal) + .next() + ).rejects.toThrow(/another Runtime request was active/iu) + expect(remoteHarness.instances).toHaveLength(1) + expect(remoteHarness.instances[0]!.runCalls).toBe(1) + + releaseGate() + await collect(first) + await runtime.dispose() + }) + + it('releases exact trust without connecting when Runtime evidence changed', async () => { + const fixture = harness() + vi.mocked( + fixture.options.runtimeInstallationManager.ensureInstalled + ).mockResolvedValueOnce({ + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + bundleDigest: digest('9'), + manifestDigest: digest('e'), + runtimeAdapterDigest: digest('c'), + acpCapabilitiesDigest: digest('f'), + platform: 'linux', + architecture: 'x64' + }) + + await expect( + createManagedRemoteAcpRuntime(fixture.options) + ).rejects.toThrow(/does not match project validation/iu) + expect( + fixture.options.agentServices.connectionManager.acquire + ).not.toHaveBeenCalled() + }) + + it('releases both leases when the live Agent identity is stale', async () => { + const fixture = harness() + fixture.connection.status.agentVersion = '2.0.0' + + await expect( + createManagedRemoteAcpRuntime(fixture.options) + ).rejects.toThrow(/connection does not match/iu) + expect(fixture.releaseConnection).toHaveBeenCalledOnce() + }) + + it('rejects the obsolete Runtime ACP v1 capability', async () => { + const fixture = harness() + vi.mocked( + fixture.connection.refreshCapabilities + ).mockResolvedValueOnce({ + ...capabilities(), + capabilities: [ + { name: 'runtime/acp', version: 1, critical: true } + ] + }) + + await expect( + createManagedRemoteAcpRuntime(fixture.options) + ).rejects.toThrow(/does not advertise/iu) + expect(fixture.releaseConnection).toHaveBeenCalledOnce() + }) +}) + +function capabilities() { + return { + generation: 7, + capabilities: [ + { name: 'runtime/acp', version: 3, critical: true }, + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } + ], + runtimes: [ + { + runtimeId: 'opencode', + version: '1.18.9', + bundleDigest: digest('b'), + acpCapabilitiesDigest: digest('f'), + sessionLoad: true, + sessionResume: true + } + ] + } +} + +function request(requestId: string) { + return { + requestId, + conversationId: 'conversation-1', + prompt: 'hello', + workMode: 'ask' as const + } +} + +async function collect( + values: AsyncGenerator +): Promise { + const collected: T[] = [] + for await (const value of values) { + collected.push(value) + } + return collected +} diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts new file mode 100644 index 00000000..d75c17a4 --- /dev/null +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -0,0 +1,704 @@ +import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' +import { + AcpRemoteRuntime, + type AcpRemoteRuntimeOptions +} from '../agent/acp-remote-runtime' +import type { + AgentExecutionRequest, + AgentRuntime, + RuntimeAuthorizer, + RuntimeEvent +} from '../agent/runtime' +import type { RuntimeSessionBindingStore } from '../agent/runtime-session-binding-store' +import type { SshExecutionSpaceDescriptor } from '../execution-space' +import type { RemoteWorkspaceProjectBinding } from '../workspace' +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' + +const RUNTIME_ACP_CAPABILITY = { + name: 'runtime/acp', + exactVersion: 3, + critical: true +} as const +const RUNTIME_MODEL_BRIDGE_CAPABILITY = { + name: 'runtime/model-bridge', + exactVersion: 1, + critical: true +} as const + +type ManagedRemoteTransportIdentity = { + controllerGeneration: number + daemonBootId: string +} + +export type ManagedRemoteAcpRuntimeOptions = { + executionSpace: SshExecutionSpaceDescriptor + selection: AgentRuntimeSelection + agentServices: Pick< + RemoteAgentServices, + 'installationManager' | 'connectionManager' | 'controllerState' + > + runtimeInstallationManager: Pick< + RemoteRuntimeInstallationManager, + 'ensureInstalled' + > + workspaceAccessFactory: Pick< + ManagedRemoteWorkspaceAccessFactory, + 'create' + > + bindingStore: RuntimeSessionBindingStore + modelBridge: ManagedModelBridge + usage?: { + provider: string + model: string + } +} + +/** + * Acquires every production lease needed by one validated remote Runtime. + * The returned Runtime owns those leases and releases them only after all ACP + * conversation channels have been closed. + */ +export async function createManagedRemoteAcpRuntime( + options: ManagedRemoteAcpRuntimeOptions +): Promise { + const descriptor = options.executionSpace + const validation = descriptor.validation + const runtimeValidation = descriptor.runtimeValidation + if ( + options.selection.provider !== 'opencode' || + validation === undefined || + runtimeValidation === undefined + ) { + throw new Error( + 'Managed remote execution requires validated OpenCode project evidence' + ) + } + const workspaceBinding = workspaceProjectBinding(descriptor) + let connection: + | Awaited< + ReturnType< + RemoteAgentServices['connectionManager']['acquire'] + > + > + | undefined + let retained = false + try { + const agent = + await options.agentServices.installationManager.ensureInstalled( + 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, + { + ...agent, + requiredCapabilities: [ + RUNTIME_ACP_CAPABILITY, + RUNTIME_MODEL_BRIDGE_CAPABILITY + ] + } + ) + connection = activeConnection + assertConnectionMatches(workspaceBinding, activeConnection) + const initialTransport = { + controllerGeneration: activeConnection.client.generation, + daemonBootId: activeConnection.status.daemonBootId + } + 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' + ) + 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, + activeConnection, + runtime, + options.modelBridge + ) + + const createRemote = ( + transport: ManagedRemoteTransportIdentity + ): AcpRemoteRuntime => { + const remoteOptions: AcpRemoteRuntimeOptions = { + runtimeId: 'opencode', + label: 'OpenCode(远程托管)', + workspacePath: descriptor.remoteRootPath, + identity: { + controllerId, + controllerGeneration: transport.controllerGeneration, + hostId: workspaceBinding.hostId, + hostRevision: workspaceBinding.hostRevision, + hostKeyGeneration: workspaceBinding.hostKeyGeneration, + workspaceIdentity: workspaceBinding.workspaceIdentity, + agentInstallationId: workspaceBinding.agentInstallationId, + daemonBootIdAtOpen: transport.daemonBootId, + runtimeBundleDigest: + runtimeValidation.runtimeBundleDigest, + runtimeAdapterDigest: + runtimeValidation.runtimeAdapterDigest, + }, + channelFactory: async (bindingId) => + resources.openChannel(transport, bindingId), + bindingStore: options.bindingStore, + modelBridgePolicy: options.modelBridge.policy, + assertHostCurrent: (identity) => { + if ( + identity.controllerId !== controllerId || + identity.hostId !== workspaceBinding.hostId || + identity.hostRevision !== workspaceBinding.hostRevision || + identity.hostKeyGeneration !== + workspaceBinding.hostKeyGeneration || + identity.agentInstallationId !== + workspaceBinding.agentInstallationId + ) { + throw new Error( + 'Remote Runtime Host identity no longer matches its project' + ) + } + }, + ...(options.usage + ? { + usage: { + runtime: 'opencode', + provider: options.usage.provider, + model: options.usage.model + } + } + : {}) + } + return new AcpRemoteRuntime(remoteOptions) + } + const transport = initialTransport + const remote = createRemote(transport) + retained = true + return new ManagedRemoteAcpRuntime( + remote, + transport, + createRemote, + resources + ) + } finally { + if (!retained) { + connection?.release() + } + } +} + +class ManagedRemoteRuntimeResources { + #workspace: + | { + transport: ManagedRemoteTransportIdentity + access: ReturnType + } + | undefined + #workspaceTail: Promise = Promise.resolve() + #disposePromise?: Promise + + constructor( + private readonly workspaceFactory: + Pick, + private readonly workspaceBinding: RemoteWorkspaceProjectBinding, + private readonly connection: NonNullable< + Awaited< + ReturnType< + RemoteAgentServices['connectionManager']['acquire'] + > + > + >, + private readonly runtime: Awaited< + ReturnType + >, + private readonly modelBridge: ManagedModelBridge + ) {} + + currentTransportIdentity(): ManagedRemoteTransportIdentity { + return { + controllerGeneration: this.connection.client.generation, + daemonBootId: this.connection.status.daemonBootId + } + } + + async prepareTransport(): Promise { + if (this.connection.state !== 'ready') { + await this.connection.reconnect() + } + assertConnectionMatches(this.workspaceBinding, this.connection) + const beforeRefresh = this.currentTransportIdentity() + const capabilities = await this.connection.refreshCapabilities() + assertConnectionMatches(this.workspaceBinding, this.connection) + assertSameTransport( + beforeRefresh, + this.currentTransportIdentity(), + 'Remote Agent transport changed while refreshing capabilities' + ) + assertRuntimeAdvertised(this.runtime, capabilities) + await this.ensureWorkspace(beforeRefresh) + assertSameTransport( + beforeRefresh, + this.currentTransportIdentity(), + 'Remote Agent transport changed while opening the Workspace' + ) + return beforeRefresh + } + + async openChannel( + expected: ManagedRemoteTransportIdentity, + bindingId: string + ) { + const transport = await this.prepareTransport() + assertSameTransport( + expected, + transport, + 'Remote Runtime transport lease is stale' + ) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: this.connection, + openIdentity: { + bindingId, + runtimeId: 'opencode', + runtimeBundleDigest: this.runtime.bundleDigest, + workspaceIdentity: this.workspaceBinding.workspaceIdentity + }, + modelBridge: this.modelBridge.channel + }) + try { + assertSameTransport( + expected, + this.currentTransportIdentity(), + 'Remote Agent transport changed while opening an ACP channel' + ) + return channel + } catch (error) { + await channel.close().catch(() => undefined) + throw error + } + } + + ensureWorkspace( + transport: ManagedRemoteTransportIdentity + ): Promise { + const operation = this.#workspaceTail.then(async () => { + if ( + this.#workspace !== undefined && + sameTransport(this.#workspace.transport, transport) + ) { + await this.#workspace.access.getIdentity() + assertSameTransport( + transport, + this.currentTransportIdentity(), + 'Remote Agent transport changed while checking the Workspace' + ) + return + } + const previous = this.#workspace + this.#workspace = undefined + if (previous !== undefined) { + await previous.access.dispose().catch(() => undefined) + } + const access = this.workspaceFactory.create( + this.workspaceBinding + ) + 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' + ) + } + assertSameTransport( + transport, + this.currentTransportIdentity(), + 'Remote Agent transport changed while opening the Workspace' + ) + this.#workspace = { transport, access } + } catch (error) { + await access.dispose().catch(() => undefined) + throw error + } + }) + this.#workspaceTail = operation.catch(() => undefined) + return operation + } + + dispose(): Promise { + this.#disposePromise ??= this.#dispose() + return this.#disposePromise + } + + async #dispose(): Promise { + await this.#workspaceTail + const errors: unknown[] = [] + try { + await this.#workspace?.access.dispose() + } catch (error) { + errors.push(error) + } + try { + this.connection.release() + } catch (error) { + errors.push(error) + } + if (errors.length > 0) { + throw new AggregateError( + errors, + 'Remote Runtime resources failed to release' + ) + } + } +} + +class ManagedRemoteAcpRuntime implements AgentRuntime { + readonly requiresToolApproval = false + readonly supportsScopedDataTools = false + readonly capability = 'chat' as const + readonly runtimeId = 'opencode' as const + #disposePromise?: Promise + #transitionTail: Promise = Promise.resolve() + #activeRuns = 0 + #draining = false + #disposed = false + + constructor( + private remote: AcpRemoteRuntime, + private transport: ManagedRemoteTransportIdentity, + private readonly createRemote: ( + transport: ManagedRemoteTransportIdentity + ) => AcpRemoteRuntime, + private readonly resources: ManagedRemoteRuntimeResources + ) {} + + get supportsToolExecution(): boolean { + return this.remote.supportsToolExecution + } + + getStatus() { + return this.remote.getStatus() + } + + run( + request: AgentExecutionRequest, + signal: AbortSignal, + authorize?: RuntimeAuthorizer + ): AsyncGenerator { + return this.#run(request, signal, authorize) + } + + async *#run( + request: AgentExecutionRequest, + signal: AbortSignal, + authorize?: RuntimeAuthorizer + ): AsyncGenerator { + const remote = await this.#acquireRun() + let completed = false + try { + yield* remote.run(request, signal, authorize) + completed = true + } finally { + this.#activeRuns -= 1 + if (completed) { + try { + const current = this.resources.currentTransportIdentity() + if ( + current.daemonBootId === this.transport.daemonBootId && + current.controllerGeneration > + this.transport.controllerGeneration + ) { + this.transport = current + } + } catch { + // A later run will perform the authoritative transport transition. + } + } + } + } + + async #acquireRun(): Promise { + let acquired: AcpRemoteRuntime | undefined + const operation = this.#transitionTail.then(async () => { + if (this.#disposed) { + throw new Error('远端 Runtime 已关闭') + } + if (this.#draining) { + throw new Error('远端 Runtime 正在退役') + } + const currentTransport = await this.resources.prepareTransport() + if (this.#disposed) { + throw new Error('远端 Runtime 已关闭') + } + if (this.#draining) { + throw new Error('远端 Runtime 正在退役') + } + if (!sameTransport(this.transport, currentTransport)) { + if (this.#activeRuns > 0) { + throw new Error( + 'Remote Agent transport changed while another Runtime request was active' + ) + } + await this.remote.dispose() + if (this.#disposed) { + throw new Error('远端 Runtime 已关闭') + } + if (this.#draining) { + throw new Error('远端 Runtime 正在退役') + } + this.remote = this.createRemote(currentTransport) + this.transport = currentTransport + } + this.#activeRuns += 1 + acquired = this.remote + }) + this.#transitionTail = operation.catch(() => undefined) + await operation + return acquired! + } + + releaseConversation(conversationId: string): Promise { + const operation = this.#transitionTail.then(() => + this.remote.releaseConversation(conversationId) + ) + this.#transitionTail = operation.catch(() => undefined) + return operation + } + + beginDrain(): Promise { + this.#draining = true + const operation = this.#transitionTail.then(() => + this.remote.beginDrain() + ) + this.#transitionTail = operation.catch(() => undefined) + return operation + } + + waitForDrain(): Promise { + return this.#transitionTail.then(() => this.remote.waitForDrain()) + } + + forceShutdown(): Promise { + this.#disposed = true + return this.#transitionTail + .then(() => this.remote.forceShutdown()) + .finally(() => this.resources.dispose()) + } + + dispose(): Promise { + this.#disposed = true + this.#disposePromise ??= this.#dispose() + return this.#disposePromise + } + + async #dispose(): Promise { + await this.#transitionTail + const results = await Promise.allSettled([ + this.remote.dispose(), + this.resources.dispose() + ]) + const errors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (errors.length > 0) { + throw new AggregateError( + errors, + 'Managed remote Runtime failed to dispose' + ) + } + } +} + +function sameTransport( + expected: ManagedRemoteTransportIdentity, + actual: ManagedRemoteTransportIdentity +): boolean { + return ( + expected.controllerGeneration === actual.controllerGeneration && + expected.daemonBootId === actual.daemonBootId + ) +} + +function assertSameTransport( + expected: ManagedRemoteTransportIdentity, + actual: ManagedRemoteTransportIdentity, + message: string +): void { + if (!sameTransport(expected, actual)) { + throw new Error(message) + } +} + +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 + } +} + +function assertAgentMatches( + binding: RemoteWorkspaceProjectBinding, + agent: Awaited< + ReturnType + > +): void { + if ( + agent.installationId !== binding.agentInstallationId || + agent.binaryDigest !== binding.agentBinaryDigest || + agent.agentVersion !== binding.agentVersion || + agent.architecture !== binding.agentArchitecture || + agent.protocol.major !== binding.agentProtocolMajor + ) { + throw new Error( + 'Installed remote Agent does not match project validation' + ) + } +} + +function assertRuntimeMatches( + validation: NonNullable< + SshExecutionSpaceDescriptor['runtimeValidation'] + >, + architecture: 'x64' | 'arm64', + runtime: Awaited< + ReturnType + > +): void { + if ( + runtime.runtimeId !== 'opencode' || + runtime.architecture !== architecture || + runtime.bundleDigest !== validation.runtimeBundleDigest || + runtime.runtimeAdapterDigest !== + validation.runtimeAdapterDigest + ) { + throw new Error( + 'Installed OpenCode Runtime does not match project validation' + ) + } +} + +function assertConnectionMatches( + binding: RemoteWorkspaceProjectBinding, + connection: Awaited< + ReturnType + > +): void { + 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.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 + ) { + throw new Error( + 'Remote Agent connection does not match project validation' + ) + } +} + +function assertRuntimeAdvertised( + runtime: Awaited< + ReturnType + >, + capabilities: Awaited< + ReturnType< + RemoteAgentServices['connectionManager']['acquire'] + > + >['capabilities'] +): void { + const capability = capabilities.capabilities.find( + (entry) => entry.name === RUNTIME_ACP_CAPABILITY.name + ) + const modelBridgeCapability = capabilities.capabilities.find( + (entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY.name + ) + const advertised = capabilities.runtimes.filter( + (entry) => entry.runtimeId === 'opencode' + ) + if ( + capability === undefined || + capability.version !== + RUNTIME_ACP_CAPABILITY.exactVersion || + !capability.critical || + modelBridgeCapability === undefined || + modelBridgeCapability.version !== + RUNTIME_MODEL_BRIDGE_CAPABILITY.exactVersion || + !modelBridgeCapability.critical || + advertised.length !== 1 || + advertised[0]!.version !== runtime.runtimeVersion || + advertised[0]!.bundleDigest !== runtime.bundleDigest || + advertised[0]!.acpCapabilitiesDigest !== + runtime.acpCapabilitiesDigest || + !advertised[0]!.sessionLoad || + !advertised[0]!.sessionResume + ) { + throw new Error( + 'Remote Agent does not advertise the validated OpenCode Runtime' + ) + } +} diff --git a/src/main/remote-agent/managed-remote-execution-services.ts b/src/main/remote-agent/managed-remote-execution-services.ts new file mode 100644 index 00000000..65eee0eb --- /dev/null +++ b/src/main/remote-agent/managed-remote-execution-services.ts @@ -0,0 +1,247 @@ +import { join } from 'node:path' +import { SqliteRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store' +import { ModelCallOperationStore } from '../agent/model-call-operation-store' +import { RemoteModelGateway } from '../agent/remote-model-gateway' +import type { ResolvedModelProfile } from '../runtime-settings-store' +import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' +import type { SshHostStore } from '../ssh/ssh-host-store' +import { + resolveBundledRemoteRuntimeResourcePaths, + type BundledRemoteRuntimeResourcePaths +} from './bundled-remote-runtime-resources' +import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator' +import { + createManagedRemoteAcpRuntime, + type ManagedRemoteAcpRuntimeOptions +} from './managed-remote-acp-runtime' +import { ManagedRemoteWorkspaceAccessFactory } from './managed-remote-workspace-access-factory' +import type { RemoteAgentServices } from './remote-agent-services' +import { RemoteProjectSaveService } from './remote-project-save-service' +import { + RemoteRuntimeInstallationManager, + type RemoteRuntimeActivator +} from './remote-runtime-installation-manager' +import { + createRemoteRuntimeResourceLoader, + loadRemoteRuntimeVerificationMetadata +} from './remote-runtime-resource-loader' +import { + createManagedModelBridge, + reconcileStartupModelCalls +} from './managed-model-bridge' + +const RUNTIME_BINDING_DATABASE_NAME = 'remote-runtime-bindings.sqlite' +const MODEL_CALL_DATABASE_NAME = 'remote-model-calls-v2.sqlite' + +export type ManagedRemoteExecutionServicesOptions = { + sshHostStore: SshHostStore + agentServices: Pick< + RemoteAgentServices, + | 'targetResolver' + | 'sshPool' + | 'installationManager' + | 'connectionManager' + | 'controllerState' + > + userDataPath: string + appPath: string + resourcesPath: string + packaged: boolean + resolveModelProfile( + selection: AgentRuntimeSelection + ): Promise + resolveRuntimeSelection( + selection: AgentRuntimeSelection + ): Promise +} + +/** + * Main-only lifecycle owner for verified remote execution primitives. + * Construction performs no SSH or provider work. + */ +export class ManagedRemoteExecutionServices { + readonly runtimeResourcePaths: BundledRemoteRuntimeResourcePaths + readonly runtimeInstallationManager: RemoteRuntimeInstallationManager + readonly runtimeValidator: ManagedRemoteProjectRuntimeValidator + readonly workspaceAccessFactory: ManagedRemoteWorkspaceAccessFactory + readonly bindingStore: SqliteRuntimeSessionBindingStore + readonly modelCallStore: ModelCallOperationStore + readonly modelGateway: RemoteModelGateway + + #disposePromise?: Promise + readonly #readiness: Promise + readonly #resolveRuntimeSelection: ManagedRemoteExecutionServicesOptions['resolveRuntimeSelection'] + readonly #sshHosts: SshHostStore + readonly #agentServices: + ManagedRemoteExecutionServicesOptions['agentServices'] + + constructor(options: ManagedRemoteExecutionServicesOptions) { + this.#agentServices = options.agentServices + this.#resolveRuntimeSelection = options.resolveRuntimeSelection + this.#sshHosts = options.sshHostStore + this.runtimeResourcePaths = + resolveBundledRemoteRuntimeResourcePaths({ + appPath: options.appPath, + resourcesPath: options.resourcesPath, + packaged: options.packaged + }) + const activate: RemoteRuntimeActivator = async ( + lease, + runtimeId, + bundleDigest, + architecture, + signal + ) => { + const agent = + await options.agentServices.installationManager.ensureInstalled( + lease.identity.hostId, + { signal } + ) + 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 + ) { + throw new Error( + 'Remote Agent or SSH host identity changed before Runtime activation' + ) + } + const result = await lease.runAgentRuntimeAction( + agent.installationId, + { + kind: 'runtime-activate', + runtimeId, + bundleDigest, + architecture + }, + signal + ) + if (result.exitCode !== 0) { + throw new Error('Remote Runtime activation failed') + } + } + this.runtimeInstallationManager = + new RemoteRuntimeInstallationManager({ + resolver: options.agentServices.targetResolver, + sshPool: options.agentServices.sshPool, + loadVerifiedBundle: createRemoteRuntimeResourceLoader( + this.runtimeResourcePaths + ), + loadVerificationMetadata: () => + loadRemoteRuntimeVerificationMetadata( + this.runtimeResourcePaths + ), + activate + }) + this.runtimeValidator = new ManagedRemoteProjectRuntimeValidator({ + installationManager: this.runtimeInstallationManager, + resolveModelProfile: options.resolveModelProfile + }) + this.workspaceAccessFactory = + new ManagedRemoteWorkspaceAccessFactory({ + installationManager: + options.agentServices.installationManager, + connectionManager: options.agentServices.connectionManager + }) + this.bindingStore = new SqliteRuntimeSessionBindingStore( + join(options.userDataPath, RUNTIME_BINDING_DATABASE_NAME) + ) + this.modelCallStore = new ModelCallOperationStore( + join(options.userDataPath, MODEL_CALL_DATABASE_NAME) + ) + this.modelGateway = new RemoteModelGateway({ + store: this.modelCallStore + }) + this.#readiness = reconcileStartupModelCalls({ + gatewayStore: this.modelCallStore, + bindingStore: this.bindingStore + }) + } + + createProjectSaveService( + options: Pick< + ConstructorParameters[0], + 'database' | 'notify' + > + ): RemoteProjectSaveService { + return new RemoteProjectSaveService({ + database: options.database, + sshHosts: this.#sshHosts, + installationManager: + this.#agentServices.installationManager, + connectionManager: this.#agentServices.connectionManager, + resolveRuntimeSelection: this.#resolveRuntimeSelection, + runtimeValidator: this.runtimeValidator, + ...(options.notify ? { notify: options.notify } : {}) + }) + } + + async initialize(): Promise { + await this.#readiness + } + + async createRuntime( + options: Pick< + ManagedRemoteAcpRuntimeOptions, + 'executionSpace' | 'selection' | 'usage' + > & { + modelProfile: ResolvedModelProfile + } + ) { + await this.#readiness + const modelBridge = createManagedModelBridge({ + profile: options.modelProfile, + gateway: this.modelGateway, + bindingStore: this.bindingStore + }) + return createManagedRemoteAcpRuntime({ + ...options, + modelBridge, + agentServices: { + installationManager: + this.#agentServices.installationManager, + connectionManager: this.#agentServices.connectionManager, + controllerState: this.#agentServices.controllerState + }, + runtimeInstallationManager: + this.runtimeInstallationManager, + workspaceAccessFactory: this.workspaceAccessFactory, + bindingStore: this.bindingStore + }) + } + + dispose(): Promise { + this.#disposePromise ??= this.#dispose() + return this.#disposePromise + } + + async #dispose(): Promise { + const errors: unknown[] = [] + const settle = async ( + operation: () => void | Promise + ): Promise => { + try { + await operation() + } catch (error) { + errors.push(error) + } + } + await settle(() => this.runtimeInstallationManager.dispose()) + await settle(() => this.bindingStore.dispose()) + await settle(() => this.modelCallStore.dispose()) + if (errors.length > 0) { + throw new AggregateError( + errors, + 'Managed remote execution services failed to dispose completely' + ) + } + } +} diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts new file mode 100644 index 00000000..d7b8f29e --- /dev/null +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + DaemonCapabilities, + DaemonStatus +} from '../../shared/agent-protocol' +import type { RemoteAgentConnection } from './remote-agent-connection-manager' +import { ManagedRemoteProjectRuntimeValidator } from './managed-remote-project-runtime-validator' +import type { RemoteProjectRuntimeValidationInput } from './remote-project-save-service' +import type { RemoteRuntimeInstallationIdentity } from './remote-runtime-installation-manager' +import type { ResolvedModelProfile } from '../runtime-settings-store' + +const digest = (character: string): string => + `sha256:${character.repeat(64)}` + +const installation: RemoteRuntimeInstallationIdentity = { + runtimeId: 'opencode', + runtimeVersion: '1.2.3', + bundleDigest: digest('b'), + manifestDigest: digest('c'), + runtimeAdapterDigest: digest('d'), + acpCapabilitiesDigest: digest('f'), + platform: 'linux', + architecture: 'x64' +} +const modelProfile: ResolvedModelProfile = { + id: '00000000-0000-4000-8000-000000000001', + name: 'Private model', + baseUrl: 'https://provider.example/v1', + modelName: 'private-model', + protocol: 'openai-responses', + authentication: 'api-key', + apiKey: 'main-only-secret' +} + +function advertisedCapabilities(): DaemonCapabilities { + return { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true }, + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } + ], + runtimes: [ + { + runtimeId: 'opencode', + version: installation.runtimeVersion, + bundleDigest: installation.bundleDigest, + acpCapabilitiesDigest: + installation.acpCapabilitiesDigest, + sessionLoad: true, + sessionResume: true + } + ] + } +} + +function harness(options: { + selection?: RemoteProjectRuntimeValidationInput['selection'] + workMode?: 'ask' | 'execute' + architecture?: 'x64' | 'arm64' + installed?: RemoteRuntimeInstallationIdentity + refreshed?: DaemonCapabilities + modelProfile?: ResolvedModelProfile | null +} = {}) { + const calls: string[] = [] + const selection = + options.selection ?? + ({ + provider: 'opencode' + } as const) + const workMode = options.workMode ?? 'ask' + const architecture = options.architecture ?? 'x64' + const installed = options.installed ?? installation + let state: RemoteAgentConnection['state'] = 'ready' + let capabilities: DaemonCapabilities = { + generation: 1, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + } + const status: DaemonStatus = { + state: 'ready', + installationId: 'agent-installation', + binaryDigest: digest('a'), + daemonBootId: 'daemon-boot', + agentVersion: '2.0.0', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture, + supervisor: 'detached-on-demand', + remoteUserIdentity: 'remote-user', + draining: false + } + const refreshCapabilities = vi.fn(async () => { + calls.push('refresh') + capabilities = structuredClone( + options.refreshed ?? advertisedCapabilities() + ) + return structuredClone(capabilities) + }) + const connection = { + identity: { + cacheKey: 'cache-key', + hostId: 'host-1', + hostRevision: 3, + hostKeyGeneration: 2, + remoteUsername: 'builder', + installationId: status.installationId, + binaryDigest: status.binaryDigest, + protocolMajor: 1, + protocolMinor: 0 + }, + status, + get capabilities() { + return capabilities + }, + client: {}, + get state() { + return state + }, + refreshCapabilities, + reconnect: vi.fn(), + release: vi.fn() + } as unknown as RemoteAgentConnection + const ensureInstalled = vi.fn(async () => { + calls.push('install') + return installed + }) + const resolveModelProfile = vi.fn(async () => { + calls.push('model') + return options.modelProfile === null + ? undefined + : options.modelProfile ?? modelProfile + }) + const validator = new ManagedRemoteProjectRuntimeValidator({ + installationManager: { ensureInstalled }, + resolveModelProfile, + now: () => new Date('2030-01-01T00:00:00.000Z') + }) + 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, + hostKeyGeneration: 2, + remoteUsername: 'builder' + }, + agent: { + installationId: status.installationId, + 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 + } + return { + validator, + input, + connection, + calls, + ensureInstalled, + refreshCapabilities, + resolveModelProfile, + setCapabilities: (value: DaemonCapabilities) => { + capabilities = value + }, + setState: (value: RemoteAgentConnection['state']) => { + state = value + } + } +} + +describe('ManagedRemoteProjectRuntimeValidator', () => { + it('installs, refreshes, and leases exact Ask Runtime evidence', 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', { + 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({ + ...advertisedCapabilities(), + generation: 3 + }) + expect(() => lease.assertCurrent()).toThrow(/stale/iu) + lease.release() + lease.release() + expect(() => lease.assertCurrent()).toThrow(/released/iu) + }) + + it('validates Execute without a second authorization tier', async () => { + const test = harness({ workMode: 'execute' }) + const lease = await test.validator.validate(test.input) + + expect(lease.evidence).toMatchObject({ + workMode: 'execute' + }) + test.setState('offline') + expect(() => lease.assertCurrent()).toThrow(/not ready/iu) + }) + + it('rejects non-OpenCode selections before installation', async () => { + const test = harness({ + selection: { + provider: 'model', + profileId: '00000000-0000-4000-8000-000000000001' + } + }) + + await expect(test.validator.validate(test.input)).rejects.toThrow( + /OpenCode Runtime/iu + ) + expect(test.ensureInstalled).not.toHaveBeenCalled() + expect(test.refreshCapabilities).not.toHaveBeenCalled() + }) + + it('requires the installed Runtime architecture to exactly match the Agent', async () => { + const test = harness({ architecture: 'arm64' }) + + await expect(test.validator.validate(test.input)).rejects.toThrow( + /architecture/iu + ) + expect(test.refreshCapabilities).not.toHaveBeenCalled() + }) + + it('rejects an unavailable model profile before remote installation', async () => { + const test = harness({ modelProfile: null }) + + await expect(test.validator.validate(test.input)).rejects.toThrow( + /model profile/iu + ) + expect(test.resolveModelProfile).toHaveBeenCalledOnce() + expect(test.ensureInstalled).not.toHaveBeenCalled() + expect(test.refreshCapabilities).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'missing model bridge', + { + ...advertisedCapabilities(), + capabilities: [ + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true } + ] + } + ], + [ + 'wrong model bridge version', + { + ...advertisedCapabilities(), + capabilities: [ + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true }, + { + name: 'runtime/model-bridge', + version: 3, + critical: true + } + ] + } + ], + [ + 'non-critical ACP', + { + ...advertisedCapabilities(), + capabilities: [ + { + name: 'runtime/acp', + version: 3, + critical: false + } + ] + } + ], + [ + 'wrong ACP version', + { + ...advertisedCapabilities(), + capabilities: [ + { + name: 'runtime/acp', + version: 1, + critical: true + } + ] + } + ], + [ + 'duplicate OpenCode Runtime', + { + ...advertisedCapabilities(), + runtimes: [ + ...advertisedCapabilities().runtimes, + ...advertisedCapabilities().runtimes + ] + } + ], + [ + 'wrong Runtime version', + { + ...advertisedCapabilities(), + runtimes: [ + { + ...advertisedCapabilities().runtimes[0]!, + version: '9.9.9' + } + ] + } + ], + [ + 'wrong bundle digest', + { + ...advertisedCapabilities(), + runtimes: [ + { + ...advertisedCapabilities().runtimes[0]!, + bundleDigest: digest('9') + } + ] + } + ], + [ + 'wrong ACP digest', + { + ...advertisedCapabilities(), + runtimes: [ + { + ...advertisedCapabilities().runtimes[0]!, + acpCapabilitiesDigest: digest('8') + } + ] + } + ], + [ + 'missing session load', + { + ...advertisedCapabilities(), + runtimes: [ + { + ...advertisedCapabilities().runtimes[0]!, + sessionLoad: false + } + ] + } + ], + [ + 'missing session resume', + { + ...advertisedCapabilities(), + runtimes: [ + { + ...advertisedCapabilities().runtimes[0]!, + sessionResume: false + } + ] + } + ] + ])('fails closed for %s advertisement', async (_name, refreshed) => { + const test = harness({ + refreshed: refreshed as DaemonCapabilities + }) + + await expect(test.validator.validate(test.input)).rejects.toThrow( + /Runtime|ACP/iu + ) + }) +}) diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.ts new file mode 100644 index 00000000..6dba39d9 --- /dev/null +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.ts @@ -0,0 +1,232 @@ +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 +} from './remote-project-save-service' +import type { + RemoteRuntimeInstallationIdentity, + RemoteRuntimeInstallationManager +} from './remote-runtime-installation-manager' + +const RUNTIME_ACP_CAPABILITY = 'runtime/acp' +const RUNTIME_ACP_CAPABILITY_VERSION = 3 +const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge' +const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1 + +export class ManagedRemoteProjectRuntimeValidator + implements RemoteProjectRuntimeValidator +{ + readonly #installationManager: Pick< + RemoteRuntimeInstallationManager, + 'ensureInstalled' + > + readonly #now: () => Date + readonly #resolveModelProfile: ( + selection: RemoteProjectRuntimeValidationInput['selection'] + ) => Promise + + constructor(options: { + installationManager: Pick< + RemoteRuntimeInstallationManager, + 'ensureInstalled' + > + resolveModelProfile( + selection: RemoteProjectRuntimeValidationInput['selection'] + ): Promise + now?: () => Date + }) { + this.#installationManager = options.installationManager + this.#resolveModelProfile = options.resolveModelProfile + this.#now = options.now ?? (() => new Date()) + } + + async validate( + input: RemoteProjectRuntimeValidationInput + ): Promise { + input.signal.throwIfAborted() + if ( + input.selection.provider !== 'opencode' || + input.runtimeSelectionKey !== + agentRuntimeSelectionKey(input.selection) + ) { + throw new Error( + 'Managed remote projects require the OpenCode Runtime' + ) + } + assertInputIdentity(input) + const modelProfile = await this.#resolveModelProfile( + input.selection + ) + input.signal.throwIfAborted() + if ( + modelProfile === undefined || + modelProfile.protocol === 'openai-images-generations' || + (modelProfile.authentication === 'api-key' && + !modelProfile.apiKey) + ) { + throw new Error( + 'Managed remote OpenCode requires a usable text model profile' + ) + } + const installation = + await this.#installationManager.ensureInstalled( + input.host.hostId, + { signal: input.signal } + ) + input.signal.throwIfAborted() + assertInstallationIdentity(input, installation) + + const refreshed = await input.connection.refreshCapabilities( + input.signal + ) + input.signal.throwIfAborted() + if ( + input.connection.state !== 'ready' || + !isDeepStrictEqual(input.connection.capabilities, refreshed) + ) { + throw new Error( + 'Remote Agent capabilities changed during Runtime validation' + ) + } + 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( + 'Remote Runtime validation lease is released' + ) + } + if (input.connection.state !== 'ready') { + throw new Error( + 'Remote Runtime validation connection is not ready' + ) + } + let current: RemoteAgentConnection['capabilities'] + try { + current = input.connection.capabilities + } catch (error) { + throw new Error( + 'Remote Runtime validation connection is not ready', + { cause: error } + ) + } + if (!isDeepStrictEqual(current, capabilitySnapshot)) { + throw new Error( + 'Remote Runtime capability validation is stale' + ) + } + }, + release: (): void => { + released = true + } + } + } +} + +function assertInputIdentity( + input: RemoteProjectRuntimeValidationInput +): void { + const connection = input.connection + if ( + connection.state !== 'ready' || + connection.identity.hostId !== input.host.hostId || + connection.identity.hostRevision !== input.host.hostRevision || + connection.identity.hostKeyGeneration !== + input.host.hostKeyGeneration || + connection.identity.remoteUsername !== + input.host.remoteUsername || + connection.identity.installationId !== + input.agent.installationId || + connection.identity.binaryDigest !== input.agent.binaryDigest || + connection.identity.protocolMajor !== input.agent.protocolMajor || + connection.status.state !== 'ready' || + connection.status.draining || + connection.status.installationId !== + input.agent.installationId || + connection.status.binaryDigest !== input.agent.binaryDigest || + connection.status.agentVersion !== input.agent.version || + connection.status.architecture !== input.agent.architecture || + connection.status.protocol.major !== input.agent.protocolMajor + ) { + throw new Error( + 'Remote Agent identity does not match Runtime validation' + ) + } +} + +function assertInstallationIdentity( + input: RemoteProjectRuntimeValidationInput, + installation: RemoteRuntimeInstallationIdentity +): void { + if ( + installation.runtimeId !== 'opencode' || + installation.platform !== 'linux' || + installation.architecture !== input.agent.architecture + ) { + throw new Error( + 'Installed OpenCode Runtime architecture is incompatible' + ) + } +} + +function assertRuntimeCapability( + capabilities: RemoteAgentConnection['capabilities'], + installation: RemoteRuntimeInstallationIdentity +): void { + const acp = capabilities.capabilities.find( + (capability) => capability.name === RUNTIME_ACP_CAPABILITY + ) + const modelBridge = capabilities.capabilities.find( + (capability) => + capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY + ) + const runtimes = capabilities.runtimes.filter( + (runtime) => runtime.runtimeId === 'opencode' + ) + if ( + acp === undefined || + acp.version !== RUNTIME_ACP_CAPABILITY_VERSION || + !acp.critical || + modelBridge === undefined || + modelBridge.version !== + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION || + !modelBridge.critical || + runtimes.length !== 1 + ) { + throw new Error( + 'Remote Agent does not advertise the required Runtime ACP capability' + ) + } + const runtime = runtimes[0]! + if ( + runtime.version !== installation.runtimeVersion || + runtime.bundleDigest !== installation.bundleDigest || + runtime.acpCapabilitiesDigest !== + installation.acpCapabilitiesDigest || + !runtime.sessionLoad || + !runtime.sessionResume + ) { + throw new Error( + 'Advertised OpenCode Runtime does not match the verified installation' + ) + } +} diff --git a/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts b/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts new file mode 100644 index 00000000..1aff84eb --- /dev/null +++ b/src/main/remote-agent/managed-remote-workspace-access-factory.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_VERSION, + type DaemonCapabilities +} 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, + type ManagedRemoteWorkspaceAccessFactoryOptions +} from './managed-remote-workspace-access-factory' +import type { + RemoteAgentConnection, + RemoteAgentConnectionManager +} from './remote-agent-connection-manager' + +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 +} +const installation: AgentInstallationIdentity = { + installationId: verifyAgentInstallationId( + binding.agentInstallationId + ), + binaryDigest: binding.agentBinaryDigest, + agentVersion: binding.agentVersion, + protocol: { ...AGENT_PROTOCOL_VERSION }, + platform: 'linux', + architecture: binding.agentArchitecture, + supervisor: 'detached-on-demand' +} +const handle: RemoteWorkspaceHandle = { + workspaceId: 'workspace-1', + workspaceIdentity: binding.workspaceIdentity, + canonicalDisplayPath: binding.remoteRootPath, + access: 'read-only', + git: 'available', + capabilities: [ + 'list', + 'stat', + 'read-text', + 'search', + 'git-status', + 'git-diff' + ], + generation: 1 +} + +describe('ManagedRemoteWorkspaceAccessFactory', () => { + it('verifies installation before network work 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}`, + canonicalDisplayPath: binding.remoteRootPath, + access: 'read-only' + }) + + expect(test.events.slice(0, 2)).toEqual([ + 'agent:ensure', + 'connection:acquire' + ]) + expect(test.protocolMethods).toContain('workspace/validate') + expect(test.protocolMethods).not.toContain('workspace/open') + expect(test.protocolMethods).not.toContain('workspace/resume') + + await access.dispose() + await access.dispose() + expect(test.connectionRelease).toHaveBeenCalledOnce() + expect( + test.protocolMethods.filter( + (method) => method === 'workspace/close' + ) + ).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') + }) + const access = test.factory.create(binding) + + await expect(access.getIdentity()).rejects.toThrow( + 'network unavailable' + ) + expect(test.connectionRelease).not.toHaveBeenCalled() + }) +}) + +type HarnessOptions = { + acquireError?: Error + ensureInstalled?: () => Promise +} + +function createHarness(options: HarnessOptions = {}): { + factory: ManagedRemoteWorkspaceAccessFactory + events: string[] + protocolMethods: string[] + ensureInstalled: ReturnType + acquire: ReturnType + connectionRelease: ReturnType +} { + const events: string[] = [] + const protocolMethods: string[] = [] + const ensureInstalled = vi.fn(async () => { + events.push('agent:ensure') + return options.ensureInstalled + ? await options.ensureInstalled() + : installation + }) + const client = { + request: vi.fn(async (method: string) => { + protocolMethods.push(method) + if (method === 'agent/capabilities') { + return capabilities + } + if (method === 'workspace/validate') { + return { + handle, + validatedAt: '2026-08-21T00:00:00.000Z' + } + } + if (method === 'workspace/close') { + return { + workspaceId: handle.workspaceId, + generation: handle.generation, + closed: true + } + } + throw new Error(`Unexpected protocol method: ${method}`) + }) + } as unknown as AgentProtocolClient + const connectionRelease = vi.fn(() => { + events.push('connection:release') + }) + const connection: RemoteAgentConnection = { + 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 + }, + status: { + state: 'ready', + installationId: binding.agentInstallationId, + binaryDigest: binding.agentBinaryDigest, + daemonBootId: 'boot-1', + agentVersion: binding.agentVersion, + protocol: { ...installation.protocol }, + platform: 'linux', + architecture: binding.agentArchitecture, + supervisor: 'detached-on-demand', + remoteUserIdentity: 'uid:1000', + draining: false + }, + capabilities, + client, + state: 'ready', + reconnect: async () => undefined, + refreshCapabilities: async () => capabilities, + updateAcpBinding: async () => undefined, + flushAcpBindings: async () => undefined, + release: connectionRelease + } + const acquire = vi.fn(async () => { + events.push('connection:acquire') + if (options.acquireError) { + throw options.acquireError + } + return connection + }) + const factoryOptions = { + installationManager: { + ensureInstalled + } as unknown as Pick, + connectionManager: { + acquire + } as unknown as Pick + } satisfies ManagedRemoteWorkspaceAccessFactoryOptions + return { + factory: new ManagedRemoteWorkspaceAccessFactory(factoryOptions), + events, + protocolMethods, + ensureInstalled, + acquire, + connectionRelease + } +} + +const capabilities: DaemonCapabilities = { + generation: 1, + capabilities: [ + { + name: 'workspace/read', + version: 1, + critical: true + } + ], + runtimes: [] +} diff --git a/src/main/remote-agent/managed-remote-workspace-access-factory.ts b/src/main/remote-agent/managed-remote-workspace-access-factory.ts new file mode 100644 index 00000000..fd9e058d --- /dev/null +++ b/src/main/remote-agent/managed-remote-workspace-access-factory.ts @@ -0,0 +1,84 @@ +import { + RemoteWorkspaceAccess, + 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 { + ProtocolRemoteWorkspaceTransport, + type RemoteWorkspaceInstallationIdentityResolver +} from './protocol-remote-workspace-transport' + +export type ManagedRemoteWorkspaceAccessFactoryOptions = { + installationManager: Pick< + AgentInstallationManager, + 'ensureInstalled' + > + connectionManager: Pick +} + +/** + * Composes persisted project validation with fresh installation and + * connection leases. The resulting WorkspaceAccess remains strictly + * read-only and owns every acquired connection lease. + */ +export class ManagedRemoteWorkspaceAccessFactory + implements RemoteWorkspaceAccessFactory +{ + constructor( + private readonly options: ManagedRemoteWorkspaceAccessFactoryOptions + ) {} + + create(bindingInput: RemoteWorkspaceProjectBinding): RemoteWorkspaceAccess { + const binding = { ...bindingInput } + const resolver: RemoteWorkspaceInstallationIdentityResolver = { + resolve: async (hostId, installationId, signal) => { + if ( + hostId !== binding.hostId || + installationId !== binding.agentInstallationId + ) { + throw new Error( + 'Remote workspace installation request 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 new RemoteWorkspaceAccess( + binding, + new ProtocolRemoteWorkspaceTransport( + this.options.connectionManager, + resolver + ) + ) + } +} + +function assertInstallationMatches( + binding: RemoteWorkspaceProjectBinding, + installation: Awaited< + ReturnType + > +): void { + if ( + installation.installationId !== binding.agentInstallationId || + installation.binaryDigest !== binding.agentBinaryDigest || + installation.agentVersion !== binding.agentVersion || + installation.architecture !== binding.agentArchitecture || + installation.protocol.major !== binding.agentProtocolMajor + ) { + throw new Error( + 'Installed Remote Agent does not match the persisted project binding' + ) + } +} diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts new file mode 100644 index 00000000..a15fc63b --- /dev/null +++ b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts @@ -0,0 +1,1528 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_LIMITS, + ChannelProtocolError, + type DaemonCapabilities +} from '../../shared/agent-protocol' +import { + createModelBridgeRequestMessage, + encodeModelBridgeMessage +} from '../../shared/model-bridge-contracts' +import type { RemoteAgentConnection } from './remote-agent-connection-manager' +import { + AgentProtocolClientError, + AgentRpcError +} from './agent-protocol-client' +import { AgentAttachTransportError } from './agent-attach-transport' +import { + ProtocolRemoteRuntimeChannelError, + createProtocolRemoteRuntimeChannel, + type RuntimeProtocolBinaryChannel, + type RuntimeProtocolBinaryFrame, + type RuntimeProtocolClient, + type RuntimeProtocolConnection, + type RuntimeProtocolMethod, + type RuntimeProtocolParams, + type RuntimeProtocolRequestOptions, + type RuntimeProtocolResult +} from './protocol-remote-runtime-channel' + +const runtimeBundleDigest = digest('a') +const acpCapabilitiesDigest = digest('b') + +type RecordedRequest = { + method: RuntimeProtocolMethod + params: unknown + options?: RuntimeProtocolRequestOptions +} + +class FakeBinaryChannel implements RuntimeProtocolBinaryChannel { + readonly channelId: string + readonly channelEpoch: string + readonly sent: Uint8Array[] = [] + consumed = 0 + closed = false + sendGate?: Promise + onConsume?: () => void + #frames: RuntimeProtocolBinaryFrame[] = [] + #receivers: Array<{ + resolve: (frame: RuntimeProtocolBinaryFrame) => void + reject: (error: unknown) => void + signal?: AbortSignal + abort?: () => void + }> = [] + #closeListeners = new Set<(error?: unknown) => void>() + + constructor(channelId: string, channelEpoch: string) { + this.channelId = channelId + this.channelEpoch = channelEpoch + } + + async send(payload: Uint8Array, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.sendGate + signal?.throwIfAborted() + this.sent.push(new Uint8Array(payload)) + } + + receive(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const frame = this.#frames.shift() + if (frame !== undefined) { + return Promise.resolve(frame) + } + return new Promise((resolve, reject) => { + const receiver = { resolve, reject, signal } + if (signal !== undefined) { + const abort = (): void => { + const index = this.#receivers.indexOf(receiver) + if (index >= 0) { + this.#receivers.splice(index, 1) + } + reject(signal.reason) + } + Object.assign(receiver, { abort }) + signal.addEventListener('abort', abort, { once: true }) + } + this.#receivers.push(receiver) + }) + } + + push(payload: Uint8Array, sequence = '1'): void { + let consumed = false + const frame: RuntimeProtocolBinaryFrame = { + payload: new Uint8Array(payload), + sequence, + consume: async () => { + if (!consumed) { + consumed = true + this.consumed += 1 + this.onConsume?.() + } + } + } + const receiver = this.#receivers.shift() + if (receiver === undefined) { + this.#frames.push(frame) + return + } + if (receiver.abort !== undefined) { + receiver.signal?.removeEventListener('abort', receiver.abort) + } + receiver.resolve(frame) + } + + close(error?: unknown): void { + if (this.closed) { + return + } + this.closed = true + for (const receiver of this.#receivers.splice(0)) { + if (receiver.abort !== undefined) { + receiver.signal?.removeEventListener('abort', receiver.abort) + } + receiver.reject(new Error('binary closed')) + } + for (const listener of this.#closeListeners) { + listener(error) + } + } + + async closeWithNotification(): Promise { + this.close() + } + + onClose(listener: (error?: unknown) => void): () => void { + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } +} + +class FakeRuntimeClient implements RuntimeProtocolClient { + generation: number + readonly requests: RecordedRequest[] = [] + readonly events: string[] = [] + readonly registrations: Array<{ + channelId: string + channelEpoch: string + kind: 'acp' + nextInboundSequence?: string + nextOutboundSequence?: string + }> = [] + binary = new FakeBinaryChannel('binding-1', '9') + readonly blobBinaries: FakeBinaryChannel[] = [] + responder: ( + method: RuntimeProtocolMethod, + params: unknown + ) => unknown | Promise = defaultResponse + #closeListeners = new Set<(error?: unknown) => void>() + + constructor(generation = 7) { + this.generation = generation + } + + async request( + method: M, + params: RuntimeProtocolParams, + options?: RuntimeProtocolRequestOptions + ): Promise> { + options?.signal?.throwIfAborted() + this.events.push(`request:${method}`) + this.requests.push({ method, params, options }) + return (await this.responder(method, params)) as RuntimeProtocolResult + } + + registerBinaryChannel(input: { + channelId: string + channelEpoch: string + kind: 'acp' + nextInboundSequence?: string + nextOutboundSequence?: string + }): RuntimeProtocolBinaryChannel { + this.events.push('register') + this.registrations.push(input) + expect(input.kind).toBe('acp') + this.binary = new FakeBinaryChannel( + input.channelId, + input.channelEpoch + ) + return this.binary + } + + allocateBinaryChannel(input: { + kind: 'blob' + }): RuntimeProtocolBinaryChannel { + expect(input.kind).toBe('blob') + const binary = new FakeBinaryChannel( + `model-${this.blobBinaries.length + 1}`, + String(100 + this.blobBinaries.length) + ) + this.blobBinaries.push(binary) + return binary + } + + onClose(listener: (error?: unknown) => void): () => void { + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + transportClose(error?: unknown): void { + this.binary.close(error) + for (const listener of this.#closeListeners) { + listener(error) + } + } +} + +type ConnectionFixture = { + connection: RuntimeProtocolConnection + client: FakeRuntimeClient + setReconnectClient(client: FakeRuntimeClient): void + setState(state: RemoteAgentConnection['state']): void + setCapabilityGeneration(generation: number): void + setCapabilities(capabilities: DaemonCapabilities): void + connectionClose(): void + release: ReturnType +} + +function connectionFixture(): ConnectionFixture { + const client = new FakeRuntimeClient() + let currentClient = client + let reconnectClient: FakeRuntimeClient | undefined + let state: RemoteAgentConnection['state'] = 'ready' + let capabilities = validCapabilities() + const release = vi.fn() + const closeListeners = new Set<() => void>() + const connection = { + identity: { + cacheKey: 'host-1:agent-1', + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + remoteUsername: 'user', + installationId: 'agent-1', + binaryDigest: digest('c'), + protocolMajor: 1, + protocolMinor: 0 + }, + status: { + state: 'ready', + installationId: 'agent-1', + binaryDigest: digest('c'), + daemonBootId: 'boot-1', + agentVersion: '1.0.0', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'user-1', + draining: false + }, + get capabilities() { + return capabilities + }, + get state() { + return state + }, + get client() { + return currentClient + }, + reconnect: vi.fn(async (signal?: AbortSignal) => { + signal?.throwIfAborted() + if (reconnectClient === undefined) { + throw new Error('no reconnect client') + } + currentClient = reconnectClient + state = 'ready' + for (const listener of clientChangeListeners) { + listener() + } + }), + onClientChange(listener: () => void) { + clientChangeListeners.add(listener) + return () => clientChangeListeners.delete(listener) + }, + updateAcpBinding: vi.fn(async () => undefined), + flushAcpBindings: vi.fn(async () => undefined), + onClose(listener: () => void) { + closeListeners.add(listener) + return () => closeListeners.delete(listener) + }, + release + } satisfies RuntimeProtocolConnection + const clientChangeListeners = new Set<() => void>() + return { + connection, + client, + setReconnectClient(next) { + reconnectClient = next + }, + setState(next) { + state = next + }, + setCapabilityGeneration(generation) { + capabilities = { ...capabilities, generation } + }, + setCapabilities(next) { + capabilities = next + }, + connectionClose() { + state = 'offline' + for (const listener of closeListeners) { + listener() + } + }, + release + } +} + +function validCapabilities(): DaemonCapabilities { + return { + generation: 11, + capabilities: [ + { name: 'runtime/acp', version: 3, critical: true } + ], + runtimes: [ + { + runtimeId: 'opencode', + version: '1.0.0', + bundleDigest: runtimeBundleDigest, + acpCapabilitiesDigest, + sessionLoad: true, + sessionResume: true + } + ] + } +} + +function modelBridgeCapabilities(): DaemonCapabilities { + const capabilities = validCapabilities() + return { + ...capabilities, + capabilities: [ + ...capabilities.capabilities, + { + name: 'runtime/model-bridge', + version: 1, + critical: true + } + ] + } +} + +function openIdentity() { + return { + bindingId: 'binding-1', + runtimeId: 'opencode', + runtimeBundleDigest, + workspaceIdentity: 'workspace-1' + } +} + +function defaultResponse( + method: RuntimeProtocolMethod, + params: unknown +): unknown { + if (method === 'runtime/openAcpChannel') { + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + acpCapabilitiesDigest + } + } + if (method === 'runtime/closeAcpChannel') { + return { + bindingId: 'binding-1', + channelEpoch: '9', + closed: true + } + } + if (method === 'runtime/getAcpCursors') { + return { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '1', + lastInboundJournaledSequence: '4', + lastMainAckSequence: '3' + } + } + if (method === 'runtime/escalateCancellation') { + return { + bindingId: 'binding-1', + stopped: true + } + } + if (method === 'runtime/completePrompt') { + return { + ...(params as Record), + status: 'completed', + processTree: 'running' + } + } + if (method === 'runtime/reconcilePrompt') { + return { + status: 'terminal', + terminalState: 'completed', + processTree: 'empty' + } + } + return params +} + +async function openChannel(fixture = connectionFixture()) { + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + releaseConnectionOnClose: true, + controlTimeoutMs: 1_000 + }) + return { ...fixture, channel } +} + +describe('ProtocolRemoteRuntimeChannel', () => { + it('opens before registering binary and streams bytes in both directions', async () => { + const fixture = await openChannel() + expect(fixture.client.events.slice(0, 2)).toEqual([ + 'request:runtime/openAcpChannel', + 'register' + ]) + + fixture.client.binary.push(Buffer.from('runtime output')) + const reader = fixture.channel.input.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array(Buffer.from('runtime output')) + }) + await vi.waitFor(() => + expect(fixture.client.binary.consumed).toBe(1) + ) + + const writer = fixture.channel.output.getWriter() + await writer.write(Buffer.from('runtime input')) + expect(fixture.client.binary.sent).toEqual([ + new Uint8Array(Buffer.from('runtime input')) + ]) + await fixture.channel.close() + }) + + it('reports the rejected Runtime method and bounded RPC category', async () => { + const fixture = await openChannel() + fixture.client.responder = (method, params) => { + if (method === 'runtime/preparePrompt') { + throw new AgentRpcError( + -32000, + 'private remote detail', + { + code: 'process', + privateValue: 'must-not-escape' + } + ) + } + return defaultResponse(method, params) + } + const error = await fixture.channel + .preparePrompt({ + bindingId: 'binding-1', + operationId: 'operation-1', + requestId: 'operation-1', + workMode: 'execute', + controllerId: 'controller-1', + controllerGeneration: 1, + connectionGeneration: fixture.channel.generation, + channelEpoch: fixture.channel.channelEpoch, + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + workspaceIdentity: 'workspace-1', + agentInstallationId: 'agent-1', + runtimeId: 'opencode', + runtimeBundleDigest, + runtimeAdapterDigest: digest('d'), + deadlineAt: new Date(Date.now() + 10_000).toISOString(), + budget: { + maximumInputBytes: 1_024, + maximumOutputBytes: 1_024 + } + }) + .then( + () => undefined, + (reason: unknown) => reason + ) + + expect(error).toBeInstanceOf(ProtocolRemoteRuntimeChannelError) + expect(error).toMatchObject({ + reason: 'remote', + remoteMethod: 'runtime/preparePrompt', + remoteRpcCode: -32000, + remoteServiceCode: 'process', + remoteRequestOutcome: 'rejected' + }) + expect((error as Error).message).toContain( + 'runtime/preparePrompt' + ) + expect((error as Error).message).toContain('process') + expect((error as Error).message).not.toContain('private') + expect((error as Error).message).not.toContain('must-not-escape') + await fixture.channel.close() + }) + + it('flushes the highest consumed cursor at prompt and close boundaries', async () => { + const fixture = await openChannel() + const reader = fixture.channel.input.getReader() + for (let sequence = 1; sequence <= 20; sequence += 1) { + fixture.client.binary.push( + Buffer.from(`frame-${sequence}`), + String(sequence) + ) + await reader.read() + } + await vi.waitFor(() => + expect(fixture.client.binary.consumed).toBe(20) + ) + + expect( + vi.mocked(fixture.connection.updateAcpBinding!).mock.calls.length + ).toBeLessThanOrEqual(21) + const flushesBeforeCompletion = vi.mocked( + fixture.connection.flushAcpBindings! + ).mock.calls.length + await fixture.channel.completePromptOperation({ + bindingId: 'binding-1', + operationId: 'operation-1', + requestId: 'operation-1' + }) + expect( + fixture.connection.flushAcpBindings + ).toHaveBeenCalledTimes(flushesBeforeCompletion + 1) + expect( + fixture.connection.updateAcpBinding + ).toHaveBeenCalledTimes(21) + expect( + vi.mocked(fixture.connection.updateAcpBinding!).mock.calls.at(-1)?.[1] + ).toMatchObject({ + cursors: { + lastInboundJournaledSequence: '20', + lastMainAckSequence: '20' + } + }) + + await fixture.channel.close() + expect( + fixture.connection.flushAcpBindings + ).toHaveBeenCalledTimes(flushesBeforeCompletion + 3) + }) + + it('durably flushes an emitted cursor before sending its Agent ACK', async () => { + const fixture = await openChannel() + const events: string[] = [] + let releaseFlush!: () => void + const flushGate = new Promise((resolve) => { + releaseFlush = resolve + }) + vi.mocked( + fixture.connection.updateAcpBinding + ).mockImplementation(async (_bindingId, binding) => { + if (binding?.cursors.lastMainAckSequence === '1') { + events.push('persist') + } + }) + vi.mocked( + fixture.connection.flushAcpBindings + ).mockImplementation(async () => { + events.push('flush-start') + await flushGate + events.push('flush-end') + }) + fixture.client.binary.onConsume = () => events.push('ack') + + const reader = fixture.channel.input.getReader() + fixture.client.binary.push(Buffer.from('durable-first'), '1') + await expect(reader.read()).resolves.toMatchObject({ + done: false, + value: new Uint8Array(Buffer.from('durable-first')) + }) + await vi.waitFor(() => + expect(events).toContain('flush-start') + ) + expect(fixture.client.binary.consumed).toBe(0) + expect(events).toEqual(['persist', 'flush-start']) + + releaseFlush() + await vi.waitFor(() => + expect(fixture.client.binary.consumed).toBe(1) + ) + expect(events).toEqual([ + 'persist', + 'flush-start', + 'flush-end', + 'ack' + ]) + await fixture.channel.close() + }) + + it('does not regress a consumed cursor when the Agent ACK view lags', async () => { + const fixture = await openChannel() + const reader = fixture.channel.input.getReader() + fixture.client.binary.push(Buffer.from('runtime output'), '1') + await reader.read() + await vi.waitFor(() => { + expect( + fixture.connection.updateAcpBinding + ).toHaveBeenCalledTimes(2) + }) + fixture.client.responder = (method, params) => { + if (method === 'runtime/getAcpCursors') { + return { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '1', + lastMainAckSequence: '0' + } + } + return defaultResponse(method, params) + } + const updatesBeforeRefresh = vi.mocked( + fixture.connection.updateAcpBinding! + ).mock.calls.length + + await expect( + fixture.channel.getBindingCursors('binding-1') + ).resolves.toEqual({ + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '1', + lastMainAckSequence: '1' + }) + expect( + vi.mocked(fixture.connection.updateAcpBinding!).mock.calls.at(-1)?.[1] + ).toMatchObject({ + cursors: { + lastInboundJournaledSequence: '1', + lastMainAckSequence: '1' + } + }) + expect( + vi.mocked(fixture.connection.updateAcpBinding!) + ).toHaveBeenCalledTimes(updatesBeforeRefresh) + + await fixture.channel.close() + }) + + it('does not consume or deliver inbound frames while paused', async () => { + const fixture = await openChannel() + await fixture.channel.setInboundPaused(true) + const reader = fixture.channel.input.getReader() + const reading = reader.read() + fixture.client.binary.push(Buffer.from('held')) + await Promise.resolve() + expect(fixture.client.binary.consumed).toBe(0) + + await fixture.channel.setInboundPaused(false) + await expect(reading).resolves.toMatchObject({ + done: false, + value: new Uint8Array(Buffer.from('held')) + }) + await vi.waitFor(() => + expect(fixture.client.binary.consumed).toBe(1) + ) + await fixture.channel.close() + }) + + it('merges a cursor refresh with concurrently consumed output at commit', async () => { + const fixture = await openChannel() + const reader = fixture.channel.input.getReader() + fixture.client.binary.push(Buffer.from('first'), '1') + await reader.read() + + let markRefreshStarted!: () => void + let releaseRefresh!: () => void + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve + }) + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve + }) + fixture.client.responder = async (method, params) => { + if (method === 'runtime/getAcpCursors') { + markRefreshStarted() + await refreshGate + return { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '2', + lastMainAckSequence: '0' + } + } + return defaultResponse(method, params) + } + + const refreshing = + fixture.channel.getBindingCursors('binding-1') + await refreshStarted + fixture.client.binary.push(Buffer.from('second'), '2') + await reader.read() + releaseRefresh() + + await expect(refreshing).resolves.toEqual({ + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '2', + lastMainAckSequence: '2' + }) + const persistedAcks = vi.mocked( + fixture.connection.updateAcpBinding! + ).mock.calls + .map((call) => call[1]?.cursors.lastMainAckSequence) + .filter((sequence): sequence is string => sequence !== undefined) + .map(BigInt) + expect(persistedAcks).toEqual( + [...persistedAcks].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + ) + ) + + await fixture.channel.close() + }) + + it('chunks output at the ACP frame limit and awaits send backpressure', async () => { + const fixture = await openChannel() + let releaseSend = (): void => undefined + fixture.client.binary.sendGate = new Promise((resolve) => { + releaseSend = resolve + }) + const writer = fixture.channel.output.getWriter() + const bytes = Buffer.alloc( + AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes * 2 + 3, + 7 + ) + let settled = false + const writing = writer.write(bytes).finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + releaseSend() + await writing + expect( + fixture.client.binary.sent.map((frame) => frame.byteLength) + ).toEqual([ + AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes, + AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes, + 3 + ]) + await fixture.channel.close() + }) + + it('rejects stale-generation controls without replaying the request', async () => { + const fixture = await openChannel() + fixture.setCapabilityGeneration(12) + const before = fixture.client.requests.length + await expect( + fixture.channel.getBindingCursors('binding-1') + ).rejects.toMatchObject({ + name: 'StaleRemoteRuntimeGenerationError' + }) + expect(fixture.client.requests).toHaveLength(before) + await fixture.channel.close() + }) + + it.each([ + { + label: 'non-critical capability', + mutate: (capabilities: DaemonCapabilities): DaemonCapabilities => ({ + ...capabilities, + capabilities: [ + { name: 'runtime/acp', version: 3, critical: false } + ] + }) + }, + { + label: 'non-exact capability version', + mutate: (capabilities: DaemonCapabilities): DaemonCapabilities => ({ + ...capabilities, + capabilities: [ + { name: 'runtime/acp', version: 1, critical: true } + ] + }) + }, + { + label: 'runtime digest mismatch', + mutate: (capabilities: DaemonCapabilities): DaemonCapabilities => ({ + ...capabilities, + runtimes: [ + { + ...capabilities.runtimes[0]!, + bundleDigest: digest('d') + } + ] + }) + } + ])('fails before open for $label', async ({ mutate }) => { + const fixture = connectionFixture() + fixture.setCapabilities(mutate(validCapabilities())) + await expect( + createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity() + }) + ).rejects.toBeInstanceOf(ProtocolRemoteRuntimeChannelError) + expect(fixture.client.requests).toHaveLength(0) + }) + + it('closes once and releases its optional connection lease once', async () => { + const fixture = await openChannel() + const first = fixture.channel.close() + const second = fixture.channel.close() + expect(first).toBe(second) + await Promise.all([first, second, fixture.channel.closed]) + expect( + fixture.client.requests.filter( + (request) => request.method === 'runtime/closeAcpChannel' + ) + ).toHaveLength(1) + expect(fixture.client.binary.closed).toBe(true) + expect(fixture.release).toHaveBeenCalledTimes(1) + }) + + it('retains persisted recovery identity when remote close is not confirmed', async () => { + const fixture = await openChannel() + fixture.client.responder = (method, params) => { + if (method === 'runtime/closeAcpChannel') { + throw new Error('transport unavailable') + } + return defaultResponse(method, params) + } + + await fixture.channel.close() + + expect(fixture.connection.updateAcpBinding).not.toHaveBeenCalledWith( + 'binding-1', + undefined + ) + }) + + it('requires model bridge v1 and allocates a fresh blob channel for each prompt', async () => { + const missing = connectionFixture() + await expect( + createProtocolRemoteRuntimeChannel({ + connection: missing.connection, + openIdentity: openIdentity(), + modelBridge: { + dispatch: async () => ({ + status: 200, + headers: {}, + bodyBase64: '' + }), + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + ).rejects.toMatchObject({ reason: 'capability-mismatch' }) + expect(missing.client.requests).toHaveLength(0) + + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + releaseConnectionOnClose: true, + modelBridge: { + dispatch: async () => ({ + status: 200, + headers: {}, + bodyBase64: '' + }), + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + const policy = { + protocol: 'openai-responses' as const, + model: 'gpt-test', + modelProfileDigest: digest('e'), + supportsImageInput: false + } + const first = await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy + }) + await expect(first.close()).resolves.toEqual({ + clean: true, + poisoned: false + }) + const second = await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-2', + requestId: 'prompt-2', + policy + }) + expect(second.channelId).not.toBe(first.channelId) + expect(second.channelEpoch).not.toBe(first.channelEpoch) + expect(fixture.client.blobBinaries).toHaveLength(2) + await second.close() + await channel.close() + }) + + it('waits for owned model bridges before releasing the ACP connection', async () => { + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + releaseConnectionOnClose: true, + modelBridge: { + dispatch: async () => ({ + status: 200, + headers: {}, + bodyBase64: '' + }), + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + const bridge = await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy: { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: digest('d'), + supportsImageInput: false + } + }) + let releaseBridge!: () => void + const bridgeGate = new Promise((resolve) => { + releaseBridge = resolve + }) + let closeStarted!: () => void + const closeWasStarted = new Promise((resolve) => { + closeStarted = resolve + }) + vi.spyOn(bridge, 'close').mockImplementation(async () => { + closeStarted() + await bridgeGate + return { clean: false, poisoned: true } + }) + + const closing = channel.close() + await closeWasStarted + expect(fixture.release).not.toHaveBeenCalled() + releaseBridge() + await closing + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('redacts cancellation reasons before issuing the control request', async () => { + const fixture = await openChannel() + const secret = new Error('token=do-not-serialize') + secret.stack = 'private stack' + await fixture.channel.escalateCancellation({ + bindingId: 'binding-1', + sessionId: 'session-1', + operationId: 'operation-1', + requestId: 'operation-1', + reason: secret + }) + const request = fixture.client.requests.find( + (entry) => entry.method === 'runtime/escalateCancellation' + ) + expect(request?.params).toEqual({ + bindingId: 'binding-1', + sessionId: 'session-1', + operationId: 'operation-1', + requestId: 'operation-1', + reason: 'error' + }) + expect(JSON.stringify(request?.params)).not.toContain('do-not-serialize') + expect(JSON.stringify(request?.params)).not.toContain('private stack') + await fixture.channel.close() + }) + + it('completes a stable prompt operation without replaying it', async () => { + const fixture = await openChannel() + await expect( + fixture.channel.completePromptOperation({ + bindingId: 'binding-1', + operationId: 'operation-1', + requestId: 'operation-1' + }) + ).resolves.toEqual({ + bindingId: 'binding-1', + operationId: 'operation-1', + requestId: 'operation-1', + status: 'completed', + processTree: 'running' + }) + expect( + fixture.client.requests.filter( + (request) => request.method === 'runtime/completePrompt' + ) + ).toHaveLength(1) + await fixture.channel.close() + }) + + it('strictly rejects invalid cursor and reconciliation responses', async () => { + const fixture = await openChannel() + fixture.client.responder = (method, params) => { + if (method === 'runtime/getAcpCursors') { + return { + lastOutboundJournaledSequence: '1', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + } + return defaultResponse(method, params) + } + await expect( + fixture.channel.getBindingCursors('binding-1') + ).rejects.toMatchObject({ reason: 'protocol' }) + + fixture.client.responder = (method, params) => { + if (method === 'runtime/reconcilePrompt') { + return { + status: 'terminal', + terminalState: 'failed', + processTree: 'running' + } + } + return defaultResponse(method, params) + } + await expect( + fixture.channel.reconcilePromptOperation({ + bindingId: 'binding-1', + operationId: 'operation-1', + requestId: 'operation-1' + }) + ).rejects.toMatchObject({ reason: 'protocol' }) + await fixture.channel.close() + }) + + it('keeps streams alive, seeds exact sequences, and ACKs replay duplicates without re-emitting them', async () => { + const fixture = await openChannel() + const recoveryAbort = new AbortController() + const deadlineAt = new Date(Date.now() + 10_000).toISOString() + fixture.channel.setRecoveryBoundary( + deadlineAt, + recoveryAbort.signal + ) + const reader = fixture.channel.input.getReader() + const writer = fixture.channel.output.getWriter() + + fixture.client.binary.push(Buffer.from('already-emitted'), '1') + await expect(reader.read()).resolves.toMatchObject({ + done: false, + value: new Uint8Array(Buffer.from('already-emitted')) + }) + await writer.write(Buffer.from('initial-input-1')) + await writer.write(Buffer.from('initial-input-2')) + + const resumed = new FakeRuntimeClient(8) + resumed.responder = (method, params) => { + if (method === 'runtime/resumeAcpChannel') { + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + deadlineAt, + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '2', + lastMainAckSequence: '0' + } + } + } + if (method === 'runtime/replayAcpChannel') { + resumed.binary.push(Buffer.from('already-emitted'), '1') + resumed.binary.push(Buffer.from('replayed-once'), '2') + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + replayedThroughSequence: '2', + live: true + } + } + return defaultResponse(method, params) + } + fixture.setReconnectClient(resumed) + + const replayed = reader.read() + fixture.client.transportClose() + await expect(replayed).resolves.toMatchObject({ + done: false, + value: new Uint8Array(Buffer.from('replayed-once')) + }) + expect( + resumed.requests.map((request) => ({ + method: request.method, + params: request.params + })) + ).toEqual([ + { + method: 'runtime/resumeAcpChannel', + params: { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9' + } + }, + { + method: 'runtime/replayAcpChannel', + params: { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + acknowledgedSequence: '0' + } + } + ]) + expect(resumed.registrations[0]).toEqual({ + channelId: 'binding-1', + channelEpoch: '9', + kind: 'acp', + nextInboundSequence: '1', + nextOutboundSequence: '3' + }) + expect( + vi.mocked(fixture.connection.flushAcpBindings!).mock.calls.length + ).toBeGreaterThanOrEqual(3) + expect( + vi.mocked(fixture.connection.updateAcpBinding!).mock.calls + ).toContainEqual([ + 'binding-1', + expect.objectContaining({ + cursors: expect.objectContaining({ + lastInboundJournaledSequence: '2', + lastMainAckSequence: '1' + }) + }) + ]) + + await writer.write(Buffer.from('live-input')) + expect(fixture.client.binary.sent).toEqual([ + new Uint8Array(Buffer.from('initial-input-1')), + new Uint8Array(Buffer.from('initial-input-2')) + ]) + expect(resumed.binary.sent).toEqual([ + new Uint8Array(Buffer.from('live-input')) + ]) + await fixture.channel.close() + }) + + it.each(['cancel', 'deadline'] as const)( + 'terminates recovery at the signed prompt %s boundary', + async (boundary) => { + const fixture = await openChannel() + const controller = new AbortController() + const deadlineAt = new Date( + Date.now() + (boundary === 'deadline' ? 5 : 10_000) + ).toISOString() + fixture.channel.setRecoveryBoundary( + deadlineAt, + controller.signal + ) + const reconnectGate = new Promise(() => {}) + vi.mocked(fixture.connection.reconnect!).mockImplementationOnce( + async (signal?: AbortSignal) => { + await Promise.race([ + reconnectGate, + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + ]) + } + ) + fixture.client.transportClose() + if (boundary === 'cancel') { + controller.abort(new Error('caller cancelled')) + } + await fixture.channel.closed + expect(fixture.channel.isCurrentGeneration()).toBe(false) + expect( + fixture.client.requests.filter( + (request) => + request.method === 'runtime/preparePrompt' + ) + ).toHaveLength(0) + } + ) + + it.each(['daemon boot', 'capability generation'] as const)( + 'denies ACP recovery after changed %s', + async (identity) => { + const fixture = await openChannel() + fixture.channel.setRecoveryBoundary( + new Date(Date.now() + 10_000).toISOString(), + new AbortController().signal + ) + fixture.setReconnectClient(new FakeRuntimeClient(8)) + if (identity === 'daemon boot') { + fixture.connection.status.daemonBootId = 'boot-2' + } else { + fixture.setCapabilityGeneration(12) + } + fixture.client.transportClose() + await fixture.channel.closed + expect(fixture.connection.reconnect).toHaveBeenCalledOnce() + expect( + (fixture.connection.client as FakeRuntimeClient).requests + ).toHaveLength(0) + } + ) + + it('fails outcome-unknown rather than replaying ACP input when outbound delivery cannot be proven', async () => { + const fixture = await openChannel() + const deadlineAt = new Date(Date.now() + 10_000).toISOString() + fixture.channel.setRecoveryBoundary( + deadlineAt, + new AbortController().signal + ) + const writer = fixture.channel.output.getWriter() + await writer.write(Buffer.from('do-not-replay')) + const resumed = new FakeRuntimeClient(8) + resumed.responder = (method, params) => { + if (method === 'runtime/resumeAcpChannel') { + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + deadlineAt, + cursors: { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + } + } + return defaultResponse(method, params) + } + fixture.setReconnectClient(resumed) + + fixture.client.transportClose() + await fixture.channel.closed + expect( + resumed.requests.filter( + (request) => request.method === 'runtime/replayAcpChannel' + ) + ).toHaveLength(0) + expect(resumed.binary.sent).toHaveLength(0) + }) + + it('recovers with an idle model bridge without replaying a Provider call', async () => { + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const dispatch = vi.fn() + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + modelBridge: { + dispatch, + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + channel.setRecoveryBoundary( + new Date(Date.now() + 10_000).toISOString(), + new AbortController().signal + ) + await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy: { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: digest('d'), + supportsImageInput: false + } + }) + const deadlineAt = new Date(Date.now() + 10_000).toISOString() + channel.setRecoveryBoundary( + deadlineAt, + new AbortController().signal + ) + const resumed = new FakeRuntimeClient(8) + resumed.responder = (method, params) => { + if (method === 'runtime/resumeAcpChannel') { + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + deadlineAt, + cursors: { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + } + } + if (method === 'runtime/replayAcpChannel') { + return { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + replayedThroughSequence: '0', + live: true + } + } + return defaultResponse(method, params) + } + fixture.setReconnectClient(resumed) + fixture.client.transportClose() + await vi.waitFor(() => + expect(fixture.connection.reconnect).toHaveBeenCalledOnce() + ) + const writer = channel.output.getWriter() + await writer.write(Buffer.from('after-idle-bridge-recovery')) + expect(resumed.binary.sent).toEqual([ + new Uint8Array(Buffer.from('after-idle-bridge-recovery')) + ]) + expect(dispatch).not.toHaveBeenCalled() + await channel.close() + }) + + it('does not recover or replay an in-flight Provider dispatch', async () => { + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const dispatch = vi.fn( + async ( + _request: unknown, + signal: AbortSignal + ): Promise => + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + ) + const poison = vi.fn(async () => undefined) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + modelBridge: { + dispatch, + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison + } + }) + channel.setRecoveryBoundary( + new Date(Date.now() + 10_000).toISOString(), + new AbortController().signal + ) + const bridge = await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy: { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: digest('d'), + supportsImageInput: false + } + }) + const request = await createModelBridgeRequestMessage({ + identity: { + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'provider-request-1', + roundIndex: 0, + modelProfileDigest: digest('d'), + messageId: 'message-1' + }, + policy: bridge.policy, + request: { + method: 'POST', + path: '/v1/responses', + headers: { 'content-type': 'application/json' }, + bodyBase64: Buffer.from( + JSON.stringify({ model: 'gpt-test' }) + ).toString('base64') + } + }) + fixture.client.blobBinaries[0]!.push( + await encodeModelBridgeMessage(request), + '1' + ) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + fixture.client.transportClose() + await channel.closed + expect(fixture.connection.reconnect).not.toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledOnce() + expect(poison).toHaveBeenCalledOnce() + }) + + it('preserves bounded transport categories before provider dispatch', async () => { + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + modelBridge: { + dispatch: vi.fn(), + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy: { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: digest('d'), + supportsImageInput: false + } + }) + const reading = channel.input.getReader().read() + fixture.client.transportClose( + new AgentProtocolClientError( + 'reader failed', + 'protocol', + new ChannelProtocolError( + 'sequence failed', + 'sequence-mismatch' + ) + ) + ) + await expect(reading).rejects.toThrow( + 'client-protocol/channel-sequence-mismatch' + ) + }) + + it('preserves a bounded Agent-side failure category', async () => { + const fixture = connectionFixture() + fixture.setCapabilities(modelBridgeCapabilities()) + const channel = await createProtocolRemoteRuntimeChannel({ + connection: fixture.connection, + openIdentity: openIdentity(), + modelBridge: { + dispatch: vi.fn(), + onDelivered: async () => undefined, + finalizePrompt: async () => undefined, + poison: async () => undefined + } + }) + await channel.openModelBridge({ + bindingId: 'binding-1', + promptOperationId: 'prompt-1', + requestId: 'prompt-1', + policy: { + protocol: 'openai-responses', + model: 'gpt-test', + modelProfileDigest: digest('d'), + supportsImageInput: false + } + }) + const reading = channel.input.getReader().read() + fixture.client.transportClose( + new AgentProtocolClientError( + 'transport closed', + 'closed', + new AgentAttachTransportError( + 'attach closed', + 'closed', + undefined, + 'dispatch/process' + ) + ) + ) + await expect(reading).rejects.toThrow( + 'client-closed/attach-closed/agent-dispatch/process' + ) + }) + + it('settles closed and releases resources when the transport closes', async () => { + const fixture = await openChannel() + fixture.client.transportClose() + await fixture.channel.closed + expect(fixture.client.binary.closed).toBe(true) + expect(fixture.channel.isCurrentGeneration()).toBe(false) + expect(fixture.release).toHaveBeenCalledTimes(1) + expect( + fixture.client.requests.filter( + (request) => request.method === 'runtime/closeAcpChannel' + ) + ).toHaveLength(0) + }) + + it.each(['binary', 'connection'] as const)( + 'settles closed when the %s closes', + async (source) => { + const fixture = await openChannel() + if (source === 'binary') { + fixture.client.binary.close() + } else { + fixture.connectionClose() + } + await fixture.channel.closed + expect(fixture.channel.isCurrentGeneration()).toBe(false) + expect(fixture.release).toHaveBeenCalledTimes(1) + } + ) +}) + +function digest(character: string): string { + return `sha256:${character.repeat(64)}` +} diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.ts b/src/main/remote-agent/protocol-remote-runtime-channel.ts new file mode 100644 index 00000000..73361e32 --- /dev/null +++ b/src/main/remote-agent/protocol-remote-runtime-channel.ts @@ -0,0 +1,1769 @@ +import { z } from 'zod' +import { + AGENT_PROTOCOL_LIMITS, + ChannelProtocolError, + acpCompletePromptRequestSchema as promptCompletionRequestSchema, + acpCompletePromptResultSchema as promptCompletionResultSchema, + acpCloseChannelRequestSchema, + acpCloseChannelResultSchema, + acpBindingCursorsSchema as cursorResultSchema, + acpCancellationReasonSchema as cancellationReasonSchema, + acpEscalateCancellationRequestSchema as cancellationEscalationRequestSchema, + acpEscalateCancellationResultSchema as cancellationEscalationResultSchema, + acpGetCursorsRequestSchema as getCursorsRequestSchema, + acpOpenChannelRequestSchema, + acpOpenChannelResultSchema, + acpReplayChannelResultSchema, + acpReconcilePromptRequestSchema as promptReconciliationRequestSchema, + acpReconcilePromptResultSchema as promptReconciliationResultSchema, + acpResumeChannelResultSchema +} from '../../shared/agent-protocol' +import { AgentFrameError } from '../../shared/agent-protocol/frame' +import { + assertRemotePromptAcceptanceMatchesPreparation, + remotePromptOperationAcceptanceSchema, + remotePromptOperationPreparationSchema +} from '../../shared/remote-agent-contracts' +import { + modelBridgePolicySchema +} from '../../shared/model-bridge-contracts' +import type { + RemoteModelBridgeOpenRequest, + RemoteModelBridgeSession, + RemoteCancellationEscalation, + RemotePromptOperationCompletion, + RemotePromptOperationCompletionResult, + RemotePromptOperationReconciliation, + RemotePromptOperationReconciliationResult, + RemoteRuntimeChannel, + RemoteRuntimeChannelCapabilities, + RuntimeSessionBindingCursors +} from '../agent/remote-runtime-channel' +import { StaleRemoteRuntimeGenerationError } from '../agent/remote-runtime-channel' +import { + AgentProtocolClientError, + AgentRpcError, + type AgentProtocolMethod, + type AgentProtocolParams, + type AgentProtocolRequestOptions, + type AgentProtocolResult +} from './agent-protocol-client' +import { AgentAttachTransportError } from './agent-attach-transport' +import type { RemoteAgentConnection } from './remote-agent-connection-manager' +import { + MainModelBridgeSession +} from './main-model-bridge-session' +import type { + MainModelBridgeDelivered, + MainModelBridgeDispatch, + MainModelBridgeFinalizePrompt, + MainModelBridgePoison +} from './main-model-bridge-dispatcher' + +const RUNTIME_ACP_CAPABILITY = 'runtime/acp' +const RUNTIME_ACP_CAPABILITY_VERSION = 3 +const RUNTIME_MODEL_BRIDGE_CAPABILITY = 'runtime/model-bridge' +const RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION = 1 +const DEFAULT_CONTROL_TIMEOUT_MS = 15_000 +const DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS = 32 +const MAXIMUM_PENDING_OUTPUT_WRITES = 256 +const MAXIMUM_OUTPUT_WRITE_BYTES = + AGENT_PROTOCOL_LIMITS.maximumBufferedProtocolBytes + +export type RuntimeProtocolMethod = + Extract +export type RuntimeProtocolParams = + AgentProtocolParams +export type RuntimeProtocolResult = + AgentProtocolResult + +export type RuntimeProtocolRequestOptions = AgentProtocolRequestOptions + +export interface RuntimeProtocolBinaryFrame { + readonly payload: Uint8Array + readonly sequence: string + consume(): Promise +} + +export interface RuntimeProtocolBinaryChannel { + readonly channelId: string + readonly channelEpoch: string + send(payload: Uint8Array, signal?: AbortSignal): Promise + receive(signal?: AbortSignal): Promise + close(error?: unknown): void + closeWithNotification?(): Promise + onClose(listener: (error?: unknown) => void): () => void +} + +/** + * Narrow protocol surface needed by this channel. + */ +export interface RuntimeProtocolClient { + readonly generation: number + request( + method: M, + params: RuntimeProtocolParams, + options?: RuntimeProtocolRequestOptions + ): Promise> + registerBinaryChannel(input: { + channelId: string + channelEpoch: string + kind: 'acp' + nextInboundSequence?: string + nextOutboundSequence?: string + }): RuntimeProtocolBinaryChannel + allocateBinaryChannel?(input: { + kind: 'blob' + }): RuntimeProtocolBinaryChannel + onClose(listener: (error?: unknown) => void): () => void +} + +export type RuntimeProtocolConnection = Pick< + RemoteAgentConnection, + 'identity' | 'status' | 'capabilities' | 'state' +> & { + readonly client: RuntimeProtocolClient + reconnect?(signal?: AbortSignal): Promise + onClientChange?(listener: () => void): () => void + updateAcpBinding( + bindingId: string, + binding: + | { + bindingId: string + channelId: string + channelEpoch: string + cursors: z.infer + } + | undefined + ): Promise + flushAcpBindings(): Promise + release?(): void + onClose?(listener: () => void): () => void +} + +export type ProtocolRemoteRuntimeChannelOptions = { + connection: RuntimeProtocolConnection + openIdentity: z.input + signal?: AbortSignal + releaseConnectionOnClose?: boolean + controlTimeoutMs?: number + maximumPendingControlRequests?: number + modelBridge?: { + dispatch: MainModelBridgeDispatch + onDelivered: MainModelBridgeDelivered + finalizePrompt: MainModelBridgeFinalizePrompt + poison: MainModelBridgePoison + requestTimeoutMs?: number + closeTimeoutMs?: number + } +} + +export class ProtocolRemoteRuntimeChannelError extends Error { + constructor( + message: string, + readonly reason: + | 'capability-mismatch' + | 'binding-mismatch' + | 'protocol' + | 'capacity' + | 'closed' + | 'remote' + | 'transport' + | 'aborted', + details: { + remoteMethod?: RuntimeProtocolMethod + remoteRpcCode?: number + remoteServiceCode?: string + remoteRequestOutcome?: 'rejected' + } = {} + ) { + super(message) + this.name = 'ProtocolRemoteRuntimeChannelError' + this.remoteMethod = details.remoteMethod + this.remoteRpcCode = details.remoteRpcCode + this.remoteServiceCode = details.remoteServiceCode + this.remoteRequestOutcome = details.remoteRequestOutcome + } + + readonly remoteMethod?: RuntimeProtocolMethod + readonly remoteRpcCode?: number + readonly remoteServiceCode?: string + readonly remoteRequestOutcome?: 'rejected' +} + +type OpenState = { + connection: RuntimeProtocolConnection + client: RuntimeProtocolClient + openIdentity: z.infer + openResult: z.infer + binary: RuntimeProtocolBinaryChannel + generation: number + capabilityGeneration: number + daemonBootId: string + releaseConnectionOnClose: boolean + controlTimeoutMs: number + maximumPendingControlRequests: number + modelBridge: ProtocolRemoteRuntimeChannelOptions['modelBridge'] +} + +/** + * Opens a stable ACP binding before registering its binary channel. + * Mutating Runtime requests are issued once and are never replayed here. + */ +export async function createProtocolRemoteRuntimeChannel( + options: ProtocolRemoteRuntimeChannelOptions +): Promise { + const openIdentity = acpOpenChannelRequestSchema.parse( + options.openIdentity + ) + const controlTimeoutMs = boundedInteger( + options.controlTimeoutMs, + DEFAULT_CONTROL_TIMEOUT_MS, + 1, + 120_000 + ) + const maximumPendingControlRequests = boundedInteger( + options.maximumPendingControlRequests, + DEFAULT_MAXIMUM_PENDING_CONTROL_REQUESTS, + 1, + 256 + ) + const connection = options.connection + const client = currentOpenClient(connection) + const generation = client.generation + const capabilities = connection.capabilities + const capabilityGeneration = capabilities.generation + const runtime = assertRuntimeCapability( + capabilities, + openIdentity.runtimeId, + openIdentity.runtimeBundleDigest, + options.modelBridge !== undefined + ) + + let openResult: z.infer + try { + const raw = await requestWithTimeout( + client, + 'runtime/openAcpChannel', + openIdentity, + controlTimeoutMs, + options.signal + ) + openResult = parseRemoteResult( + acpOpenChannelResultSchema, + raw, + 'Remote Runtime open response is invalid' + ) + } catch (error) { + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw redactProtocolFailure( + error, + 'runtime/openAcpChannel', + options.signal + ) + } + + if ( + !matchesGeneration( + connection, + client, + generation, + capabilityGeneration + ) + ) { + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw new StaleRemoteRuntimeGenerationError() + } + if ( + openResult.bindingId !== openIdentity.bindingId || + openResult.channelId !== openIdentity.bindingId || + openResult.acpCapabilitiesDigest !== runtime.acpCapabilitiesDigest + ) { + await bestEffortCloseOpenBinding( + client, + openIdentity.bindingId, + openResult, + controlTimeoutMs + ) + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime open identity does not match the requested binding', + 'binding-mismatch' + ) + } + + let binary: RuntimeProtocolBinaryChannel + try { + binary = client.registerBinaryChannel({ + channelId: openResult.channelId, + channelEpoch: openResult.channelEpoch, + kind: 'acp' + }) + } catch { + await bestEffortCloseOpenBinding( + client, + openIdentity.bindingId, + openResult, + controlTimeoutMs + ) + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime binary channel could not be registered', + 'transport' + ) + } + if ( + binary.channelId !== openResult.channelId || + binary.channelEpoch !== openResult.channelEpoch + ) { + binary.close() + await bestEffortCloseOpenBinding( + client, + openIdentity.bindingId, + openResult, + controlTimeoutMs + ) + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime binary channel identity is invalid', + 'binding-mismatch' + ) + } + + try { + await connection.updateAcpBinding(openIdentity.bindingId, { + bindingId: openIdentity.bindingId, + channelId: openResult.channelId, + channelEpoch: openResult.channelEpoch, + cursors: { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + }) + await connection.flushAcpBindings() + } catch { + binary.close() + await bestEffortCloseOpenBinding( + client, + openIdentity.bindingId, + openResult, + controlTimeoutMs + ) + if (options.releaseConnectionOnClose) { + connection.release?.() + } + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime recovery identity could not be persisted', + 'transport' + ) + } + + return new ProtocolRemoteRuntimeChannel({ + connection, + client, + openIdentity, + openResult, + binary, + generation, + capabilityGeneration, + daemonBootId: connection.status.daemonBootId, + releaseConnectionOnClose: + options.releaseConnectionOnClose ?? false, + controlTimeoutMs, + maximumPendingControlRequests, + modelBridge: options.modelBridge + }) +} + +export class ProtocolRemoteRuntimeChannel + implements RemoteRuntimeChannel +{ + readonly input: ReadableStream + readonly output: WritableStream + readonly channelEpoch: string + readonly advertisedAcpCapabilitiesDigest: string + readonly capabilities: RemoteRuntimeChannelCapabilities = { + cancellationEscalation: true, + promptOperationReconciliation: true, + modelBridge: false + } + readonly closed: Promise + + readonly #state: OpenState + readonly #lifetime = new AbortController() + readonly #closedResolve: () => void + #readController?: ReadableStreamDefaultController + #writeController?: WritableStreamDefaultController + #pendingFrame?: RuntimeProtocolBinaryFrame + #inboundPersistence?: Promise + #cursorPersistenceTail: Promise = Promise.resolve() + #paused = false + #pauseWaiters = new Set<() => void>() + #pendingControlRequests = 0 + #activeBinarySends = 0 + #recovering = false + #recoveryPromise?: Promise + #recoveryWaiters = new Set<() => void>() + #recoveryDeadlineAt?: number + #recoverySignal?: AbortSignal + #transportDiagnostic?: string + #removeRecoveryAbort?: () => void + #lastDeliveredInboundSequence = 0n + #lastAcknowledgedInboundSequence = 0n + #durableAcknowledgedInboundSequence = 0n + #lastOutboundSentSequence = 0n + #recoveryCursors: z.infer = { + lastOutboundJournaledSequence: '0', + lastOutboundDeliveredSequence: '0', + lastInboundJournaledSequence: '0', + lastMainAckSequence: '0' + } + #closing = false + #closePromise?: Promise + #released = false + #modelBridges = new Set() + #unsubscribeClientClose: () => void = () => undefined + #unsubscribeConnectionClose: () => void = () => undefined + #unsubscribeBinaryClose: () => void = () => undefined + #unsubscribeClientChange: () => void = () => undefined + + constructor(state: OpenState) { + this.#state = state + this.channelEpoch = state.openResult.channelEpoch + this.advertisedAcpCapabilitiesDigest = + state.openResult.acpCapabilitiesDigest + this.capabilities.modelBridge = state.modelBridge !== undefined + let resolveClosed = (): void => undefined + this.closed = new Promise((resolve) => { + resolveClosed = resolve + }) + this.#closedResolve = resolveClosed + + this.input = new ReadableStream( + { + start: (controller) => { + this.#readController = controller + }, + pull: async (controller) => { + await this.#pullInput(controller) + }, + cancel: async () => { + await this.close() + } + }, + { highWaterMark: 0 } + ) + let pendingWriteBytes = 0 + let pendingWrites = 0 + this.output = new WritableStream( + { + start: (controller) => { + this.#writeController = controller + }, + write: async (chunk) => { + try { + await this.#writeOutput(chunk) + } finally { + pendingWriteBytes -= chunk.byteLength + pendingWrites -= 1 + } + }, + close: async () => { + await this.close() + }, + abort: async () => { + await this.close() + } + }, + { + highWaterMark: MAXIMUM_OUTPUT_WRITE_BYTES, + size: (chunk) => { + if ( + chunk.byteLength > MAXIMUM_OUTPUT_WRITE_BYTES || + pendingWrites >= MAXIMUM_PENDING_OUTPUT_WRITES || + pendingWriteBytes + chunk.byteLength > + MAXIMUM_OUTPUT_WRITE_BYTES + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime output queue limit reached', + 'capacity' + ) + } + pendingWrites += 1 + pendingWriteBytes += chunk.byteLength + return chunk.byteLength + } + } + ) + + this.#subscribeTransport(state.client, state.binary) + this.#unsubscribeConnectionClose = + state.connection.onClose?.(() => { + void this.#transportInterrupted() + }) ?? (() => undefined) + this.#unsubscribeClientChange = + state.connection.onClientChange?.(() => { + if (this.#recovering) { + this.#wakeRecoveryWaiters() + } + }) ?? (() => undefined) + } + + get generation(): number { + return this.#state.generation + } + + isCurrentGeneration(): boolean { + return ( + !this.#closing && + (this.#recovering || + matchesGeneration( + this.#state.connection, + this.#state.client, + this.generation, + this.#state.capabilityGeneration + )) + ) + } + + async getBindingCursors( + bindingId: string + ): Promise { + this.#assertBinding(bindingId) + await this.#flushRecoveryPersistence() + const raw = await this.#request( + 'runtime/getAcpCursors', + getCursorsRequestSchema.parse({ bindingId }) + ) + const remoteCursors = parseRemoteResult( + cursorResultSchema, + raw, + 'Remote Runtime cursor response is invalid' + ) + await this.#persistRecoveryCursors(remoteCursors) + await this.#flushRecoveryPersistence() + return this.#recoveryCursors + } + + async preparePrompt( + preparation: z.input + ): Promise< + z.infer + > { + const parsed = remotePromptOperationPreparationSchema.parse( + preparation + ) + this.#assertPreparationIdentity(parsed) + const raw = await this.#request( + 'runtime/preparePrompt', + parsed + ) + const acceptance = parseRemoteResult( + remotePromptOperationAcceptanceSchema, + raw, + 'Remote Runtime prompt acceptance is invalid' + ) + try { + assertRemotePromptAcceptanceMatchesPreparation( + parsed, + acceptance + ) + } catch { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime prompt acceptance identity is invalid', + 'binding-mismatch' + ) + } + this.#assertCurrent() + return acceptance + } + + setRecoveryBoundary( + deadlineAt: string, + signal: AbortSignal + ): void { + const deadline = Date.parse(deadlineAt) + if (!Number.isFinite(deadline)) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime recovery deadline is invalid', + 'protocol' + ) + } + this.clearRecoveryBoundary() + this.#recoveryDeadlineAt = deadline + this.#recoverySignal = signal + const abort = (): void => { + this.#wakeRecoveryWaiters() + if (this.#recovering) { + void this.#fatalTransport( + new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime recovery was aborted', + 'aborted' + ) + ) + } + } + signal.addEventListener('abort', abort, { once: true }) + this.#removeRecoveryAbort = () => + signal.removeEventListener('abort', abort) + } + + clearRecoveryBoundary(): void { + this.#removeRecoveryAbort?.() + this.#removeRecoveryAbort = undefined + this.#recoverySignal = undefined + this.#recoveryDeadlineAt = undefined + } + + async openModelBridge( + request: RemoteModelBridgeOpenRequest + ): Promise { + this.#assertBinding(request.bindingId) + if ( + request.promptOperationId !== request.requestId || + this.#state.modelBridge === undefined || + this.#state.client.allocateBinaryChannel === undefined + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime model bridge is unavailable or its prompt identity is invalid', + 'capability-mismatch' + ) + } + const policy = modelBridgePolicySchema.parse(request.policy) + let binary: RuntimeProtocolBinaryChannel + try { + binary = this.#state.client.allocateBinaryChannel({ + kind: 'blob' + }) + } catch { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime model bridge channel could not be allocated', + 'transport' + ) + } + try { + this.#assertCurrent() + const session = new MainModelBridgeSession({ + identity: { + bindingId: request.bindingId, + promptOperationId: request.promptOperationId + }, + policy, + channel: binary, + isCurrentGeneration: () => this.isCurrentGeneration(), + dispatch: this.#state.modelBridge.dispatch, + onDelivered: this.#state.modelBridge.onDelivered, + finalizePrompt: this.#state.modelBridge.finalizePrompt, + poison: this.#state.modelBridge.poison, + requestTimeoutMs: + this.#state.modelBridge.requestTimeoutMs, + closeTimeoutMs: + this.#state.modelBridge.closeTimeoutMs + }) + this.#modelBridges.add(session) + void session.closed.finally(() => { + this.#modelBridges.delete(session) + }) + return session + } catch (error) { + binary.close(error) + throw error + } + } + + async setInboundPaused(paused: boolean): Promise { + this.#assertCurrent() + this.#paused = paused + if (!paused) { + this.#resumeInput() + } + } + + async completePromptOperation( + operation: RemotePromptOperationCompletion + ): Promise { + this.#assertBinding(operation.bindingId) + await this.#flushRecoveryPersistence() + const request = promptCompletionRequestSchema.parse(operation) + const raw = await this.#request('runtime/completePrompt', request) + const result = parseRemoteResult( + promptCompletionResultSchema, + raw, + 'Remote Runtime completion response is invalid' + ) + if ( + result.bindingId !== request.bindingId || + result.operationId !== request.operationId || + result.requestId !== request.requestId + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime completion identity is invalid', + 'binding-mismatch' + ) + } + return result + } + + async escalateCancellation( + escalation: RemoteCancellationEscalation + ): Promise { + this.#assertBinding(escalation.bindingId) + const request = cancellationEscalationRequestSchema.parse({ + bindingId: escalation.bindingId, + sessionId: escalation.sessionId, + operationId: escalation.operationId, + requestId: escalation.requestId, + reason: redactCancellationReason(escalation.reason) + }) + const raw = await this.#request( + 'runtime/escalateCancellation', + request + ) + const result = parseRemoteResult( + cancellationEscalationResultSchema, + raw, + 'Remote Runtime cancellation response is invalid' + ) + if (result.bindingId !== escalation.bindingId) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime cancellation binding is invalid', + 'binding-mismatch' + ) + } + } + + async reconcilePromptOperation( + operation: RemotePromptOperationReconciliation + ): Promise { + this.#assertBinding(operation.bindingId) + const request = promptReconciliationRequestSchema.parse(operation) + const raw = await this.#request( + 'runtime/reconcilePrompt', + request + ) + return parseRemoteResult( + promptReconciliationResultSchema, + raw, + 'Remote Runtime reconciliation response is invalid' + ) + } + + close(): Promise { + return this.#beginClose(true) + } + + async #pullInput( + controller: ReadableStreamDefaultController + ): Promise { + try { + for (;;) { + await this.#waitUntilReadable() + if (this.#closing) { + return + } + const binary = this.#state.binary + const frame = + this.#pendingFrame ?? + (await binary.receive(this.#lifetime.signal)) + this.#pendingFrame = frame + await this.#waitUntilReadable() + if (this.#closing) { + return + } + if (binary !== this.#state.binary) { + this.#pendingFrame = undefined + continue + } + this.#assertCurrent() + const sequence = BigInt(frame.sequence) + if (sequence > this.#lastDeliveredInboundSequence + 1n) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime replay output sequence is not contiguous', + 'protocol' + ) + } + this.#pendingFrame = undefined + const emitted = sequence > this.#lastDeliveredInboundSequence + const persistence = this.#consumeInboundFrame( + frame, + sequence, + emitted, + controller + ) + this.#inboundPersistence = persistence + try { + await persistence + } finally { + if (this.#inboundPersistence === persistence) { + this.#inboundPersistence = undefined + } + } + if (!emitted) { + continue + } + return + } + } catch (error) { + if (this.#closing || this.#lifetime.signal.aborted) { + return + } + if ( + error instanceof ProtocolRemoteRuntimeChannelError && + (error.reason === 'protocol' || + error.reason === 'binding-mismatch') + ) { + await this.#fatalTransport(error) + return + } + if (!this.#recovering) { + void this.#transportInterrupted() + } + } + } + + async #writeOutput(chunk: Uint8Array): Promise { + await this.#waitForRecovery() + this.#assertCurrent() + if (chunk.byteLength > MAXIMUM_OUTPUT_WRITE_BYTES) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime output write exceeds the bounded queue', + 'capacity' + ) + } + try { + for ( + let offset = 0; + offset < chunk.byteLength; + offset += AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes + ) { + this.#assertCurrent() + const end = Math.min( + chunk.byteLength, + offset + AGENT_PROTOCOL_LIMITS.maximumAcpFrameBytes + ) + const frame = new Uint8Array(chunk.subarray(offset, end)) + const binary = this.#state.binary + this.#activeBinarySends += 1 + try { + await binary.send(frame, this.#lifetime.signal) + this.#lastOutboundSentSequence += 1n + } finally { + this.#activeBinarySends -= 1 + } + } + } catch (error) { + if (error instanceof StaleRemoteRuntimeGenerationError) { + throw error + } + if (this.#closing || this.#lifetime.signal.aborted) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime channel is closed', + 'closed' + ) + } + await this.#fatalTransport( + new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime ACP input delivery is uncertain', + 'transport' + ) + ) + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime ACP input delivery is uncertain', + 'transport' + ) + } + } + + async #request( + method: M, + params: RuntimeProtocolParams + ): Promise> { + await this.#waitForRecovery() + this.#assertCurrent() + if ( + this.#pendingControlRequests >= + this.#state.maximumPendingControlRequests + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime control request limit reached', + 'capacity' + ) + } + this.#pendingControlRequests += 1 + try { + const result = await requestWithTimeout( + this.#state.client, + method, + params, + this.#state.controlTimeoutMs, + this.#lifetime.signal + ) + this.#assertCurrent() + return result + } catch (error) { + if (error instanceof StaleRemoteRuntimeGenerationError) { + throw error + } + throw redactProtocolFailure(error, method) + } finally { + this.#pendingControlRequests -= 1 + } + } + + #assertCurrent(): void { + if (this.#closing) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime channel is closed', + 'closed' + ) + } + if (!this.isCurrentGeneration()) { + throw new StaleRemoteRuntimeGenerationError() + } + } + + #assertBinding(bindingId: string): void { + this.#assertCurrent() + if (bindingId !== this.#state.openIdentity.bindingId) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime binding identity does not match', + 'binding-mismatch' + ) + } + } + + #assertPreparationIdentity( + preparation: z.infer< + typeof remotePromptOperationPreparationSchema + > + ): void { + this.#assertBinding(preparation.bindingId) + if ( + preparation.runtimeId !== this.#state.openIdentity.runtimeId || + preparation.runtimeBundleDigest !== + this.#state.openIdentity.runtimeBundleDigest || + preparation.workspaceIdentity !== + this.#state.openIdentity.workspaceIdentity || + preparation.channelEpoch !== this.channelEpoch || + preparation.connectionGeneration !== this.generation + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime prompt identity does not match the open channel', + 'binding-mismatch' + ) + } + } + + async #waitUntilResumed(): Promise { + while (this.#paused && !this.#closing) { + await new Promise((resolve) => { + this.#pauseWaiters.add(resolve) + }) + } + } + + async #waitUntilReadable(): Promise { + await this.#waitUntilResumed() + await this.#waitForRecovery() + } + + async #waitForRecovery(): Promise { + while (this.#recovering && !this.#closing) { + const recovery = this.#recoveryPromise + if (recovery !== undefined) { + await recovery + } else { + await new Promise((resolve) => { + this.#recoveryWaiters.add(resolve) + }) + } + } + } + + #wakeRecoveryWaiters(): void { + for (const resolve of this.#recoveryWaiters) { + resolve() + } + this.#recoveryWaiters.clear() + } + + #resumeInput(): void { + for (const resolve of this.#pauseWaiters) { + resolve() + } + this.#pauseWaiters.clear() + } + + #subscribeTransport( + client: RuntimeProtocolClient, + binary: RuntimeProtocolBinaryChannel + ): void { + this.#unsubscribeClientClose() + this.#unsubscribeBinaryClose() + this.#unsubscribeClientClose = client.onClose((error) => { + this.#captureTransportDiagnostic(error) + void this.#transportInterrupted() + }) + this.#unsubscribeBinaryClose = binary.onClose((error) => { + this.#captureTransportDiagnostic(error) + void this.#transportInterrupted() + }) + } + + #captureTransportDiagnostic(error: unknown): void { + this.#transportDiagnostic ??= boundedTransportDiagnostic(error) + } + + #transportInterrupted(): Promise { + if (this.#closing) { + return this.closed + } + if (this.#recoveryPromise !== undefined) { + return this.#recoveryPromise + } + const providerDeliveryMayBeUncertain = [ + ...this.#modelBridges + ].some( + (bridge) => bridge.providerDeliveryMayBeUncertain + ) + if ( + this.#recoveryDeadlineAt === undefined || + this.#recoverySignal === undefined || + this.#recoverySignal.aborted || + Date.now() >= this.#recoveryDeadlineAt || + this.#activeBinarySends > 0 || + this.#pendingControlRequests > 0 || + providerDeliveryMayBeUncertain || + this.#state.connection.reconnect === undefined + ) { + return this.#fatalTransport( + new ProtocolRemoteRuntimeChannelError( + appendTransportDiagnostic( + this.#activeBinarySends > 0 + ? 'Remote Runtime ACP input delivery is uncertain' + : providerDeliveryMayBeUncertain + ? 'Remote Runtime model provider delivery is uncertain' + : 'Remote Runtime transport cannot be recovered safely', + this.#transportDiagnostic + ), + 'transport' + ) + ) + } + this.#recovering = true + this.#pendingFrame = undefined + this.#unsubscribeClientClose() + this.#unsubscribeBinaryClose() + const recovery = this.#recoverTransport() + .catch(async (error: unknown) => { + await this.#fatalTransport( + error instanceof Error + ? error + : new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime transport recovery failed', + 'transport' + ) + ) + }) + .finally(() => { + this.#recovering = false + this.#recoveryPromise = undefined + this.#wakeRecoveryWaiters() + }) + this.#recoveryPromise = recovery + return recovery + } + + async #recoverTransport(): Promise { + await this.#flushRecoveryPersistence() + const deadlineAt = this.#recoveryDeadlineAt! + const callerSignal = this.#recoverySignal! + const remaining = deadlineAt - Date.now() + if (remaining <= 0) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime recovery deadline expired', + 'aborted' + ) + } + const recoveryController = new AbortController() + const abort = (): void => { + recoveryController.abort( + callerSignal.reason ?? + new DOMException( + 'Remote Runtime recovery aborted', + 'AbortError' + ) + ) + } + callerSignal.addEventListener('abort', abort, { once: true }) + const timeout = setTimeout(() => { + recoveryController.abort( + new DOMException( + 'Remote Runtime recovery timed out', + 'TimeoutError' + ) + ) + }, remaining) + try { + await this.#state.connection.reconnect!( + recoveryController.signal + ) + recoveryController.signal.throwIfAborted() + const client = currentOpenClient(this.#state.connection) + const capabilities = this.#state.connection.capabilities + if ( + client.generation <= this.#state.generation || + this.#state.connection.status.daemonBootId !== + this.#state.daemonBootId || + capabilities.generation !== + this.#state.capabilityGeneration + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime reconnect identity changed', + 'binding-mismatch' + ) + } + assertRuntimeCapability( + capabilities, + this.#state.openIdentity.runtimeId, + this.#state.openIdentity.runtimeBundleDigest, + this.#state.modelBridge !== undefined + ) + const resume = parseRemoteResult( + acpResumeChannelResultSchema, + await requestWithTimeout( + client, + 'runtime/resumeAcpChannel', + { + bindingId: this.#state.openIdentity.bindingId, + channelId: this.#state.openResult.channelId, + channelEpoch: this.#state.openResult.channelEpoch + }, + Math.min( + this.#state.controlTimeoutMs, + Math.max(1, deadlineAt - Date.now()) + ), + recoveryController.signal + ), + 'Remote Runtime resume response is invalid' + ) + if ( + resume.bindingId !== this.#state.openIdentity.bindingId || + resume.channelId !== this.#state.openResult.channelId || + resume.channelEpoch !== this.#state.openResult.channelEpoch || + Date.parse(resume.deadlineAt) !== deadlineAt || + BigInt(resume.cursors.lastOutboundDeliveredSequence) !== + BigInt(resume.cursors.lastOutboundJournaledSequence) || + BigInt(resume.cursors.lastOutboundJournaledSequence) !== + this.#lastOutboundSentSequence || + this.#durableAcknowledgedInboundSequence > + BigInt(resume.cursors.lastInboundJournaledSequence) + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime resume identity or delivery proof is invalid', + 'binding-mismatch' + ) + } + const binary = client.registerBinaryChannel({ + channelId: resume.channelId, + channelEpoch: resume.channelEpoch, + kind: 'acp', + nextOutboundSequence: ( + BigInt( + resume.cursors.lastOutboundJournaledSequence + ) + 1n + ).toString(), + nextInboundSequence: ( + BigInt(resume.cursors.lastMainAckSequence) + 1n + ).toString() + }) + this.#state.client = client + this.#state.binary = binary + this.#state.generation = client.generation + this.#subscribeTransport(client, binary) + const replay = parseRemoteResult( + acpReplayChannelResultSchema, + await requestWithTimeout( + client, + 'runtime/replayAcpChannel', + { + bindingId: this.#state.openIdentity.bindingId, + channelId: this.#state.openResult.channelId, + channelEpoch: this.#state.openResult.channelEpoch, + acknowledgedSequence: + resume.cursors.lastMainAckSequence + }, + Math.min( + this.#state.controlTimeoutMs, + Math.max(1, deadlineAt - Date.now()) + ), + recoveryController.signal + ), + 'Remote Runtime replay response is invalid' + ) + if ( + replay.bindingId !== this.#state.openIdentity.bindingId || + replay.channelId !== this.#state.openResult.channelId || + replay.channelEpoch !== this.#state.openResult.channelEpoch || + BigInt(replay.replayedThroughSequence) < + BigInt(resume.cursors.lastInboundJournaledSequence) || + !replay.live + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime replay did not reach live state', + 'protocol' + ) + } + await this.#persistRecoveryCursors({ + ...resume.cursors, + lastInboundJournaledSequence: + replay.replayedThroughSequence, + lastMainAckSequence: + BigInt(resume.cursors.lastMainAckSequence) > + this.#durableAcknowledgedInboundSequence + ? resume.cursors.lastMainAckSequence + : this.#durableAcknowledgedInboundSequence.toString() + }) + await this.#flushRecoveryPersistence() + } finally { + clearTimeout(timeout) + callerSignal.removeEventListener('abort', abort) + } + } + + async #persistObservedAck(sequenceValue: bigint): Promise { + const sequence = sequenceValue.toString() + const cursors = cursorResultSchema.parse({ + ...this.#recoveryCursors, + lastInboundJournaledSequence: + BigInt(this.#recoveryCursors.lastInboundJournaledSequence) > + sequenceValue + ? this.#recoveryCursors.lastInboundJournaledSequence + : sequence, + lastMainAckSequence: sequence + }) + await this.#persistRecoveryCursors(cursors) + } + + async #consumeInboundFrame( + frame: RuntimeProtocolBinaryFrame, + sequence: bigint, + emitted: boolean, + controller: ReadableStreamDefaultController + ): Promise { + if (emitted) { + this.#lastDeliveredInboundSequence = sequence + controller.enqueue(new Uint8Array(frame.payload)) + } + await this.#persistObservedAck(sequence) + await this.#flushCursorPersistence() + this.#durableAcknowledgedInboundSequence = + this.#durableAcknowledgedInboundSequence > sequence + ? this.#durableAcknowledgedInboundSequence + : sequence + await frame.consume() + this.#lastAcknowledgedInboundSequence = + this.#lastAcknowledgedInboundSequence > sequence + ? this.#lastAcknowledgedInboundSequence + : sequence + } + + async #persistRecoveryCursors( + cursors: z.infer + ): Promise { + const parsed = cursorResultSchema.parse(cursors) + const persistence = this.#cursorPersistenceTail + .catch(() => undefined) + .then(async () => { + const merged = mergeMonotonicCursors( + this.#recoveryCursors, + parsed + ) + if (sameCursors(merged, this.#recoveryCursors)) { + return + } + await this.#state.connection.updateAcpBinding( + this.#state.openIdentity.bindingId, + { + bindingId: this.#state.openIdentity.bindingId, + channelId: this.#state.openResult.channelId, + channelEpoch: this.#state.openResult.channelEpoch, + cursors: merged + } + ) + this.#recoveryCursors = merged + }) + this.#cursorPersistenceTail = persistence + await persistence + } + + async #flushRecoveryPersistence(): Promise { + await this.#inboundPersistence + await this.#flushCursorPersistence() + } + + async #flushCursorPersistence(): Promise { + for (;;) { + const persistence = this.#cursorPersistenceTail + await persistence + await this.#state.connection.flushAcpBindings() + if (persistence === this.#cursorPersistenceTail) { + break + } + } + } + + async #fatalTransport(error: Error): Promise { + if (this.#closing) { + return await this.closed + } + try { + this.#readController?.error(error) + } catch { + // The stream may already have been cancelled or errored. + } + try { + this.#writeController?.error(error) + } catch { + // The stream may already have been closed or errored. + } + await this.#beginClose(false) + } + + #beginClose(requestRemoteClose: boolean): Promise { + if (this.#closePromise !== undefined) { + return this.#closePromise + } + if (this.#closing) { + return this.closed + } + const canRequestRemoteClose = + requestRemoteClose && + matchesGeneration( + this.#state.connection, + this.#state.client, + this.generation, + this.#state.capabilityGeneration + ) + this.#closing = true + this.clearRecoveryBoundary() + this.#lifetime.abort( + new DOMException('Remote Runtime channel closed', 'AbortError') + ) + const bridgeCloses = [...this.#modelBridges].map(async (bridge) => { + await bridge.close('acp-channel-closed') + }) + this.#resumeInput() + this.#wakeRecoveryWaiters() + this.#state.binary.close() + try { + this.#readController?.close() + } catch { + // The stream may already have been cancelled or errored. + } + try { + this.#writeController?.error( + new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime channel is closed', + 'closed' + ) + ) + } catch { + // The stream may already have been closed or errored. + } + this.#closePromise = (async () => { + await Promise.allSettled(bridgeCloses) + await this.#flushRecoveryPersistence().catch(() => undefined) + if (canRequestRemoteClose) { + const remoteClosed = await bestEffortCloseOpenBinding( + this.#state.client, + this.#state.openIdentity.bindingId, + this.#state.openResult, + this.#state.controlTimeoutMs + ) + if (remoteClosed) { + await this.#state.connection + .updateAcpBinding( + this.#state.openIdentity.bindingId, + undefined + ) + .catch(() => undefined) + await this.#state.connection + .flushAcpBindings() + .catch(() => undefined) + } + } + this.#finishClose() + })() + return this.#closePromise + } + + #finishClose(): void { + this.#unsubscribeClientClose() + this.#unsubscribeConnectionClose() + this.#unsubscribeBinaryClose() + this.#unsubscribeClientChange() + if ( + this.#state.releaseConnectionOnClose && + !this.#released + ) { + this.#released = true + this.#state.connection.release?.() + } + this.#closedResolve() + } +} + +function boundedTransportDiagnostic( + error: unknown +): string | undefined { + const parts: string[] = [] + let current = error + for (let depth = 0; depth < 4; depth += 1) { + if (current instanceof AgentProtocolClientError) { + parts.push(`client-${current.code}`) + current = current.cause + continue + } + if (current instanceof AgentAttachTransportError) { + parts.push(`attach-${current.code}`) + if (current.diagnostic !== undefined) { + parts.push(`agent-${current.diagnostic}`) + } + current = current.cause + continue + } + if (current instanceof ChannelProtocolError) { + parts.push(`channel-${current.code}`) + } else if (current instanceof AgentFrameError) { + parts.push(`frame-${current.code}`) + } + break + } + return parts.length === 0 ? undefined : parts.join('/') +} + +function appendTransportDiagnostic( + message: string, + diagnostic: string | undefined +): string { + return diagnostic === undefined + ? message + : `${message} (${diagnostic})` +} + +function currentOpenClient( + connection: RuntimeProtocolConnection +): RuntimeProtocolClient { + if (connection.state !== 'ready') { + throw new StaleRemoteRuntimeGenerationError() + } + try { + const client = connection.client + if ( + !Number.isSafeInteger(client.generation) || + client.generation < 1 + ) { + throw new StaleRemoteRuntimeGenerationError() + } + return client + } catch (error) { + if (error instanceof StaleRemoteRuntimeGenerationError) { + throw error + } + throw new StaleRemoteRuntimeGenerationError() + } +} + +function mergeMonotonicCursors( + current: z.infer, + observed: z.infer +): z.infer { + return cursorResultSchema.parse({ + lastOutboundJournaledSequence: maximumSequence( + current.lastOutboundJournaledSequence, + observed.lastOutboundJournaledSequence + ), + lastOutboundDeliveredSequence: maximumSequence( + current.lastOutboundDeliveredSequence, + observed.lastOutboundDeliveredSequence + ), + lastInboundJournaledSequence: maximumSequence( + current.lastInboundJournaledSequence, + observed.lastInboundJournaledSequence + ), + lastMainAckSequence: maximumSequence( + current.lastMainAckSequence, + observed.lastMainAckSequence + ) + }) +} + +function maximumSequence(left: string, right: string): string { + return BigInt(left) >= BigInt(right) ? left : right +} + +function sameCursors( + left: z.infer, + right: z.infer +): boolean { + return ( + left.lastOutboundJournaledSequence === + right.lastOutboundJournaledSequence && + left.lastOutboundDeliveredSequence === + right.lastOutboundDeliveredSequence && + left.lastInboundJournaledSequence === + right.lastInboundJournaledSequence && + left.lastMainAckSequence === right.lastMainAckSequence + ) +} + +function matchesGeneration( + connection: RuntimeProtocolConnection, + client: RuntimeProtocolClient, + generation: number, + capabilityGeneration: number +): boolean { + try { + return ( + connection.state === 'ready' && + connection.client === client && + client.generation === generation && + connection.capabilities.generation === capabilityGeneration + ) + } catch { + return false + } +} + +function assertRuntimeCapability( + capabilities: RuntimeProtocolConnection['capabilities'], + runtimeId: string, + runtimeBundleDigest: string, + requireModelBridge: boolean +): RuntimeProtocolConnection['capabilities']['runtimes'][number] { + const capability = capabilities.capabilities.find( + (entry) => entry.name === RUNTIME_ACP_CAPABILITY + ) + const runtimes = capabilities.runtimes.filter( + (entry) => entry.runtimeId === runtimeId + ) + const modelBridgeCapability = capabilities.capabilities.find( + (entry) => entry.name === RUNTIME_MODEL_BRIDGE_CAPABILITY + ) + if ( + !Number.isSafeInteger(capabilities.generation) || + capabilities.generation < 1 || + capability === undefined || + capability.version !== RUNTIME_ACP_CAPABILITY_VERSION || + !capability.critical || + (requireModelBridge && + (modelBridgeCapability === undefined || + modelBridgeCapability.version !== + RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION || + !modelBridgeCapability.critical)) || + runtimes.length !== 1 || + runtimes[0]!.bundleDigest !== runtimeBundleDigest + ) { + throw new ProtocolRemoteRuntimeChannelError( + 'Remote Agent Runtime capability does not exactly match the requested Runtime', + 'capability-mismatch' + ) + } + return runtimes[0]! +} + +async function requestWithTimeout( + client: RuntimeProtocolClient, + method: M, + params: RuntimeProtocolParams, + timeoutMs: number, + externalSignal?: AbortSignal +): Promise> { + const controller = new AbortController() + const abort = (): void => { + controller.abort( + new DOMException('Remote Runtime request aborted', 'AbortError') + ) + } + externalSignal?.throwIfAborted() + externalSignal?.addEventListener('abort', abort, { once: true }) + const timeout = setTimeout(() => { + controller.abort( + new DOMException('Remote Runtime request timed out', 'TimeoutError') + ) + }, timeoutMs) + try { + return await client.request(method, params, { + signal: controller.signal + }) + } finally { + clearTimeout(timeout) + externalSignal?.removeEventListener('abort', abort) + } +} + +async function bestEffortCloseOpenBinding( + client: RuntimeProtocolClient, + bindingId: string, + openResult: z.infer, + timeoutMs: number +): Promise { + const request = acpCloseChannelRequestSchema.parse({ + bindingId, + channelId: openResult.channelId, + channelEpoch: openResult.channelEpoch, + reason: 'released' + }) + try { + const raw = await requestWithTimeout( + client, + 'runtime/closeAcpChannel', + request, + timeoutMs + ) + const result = acpCloseChannelResultSchema.safeParse(raw) + if ( + !result.success || + result.data.bindingId !== bindingId || + result.data.channelEpoch !== openResult.channelEpoch || + !result.data.closed + ) { + return false + } + return true + } catch { + // Local cleanup must complete even when the remote transport is gone. + return false + } +} + +function parseRemoteResult( + schema: z.ZodType, + value: unknown, + message: string +): T { + const parsed = schema.safeParse(value) + if (!parsed.success) { + throw new ProtocolRemoteRuntimeChannelError(message, 'protocol') + } + return parsed.data +} + +function redactProtocolFailure( + error: unknown, + method: RuntimeProtocolMethod, + externalSignal?: AbortSignal +): Error { + if (error instanceof StaleRemoteRuntimeGenerationError) { + return error + } + if (error instanceof ProtocolRemoteRuntimeChannelError) { + return error + } + if (externalSignal?.aborted) { + return new ProtocolRemoteRuntimeChannelError( + 'Remote Runtime request was aborted', + 'aborted' + ) + } + if ( + error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) { + return new ProtocolRemoteRuntimeChannelError( + error.name === 'TimeoutError' + ? 'Remote Runtime request timed out' + : 'Remote Runtime request was aborted', + 'aborted' + ) + } + if (error instanceof AgentRpcError) { + const serviceCode = safeRemoteServiceCode(error.data) + const detail = [ + `RPC ${error.rpcCode}`, + ...(serviceCode === undefined ? [] : [serviceCode]) + ].join(', ') + return new ProtocolRemoteRuntimeChannelError( + `Remote Runtime ${method} was rejected (${detail})`, + 'remote', + { + remoteMethod: method, + remoteRpcCode: error.rpcCode, + ...(serviceCode === undefined + ? {} + : { remoteServiceCode: serviceCode }), + remoteRequestOutcome: 'rejected' + } + ) + } + return new ProtocolRemoteRuntimeChannelError( + `Remote Runtime ${method} protocol request failed`, + 'transport' + ) +} + +function safeRemoteServiceCode(data: unknown): string | undefined { + if ( + data === null || + typeof data !== 'object' || + !('code' in data) || + typeof data.code !== 'string' || + !/^[a-z][a-z0-9-]{0,63}$/u.test(data.code) + ) { + return undefined + } + return data.code +} + +function redactCancellationReason( + reason: unknown +): z.infer { + if (reason === undefined || reason === null) { + return 'unspecified' + } + if (reason instanceof DOMException) { + if (reason.name === 'TimeoutError') { + return 'timeout' + } + if (reason.name === 'AbortError') { + return 'aborted' + } + return 'error' + } + if (reason instanceof Error) { + return reason.name === 'TimeoutError' ? 'timeout' : 'error' + } + return 'requested' +} + +function boundedInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number +): number { + if (value === undefined) { + return fallback + } + if ( + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + throw new RangeError('Remote Runtime channel limit is invalid') + } + return value +} diff --git a/src/main/remote-agent/protocol-remote-workspace-transport.test.ts b/src/main/remote-agent/protocol-remote-workspace-transport.test.ts new file mode 100644 index 00000000..a0c699d6 --- /dev/null +++ b/src/main/remote-agent/protocol-remote-workspace-transport.test.ts @@ -0,0 +1,823 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_VERSION, + type AgentFrame, + type AttachWelcome, + type DaemonCapabilities +} from '../../shared/agent-protocol' +import { + decodeAgentFrame, + encodeAgentFrame +} from '../../shared/agent-protocol/frame' +import type { + RemoteWorkspaceHandle +} from '../../shared/remote-agent-contracts' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' +import type { AgentAttachTransport } from './agent-attach-transport' +import { AgentProtocolClient } from './agent-protocol-client' +import type { + RemoteAgentConnection, + RemoteAgentConnectionManager, + RemoteAgentInstallationIdentity +} from './remote-agent-connection-manager' +import { + ProtocolRemoteWorkspaceTransport, + ProtocolRemoteWorkspaceTransportError, + type RemoteWorkspaceInstallationIdentityResolver +} from './protocol-remote-workspace-transport' +import type { + RemoteWorkspaceProjectBinding, + RemoteWorkspaceTransportBinding +} from '../workspace/remote-workspace-access' + +type ControllerLease = { + controllerId: string + connectionId: string + generation: number + capabilityGeneration: number +} + +type ControllerRegistryLike = { + attach(controllerId: string): ControllerLease + assertCurrent( + controllerId: string, + generation: number + ): ControllerLease + revokeCapabilities(controllerId: string): number +} + +type ProtocolMethodHandler = ( + params: unknown, + context: { + controller: ControllerLease + channelId: string + signal?: AbortSignal + } +) => unknown | Promise + +const controllerRegistryModule = + '../../agent-daemon/controller-registry' +const eventJournalModule = '../../agent-daemon/event-journal' +const protocolServerModule = '../../agent-daemon/protocol-server' +const { ControllerRegistry } = (await import( + controllerRegistryModule +)) as { + ControllerRegistry: new () => ControllerRegistryLike +} +const { EventJournal } = (await import(eventJournalModule)) as { + EventJournal: new (path: string) => { close(): void } +} +const { AgentProtocolServer } = (await import( + protocolServerModule +)) as { + AgentProtocolServer: new (options: { + controllers: ControllerRegistryLike + events: unknown + status: () => unknown + methods: Record + }) => { + accept(socket: unknown, controller: ControllerLease): void + } +} + +const temporaryPaths: string[] = [] +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 +} +const installation: RemoteAgentInstallationIdentity = { + installationId: verifyAgentInstallationId('installation-test'), + binaryDigest: digest, + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand' +} +const handle: RemoteWorkspaceHandle = { + workspaceId: 'workspace-1', + workspaceIdentity: binding.workspaceIdentity, + canonicalDisplayPath: binding.remoteRootPath, + access: 'read-only', + git: 'available', + capabilities: [ + 'list', + 'stat', + 'read-text', + 'search', + 'git-status', + 'git-diff' + ], + generation: 5 +} + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('ProtocolRemoteWorkspaceTransport', () => { + it('bridges fixed validate/read/Git methods through a real protocol client and server', async () => { + const calls: string[] = [] + const test = createHarness({ + 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, + requestedAccess: 'read-only', + 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, + generation: handle.generation, + relativePath: '', + limit: 10 + }) + ).resolves.toMatchObject({ + entries: [{ relativePath: 'README.md', name: 'README.md' }] + }) + await expect( + lease.statWorkspace({ + workspaceId: handle.workspaceId, + generation: handle.generation, + relativePath: 'README.md' + }) + ).resolves.toMatchObject({ + relativePath: 'README.md', + byteLength: 5 + }) + await expect( + lease.readWorkspaceText({ + workspaceId: handle.workspaceId, + generation: handle.generation, + relativePath: 'README.md', + offsetBytes: 0, + maximumBytes: 10 + }) + ).resolves.toMatchObject({ content: 'hello', bytesRead: 5 }) + await expect( + lease.searchWorkspace({ + workspaceId: handle.workspaceId, + generation: handle.generation, + query: 'hello', + caseSensitive: true, + limit: 10 + }) + ).resolves.toMatchObject({ + matches: [{ relativePath: 'README.md', line: 1 }] + }) + await expect( + lease.getGitStatus({ + workspaceId: handle.workspaceId, + generation: handle.generation, + includeIgnored: false, + maximumEntries: 10 + }) + ).resolves.toMatchObject({ + repositoryIdentity: 'repository-1', + branch: 'main' + }) + await expect( + lease.getGitDiff({ + workspaceId: handle.workspaceId, + generation: handle.generation, + staged: false, + maximumBytes: 100 + }) + ).resolves.toMatchObject({ + repositoryIdentity: 'repository-1', + patch: '+hello\n' + }) + await expect( + lease.closeWorkspace({ + workspaceId: handle.workspaceId, + generation: handle.generation + }) + ).resolves.toEqual({ + workspaceId: handle.workspaceId, + generation: handle.generation, + closed: true + }) + + expect(calls).toEqual([ + 'workspace/validate', + 'workspace/open', + 'workspace/resume', + 'workspace/list', + 'workspace/stat', + 'workspace/readText', + 'workspace/search', + 'git/status', + 'git/diff', + 'workspace/close' + ]) + lease.release() + expect(test.release).toHaveBeenCalledOnce() + 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 + } + + const lease = await test.transport.acquireLease(binding) + + expect(lease.resumableHandle).toEqual(nonGitHandle) + 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, + installationId: + verifyAgentInstallationId('installation-other') + } + }) + await expect( + installationMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'binding-mismatch' }) + expect(installationMismatch.acquire).not.toHaveBeenCalled() + installationMismatch.close() + + const statusMismatch = createHarness({ + connectionStatus: { draining: true } + }) + await expect( + statusMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'binding-mismatch' }) + expect(statusMismatch.release).toHaveBeenCalledOnce() + statusMismatch.close() + + const connectionDigestMismatch = createHarness({ + identity: { binaryDigest: `sha256:${'b'.repeat(64)}` } + }) + await expect( + connectionDigestMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'binding-mismatch' }) + expect(connectionDigestMismatch.release).toHaveBeenCalledOnce() + connectionDigestMismatch.close() + + for (const changedInstallation of [ + { ...installation, binaryDigest: `sha256:${'b'.repeat(64)}` }, + { ...installation, agentVersion: '0.11.1' }, + { ...installation, architecture: 'arm64' as const } + ]) { + const identityMismatch = createHarness({ + installation: changedInstallation + }) + await expect( + identityMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'binding-mismatch' }) + expect(identityMismatch.acquire).not.toHaveBeenCalled() + identityMismatch.close() + } + + for (const changedStatus of [ + { binaryDigest: `sha256:${'b'.repeat(64)}` }, + { agentVersion: '0.11.1' }, + { architecture: 'arm64' as const } + ]) { + const liveStatusMismatch = createHarness({ + connectionStatus: changedStatus + }) + await expect( + liveStatusMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'binding-mismatch' }) + expect(liveStatusMismatch.release).toHaveBeenCalledOnce() + liveStatusMismatch.close() + } + + const methods = workspaceMethods() + delete methods['git/diff'] + const capabilityMismatch = createHarness({ methods }) + await expect( + capabilityMismatch.transport.acquireLease(binding) + ).rejects.toMatchObject({ reason: 'capability-missing' }) + expect(capabilityMismatch.release).toHaveBeenCalledOnce() + capabilityMismatch.close() + }) + + it('rejects stale capability generations before dispatching a workspace call', async () => { + const calls: string[] = [] + const test = createHarness({ + methods: workspaceMethods(calls) + }) + const lease = await test.transport.acquireLease(binding) + test.controllers.revokeCapabilities(test.controller.controllerId) + + await expect( + lease.listWorkspace({ + workspaceId: handle.workspaceId, + generation: handle.generation, + relativePath: '', + limit: 10 + }) + ).rejects.toEqual( + expect.objectContaining({ + reason: 'stale-generation' + }) + ) + expect(calls).not.toContain('workspace/list') + lease.release() + test.close() + }) + + it('preserves cancellation through AgentProtocolClient requests', async () => { + let finish!: () => void + const gate = new Promise((resolveGate) => { + finish = resolveGate + }) + const methods = workspaceMethods() + methods['workspace/readText'] = vi.fn(async () => { + await gate + return readResult() + }) + const test = createHarness({ methods }) + const lease = await test.transport.acquireLease(binding) + const controller = new AbortController() + const operation = lease.readWorkspaceText( + { + workspaceId: handle.workspaceId, + generation: handle.generation, + relativePath: 'README.md', + offsetBytes: 0, + maximumBytes: 10 + }, + controller.signal + ) + await vi.waitFor(() => { + expect(methods['workspace/readText']).toHaveBeenCalled() + }) + controller.abort(new Error('read canceled')) + + await expect(operation).rejects.toThrow('read canceled') + finish() + lease.release() + 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'] + ) + const methods = workspaceMethods() + methods['workspace/close'] = closeHandler + const test = createHarness({ methods }) + const [first, second] = await Promise.all([ + test.transport.acquireLease(binding), + test.transport.acquireLease(binding) + ]) + expect(test.acquire).toHaveBeenCalledTimes(2) + const request = { + workspaceId: handle.workspaceId, + generation: handle.generation + } + + const [firstClose, duplicateClose] = await Promise.all([ + first.closeWorkspace(request), + first.closeWorkspace(request) + ]) + expect(firstClose).toEqual(duplicateClose) + expect(closeHandler).toHaveBeenCalledOnce() + + first.release() + first.release() + expect(test.release).toHaveBeenCalledTimes(1) + second.release() + second.release() + expect(test.release).toHaveBeenCalledTimes(2) + expect(() => first.binding).toThrow( + ProtocolRemoteWorkspaceTransportError + ) + test.close() + }) +}) + +type HarnessOptions = { + methods?: Record + identity?: Partial + installation?: RemoteAgentInstallationIdentity + connectionStatus?: Partial +} + +function createHarness(options: HarnessOptions = {}) { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-workspace-transport-')) + temporaryPaths.push(root) + const controllers = new ControllerRegistry() + const controller = controllers.attach('controller-test') + const events = new EventJournal(resolve(root, 'events.sqlite')) + const status = { + state: 'ready' as const, + installationId: binding.agentInstallationId, + binaryDigest: digest, + daemonBootId: 'boot-test', + agentVersion: '0.11.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux' as const, + architecture: 'x64' as const, + supervisor: 'detached-on-demand' as const, + remoteUserIdentity: 'uid:1000', + draining: false, + ...options.connectionStatus + } + const server = new AgentProtocolServer({ + controllers, + events, + status: () => status, + methods: options.methods ?? workspaceMethods() + }) + const socket = new MemorySocket() + const transport = new MemoryAgentTransport(socket, controller) + socket.onWrite = (data) => transport.accept(data) + server.accept(socket, controller) + const client = new AgentProtocolClient( + transport as unknown as AgentAttachTransport + ) + const release = vi.fn() + const currentCapabilities = (): DaemonCapabilities => { + const current = controllers.assertCurrent( + controller.controllerId, + controller.generation + ) + const advertised = + Object.keys(options.methods ?? workspaceMethods()).length >= 10 + return { + generation: current.capabilityGeneration, + capabilities: advertised + ? [ + { + name: 'workspace/read', + version: 1, + critical: true + } + ] + : [], + runtimes: [] + } + } + const acquire = vi.fn(async () => { + let released = false + return { + identity: { + cacheKey: 'connection-cache', + hostId: binding.hostId, + hostRevision: binding.hostRevision, + hostKeyGeneration: binding.hostKeyGeneration, + remoteUsername: binding.remoteUsername, + installationId: binding.agentInstallationId, + binaryDigest: digest, + protocolMajor: AGENT_PROTOCOL_VERSION.major, + protocolMinor: AGENT_PROTOCOL_VERSION.minor, + ...options.identity + }, + status, + get capabilities(): DaemonCapabilities { + return currentCapabilities() + }, + client, + state: 'ready' as const, + reconnect: async () => undefined, + refreshCapabilities: async () => currentCapabilities(), + updateAcpBinding: async () => undefined, + flushAcpBindings: async () => undefined, + release: () => { + if (!released) { + released = true + release() + } + } + } satisfies RemoteAgentConnection + }) + const resolution = { + installation: options.installation ?? installation, + durableWorkspaceValidation: undefined + } as { + installation: RemoteAgentInstallationIdentity + durableWorkspaceValidation?: + import('./protocol-remote-workspace-transport').DurableRemoteWorkspaceValidation + } + const resolver: RemoteWorkspaceInstallationIdentityResolver = { + resolve: vi.fn(async () => resolution) + } + const workspaceTransport = new ProtocolRemoteWorkspaceTransport( + { acquire } as unknown as Pick< + RemoteAgentConnectionManager, + 'acquire' + >, + resolver + ) + return { + transport: workspaceTransport, + controllers, + controller, + resolution, + acquire, + release, + close: () => { + client.dispose() + socket.destroy() + events.close() + } + } +} + +function workspaceMethods( + calls: string[] = [] +): Record { + const method = ( + name: string, + result: unknown + ): ProtocolMethodHandler => + vi.fn(async () => { + calls.push(name) + return result + }) + return { + 'workspace/validate': method('workspace/validate', { + handle, + validatedAt: timestamp + }), + 'workspace/open': method('workspace/open', handle), + 'workspace/resume': method('workspace/resume', { + resumed: true, + handle + }), + 'workspace/close': method('workspace/close', { + workspaceId: handle.workspaceId, + generation: handle.generation, + closed: true + }), + 'workspace/list': method('workspace/list', { + entries: [ + { + relativePath: 'README.md', + name: 'README.md', + kind: 'file', + byteLength: 5, + modifiedAt: timestamp, + digest, + executable: false + } + ] + }), + 'workspace/stat': method('workspace/stat', { + relativePath: 'README.md', + name: 'README.md', + kind: 'file', + byteLength: 5, + modifiedAt: timestamp, + digest, + executable: false + }), + 'workspace/readText': method('workspace/readText', readResult()), + 'workspace/search': method('workspace/search', { + matches: [ + { + relativePath: 'README.md', + line: 1, + column: 1, + snippet: 'hello' + } + ], + truncated: false + }), + 'git/status': method('git/status', { + repositoryIdentity: 'repository-1', + branch: 'main', + entries: [], + truncated: false + }), + 'git/diff': method('git/diff', { + repositoryIdentity: 'repository-1', + patch: '+hello\n', + byteLength: 7, + truncated: false + }) + } +} + +function readResult() { + return { + relativePath: 'README.md', + content: 'hello', + offsetBytes: 0, + bytesRead: 5, + totalBytes: 5, + digest, + truncated: false + } +} + +function transportBinding( + _test: ReturnType, + capabilityGeneration: number +): 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, + capabilityGeneration + } +} + +class MemorySocket extends EventEmitter { + onWrite?: (data: Uint8Array) => void + destroyed = false + + pause(): this { + return this + } + + resume(): this { + return this + } + + write( + data: Uint8Array, + callback?: (error?: Error | null) => void + ): boolean { + this.onWrite?.(data) + queueMicrotask(() => callback?.()) + return true + } + + receive(data: Uint8Array): void { + this.emit('data', Buffer.from(data)) + } + + destroy(): this { + if (!this.destroyed) { + this.destroyed = true + queueMicrotask(() => this.emit('close')) + } + return this + } +} + +class MemoryAgentTransport { + readonly welcome: AttachWelcome + #frames: AgentFrame[] = [] + #readers: Array<{ + resolve: (frame: AgentFrame) => void + reject: (error: unknown) => void + }> = [] + #closeListeners = new Set<(error: Error) => void>() + #disposed = false + + constructor( + private readonly socket: MemorySocket, + controller: ControllerLease + ) { + this.welcome = { + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId: controller.connectionId, + generation: controller.generation, + installationId: binding.agentInstallationId, + binaryDigest: digest, + daemonBootId: 'boot-test', + serverNonce: 'server-test' + } + } + + async send(frame: AgentFrame): Promise { + if (this.#disposed) { + throw new Error('transport disposed') + } + this.socket.receive(encodeAgentFrame(frame)) + } + + receive(): Promise { + const frame = this.#frames.shift() + if (frame !== undefined) { + return Promise.resolve(frame) + } + return new Promise((resolveFrame, reject) => { + this.#readers.push({ resolve: resolveFrame, reject }) + }) + } + + accept(data: Uint8Array): void { + const frame = decodeAgentFrame(data, { + protocolMajor: AGENT_PROTOCOL_VERSION.major, + maximumProtocolMinor: AGENT_PROTOCOL_VERSION.minor, + connectionId: this.welcome.connectionId, + generation: this.welcome.generation + }) + const reader = this.#readers.shift() + if (reader === undefined) { + this.#frames.push(frame) + } else { + reader.resolve(frame) + } + } + + onClose(listener: (error: Error) => void): () => void { + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + dispose(): void { + if (this.#disposed) { + return + } + this.#disposed = true + const error = new Error('transport disposed') + for (const reader of this.#readers.splice(0)) { + reader.reject(error) + } + for (const listener of this.#closeListeners) { + listener(error) + } + this.#closeListeners.clear() + } +} diff --git a/src/main/remote-agent/protocol-remote-workspace-transport.ts b/src/main/remote-agent/protocol-remote-workspace-transport.ts new file mode 100644 index 00000000..3eb1d726 --- /dev/null +++ b/src/main/remote-agent/protocol-remote-workspace-transport.ts @@ -0,0 +1,562 @@ +import { + REMOTE_WORKSPACE_READ_CAPABILITIES, + remoteWorkspaceHandleSchema, + type RemoteWorkspaceCloseResult, + type RemoteWorkspaceHandle +} from '../../shared/remote-agent-contracts' +import type { + RemoteWorkspaceProjectBinding, + RemoteWorkspaceTransport, + RemoteWorkspaceTransportBinding, + RemoteWorkspaceTransportLease +} from '../workspace/remote-workspace-access' +import type { + AgentProtocolClient, + AgentProtocolParams, + AgentProtocolResult +} from './agent-protocol-client' +import type { + RemoteAgentConnection, + RemoteAgentConnectionManager, + RemoteAgentInstallationIdentity +} from './remote-agent-connection-manager' + +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 +} + +export class ProtocolRemoteWorkspaceTransportError extends Error { + constructor( + message: string, + readonly reason: + | 'binding-mismatch' + | 'capability-missing' + | 'stale-generation' + | 'closed' + ) { + super(message) + this.name = 'ProtocolRemoteWorkspaceTransportError' + } +} + +/** + * The closed, read-only Workspace/Git bridge over AgentProtocolClient. + * Deliberately no generic protocol request method is exposed. + */ +export class ProtocolRemoteWorkspaceTransport + implements RemoteWorkspaceTransport +{ + constructor( + private readonly connections: Pick< + RemoteAgentConnectionManager, + 'acquire' + >, + private readonly installations: + RemoteWorkspaceInstallationIdentityResolver + ) {} + + async acquireLease( + binding: RemoteWorkspaceProjectBinding, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + const projectBinding = { ...binding } + const resolved = await this.installations.resolve( + projectBinding.hostId, + projectBinding.agentInstallationId, + signal + ) + let resolution: RemoteWorkspaceInstallationResolution | undefined + let connection: RemoteAgentConnection | undefined + let retained = false + 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) + const capabilities = await connection.client.request( + 'agent/capabilities', + {}, + { signal } + ) + signal?.throwIfAborted() + assertWorkspaceReadCapability(capabilities) + if ( + capabilities.generation !== + connection.capabilities.generation + ) { + 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 + }) + retained = true + return lease + } finally { + if (!retained) { + releaseLeaseResources(connection) + } + } + } +} + +function snapshotResolution( + resolution: RemoteWorkspaceInstallationResolution +): RemoteWorkspaceInstallationResolution { + const installation = resolution.installation + const durable = resolution.durableWorkspaceValidation + return { + installation: { + ...installation, + ...(installation.protocol === undefined + ? {} + : { protocol: { ...installation.protocol } }), + ...(installation.requiredCapabilities === undefined + ? {} + : { + requiredCapabilities: + installation.requiredCapabilities.map((entry) => ({ + ...entry + })) + }) + }, + ...(durable === undefined + ? {} + : { + durableWorkspaceValidation: { + binding: { ...durable.binding }, + workspaceIdentity: durable.workspaceIdentity, + handle: { + ...durable.handle, + capabilities: [...durable.handle.capabilities] + }, + validatedAt: durable.validatedAt + } + }) + } +} + +type LeaseState = { + binding: RemoteWorkspaceProjectBinding + resolution: RemoteWorkspaceInstallationResolution + connection: RemoteAgentConnection + acquiredCapabilityGeneration: number + resumableHandle?: RemoteWorkspaceHandle +} + +function createLease(state: LeaseState): RemoteWorkspaceTransportLease { + let released = false + let closing: + | { + workspaceId: string + generation: number + promise: Promise + } + | undefined + + const assertOpen = (): void => { + if (released) { + throw new ProtocolRemoteWorkspaceTransportError( + 'Remote workspace transport lease is closed', + 'closed' + ) + } + } + + const currentClient = (): AgentProtocolClient => { + assertOpen() + if (state.connection.state !== 'ready') { + throw staleGenerationError() + } + assertConnectionIdentity( + state.binding, + state.resolution, + state.connection + ) + assertConnectionStatus( + state.binding, + state.resolution, + state.connection.status + ) + if ( + state.connection.capabilities.generation !== + state.acquiredCapabilityGeneration + ) { + throw staleGenerationError() + } + return state.connection.client + } + + const request = async < + M extends WorkspaceReadProtocolMethod + >( + method: M, + params: AgentProtocolParams, + signal?: AbortSignal + ): Promise> => { + signal?.throwIfAborted() + const client = currentClient() + const capabilities = state.connection.capabilities + assertWorkspaceReadCapability(capabilities) + if ( + capabilities.generation !== + state.acquiredCapabilityGeneration + ) { + throw staleGenerationError() + } + if (currentClient() !== client) { + throw staleGenerationError() + } + return await client.request(method, params, { signal }) + } + + return { + get binding() { + currentClient() + return liveBinding( + state.binding, + state.resolution, + state.connection, + 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 ( + closing?.workspaceId === value.workspaceId && + closing.generation === value.generation + ) { + return closing.promise + } + const promise = request('workspace/close', value) + closing = { + workspaceId: value.workspaceId, + generation: value.generation, + promise + } + return promise + }, + listWorkspace: async (value, signal) => + await request('workspace/list', value, signal), + statWorkspace: async (value, signal) => + await request('workspace/stat', value, signal), + readWorkspaceText: async (value, signal) => + await request('workspace/readText', value, signal), + searchWorkspace: async (value, signal) => + await request('workspace/search', value, signal), + getGitStatus: async (value, signal) => + await request('git/status', value, signal), + getGitDiff: async (value, signal) => + await request('git/diff', value, signal), + release: () => { + if (released) { + return + } + released = true + releaseLeaseResources( + state.connection + ) + } + } +} + +function releaseLeaseResources( + connection: RemoteAgentConnection | undefined +): void { + const errors: unknown[] = [] + if (connection !== undefined) { + try { + connection.release() + } catch (error) { + errors.push(error) + } + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Failed to release remote workspace transport resources' + ) + } +} + +type WorkspaceReadProtocolMethod = + | 'workspace/validate' + | 'workspace/open' + | 'workspace/resume' + | 'workspace/close' + | 'workspace/list' + | 'workspace/stat' + | 'workspace/readText' + | 'workspace/search' + | 'git/status' + | 'git/diff' + +function requireWorkspaceReadCapability( + installation: RemoteAgentInstallationIdentity +): RemoteAgentInstallationIdentity { + const requirements = [...(installation.requiredCapabilities ?? [])] + const index = requirements.findIndex( + (requirement) => requirement.name === WORKSPACE_READ_CAPABILITY + ) + const required = { + name: WORKSPACE_READ_CAPABILITY, + minimumVersion: WORKSPACE_READ_CAPABILITY_VERSION, + critical: true + } + if (index < 0) { + requirements.push(required) + } else { + const current = requirements[index]! + requirements[index] = { + name: current.name, + ...(current.exactVersion === undefined + ? { + minimumVersion: Math.max( + current.minimumVersion!, + WORKSPACE_READ_CAPABILITY_VERSION + ) + } + : { exactVersion: current.exactVersion }), + critical: true + } + } + return { + ...installation, + requiredCapabilities: requirements + } +} + +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, + connection: RemoteAgentConnection +): void { + const identity = connection.identity + 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.binaryDigest !== resolution.installation.binaryDigest || + identity.protocolMajor !== binding.agentProtocolMajor || + protocol === undefined || + identity.protocolMinor !== protocol.minor + ) { + throw bindingMismatchError() + } +} + +function assertConnectionStatus( + binding: RemoteWorkspaceProjectBinding, + resolution: RemoteWorkspaceInstallationResolution, + status: RemoteAgentConnection['status'] +): void { + const installation = resolution.installation + if ( + status.state !== 'ready' || + status.draining || + status.installationId !== binding.agentInstallationId || + status.binaryDigest !== binding.agentBinaryDigest || + 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.platform !== installation.platform || + status.supervisor !== installation.supervisor || + installation.agentVersion === undefined + ) { + throw bindingMismatchError() + } +} + +function assertWorkspaceReadCapability( + capabilities: RemoteAgentConnection['capabilities'] +): void { + const capability = capabilities.capabilities.find( + (entry) => entry.name === WORKSPACE_READ_CAPABILITY + ) + if ( + !Number.isSafeInteger(capabilities.generation) || + capabilities.generation < 1 || + capability === undefined || + capability.version < WORKSPACE_READ_CAPABILITY_VERSION || + !capability.critical + ) { + throw new ProtocolRemoteWorkspaceTransportError( + 'Remote Agent does not advertise the critical workspace/read capability', + 'capability-missing' + ) + } +} + +function liveBinding( + binding: RemoteWorkspaceProjectBinding, + resolution: RemoteWorkspaceInstallationResolution, + connection: RemoteAgentConnection, + capabilityGeneration: number +): RemoteWorkspaceTransportBinding { + assertConnectionIdentity(binding, resolution, connection) + assertConnectionStatus(binding, resolution, connection.status) + if ( + connection.capabilities.generation !== capabilityGeneration + ) { + throw staleGenerationError() + } + return { + hostId: connection.identity.hostId, + hostRevision: connection.identity.hostRevision, + hostKeyGeneration: connection.identity.hostKeyGeneration, + remoteUsername: connection.identity.remoteUsername, + agentInstallationId: connection.identity.installationId, + agentBinaryDigest: connection.identity.binaryDigest, + agentVersion: connection.status.agentVersion, + agentArchitecture: connection.status.architecture, + agentProtocolMajor: connection.identity.protocolMajor, + capabilityGeneration + } +} + +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', + 'binding-mismatch' + ) +} + +function staleGenerationError(): ProtocolRemoteWorkspaceTransportError { + return new ProtocolRemoteWorkspaceTransportError( + 'Remote workspace capability generation is stale', + 'stale-generation' + ) +} diff --git a/src/main/remote-agent/remote-agent-connection-manager.test.ts b/src/main/remote-agent/remote-agent-connection-manager.test.ts new file mode 100644 index 00000000..888172f9 --- /dev/null +++ b/src/main/remote-agent/remote-agent-connection-manager.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROTOCOL_VERSION, + type AttachWelcome, + type DaemonCapabilities +} from '../../shared/agent-protocol' +import type { + SshConnectionLease, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { verifyAgentInstallationId } from '../ssh/ssh-agent-command' +import type { AgentAttachTransport } from './agent-attach-transport' +import type { + AgentProtocolClient, + AgentProtocolMethod +} from './agent-protocol-client' +import { + ControllerStateStore, + type ControllerStateFile, + type PersistedControllerState +} from './controller-state-store' +import { + RemoteAgentConnectionManager, + type RemoteAgentSshPool +} from './remote-agent-connection-manager' + +class MemoryStateFile implements ControllerStateFile { + value?: unknown + + async read(): Promise { + return structuredClone(this.value) + } + + async write(value: PersistedControllerState): Promise { + this.value = structuredClone(value) + } +} + +class FakeProtocolClient { + readonly connectionId: string + readonly generation: number + capabilitiesGate?: Promise + resumeGate?: Promise + capabilities: DaemonCapabilities = { + generation: 1, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + } + disposed = false + readonly requests: Array<{ method: AgentProtocolMethod; params: unknown }> = + [] + #closeListeners = new Set<(error: Error) => void>() + + constructor(index = 0) { + this.connectionId = `connection-${index + 1}` + this.generation = index + 2 + } + + async request( + method: AgentProtocolMethod, + params: unknown + ): Promise { + this.requests.push({ method, params }) + if (method === 'agent/status') { + return { + state: 'ready', + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + agentVersion: '1.0.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'user-1', + draining: false + } + } + if (method === 'agent/capabilities') { + await this.capabilitiesGate + return structuredClone(this.capabilities) + } + if (method === 'controller/resume') { + await this.resumeGate + const request = params as { + daemonBootId: string + capabilityGeneration: number + } + return { + resumed: true, + generation: this.generation, + daemonBootId: request.daemonBootId, + capabilityGeneration: request.capabilityGeneration, + leaseDeadlineAt: '2030-01-01T00:00:00.000Z' + } + } + throw new Error(`Unexpected method ${method}`) + } + + onClose(listener: (error: Error) => void): () => void { + this.#closeListeners.add(listener) + return () => this.#closeListeners.delete(listener) + } + + transportClose(): void { + for (const listener of this.#closeListeners) { + listener(new Error('transport closed')) + } + } + + dispose(): void { + this.disposed = true + } +} + +function target(revision = 1): SshConnectionPoolTarget { + return { + host: { + id: 'host-1', + name: 'Builder', + hostname: 'builder.example', + port: 22, + username: 'goodbuddy', + authentication: 'password', + password: 'not-persisted', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'a2V5', + fingerprintSha256: `SHA256:${'a'.repeat(43)}`, + generation: revision + } + }, + hostRevision: revision, + hostKeyGeneration: revision + } +} + +const installation = { + installationId: verifyAgentInstallationId('agent-v1'), + binaryDigest: `sha256:${'a'.repeat(64)}`, + agentVersion: '1.0.0', + protocol: AGENT_PROTOCOL_VERSION, + platform: 'linux' as const, + architecture: 'x64' as const, + supervisor: 'detached-on-demand' as const, + requiredCapabilities: [ + { name: 'workspace/read', minimumVersion: 1, critical: true } + ] +} + +function harness( + connectGate?: Promise, + reconnectResumeGate?: Promise +) { + let currentTarget = target() + const release = vi.fn() + const sshLease = { + identity: { + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + authenticationIdentity: 'b'.repeat(64) + }, + isUsable: () => true, + release + } as unknown as SshConnectionLease + const sshPool: RemoteAgentSshPool = { + acquire: vi.fn(async () => sshLease), + disposeHost: vi.fn() + } + const resolver = { + resolve: vi.fn(async () => currentTarget) + } + const clients: FakeProtocolClient[] = [] + const stateFile = new MemoryStateFile() + const connectTransport = vi.fn(async () => { + await connectGate + const welcome: AttachWelcome = { + type: 'goodbuddy-agent-welcome', + protocol: AGENT_PROTOCOL_VERSION, + connectionId: `connection-${connectTransport.mock.calls.length}`, + generation: connectTransport.mock.calls.length + 1, + installationId: 'agent-v1', + binaryDigest: `sha256:${'a'.repeat(64)}`, + daemonBootId: 'boot-1', + serverNonce: 'server-1' + } + return { + welcome, + dispose: vi.fn() + } as unknown as AgentAttachTransport + }) + const manager = new RemoteAgentConnectionManager({ + resolver, + sshPool, + controllerState: new ControllerStateStore(stateFile), + goodBuddyVersion: '0.11.0', + idleTimeoutMs: 0, + connectTransport, + createProtocolClient: () => { + const client = new FakeProtocolClient(clients.length) + if (clients.length > 0) { + client.resumeGate = reconnectResumeGate + } + clients.push(client) + return client as unknown as AgentProtocolClient + } + }) + return { + manager, + resolver, + sshPool, + release, + connectTransport, + clients, + stateFile, + changeRevision: (revision: number) => { + currentTarget = target(revision) + } + } +} + +describe('RemoteAgentConnectionManager', () => { + it('deduplicates concurrent connects and ref-counts the shared connection', async () => { + const test = harness() + const [first, second] = await Promise.all([ + test.manager.acquire('host-1', installation), + test.manager.acquire('host-1', installation) + ]) + expect(first.client).toBe(second.client) + expect(test.connectTransport).toHaveBeenCalledTimes(1) + expect(test.sshPool.acquire).toHaveBeenCalledTimes(1) + first.release() + expect(test.release).not.toHaveBeenCalled() + second.release() + expect(test.release).toHaveBeenCalledTimes(1) + await test.manager.dispose() + }) + + it('isolates cache entries across Host revisions', async () => { + const test = harness() + const first = await test.manager.acquire('host-1', installation) + test.changeRevision(2) + const second = await test.manager.acquire('host-1', installation) + expect(first.identity.cacheKey).not.toBe(second.identity.cacheKey) + expect(test.connectTransport).toHaveBeenCalledTimes(2) + first.release() + second.release() + await test.manager.dispose() + }) + + it('cancels one waiter without canceling a shared connect', async () => { + let openTransport!: () => void + const gate = new Promise((resolve) => { + openTransport = resolve + }) + const test = harness(gate) + const controller = new AbortController() + const canceled = test.manager.acquire( + 'host-1', + installation, + controller.signal + ) + const retained = test.manager.acquire('host-1', installation) + await vi.waitFor(() => + expect(test.connectTransport).toHaveBeenCalledTimes(1) + ) + controller.abort(new Error('waiter canceled')) + await expect(canceled).rejects.toThrow('waiter canceled') + openTransport() + const lease = await retained + expect(lease.state).toBe('ready') + lease.release() + await test.manager.dispose() + }) + + it('invalidates every connection for a Host without stopping its daemon', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + await test.manager.invalidateHost('host-1') + expect(test.sshPool.disposeHost).toHaveBeenCalledWith('host-1') + expect(() => lease.client).toThrow() + lease.release() + await test.manager.dispose() + }) + + it('does not publish a connection that finishes after Host invalidation', async () => { + let finishTransport!: () => void + const gate = new Promise((resolve) => { + finishTransport = resolve + }) + const test = harness(gate) + const acquiring = test.manager.acquire('host-1', installation) + await vi.waitFor(() => + expect(test.connectTransport).toHaveBeenCalledOnce() + ) + + await test.manager.invalidateHost('host-1') + finishTransport() + + await expect(acquiring).rejects.toMatchObject({ + disposition: 'transient', + reason: 'network' + }) + expect(test.clients[0]?.disposed).toBe(true) + expect(test.release).toHaveBeenCalledOnce() + await test.manager.dispose() + }) + + it('refreshes and validates capabilities on the current client generation', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + const refreshed: DaemonCapabilities = { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true }, + { name: 'runtime/acp', version: 3, critical: true } + ], + runtimes: [ + { + runtimeId: 'opencode', + version: '1.0.0', + bundleDigest: `sha256:${'b'.repeat(64)}`, + acpCapabilitiesDigest: `sha256:${'c'.repeat(64)}`, + sessionLoad: true, + sessionResume: true + } + ] + } + test.clients[0]!.capabilities = refreshed + + await expect(lease.refreshCapabilities()).resolves.toEqual( + refreshed + ) + expect(lease.capabilities).toEqual(refreshed) + + test.clients[0]!.capabilities = { + generation: 3, + capabilities: [ + { name: 'runtime/acp', version: 3, critical: true } + ], + runtimes: refreshed.runtimes + } + await expect(lease.refreshCapabilities()).rejects.toMatchObject({ + reason: 'capability', + disposition: 'terminal' + }) + expect(lease.capabilities).toEqual(refreshed) + lease.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', { + ...installation, + requiredCapabilities: [ + { + name: 'workspace/read', + exactVersion: 1, + critical: true + } + ] + }) + test.clients[0]!.capabilities = { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 2, critical: true } + ], + runtimes: [] + } + + await expect(lease.refreshCapabilities()).rejects.toMatchObject({ + reason: 'capability', + disposition: 'terminal' + }) + lease.release() + await test.manager.dispose() + }) + + it('refuses capability refresh after its lease is released', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + lease.release() + await expect(lease.refreshCapabilities()).rejects.toThrow( + 'lease is released' + ) + await test.manager.dispose() + }) + + it('does not publish a capability refresh after its lease is released in flight', async () => { + const test = harness() + const [refreshing, retained] = await Promise.all([ + test.manager.acquire('host-1', installation), + test.manager.acquire('host-1', installation) + ]) + let finishRefresh!: () => void + test.clients[0]!.capabilitiesGate = new Promise((resolve) => { + finishRefresh = resolve + }) + test.clients[0]!.capabilities = { + generation: 2, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + } + + const refresh = refreshing.refreshCapabilities() + refreshing.release() + finishRefresh() + + await expect(refresh).rejects.toThrow('lease is released') + expect(retained.capabilities.generation).toBe(1) + retained.release() + await test.manager.dispose() + }) + + it('proves the exact predecessor tuple and notifies retained leases after a client swap', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + const originalClient = lease.client + await lease.updateAcpBinding('binding-1', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '4', + lastMainAckSequence: '3' + } + }) + const changed = vi.fn() + lease.onClientChange?.(changed) + + test.clients[0]!.transportClose() + expect(lease.state).toBe('offline') + await lease.reconnect() + + expect(lease.client).not.toBe(originalClient) + expect(lease.client.generation).toBe(3) + expect(changed).toHaveBeenCalledOnce() + expect( + test.clients[1]!.requests.find( + (request) => request.method === 'controller/resume' + ) + ).toEqual({ + method: 'controller/resume', + params: { + previousGeneration: 2, + previousConnectionId: 'connection-1', + daemonBootId: 'boot-1', + capabilityGeneration: 1 + } + }) + lease.release() + await test.manager.dispose() + }) + + it('persists ACP recovery cursors while the retained transport is offline', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + await lease.updateAcpBinding('binding-1', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '3', + lastMainAckSequence: '1' + } + }) + test.clients[0]!.transportClose() + expect(lease.state).toBe('offline') + + await expect( + lease.updateAcpBinding('binding-1', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '3', + lastMainAckSequence: '2' + } + }) + ).resolves.toBeUndefined() + await expect(lease.flushAcpBindings()).resolves.toBeUndefined() + expect(test.stateFile.value).toMatchObject({ + connections: [ + { + acpBindings: [ + { + bindingId: 'binding-1', + cursors: { + lastMainAckSequence: '2' + } + } + ] + } + ] + }) + + lease.release() + await expect( + lease.flushAcpBindings() + ).rejects.toThrow(/released/iu) + await test.manager.dispose() + }) + + it('preserves ACP cursors persisted while reconnect resume is in flight', async () => { + let finishResume!: () => void + const resumeGate = new Promise((resolve) => { + finishResume = resolve + }) + const test = harness(undefined, resumeGate) + const lease = await test.manager.acquire('host-1', installation) + await lease.updateAcpBinding('binding-1', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '3', + lastMainAckSequence: '1' + } + }) + await lease.flushAcpBindings() + test.clients[0]!.transportClose() + const reconnect = lease.reconnect() + await vi.waitFor(() => + expect( + test.clients[1]?.requests.some( + (request) => request.method === 'controller/resume' + ) + ).toBe(true) + ) + + await lease.updateAcpBinding('binding-1', { + bindingId: 'binding-1', + channelId: 'binding-1', + channelEpoch: '9', + cursors: { + lastOutboundJournaledSequence: '2', + lastOutboundDeliveredSequence: '2', + lastInboundJournaledSequence: '3', + lastMainAckSequence: '2' + } + }) + await lease.flushAcpBindings() + finishResume() + await reconnect + + expect(test.stateFile.value).toMatchObject({ + connections: [ + { + acpBindings: [ + { + bindingId: 'binding-1', + cursors: { + lastMainAckSequence: '2' + } + } + ] + } + ] + }) + lease.release() + await test.manager.dispose() + }) + + it('shuts down channels and refuses later acquisition', async () => { + const test = harness() + const lease = await test.manager.acquire('host-1', installation) + await test.manager.dispose() + expect(test.clients[0]!.disposed).toBe(true) + expect(() => lease.client).toThrow() + await expect( + test.manager.acquire('host-1', installation) + ).rejects.toMatchObject({ reason: 'shutdown' }) + }) +}) diff --git a/src/main/remote-agent/remote-agent-connection-manager.ts b/src/main/remote-agent/remote-agent-connection-manager.ts new file mode 100644 index 00000000..0705194b --- /dev/null +++ b/src/main/remote-agent/remote-agent-connection-manager.ts @@ -0,0 +1,1090 @@ +import { createHash, createHmac, randomBytes } from 'node:crypto' +import { + AGENT_PROTOCOL_VERSION, + agentCapabilitySchema, + daemonCapabilitiesSchema, + daemonStatusSchema, + protocolVersionSchema, + sha256DigestSchema, + type DaemonCapabilities, + type DaemonStatus +} from '../../shared/agent-protocol' +import type { + SshConnectionLease, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { + verifyAgentInstallationId, + type VerifiedAgentInstallationId +} from '../ssh/ssh-agent-command' +import { defaultSystemAgent } from '../ssh/ssh-transport' +import { AgentAttachTransport } from './agent-attach-transport' +import { + AgentProtocolClient, + type AgentProtocolClientError +} from './agent-protocol-client' +import { + ControllerStateStore, + type ControllerAcpRecoveryBinding, + type ControllerConnectionState +} from './controller-state-store' + +const DEFAULT_IDLE_TIMEOUT_MS = 60_000 +const DEFAULT_ATTACH_TIMEOUT_MS = 10_000 +const MAXIMUM_CONNECTIONS = 32 + +export interface RemoteAgentTargetResolver { + resolve(hostId: string): Promise +} + +export interface RemoteAgentSshPool { + acquire( + target: SshConnectionPoolTarget, + signal?: AbortSignal + ): Promise + disposeHost(hostId: string): void +} + +export type RemoteAgentInstallationIdentity = { + installationId: VerifiedAgentInstallationId + binaryDigest: string + agentVersion?: string + protocol?: { + major: number + minor: number + } + platform: 'linux' + architecture: 'x64' | 'arm64' + supervisor: 'detached-on-demand' + requiredCapabilities?: ReadonlyArray<{ + name: string + minimumVersion?: number + exactVersion?: number + critical?: boolean + }> +} + +export type RemoteAgentConnectionIdentity = { + cacheKey: string + hostId: string + hostRevision: number + hostKeyGeneration: number + remoteUsername: string + installationId: string + binaryDigest: string + protocolMajor: number + protocolMinor: number +} + +export type RemoteAgentConnectionState = + | 'connecting' + | 'ready' + | 'offline' + | 'terminal' + | 'disposed' + +export interface RemoteAgentConnection { + readonly identity: RemoteAgentConnectionIdentity + readonly status: DaemonStatus + readonly capabilities: DaemonCapabilities + readonly client: AgentProtocolClient + readonly state: RemoteAgentConnectionState + refreshCapabilities(signal?: AbortSignal): Promise + reconnect(signal?: AbortSignal): Promise + onClientChange?(listener: () => void): () => void + updateAcpBinding( + bindingId: string, + binding: ControllerAcpRecoveryBinding | undefined + ): Promise + flushAcpBindings(): Promise + release(): void +} + +export class RemoteAgentConnectionError extends Error { + constructor( + message: string, + readonly disposition: 'transient' | 'terminal', + readonly reason: + | 'network' + | 'host-invalidated' + | 'host-identity' + | 'authentication' + | 'installation' + | 'protocol' + | 'daemon-status' + | 'platform' + | 'capability' + | 'shutdown', + readonly cause?: unknown + ) { + super(message) + this.name = 'RemoteAgentConnectionError' + } +} + +type ActiveConnection = { + sshLease: SshConnectionLease + transport: AgentAttachTransport + client: AgentProtocolClient + status: DaemonStatus + capabilities: DaemonCapabilities + unsubscribeClose: () => void +} + +type ConnectionEntry = { + key: string + stateKey: string + hostId: string + identity: RemoteAgentConnectionIdentity + installation: RemoteAgentInstallationIdentity + target: SshConnectionPoolTarget + controller: AbortController + connectPromise: Promise + reconnectPromise?: Promise + connection?: ActiveConnection + connectionState: RemoteAgentConnectionState + failure?: RemoteAgentConnectionError + refs: number + waiters: number + idleTimer?: ReturnType + clientChangeListeners: Set<() => void> +} + +export class RemoteAgentConnectionManager { + readonly #resolver: RemoteAgentTargetResolver + readonly #sshPool: RemoteAgentSshPool + readonly #controllerState: ControllerStateStore + readonly #connectTransport: typeof AgentAttachTransport.connect + readonly #createProtocolClient: ( + transport: AgentAttachTransport + ) => AgentProtocolClient + readonly #goodBuddyVersion: string + readonly #idleTimeoutMs: number + readonly #attachTimeoutMs: number + readonly #identityKey = randomBytes(32) + readonly #entries = new Map() + #disposed = false + + constructor(options: { + resolver: RemoteAgentTargetResolver + sshPool: RemoteAgentSshPool + controllerState: ControllerStateStore + goodBuddyVersion: string + idleTimeoutMs?: number + attachTimeoutMs?: number + connectTransport?: typeof AgentAttachTransport.connect + createProtocolClient?: ( + transport: AgentAttachTransport + ) => AgentProtocolClient + }) { + this.#resolver = options.resolver + this.#sshPool = options.sshPool + this.#controllerState = options.controllerState + this.#connectTransport = + options.connectTransport ?? AgentAttachTransport.connect + this.#createProtocolClient = + options.createProtocolClient ?? + ((transport) => new AgentProtocolClient(transport)) + this.#goodBuddyVersion = options.goodBuddyVersion + this.#idleTimeoutMs = + options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + this.#attachTimeoutMs = + options.attachTimeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS + if ( + this.#goodBuddyVersion.length < 1 || + Buffer.byteLength(this.#goodBuddyVersion, 'utf8') > 64 + ) { + throw new Error('Invalid GoodBuddy version for Agent attach') + } + if ( + !Number.isSafeInteger(this.#idleTimeoutMs) || + this.#idleTimeoutMs < 0 || + !Number.isSafeInteger(this.#attachTimeoutMs) || + this.#attachTimeoutMs < 1 || + this.#attachTimeoutMs > 60_000 + ) { + throw new RangeError('Invalid remote Agent connection timeout') + } + } + + async acquire( + hostId: string, + installationInput: RemoteAgentInstallationIdentity, + signal?: AbortSignal + ): Promise { + if (this.#disposed) { + throw terminalError( + 'Remote Agent connection manager is closed', + 'shutdown' + ) + } + signal?.throwIfAborted() + const installation = validateInstallation(installationInput) + // One atomic resolver call supplies the complete current host target. + const target = await this.#resolver.resolve(hostId) + signal?.throwIfAborted() + if (target.host.id !== hostId) { + throw terminalError( + 'Resolved SSH Host identity does not match the request', + 'host-identity' + ) + } + const identity = this.#identity(target, installation) + let entry = this.#entries.get(identity.cacheKey) + if (entry === undefined) { + if (this.#entries.size >= MAXIMUM_CONNECTIONS) { + throw new RemoteAgentConnectionError( + 'Remote Agent connection cache is full', + 'transient', + 'network' + ) + } + const controller = new AbortController() + entry = { + key: identity.cacheKey, + stateKey: recoveryStateKey(target, installation), + hostId, + identity, + installation, + target, + controller, + connectPromise: Promise.resolve(undefined as never), + connectionState: 'connecting', + refs: 0, + waiters: 0, + clientChangeListeners: new Set() + } + entry.connectPromise = this.#connect(entry, target, false) + this.#entries.set(identity.cacheKey, entry) + } else if ( + entry.connectionState === 'terminal' || + entry.connectionState === 'disposed' + ) { + throw ( + entry.failure ?? + terminalError('Remote Agent connection is not reusable', 'protocol') + ) + } + if (entry.idleTimer !== undefined) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + entry.waiters += 1 + try { + await waitForShared(entry.connectPromise, signal) + if ( + this.#disposed || + this.#entries.get(entry.key) !== entry || + entry.connectionState !== 'ready' + ) { + throw new RemoteAgentConnectionError( + 'Remote Agent connection request became stale', + 'transient', + 'network' + ) + } + entry.refs += 1 + return this.#lease(entry) + } finally { + entry.waiters -= 1 + if (entry.waiters === 0 && entry.refs === 0) { + if (entry.connectionState === 'connecting') { + this.#disposeEntry(entry) + } else { + this.#scheduleIdle(entry) + } + } + } + } + + async invalidateHost(hostId: string): Promise { + for (const entry of [...this.#entries.values()]) { + if (entry.hostId === hostId) { + entry.failure = terminalError( + 'SSH Host was invalidated', + 'host-invalidated' + ) + entry.connectionState = 'terminal' + this.#disposeEntry(entry) + } + } + this.#sshPool.disposeHost(hostId) + await this.#controllerState.invalidateHost(hostId) + } + + async dispose(): Promise { + if (this.#disposed) { + return + } + this.#disposed = true + for (const entry of [...this.#entries.values()]) { + entry.failure = terminalError( + 'Remote Agent connection manager was shut down', + 'shutdown' + ) + this.#disposeEntry(entry) + } + await this.#controllerState.flush() + this.#controllerState.dispose() + } + + async #connect( + entry: ConnectionEntry, + target: SshConnectionPoolTarget, + requireExactResume: boolean + ): Promise { + let sshLease: SshConnectionLease | undefined + let transport: AgentAttachTransport | undefined + let client: AgentProtocolClient | undefined + const connectionTimer = setTimeout(() => { + entry.controller.abort( + new RemoteAgentConnectionError( + 'Remote Agent readiness handshake timed out', + 'transient', + 'network' + ) + ) + }, this.#attachTimeoutMs) + try { + const controllerId = + await this.#controllerState.getControllerId() + sshLease = await this.#sshPool.acquire( + target, + entry.controller.signal + ) + transport = await this.#connectTransport({ + sshLease, + installationId: entry.installation.installationId, + preface: { + type: 'goodbuddy-agent-attach', + protocol: protocolVersion(entry.installation), + goodBuddyVersion: this.#goodBuddyVersion, + controllerId, + clientNonce: randomBytes(24).toString('base64url'), + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration + }, + signal: entry.controller.signal, + timeoutMs: this.#attachTimeoutMs + }) + this.#validateWelcome(entry, transport) + client = this.#createProtocolClient(transport) + const status = daemonStatusSchema.parse( + await client.request('agent/status', {}) + ) + const capabilities = daemonCapabilitiesSchema.parse( + await client.request('agent/capabilities', {}) + ) + this.#validateDaemon(entry, transport, status, capabilities) + let previous = await this.#controllerState.getConnection( + entry.stateKey + ) + if (previous !== undefined) { + if ( + previous.daemonBootId === + transport.welcome.daemonBootId && + previous.capabilityGeneration === capabilities.generation + ) { + await this.#resume( + client, + previous, + transport.welcome.daemonBootId, + capabilities.generation + ) + } else if (requireExactResume) { + throw terminalError( + previous.daemonBootId !== + transport.welcome.daemonBootId + ? 'Agent daemon boot changed before controller resume' + : 'Agent capability generation changed before controller resume', + previous.daemonBootId !== + transport.welcome.daemonBootId + ? 'daemon-status' + : 'capability' + ) + } else { + previous = undefined + } + } + if ( + this.#disposed || + this.#entries.get(entry.key) !== entry || + entry.connectionState === 'disposed' || + entry.controller.signal.aborted + ) { + throw new RemoteAgentConnectionError( + 'Remote Agent connection request became stale', + 'transient', + 'network' + ) + } + const active: ActiveConnection = { + sshLease, + transport, + client, + status, + capabilities, + unsubscribeClose: () => undefined + } + active.unsubscribeClose = client.onClose((error) => { + this.#connectionClosed(entry, error) + }) + entry.connection = active + entry.connectionState = 'ready' + entry.failure = undefined + await this.#persistConnection( + entry, + active, + previous, + previous !== undefined + ) + for (const listener of entry.clientChangeListeners) { + try { + listener() + } catch { + // One retained lease cannot invalidate the shared client swap. + } + } + return active + } catch (error) { + const published = entry.connection + if (published !== undefined && published.client === client) { + published.unsubscribeClose() + entry.connection = undefined + } + client?.dispose() + transport?.dispose() + sshLease?.release() + const classified = classifyConnectionError(error) + if ( + this.#entries.get(entry.key) === entry && + entry.connectionState !== 'disposed' + ) { + entry.failure = classified + entry.connectionState = + classified.disposition === 'terminal' + ? 'terminal' + : 'offline' + if (entry.refs === 0) { + this.#entries.delete(entry.key) + } + } + throw classified + } finally { + clearTimeout(connectionTimer) + } + } + + async #resume( + client: AgentProtocolClient, + previous: ControllerConnectionState, + daemonBootId: string, + capabilityGeneration: number + ): Promise { + if (previous.daemonBootId !== daemonBootId) { + throw terminalError( + 'Agent daemon boot changed before controller resume', + 'daemon-status' + ) + } + if (previous.capabilityGeneration !== capabilityGeneration) { + throw terminalError( + 'Agent capability generation changed before controller resume', + 'capability' + ) + } + let result + try { + result = await client.request('controller/resume', { + previousGeneration: previous.previousGeneration, + previousConnectionId: previous.previousConnectionId, + daemonBootId, + capabilityGeneration: previous.capabilityGeneration + }) + } catch (error) { + throw terminalError( + 'Agent refused the stable controller generation resume', + 'protocol', + error + ) + } + if ( + !result.resumed || + result.generation !== client.generation || + result.daemonBootId !== daemonBootId || + result.capabilityGeneration !== capabilityGeneration + ) { + throw terminalError( + 'Agent refused the stable controller generation resume', + 'protocol' + ) + } + } + + #validateWelcome( + entry: ConnectionEntry, + transport: AgentAttachTransport + ): void { + const welcome = transport.welcome + const expectedProtocol = protocolVersion(entry.installation) + if ( + welcome.protocol.major !== expectedProtocol.major || + welcome.protocol.minor > expectedProtocol.minor + ) { + throw terminalError( + 'GoodBuddy Agent protocol is incompatible', + 'protocol' + ) + } + if ( + welcome.installationId !== entry.installation.installationId || + welcome.binaryDigest !== entry.installation.binaryDigest + ) { + throw terminalError( + 'GoodBuddy Agent installation handshake identity is invalid', + 'installation' + ) + } + } + + #validateDaemon( + entry: ConnectionEntry, + transport: AgentAttachTransport, + status: DaemonStatus, + capabilities: DaemonCapabilities + ): void { + const expected = entry.installation + if ( + status.state !== 'ready' || + status.draining || + status.installationId !== expected.installationId || + status.binaryDigest !== expected.binaryDigest || + status.daemonBootId !== transport.welcome.daemonBootId + ) { + throw terminalError( + 'GoodBuddy Agent daemon is not ready with the expected installation', + 'daemon-status' + ) + } + if ( + status.platform !== expected.platform || + status.architecture !== expected.architecture || + status.supervisor !== expected.supervisor + ) { + throw terminalError( + 'GoodBuddy Agent platform or supervisor is incompatible', + 'platform' + ) + } + const protocol = protocolVersion(expected) + if ( + status.protocol.major !== protocol.major || + status.protocol.minor > protocol.minor + ) { + throw terminalError( + 'GoodBuddy Agent status reports an incompatible protocol', + 'protocol' + ) + } + if ( + expected.agentVersion !== undefined && + status.agentVersion !== expected.agentVersion + ) { + throw terminalError( + 'GoodBuddy Agent version does not match its installation', + 'installation' + ) + } + if ( + capabilities.generation < 1 || + !requiredCapabilitiesPresent( + capabilities, + expected.requiredCapabilities ?? [] + ) + ) { + throw terminalError( + 'GoodBuddy Agent is missing a required capability', + 'capability' + ) + } + } + + #lease(entry: ConnectionEntry): RemoteAgentConnection { + let released = false + const assertRetained = (): void => { + if ( + released || + this.#entries.get(entry.key) !== entry || + entry.connectionState === 'disposed' + ) { + throw new Error('Remote Agent connection lease is released') + } + } + const assertActive = (): ActiveConnection => { + assertRetained() + const connection = entry.connection + if (connection === undefined || entry.connectionState !== 'ready') { + throw ( + entry.failure ?? + new RemoteAgentConnectionError( + 'Remote Agent connection is offline', + 'transient', + 'network' + ) + ) + } + return connection + } + return { + identity: entry.identity, + get status() { + return assertActive().status + }, + get capabilities() { + return assertActive().capabilities + }, + get client() { + return assertActive().client + }, + get state() { + 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 } + ) + ) + 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, + 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) => { + if (released) { + throw new Error('Remote Agent connection lease is released') + } + await this.#reconnect(entry, signal) + }, + onClientChange: (listener) => { + if (released) { + throw new Error('Remote Agent connection lease is released') + } + entry.clientChangeListeners.add(listener) + return () => entry.clientChangeListeners.delete(listener) + }, + updateAcpBinding: async (bindingId, binding) => { + assertRetained() + await this.#controllerState.updateAcpBinding( + entry.stateKey, + bindingId, + binding + ) + }, + flushAcpBindings: async () => { + assertRetained() + await this.#controllerState.flush() + }, + release: () => { + if (released) { + return + } + released = true + entry.refs -= 1 + if (entry.refs === 0 && entry.waiters === 0) { + this.#scheduleIdle(entry) + } + } + } + } + + async #reconnect( + entry: ConnectionEntry, + signal?: AbortSignal + ): Promise { + if (entry.connectionState === 'ready') { + return + } + if ( + entry.connectionState === 'terminal' || + entry.connectionState === 'disposed' + ) { + throw ( + entry.failure ?? + terminalError('Remote Agent connection cannot reconnect', 'protocol') + ) + } + if (entry.reconnectPromise !== undefined) { + await waitForShared(entry.reconnectPromise, signal) + return + } + const reconnect = (async () => { + const target = await this.#resolver.resolve(entry.hostId) + const identity = this.#identity(target, entry.installation) + if (identity.cacheKey !== entry.key) { + entry.failure = terminalError( + 'SSH Host identity changed while reconnecting', + 'host-identity' + ) + entry.connectionState = 'terminal' + throw entry.failure + } + entry.controller = new AbortController() + entry.connectionState = 'connecting' + entry.connectPromise = this.#connect(entry, target, true) + await entry.connectPromise + })() + entry.reconnectPromise = reconnect.finally(() => { + entry.reconnectPromise = undefined + }) + await waitForShared(entry.reconnectPromise, signal) + } + + #connectionClosed( + entry: ConnectionEntry, + error: AgentProtocolClientError + ): void { + if ( + this.#entries.get(entry.key) !== entry || + entry.connectionState === 'disposed' + ) { + return + } + const active = entry.connection + entry.connection = undefined + entry.failure = new RemoteAgentConnectionError( + 'Remote Agent transport disconnected', + 'transient', + 'network', + error + ) + entry.connectionState = 'offline' + active?.unsubscribeClose() + active?.sshLease.release() + if (entry.refs === 0 && entry.waiters === 0) { + this.#scheduleIdle(entry) + } + } + + async #persistConnection( + entry: ConnectionEntry, + active: ActiveConnection, + previous?: Partial, + preserveRecovery = false + ): Promise { + const connection = { + cacheKey: entry.stateKey, + hostId: entry.hostId, + installationId: entry.installation.installationId, + protocolMajor: protocolVersion(entry.installation).major, + previousConnectionId: active.client.connectionId, + previousGeneration: active.client.generation, + daemonBootId: active.status.daemonBootId, + capabilityGeneration: active.capabilities.generation, + acpBindings: previous?.acpBindings ?? [] + } + if (preserveRecovery) { + await this.#controllerState.updateConnectionPreservingRecovery( + connection + ) + } else { + await this.#controllerState.updateConnection(connection) + } + } + + #identity( + target: SshConnectionPoolTarget, + installation: RemoteAgentInstallationIdentity + ): RemoteAgentConnectionIdentity { + const protocol = protocolVersion(installation) + const hostKey = target.host.hostKey + if ( + hostKey === undefined || + hostKey.generation !== target.hostKeyGeneration + ) { + throw terminalError( + 'SSH Host Key generation is not current', + 'host-identity' + ) + } + const authenticationValue = + target.host.authentication === 'password' + ? target.host.password + : defaultSystemAgent() + if (!authenticationValue) { + throw terminalError( + 'SSH authentication identity is unavailable', + 'authentication' + ) + } + const authIdentity = createHmac('sha256', this.#identityKey) + .update(authenticationValue) + .digest('hex') + const material = JSON.stringify([ + target.host.id, + target.hostRevision, + target.hostKeyGeneration, + target.host.hostname, + target.host.port, + target.host.username, + target.host.authentication, + hostKey.algorithm, + hostKey.publicKeyBase64, + authIdentity, + installation.installationId, + installation.binaryDigest, + protocol.major, + protocol.minor + ]) + return { + cacheKey: createHash('sha256').update(material).digest('hex'), + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + remoteUsername: target.host.username, + installationId: installation.installationId, + binaryDigest: installation.binaryDigest, + protocolMajor: protocol.major, + protocolMinor: protocol.minor + } + } + + #scheduleIdle(entry: ConnectionEntry): void { + if ( + this.#entries.get(entry.key) !== entry || + entry.idleTimer !== undefined + ) { + return + } + if (this.#idleTimeoutMs === 0) { + this.#disposeEntry(entry) + return + } + entry.idleTimer = setTimeout(() => { + if (entry.refs === 0 && entry.waiters === 0) { + this.#disposeEntry(entry) + } + }, this.#idleTimeoutMs) + } + + #disposeEntry(entry: ConnectionEntry): void { + if (this.#entries.get(entry.key) === entry) { + this.#entries.delete(entry.key) + } + if (entry.idleTimer !== undefined) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + entry.connectionState = 'disposed' + entry.controller.abort() + entry.connection?.unsubscribeClose() + entry.connection?.client.dispose() + entry.connection?.transport.dispose() + entry.connection?.sshLease.release() + entry.connection = undefined + entry.clientChangeListeners.clear() + } +} + +function validateInstallation( + input: RemoteAgentInstallationIdentity +): RemoteAgentInstallationIdentity { + const installationId = verifyAgentInstallationId(input.installationId) + const binaryDigest = sha256DigestSchema.parse(input.binaryDigest) + const protocol = protocolVersionSchema.parse( + input.protocol ?? AGENT_PROTOCOL_VERSION + ) + if ( + input.platform !== 'linux' || + !['x64', 'arm64'].includes(input.architecture) || + input.supervisor !== 'detached-on-demand' + ) { + throw terminalError( + 'Remote Agent installation platform is unsupported', + 'platform' + ) + } + const requiredCapabilities = (input.requiredCapabilities ?? []).map( + (requirement) => ({ + name: agentCapabilitySchema.shape.name.parse(requirement.name), + ...(requirement.minimumVersion === undefined + ? {} + : { minimumVersion: requirement.minimumVersion }), + ...(requirement.exactVersion === undefined + ? {} + : { exactVersion: requirement.exactVersion }), + critical: requirement.critical + }) + ) + for (const requirement of requiredCapabilities) { + const versions = [ + requirement.minimumVersion, + requirement.exactVersion + ].filter((value): value is number => value !== undefined) + if ( + versions.length !== 1 || + !Number.isSafeInteger(versions[0]) || + versions[0]! < 1 || + versions[0]! > 65_535 + ) { + throw new Error('Invalid required Agent capability version') + } + } + return { + ...input, + installationId, + binaryDigest, + protocol, + requiredCapabilities + } +} + +function protocolVersion( + installation: RemoteAgentInstallationIdentity +): { major: number; minor: number } { + return protocolVersionSchema.parse( + installation.protocol ?? AGENT_PROTOCOL_VERSION + ) +} + +function recoveryStateKey( + target: SshConnectionPoolTarget, + installation: RemoteAgentInstallationIdentity +): string { + const protocol = protocolVersion(installation) + return createHash('sha256') + .update( + JSON.stringify([ + 'goodbuddy-controller-recovery-v1', + target.host.id, + target.hostRevision, + target.hostKeyGeneration, + installation.installationId, + installation.binaryDigest, + protocol.major + ]) + ) + .digest('hex') +} + +function requiredCapabilitiesPresent( + capabilities: DaemonCapabilities, + requirements: ReadonlyArray<{ + name: string + minimumVersion?: number + exactVersion?: number + critical?: boolean + }> +): boolean { + const byName = new Map( + capabilities.capabilities.map((capability) => [ + capability.name, + capability + ]) + ) + return requirements.every((requirement) => { + const available = byName.get(requirement.name) + return ( + available !== undefined && + (requirement.exactVersion === undefined + ? available.version >= requirement.minimumVersion! + : available.version === requirement.exactVersion) && + (!requirement.critical || available.critical) + ) + }) +} + +function classifyConnectionError(error: unknown): RemoteAgentConnectionError { + if (error instanceof RemoteAgentConnectionError) { + return error + } + const message = error instanceof Error ? error.message : String(error) + if ( + /host key|主机密钥|authentication|认证|credential|凭据/iu.test( + message + ) + ) { + return terminalError(message, 'authentication', error) + } + return new RemoteAgentConnectionError( + 'Remote Agent connection failed', + 'transient', + 'network', + error + ) +} + +function terminalError( + message: string, + reason: RemoteAgentConnectionError['reason'], + cause?: unknown +): RemoteAgentConnectionError { + return new RemoteAgentConnectionError( + message, + 'terminal', + reason, + cause + ) +} + +function waitForShared( + promise: Promise, + signal?: AbortSignal +): Promise { + if (signal === undefined) { + return promise + } + if (signal.aborted) { + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const abort = (): void => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', abort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} diff --git a/src/main/remote-agent/remote-agent-services.test.ts b/src/main/remote-agent/remote-agent-services.test.ts new file mode 100644 index 00000000..5ca486a0 --- /dev/null +++ b/src/main/remote-agent/remote-agent-services.test.ts @@ -0,0 +1,285 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' +import type { SshHostStore } from '../ssh/ssh-host-store' +import type { AgentInstallationManager } from './agent-installation-manager' +import type { ControllerStateStore } from './controller-state-store' +import type { RemoteAgentConnectionManager } from './remote-agent-connection-manager' +import { + RemoteAgentServices, + type RemoteAgentServicesOptions +} from './remote-agent-services' + +type Factories = NonNullable +type InstallationManagerOptions = Parameters< + NonNullable +>[0] +type ConnectionManagerOptions = Parameters< + NonNullable +>[0] + +const target: SshConnectionPoolTarget = { + host: { + id: 'host-1', + name: 'Builder', + hostname: 'builder.example', + port: 22, + username: 'goodbuddy', + authentication: 'password', + password: 'not-persisted', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'a2V5', + fingerprintSha256: `SHA256:${'a'.repeat(43)}`, + generation: 1 + } + }, + hostRevision: 1, + hostKeyGeneration: 1 +} + +function harness(overrides: { + installationDispose?: () => Promise + connectionDispose?: () => Promise + controllerFlush?: () => Promise + controllerDispose?: () => void + poolDispose?: () => void + invalidateHost?: (hostId: string) => Promise +} = {}) { + const resolveConnectionTarget = vi.fn(async () => target) + const sshHostStore = { + resolveConnectionTarget + } as unknown as SshHostStore + const sshPool = { + acquire: vi.fn(), + disposeHost: vi.fn(), + dispose: vi.fn(overrides.poolDispose ?? (() => undefined)) + } + const controllerState = { + flush: vi.fn(overrides.controllerFlush ?? (async () => undefined)), + dispose: vi.fn(overrides.controllerDispose ?? (() => undefined)) + } + const installationManager = { + ensureInstalled: vi.fn(), + dispose: vi.fn( + overrides.installationDispose ?? (async () => undefined) + ) + } + const connectionManager = { + acquire: vi.fn(), + invalidateHost: vi.fn( + overrides.invalidateHost ?? (async () => undefined) + ), + dispose: vi.fn( + overrides.connectionDispose ?? (async () => undefined) + ) + } + const createSshConnectionPool = vi.fn(() => sshPool) + const createControllerStateStore = vi.fn( + (filePath: string) => { + void filePath + return controllerState as unknown as ControllerStateStore + } + ) + const createAgentInstallationManager = vi.fn( + (options: InstallationManagerOptions) => { + void options + return installationManager as unknown as AgentInstallationManager + } + ) + const createConnectionManager = vi.fn( + (options: ConnectionManagerOptions) => { + void options + return connectionManager as unknown as RemoteAgentConnectionManager + } + ) + const services = new RemoteAgentServices({ + sshHostStore, + goodBuddyVersion: '0.11.0', + userDataPath: join('profile', 'GoodBuddy'), + appPath: join('workspace', 'goodbuddy'), + resourcesPath: join('installed', 'resources'), + packaged: false, + factories: { + createSshConnectionPool: + createSshConnectionPool as never, + createControllerStateStore, + createAgentInstallationManager, + createConnectionManager + } + }) + + return { + services, + resolveConnectionTarget, + sshPool, + controllerState, + installationManager, + connectionManager, + createSshConnectionPool, + createControllerStateStore, + createAgentInstallationManager, + createConnectionManager + } +} + +describe('RemoteAgentServices', () => { + it('shares one atomic host resolver and SSH pool across services', async () => { + const instance = harness() + const installerOptions = + instance.createAgentInstallationManager.mock.calls[0]?.[0] + const connectionOptions = + instance.createConnectionManager.mock.calls[0]?.[0] + + expect(instance.createSshConnectionPool).toHaveBeenCalledOnce() + expect(installerOptions?.resolver).toBe( + connectionOptions?.resolver + ) + expect(installerOptions?.resolver).toBe( + instance.services.targetResolver + ) + expect(installerOptions?.sshPool).toBe(connectionOptions?.sshPool) + expect(installerOptions?.sshPool).toBe(instance.services.sshPool) + + await expect( + instance.services.targetResolver.resolve('host-1') + ).resolves.toBe(target) + expect(instance.resolveConnectionTarget).toHaveBeenCalledWith( + 'host-1' + ) + }) + + it('constructs lazily with bundled paths and no host or network work', () => { + const instance = harness() + + expect(instance.resolveConnectionTarget).not.toHaveBeenCalled() + expect(instance.sshPool.acquire).not.toHaveBeenCalled() + expect(instance.installationManager.ensureInstalled).not.toHaveBeenCalled() + expect(instance.connectionManager.acquire).not.toHaveBeenCalled() + expect(instance.services.resourcePaths).toEqual({ + keyRegistryPath: join( + 'workspace', + 'goodbuddy', + 'resources', + 'agent-release-keys.json' + ), + runtimeLockPath: join( + 'workspace', + 'goodbuddy', + 'agent-runtime-lock.json' + ), + bundleDirectories: { + x64: join( + 'workspace', + 'goodbuddy', + '.agent-resources', + 'linux-x64' + ), + arm64: join( + 'workspace', + 'goodbuddy', + '.agent-resources', + 'linux-arm64' + ) + } + }) + expect( + instance.createControllerStateStore + ).toHaveBeenCalledWith( + join( + 'profile', + 'GoodBuddy', + 'remote-agent-controller-state.json' + ) + ) + }) + + it('keeps default service construction and shutdown lazy', async () => { + const resolveConnectionTarget = vi.fn(async () => target) + const services = new RemoteAgentServices({ + sshHostStore: { + resolveConnectionTarget + } as unknown as SshHostStore, + goodBuddyVersion: '0.11.0', + userDataPath: join('profile', 'GoodBuddy'), + appPath: join('workspace', 'goodbuddy'), + resourcesPath: join('installed', 'resources'), + packaged: false + }) + + expect(resolveConnectionTarget).not.toHaveBeenCalled() + await services.dispose() + expect(resolveConnectionTarget).not.toHaveBeenCalled() + }) + + it('delegates credential-free host invalidation immediately', async () => { + let finishPersistence: (() => void) | undefined + const persistence = new Promise((resolve) => { + finishPersistence = resolve + }) + const invalidateHost = vi.fn(() => persistence) + const instance = harness({ invalidateHost }) + + const invalidation = instance.services.invalidateHost('host-1') + + expect(invalidateHost).toHaveBeenCalledWith('host-1') + expect(invalidation).toBe(persistence) + finishPersistence?.() + await invalidation + }) + + it('disposes in dependency order once and aggregates failures', async () => { + const order: string[] = [] + const installerError = new Error('installer failed') + const connectionError = new Error('connections failed') + const controllerError = new Error('controller failed') + const poolError = new Error('pool failed') + const instance = harness({ + installationDispose: async () => { + order.push('installer') + throw installerError + }, + connectionDispose: async () => { + order.push('connections') + throw connectionError + }, + controllerFlush: async () => { + order.push('controller-flush') + throw controllerError + }, + controllerDispose: () => { + order.push('controller-dispose') + }, + poolDispose: () => { + order.push('ssh-pool') + throw poolError + } + }) + + const first = instance.services.dispose() + const second = instance.services.dispose() + + expect(second).toBe(first) + const result = await first.catch((error: unknown) => error) + + expect(result).toBeInstanceOf(AggregateError) + expect((result as AggregateError).errors).toEqual([ + installerError, + connectionError, + controllerError, + poolError + ]) + expect(order).toEqual([ + 'installer', + 'connections', + 'controller-flush', + 'controller-dispose', + 'ssh-pool' + ]) + expect(instance.installationManager.dispose).toHaveBeenCalledOnce() + expect(instance.connectionManager.dispose).toHaveBeenCalledOnce() + expect(instance.controllerState.flush).toHaveBeenCalledOnce() + expect(instance.controllerState.dispose).toHaveBeenCalledOnce() + expect(instance.sshPool.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/remote-agent/remote-agent-services.ts b/src/main/remote-agent/remote-agent-services.ts new file mode 100644 index 00000000..65002da3 --- /dev/null +++ b/src/main/remote-agent/remote-agent-services.ts @@ -0,0 +1,142 @@ +import { join } from 'node:path' +import { SshConnectionPool } from '../ssh/ssh-connection-pool' +import type { SshHostStore } from '../ssh/ssh-host-store' +import { AgentInstallationManager } from './agent-installation-manager' +import { + resolveBundledAgentResourcePaths, + type BundledAgentResourcePaths +} from './bundled-agent-resources' +import { + ControllerStateStore, + JsonControllerStateFile +} from './controller-state-store' +import { + RemoteAgentConnectionManager, + type RemoteAgentTargetResolver +} from './remote-agent-connection-manager' + +const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json' + +type RemoteAgentServiceFactories = { + createSshConnectionPool: () => SshConnectionPool + createControllerStateStore: ( + filePath: string + ) => ControllerStateStore + createAgentInstallationManager: (options: { + resolver: RemoteAgentTargetResolver + sshPool: SshConnectionPool + resourcePaths: BundledAgentResourcePaths + }) => AgentInstallationManager + createConnectionManager: (options: { + resolver: RemoteAgentTargetResolver + sshPool: SshConnectionPool + controllerState: ControllerStateStore + goodBuddyVersion: string + }) => RemoteAgentConnectionManager +} + +export type RemoteAgentServicesOptions = { + sshHostStore: SshHostStore + goodBuddyVersion: string + userDataPath: string + appPath: string + resourcesPath: string + packaged: boolean + factories?: Partial +} + +/** + * Main-process lifecycle owner for the dormant remote Agent control plane. + * Construction only composes local objects and paths; network, disk, bundle + * verification, and installation work remain lazy until an explicit caller + * uses the corresponding service. + */ +export class RemoteAgentServices { + readonly targetResolver: RemoteAgentTargetResolver + readonly resourcePaths: BundledAgentResourcePaths + readonly sshPool: SshConnectionPool + readonly controllerState: ControllerStateStore + readonly installationManager: AgentInstallationManager + readonly connectionManager: RemoteAgentConnectionManager + + #disposePromise: Promise | undefined + + constructor(options: RemoteAgentServicesOptions) { + const factories: RemoteAgentServiceFactories = { + createSshConnectionPool: () => new SshConnectionPool(), + createControllerStateStore: (filePath) => + new ControllerStateStore(new JsonControllerStateFile(filePath)), + createAgentInstallationManager: (managerOptions) => + new AgentInstallationManager(managerOptions), + createConnectionManager: (managerOptions) => + new RemoteAgentConnectionManager(managerOptions), + ...options.factories + } + + this.targetResolver = { + resolve: (hostId) => + options.sshHostStore.resolveConnectionTarget(hostId) + } + this.resourcePaths = resolveBundledAgentResourcePaths({ + appPath: options.appPath, + resourcesPath: options.resourcesPath, + packaged: options.packaged + }) + this.sshPool = factories.createSshConnectionPool() + this.controllerState = factories.createControllerStateStore( + join(options.userDataPath, CONTROLLER_STATE_FILE_NAME) + ) + this.installationManager = + factories.createAgentInstallationManager({ + resolver: this.targetResolver, + sshPool: this.sshPool, + resourcePaths: this.resourcePaths + }) + this.connectionManager = factories.createConnectionManager({ + resolver: this.targetResolver, + sshPool: this.sshPool, + controllerState: this.controllerState, + goodBuddyVersion: options.goodBuddyVersion + }) + } + + /** + * Invalidates live and persisted controller state. The connection manager + * closes matching connections and pool entries before its first await, so a + * credential-free SSH host lifecycle hook starts fail-closed synchronously. + */ + invalidateHost(hostId: string): Promise { + return this.connectionManager.invalidateHost(hostId) + } + + dispose(): Promise { + this.#disposePromise ??= this.#dispose() + return this.#disposePromise + } + + async #dispose(): Promise { + const errors: unknown[] = [] + const settle = async ( + operation: () => void | Promise + ): Promise => { + try { + await operation() + } catch (error) { + errors.push(error) + } + } + + await settle(() => this.installationManager.dispose()) + await settle(() => this.connectionManager.dispose()) + await settle(() => this.controllerState.flush()) + await settle(() => this.controllerState.dispose()) + await settle(() => this.sshPool.dispose()) + + if (errors.length > 0) { + throw new AggregateError( + errors, + 'Remote Agent services failed to dispose completely' + ) + } + } +} diff --git a/src/main/remote-agent/remote-project-save-service.test.ts b/src/main/remote-agent/remote-project-save-service.test.ts new file mode 100644 index 00000000..4348f492 --- /dev/null +++ b/src/main/remote-agent/remote-project-save-service.test.ts @@ -0,0 +1,510 @@ +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' +import type { AgentInstallationIdentity } from './agent-installation-manager' +import type { RemoteAgentConnection } from './remote-agent-connection-manager' +import { + RemoteProjectSaveService, + type RemoteProjectSaveOwner, + type RemoteProjectSaveServiceOptions, + type RemoteProjectRuntimeValidationLease +} from './remote-project-save-service' + +const hostId = '00000000-0000-4000-8000-000000000001' +const projectId = '00000000-0000-4000-8000-000000000002' +const root = '/srv/project' +const digest = `sha256:${'a'.repeat(64)}` +const services: RemoteProjectSaveService[] = [] + +afterEach(async () => { + await Promise.all(services.splice(0).map((service) => service.dispose())) +}) + +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') + } +} + +const draft = { + name: 'Remote', + description: 'Validated remotely', + defaultWorkMode: 'ask' as const, + runtimeSelection: { provider: 'opencode' as const }, + hostId, + remoteRootPath: root +} + +function target(): SshConnectionTarget { + return { + host: { + id: hostId, + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: Buffer.from('key').toString('base64'), + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + acceptedAt: '2030-01-01T00:00:00.000Z', + generation: 2 + } + }, + hostRevision: 3, + hostKeyGeneration: 2 + } +} + +const installation: AgentInstallationIdentity = { + installationId: verifyAgentInstallationId('agent-installation'), + binaryDigest: digest, + agentVersion: '1.2.3', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand' +} + +function project(): AssistantProject { + return { + id: projectId, + ...draft, + rootPath: root, + kind: 'user', + executionSpace: { kind: 'ssh', hostId, remoteRootPath: root }, + status: 'active', + createdAt: '2030-01-01T00:00:00.000Z', + updatedAt: '2030-01-01T00:00:00.000Z' + } +} + +function harness(options: { + ensureInstalled?: (signal: AbortSignal) => Promise + validateWorkspace?: () => unknown + create?: () => AssistantProject + getProject?: () => AssistantProject + resolveRuntimeSelection?: ( + selection: AgentRuntimeSelection + ) => Promise +} = {}) { + const calls: string[] = [] + const currentTarget = target() + const workspace = { + workspaceId: 'workspace-id', + workspaceIdentity: 'workspace-identity', + canonicalDisplayPath: root, + access: 'read-only' as const, + git: 'available' as const, + capabilities: [ + 'list', + 'stat', + 'read-text', + 'search', + 'git-status', + 'git-diff' + ], + 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 + } + } + throw new Error(`Unexpected method: ${method}`) + }) + const connection = { + identity: { + cacheKey: 'cache', + hostId, + hostRevision: 3, + hostKeyGeneration: 2, + remoteUsername: 'builder', + installationId: installation.installationId, + binaryDigest: digest, + protocolMajor: 1, + protocolMinor: 0 + }, + status: { + state: 'ready', + installationId: installation.installationId, + binaryDigest: digest, + daemonBootId: 'boot', + agentVersion: '1.2.3', + protocol: { major: 1, minor: 0 }, + platform: 'linux', + architecture: 'x64', + supervisor: 'detached-on-demand', + remoteUserIdentity: 'user', + draining: false + }, + capabilities: { + generation: 1, + capabilities: [ + { name: 'workspace/read', version: 1, critical: true } + ], + runtimes: [] + }, + client: { request }, + state: 'ready', + release: connectionRelease + } as unknown as RemoteAgentConnection + 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 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 service = new RemoteProjectSaveService({ + database: { + getProject: vi.fn(() => options.getProject?.() ?? project()), + createValidatedSshProject: createProject, + updateValidatedSshProject: updateProject + }, + sshHosts: { + resolveConnectionTarget: vi.fn(async () => { + calls.push('host') + return structuredClone(currentTarget) + }), + 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 + }) + }, + connectionManager: { + acquire: vi.fn(async () => { + calls.push('attach') + return connection + }) + }, + resolveRuntimeSelection: + options.resolveRuntimeSelection ?? + (async (selection) => selection), + runtimeValidator: { + validate: vi.fn(async (input) => { + calls.push('runtime') + return { + ...runtimeLease, + evidence: { + ...runtimeLease.evidence, + runtimeSelectionKey: input.runtimeSelectionKey + } + } + }) + }, + notify: (_owner, value) => progress.push(value.phase) + }) + services.push(service) + return { + service, + calls, + createProject, + updateProject, + runtimeRelease, + connectionRelease, + progress + } +} + +describe('RemoteProjectSaveService awaited save', () => { + it('validates and creates in one awaited request, then cleans up in reverse order', 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', + 'workspace', + 'runtime', + 'saving' + ]) + expect(value.createProject).toHaveBeenCalledOnce() + expect(value.calls.slice(-3)).toEqual([ + 'runtime-release', + 'workspace/close', + 'connection-release' + ]) + }) + + it('accepts a readable non-Git remote workspace', async () => { + 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' + }) + }) + + await expect( + value.service.save(new Owner(1), { + intent: 'create', + draft + }) + ).resolves.toMatchObject({ id: projectId }) + expect(value.createProject).toHaveBeenCalledOnce() + }) + + it('updates using the project revision captured before remote validation', async () => { + const value = harness() + await value.service.save(new Owner(1), { + intent: 'update', + draft: { projectId, ...draft } + }) + expect(value.updateProject).toHaveBeenCalledWith( + projectId, + '2030-01-01T00:00:00.000Z', + expect.any(Object) + ) + }) + + it('validates and persists the configured Runtime profile selection', async () => { + const configuredSelection = { + provider: 'opencode' as const, + profileId: '00000000-0000-4000-8000-000000000003' + } + const value = harness({ + resolveRuntimeSelection: vi.fn(async () => configuredSelection) + }) + + await value.service.save(new Owner(1), { + intent: 'create', + draft + }) + + expect(value.createProject).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ + runtimeSelection: configuredSelection + }) + }) + ) + }) + + it('activates a persisted SSH project through the full update pipeline', async () => { + const value = harness() + + await expect( + value.service.activate(new Owner(1), projectId) + ).resolves.toMatchObject({ id: projectId }) + + expect(value.updateProject).toHaveBeenCalledWith( + projectId, + '2030-01-01T00:00:00.000Z', + expect.objectContaining({ + project: expect.objectContaining({ + name: draft.name, + description: draft.description, + defaultWorkMode: draft.defaultWorkMode, + runtimeSelection: draft.runtimeSelection + }), + executionSpace: expect.objectContaining({ + hostId, + remoteRootPath: root + }) + }) + ) + expect(value.calls).toEqual(expect.arrayContaining([ + 'host', + 'install', + 'attach', + 'workspace/validate', + 'runtime', + 'database-update' + ])) + }) + + it('coalesces repeated activation for the same owner and project', async () => { + let releaseInstallation!: () => void + const installationGate = new Promise((resolve) => { + releaseInstallation = resolve + }) + const value = harness({ + ensureInstalled: async () => { + await installationGate + return installation + } + }) + const owner = new Owner(1) + + const first = value.service.activate(owner, projectId) + await vi.waitFor(() => expect(value.calls).toContain('install')) + const second = value.service.activate(owner, projectId) + await expect( + value.service.save(owner, { intent: 'create', draft }) + ).rejects.toThrow('already in progress') + + expect( + value.calls.filter((call) => call === 'install') + ).toHaveLength(1) + releaseInstallation() + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ id: projectId }), + expect.objectContaining({ id: projectId }) + ]) + expect(value.updateProject).toHaveBeenCalledOnce() + }) + + it('rejects activation for a local project before remote work starts', async () => { + const value = harness({ + getProject: () => ({ + ...project(), + rootPath: 'C:\\workspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\workspace' + } + }) + }) + + await expect( + value.service.activate(new Owner(1), projectId) + ).rejects.toThrow('active managed SSH projects') + expect(value.calls).toEqual([]) + expect(value.updateProject).not.toHaveBeenCalled() + }) + + it('does not write when workspace validation fails', async () => { + const value = harness({ + validateWorkspace: () => { + throw new Error('workspace unavailable') + } + }) + await expect( + value.service.save(new Owner(1), { intent: 'create', draft }) + ).rejects.toThrow('workspace unavailable') + expect(value.createProject).not.toHaveBeenCalled() + expect(value.connectionRelease).toHaveBeenCalledOnce() + }) + + it('cancels the current owner save without an operation id', async () => { + const value = harness({ + ensureInstalled: (signal) => + 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')) + value.service.cancelCurrent(owner) + await expect(saving).rejects.toMatchObject({ name: 'AbortError' }) + expect(value.createProject).not.toHaveBeenCalled() + }) + + it('cancels when the renderer owner is destroyed', async () => { + const value = harness({ + ensureInstalled: (signal) => + 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(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 }) + }) +}) diff --git a/src/main/remote-agent/remote-project-save-service.ts b/src/main/remote-agent/remote-project-save-service.ts new file mode 100644 index 00000000..91cbe26a --- /dev/null +++ b/src/main/remote-agent/remote-project-save-service.ts @@ -0,0 +1,673 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + AssistantProject, + ProjectRuntimeValidation, + SshExecutionValidation +} from '../../shared/assistant-contracts' +import { projectRuntimeValidationSchema } from '../../shared/assistant-contracts' +import { + remoteProjectSaveRequestSchema, + type RemoteProjectSavePhase, + type RemoteProjectSaveProgress, + type RemoteProjectSaveRequest +} from '../../shared/remote-project-candidate-contracts' +import { sha256DigestSchema } from '../../shared/agent-protocol/contracts' +import { + REMOTE_WORKSPACE_READ_CAPABILITIES, + remoteWorkspaceValidateResultSchema, + type RemoteWorkspaceHandle +} from '../../shared/remote-agent-contracts' +import { + agentRuntimeSelectionKey, + type AgentRuntimeSelection +} from '../../shared/runtime-selection-contracts' +import type { + AssistantDatabase, + ValidatedSshHostPrecondition, + ValidatedSshProjectWrite +} from '../assistant/assistant-database' +import type { + CurrentSshConnectionTarget, + SshConnectionTarget, + SshHostStore +} from '../ssh/ssh-host-store' +import type { + AgentInstallationIdentity, + AgentInstallationManager +} from './agent-installation-manager' +import type { + RemoteAgentConnection, + RemoteAgentConnectionManager +} from './remote-agent-connection-manager' + +const CLEANUP_TIMEOUT_MS = 5_000 +const WORKSPACE_READ_CAPABILITY = { + name: 'workspace/read', + minimumVersion: 1, + critical: true +} as const + +export interface RemoteProjectSaveOwner { + readonly id: number + isDestroyed(): boolean + on(event: 'destroyed', listener: () => void): unknown + removeListener(event: 'destroyed', listener: () => void): unknown +} + +export type RemoteProjectRuntimeValidationInput = Readonly<{ + selection: AgentRuntimeSelection + runtimeSelectionKey: string + workMode: 'ask' | 'execute' + host: Readonly<{ + hostId: string + hostRevision: number + hostKeyGeneration: number + remoteUsername: string + }> + agent: Readonly<{ + installationId: string + 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 +} + +export interface RemoteProjectRuntimeValidator { + validate( + input: RemoteProjectRuntimeValidationInput + ): Promise +} + +export class UnavailableRemoteProjectRuntimeValidator + implements RemoteProjectRuntimeValidator +{ + async validate(): Promise { + throw new Error('Remote Runtime validation is unavailable') + } +} + +type ActiveSave = { + controller: AbortController + done: Promise + resolveDone: () => void + destroyedListener: () => void + installation?: AgentInstallationIdentity + connection?: RemoteAgentConnection + workspace?: RemoteWorkspaceHandle + runtimeLease?: RemoteProjectRuntimeValidationLease +} + +export type RemoteProjectSaveServiceOptions = { + database: Pick< + AssistantDatabase, + | 'getProject' + | 'createValidatedSshProject' + | 'updateValidatedSshProject' + > + sshHosts: Pick< + SshHostStore, + 'resolveConnectionTarget' | 'assertConnectionTargetCurrent' + > + installationManager: Pick + connectionManager: Pick + resolveRuntimeSelection( + selection: AgentRuntimeSelection + ): Promise + runtimeValidator?: RemoteProjectRuntimeValidator + notify?: ( + owner: RemoteProjectSaveOwner, + progress: RemoteProjectSaveProgress + ) => void + cleanupTimeoutMs?: number +} + +export class RemoteProjectSaveService { + readonly #database: RemoteProjectSaveServiceOptions['database'] + readonly #sshHosts: RemoteProjectSaveServiceOptions['sshHosts'] + readonly #installationManager: RemoteProjectSaveServiceOptions['installationManager'] + readonly #connectionManager: RemoteProjectSaveServiceOptions['connectionManager'] + readonly #resolveRuntimeSelection: RemoteProjectSaveServiceOptions['resolveRuntimeSelection'] + readonly #runtimeValidator: RemoteProjectRuntimeValidator + readonly #notify?: RemoteProjectSaveServiceOptions['notify'] + readonly #cleanupTimeoutMs: number + readonly #active = new Map() + readonly #activations = new Map< + RemoteProjectSaveOwner, + { projectId: string; promise: Promise } + >() + #disposed = false + + constructor(options: RemoteProjectSaveServiceOptions) { + this.#database = options.database + this.#sshHosts = options.sshHosts + this.#installationManager = options.installationManager + this.#connectionManager = options.connectionManager + this.#resolveRuntimeSelection = options.resolveRuntimeSelection + this.#runtimeValidator = + options.runtimeValidator ?? + new UnavailableRemoteProjectRuntimeValidator() + this.#notify = options.notify + this.#cleanupTimeoutMs = + options.cleanupTimeoutMs ?? CLEANUP_TIMEOUT_MS + if ( + !Number.isSafeInteger(this.#cleanupTimeoutMs) || + this.#cleanupTimeoutMs < 1 + ) { + throw new RangeError('Invalid remote project cleanup timeout') + } + } + + async save( + owner: RemoteProjectSaveOwner, + input: unknown + ): Promise { + this.#assertOwnerAvailable(owner) + if (this.#active.has(owner)) { + throw new Error('A remote project save is already in progress') + } + const request = remoteProjectSaveRequestSchema.parse(input) + let resolveDone!: () => void + const active: ActiveSave = { + controller: new AbortController(), + done: new Promise((resolve) => { + resolveDone = resolve + }), + resolveDone, + destroyedListener: () => { + active.controller.abort( + new DOMException('Remote project owner was destroyed', 'AbortError') + ) + } + } + this.#active.set(owner, active) + owner.on('destroyed', active.destroyedListener) + + try { + return await this.#performSave(owner, active, request) + } finally { + await this.#cleanup(active) + owner.removeListener('destroyed', active.destroyedListener) + if (this.#active.get(owner) === active) { + this.#active.delete(owner) + } + active.resolveDone() + } + } + + async activate( + owner: RemoteProjectSaveOwner, + projectId: string + ): Promise { + this.#assertOwnerAvailable(owner) + const existing = this.#activations.get(owner) + if (existing) { + if (existing.projectId === projectId) { + return await existing.promise + } + throw new Error('A remote project save is already in progress') + } + const project = this.#database.getProject(projectId) + if ( + project.kind !== 'user' || + project.channel !== undefined || + project.status !== 'active' || + project.executionSpace.kind !== 'ssh' || + project.runtimeSelection === undefined + ) { + throw new Error('Only active managed SSH projects can be activated') + } + const promise = this.save(owner, { + intent: 'update', + draft: { + projectId: project.id, + name: project.name, + description: project.description, + defaultWorkMode: project.defaultWorkMode, + runtimeSelection: project.runtimeSelection, + hostId: project.executionSpace.hostId, + remoteRootPath: project.executionSpace.remoteRootPath + } + }) + this.#activations.set(owner, { projectId, promise }) + try { + return await promise + } finally { + if (this.#activations.get(owner)?.promise === promise) { + this.#activations.delete(owner) + } + } + } + + cancelCurrent(owner: RemoteProjectSaveOwner): void { + this.#active.get(owner)?.controller.abort( + new DOMException('Remote project save cancelled', 'AbortError') + ) + } + + async dispose(): Promise { + if (this.#disposed) return + this.#disposed = true + const saves = [...this.#active.entries()] + for (const [, active] of saves) { + active.controller.abort( + new DOMException('Remote project service disposed', 'AbortError') + ) + } + await Promise.allSettled(saves.map(([, active]) => active.done)) + } + + async #performSave( + owner: RemoteProjectSaveOwner, + active: ActiveSave, + request: RemoteProjectSaveRequest + ): Promise { + const { signal } = active.controller + const expectedUpdatedAt = + request.intent === 'update' + ? this.#readUpdateRevision(request) + : undefined + const runtimeSelection = await this.#resolveRuntimeSelection( + request.draft.runtimeSelection + ) + signal.throwIfAborted() + + this.#progress(owner, 'host') + const target = await this.#sshHosts.resolveConnectionTarget( + request.draft.hostId + ) + signal.throwIfAborted() + assertTarget(request.draft.hostId, target) + + this.#progress(owner, 'agent') + const installation = await this.#installationManager.ensureInstalled( + request.draft.hostId, + { signal } + ) + signal.throwIfAborted() + assertInstallation(installation) + active.installation = installation + const connection = await this.#connectionManager.acquire( + request.draft.hostId, + { + ...installation, + requiredCapabilities: [WORKSPACE_READ_CAPABILITY] + }, + signal + ) + active.connection = connection + signal.throwIfAborted() + assertConnectionIdentity(target, installation, connection) + + this.#progress(owner, 'workspace') + const validated = remoteWorkspaceValidateResultSchema.parse( + await connection.client.request( + 'workspace/validate', + { + remoteRootPath: request.draft.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [...REMOTE_WORKSPACE_READ_CAPABILITIES] + }, + { signal } + ) + ) + active.workspace = validated.handle + signal.throwIfAborted() + assertWorkspace(validated.handle) + if ( + validated.handle.canonicalDisplayPath !== + request.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, + hostKeyGeneration: target.hostKeyGeneration, + remoteUsername: target.host.username + }, + agent: { + installationId: installation.installationId, + binaryDigest: installation.binaryDigest, + version: installation.agentVersion, + 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, + 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 + ) + } + + #readUpdateRevision( + request: Extract + ): string { + const project = this.#database.getProject(request.draft.projectId) + if ( + project.kind !== 'user' || + project.channel !== undefined || + project.executionSpace.kind !== 'ssh' || + project.executionSpace.hostId !== request.draft.hostId || + project.executionSpace.remoteRootPath !== + request.draft.remoteRootPath + ) { + throw new Error( + 'Only an existing user SSH project with the same Host and root can be updated' + ) + } + return project.updatedAt + } + + #validatedWrite( + 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 + } + return { + project: { + name: request.draft.name, + description: request.draft.description, + rootPath: workspace.canonicalDisplayPath, + defaultWorkMode: request.draft.defaultWorkMode, + runtimeSelection + }, + executionSpace: { + kind: 'ssh', + hostId: target.host.id, + remoteRootPath: workspace.canonicalDisplayPath, + validation: executionValidation + }, + runtimeValidation: structuredClone(runtime), + assertSshHostCurrent: (expected) => { + assertPrecondition( + expected, + target, + installation, + workspace + ) + this.#sshHosts.assertConnectionTargetCurrent( + currentTargetEvidence(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') + } + } + } + } + + async #cleanup(active: ActiveSave): Promise { + try { + active.runtimeLease?.release() + } catch { + // Continue releasing independently owned resources. + } + const workspace = active.workspace + const connection = active.connection + if (workspace && connection) { + try { + await connection.client.request( + 'workspace/close', + { + workspaceId: workspace.workspaceId, + generation: workspace.generation + }, + { signal: AbortSignal.timeout(this.#cleanupTimeoutMs) } + ) + } catch { + // The connection may already be offline after cancellation. + } + } + try { + connection?.release() + } catch { + // A concurrently closed connection is already released. + } + } + + #progress( + owner: RemoteProjectSaveOwner, + phase: RemoteProjectSavePhase + ): void { + if (!this.#notify || owner.isDestroyed()) return + try { + this.#notify(owner, { phase }) + } catch { + // Renderer progress observers cannot affect the save. + } + } + + #assertOwnerAvailable(owner: RemoteProjectSaveOwner): void { + if (this.#disposed) { + throw new Error('Remote project save service is disposed') + } + if ( + !owner || + !Number.isSafeInteger(owner.id) || + owner.id < 0 || + owner.isDestroyed() + ) { + throw new Error('Remote project owner is unavailable') + } + } +} + +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 { + if ( + !/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/u.test( + installation.installationId + ) || + !sha256DigestSchema.safeParse(installation.binaryDigest).success || + installation.platform !== 'linux' || + !['x64', 'arm64'].includes(installation.architecture) || + installation.supervisor !== 'detached-on-demand' + ) { + throw new Error('Remote Agent installation is incompatible') + } +} + +function assertConnectionIdentity( + target: SshConnectionTarget, + installation: AgentInstallationIdentity, + connection: RemoteAgentConnection +): void { + if ( + connection.state !== 'ready' || + connection.identity.hostId !== target.host.id || + connection.identity.hostRevision !== target.hostRevision || + connection.identity.hostKeyGeneration !== target.hostKeyGeneration || + connection.identity.remoteUsername !== target.host.username || + connection.identity.installationId !== installation.installationId || + connection.identity.binaryDigest !== installation.binaryDigest || + connection.status.state !== 'ready' || + connection.status.draining || + connection.status.installationId !== installation.installationId || + connection.status.binaryDigest !== installation.binaryDigest + ) { + throw new Error('Remote Agent identity is incompatible') + } +} + +function assertCriticalWorkspaceRead( + connection: RemoteAgentConnection +): void { + const capability = connection.capabilities.capabilities.find( + (entry) => entry.name === WORKSPACE_READ_CAPABILITY.name + ) + if ( + !capability || + capability.version < WORKSPACE_READ_CAPABILITY.minimumVersion || + !capability.critical + ) { + throw new Error('Remote Agent lacks workspace read capability') + } +} + +function assertWorkspace(handle: RemoteWorkspaceHandle): void { + if ( + handle.access !== 'read-only' || + REMOTE_WORKSPACE_READ_CAPABILITIES.some( + (capability) => !handle.capabilities.includes(capability) + ) || + handle.capabilities.includes('write-text-atomic') || + handle.capabilities.includes('apply-change-set') + ) { + throw new Error('Remote workspace validation is incompatible') + } +} + +function assertRuntimeEvidence( + evidence: ProjectRuntimeValidation, + selectionKey: string, + installationId: string, + workMode: 'ask' | 'execute' +): void { + if ( + evidence.runtimeSelectionKey !== selectionKey || + evidence.agentInstallationIdAtValidation !== installationId || + evidence.workMode !== workMode + ) { + throw new Error('Remote Runtime validation does not match the project') + } +} + +function assertPrecondition( + expected: ValidatedSshHostPrecondition, + target: SshConnectionTarget, + installation: AgentInstallationIdentity, + workspace: RemoteWorkspaceHandle +): void { + if ( + expected.hostId !== target.host.id || + expected.hostRevision !== target.hostRevision || + expected.hostKeyGeneration !== target.hostKeyGeneration || + expected.remoteUsername !== target.host.username || + expected.remoteRootPath !== workspace.canonicalDisplayPath || + expected.workspaceIdentity !== workspace.workspaceIdentity || + expected.agentProtocolMajor !== installation.protocol.major || + expected.agentInstallationId !== installation.installationId || + expected.agentBinaryDigest !== installation.binaryDigest || + expected.agentVersion !== installation.agentVersion || + expected.agentArchitecture !== installation.architecture + ) { + throw new Error('Validated SSH project precondition changed') + } +} + +function currentTargetEvidence( + target: SshConnectionTarget +): CurrentSshConnectionTarget { + return { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + username: target.host.username + } +} diff --git a/src/main/remote-agent/remote-runtime-installation-manager.test.ts b/src/main/remote-agent/remote-runtime-installation-manager.test.ts new file mode 100644 index 00000000..a7987198 --- /dev/null +++ b/src/main/remote-agent/remote-runtime-installation-manager.test.ts @@ -0,0 +1,1024 @@ +import { + createHash, + generateKeyPairSync, + sign +} from 'node:crypto' +import { + mkdir, + mkdtemp, + readFile, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { + AgentReleaseKeyRegistry +} from '../../shared/agent-installation-contracts' +import { + digestRemoteRuntimeBundleIdentity, + digestRemoteRuntimeBundleManifest, + type RemoteRuntimeBundleManifest, + type RemoteRuntimeLock +} from '../../shared/remote-runtime-launch-contracts' +import { + runtimeManifestSignaturePayload +} from '../../shared/node/runtime-bundle-verifier' +import type { + SftpEntryMetadata, + StagedSftp +} from '../ssh/bounded-sftp' +import type { + SshConnectionLease, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' +import { + RemoteRuntimeBundleResourcesUnavailableError, + RemoteRuntimeInstallationError, + RemoteRuntimeInstallationManager, + type VerifiedRemoteRuntimeInstallationBundle +} from './remote-runtime-installation-manager' + +type RemoteEntry = { + type: 'file' | 'directory' | 'symbolic-link' + mode: number + uid: number + contents?: Buffer +} + +const home = '/home/tester' +const uid = 1000 + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} + +function missing(): Error & { code: number } { + return Object.assign(new Error('missing'), { code: 2 }) +} + +class MemorySftp implements StagedSftp { + readonly stagingDirectory = home + readonly operations: string[] = [] + readonly entries = new Map([ + [home, { type: 'directory', mode: 0o700, uid }] + ]) + mutateRead?: (path: string, contents: Buffer) => Buffer + closed = false + + private absolute(path: string): string { + return `${home}/${path}` + } + + private metadata(entry: RemoteEntry): SftpEntryMetadata { + return { + type: entry.type, + size: entry.contents?.byteLength ?? 0, + mode: entry.mode, + uid: entry.uid, + gid: entry.uid, + atime: 1, + mtime: 1 + } + } + + async mkdir(path: string): Promise { + this.operations.push(`mkdir:${path}`) + const absolute = this.absolute(path) + if (this.entries.has(absolute)) { + throw new Error('exists') + } + this.entries.set(absolute, { + type: 'directory', + mode: 0o700, + uid + }) + } + + async writeFile(path: string, contents: Buffer): Promise { + this.operations.push(`write:${path}`) + const absolute = this.absolute(path) + if (this.entries.has(absolute)) { + throw new Error('exists') + } + this.entries.set(absolute, { + type: 'file', + mode: 0o600, + uid, + contents: Buffer.from(contents) + }) + } + + async readFile(path: string): Promise { + this.operations.push(`read:${path}`) + const contents = Buffer.from( + this.entry(path).contents ?? Buffer.alloc(0) + ) + return this.mutateRead?.(path, contents) ?? contents + } + + async lstat(path: string): Promise { + return this.metadata(this.entry(path)) + } + + async stat(path: string): Promise { + const entry = this.entry(path) + if (entry.type === 'symbolic-link') { + throw new Error('symbolic link') + } + return this.metadata(entry) + } + + async chmod( + path: string, + mode: 0o600 | 0o644 | 0o700 | 0o755 + ): Promise { + this.operations.push(`chmod:${path}:${mode.toString(8)}`) + this.entry(path).mode = mode + } + + async setExecutable(path: string): Promise { + await this.chmod(path, 0o700) + } + + async rename(source: string, destination: string): Promise { + this.operations.push(`rename:${source}:${destination}`) + if (this.entries.has(this.absolute(destination))) { + throw new Error('destination exists') + } + this.moveTree(source, destination) + } + + async replaceFile( + source: string, + destination: string + ): Promise { + this.operations.push(`replace:${source}:${destination}`) + const sourceEntry = this.entry(source) + const destinationEntry = this.entries.get( + this.absolute(destination) + ) + if ( + sourceEntry.type !== 'file' || + (destinationEntry && destinationEntry.type !== 'file') + ) { + throw new Error('unsafe replacement') + } + this.entries.delete(this.absolute(source)) + this.entries.delete(this.absolute(destination)) + this.entries.set(this.absolute(destination), sourceEntry) + } + + async unlink(path: string): Promise { + this.operations.push(`unlink:${path}`) + if (this.entry(path).type !== 'file') { + throw new Error('not a file') + } + this.entries.delete(this.absolute(path)) + } + + async rmdir(path: string): Promise { + this.operations.push(`rmdir:${path}`) + const absolute = this.absolute(path) + if (this.entry(path).type !== 'directory') { + throw new Error('not a directory') + } + if ( + [...this.entries.keys()].some( + (candidate) => candidate.startsWith(`${absolute}/`) + ) + ) { + throw new Error('not empty') + } + this.entries.delete(absolute) + } + + close(): void { + this.closed = true + } + + add(path: string, entry: RemoteEntry): void { + this.entries.set(this.absolute(path), entry) + } + + private entry(path: string): RemoteEntry { + const entry = this.entries.get(this.absolute(path)) + if (!entry) { + throw missing() + } + return entry + } + + private moveTree(source: string, destination: string): void { + const sourceAbsolute = this.absolute(source) + const destinationAbsolute = this.absolute(destination) + const matches = [...this.entries.entries()].filter( + ([path]) => + path === sourceAbsolute || + path.startsWith(`${sourceAbsolute}/`) + ) + if (matches.length === 0) { + throw missing() + } + for (const [path] of matches) { + this.entries.delete(path) + } + for (const [path, entry] of matches) { + this.entries.set( + `${destinationAbsolute}${path.slice(sourceAbsolute.length)}`, + entry + ) + } + } +} + +const registry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [{ + keyId: 'test-key', + publicKeySpkiBase64: 'AAAA', + environment: 'test' + }], + revocations: [] +} + +const runtimeLock: RemoteRuntimeLock = { + formatVersion: 1, + runtimes: { + opencode: { + version: '1.18.9', + provider: 'opencode', + entrypoint: 'bin/opencode', + entrypointIdentity: 'opencode-acp', + argvPrefix: ['acp'], + allowedEnvironmentNames: [ + '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-${'A'.repeat(86)}==` + }, + arm64: { + package: 'opencode-linux-arm64', + integrity: `sha512-${'B'.repeat(86)}==` + } + } + } + } +} + +function canonical(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +function target( + revision = 1, + hostId = 'host-1' +): SshConnectionPoolTarget { + return { + host: { + id: hostId, + name: 'Host', + hostname: 'host.example', + port: 22, + username: 'tester', + authentication: 'password', + password: 'not-logged', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'AAAA', + fingerprintSha256: `SHA256:${'a'.repeat(43)}`, + generation: revision + } + }, + hostRevision: revision, + hostKeyGeneration: revision + } +} + +async function bundleFixture(): Promise< + VerifiedRemoteRuntimeInstallationBundle +> { + const directory = await mkdtemp( + join(tmpdir(), 'runtime-manager-') + ) + const files = [ + { + path: 'bin/opencode', + contents: Buffer.from('opencode'), + mode: '0755' as const + }, + { + path: 'licenses/LICENSE', + contents: Buffer.from('license'), + mode: '0644' as const + } + ] + const initial: RemoteRuntimeBundleManifest = { + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + provider: 'opencode', + platform: 'linux', + architecture: 'x64', + signingKeyId: 'test-key', + bundleDigest: `sha256:${'0'.repeat(64)}`, + adapterDigest: `sha256:${'1'.repeat(64)}`, + sourcePackage: { + name: 'opencode-linux-x64-baseline', + integrity: runtimeLock.runtimes.opencode.targets.x64.integrity + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: sha256(files[0]!.contents), + argvPrefix: ['acp'] + }, + files: files.map((file) => ({ + path: file.path, + size: file.contents.byteLength, + sha256: sha256(file.contents), + mode: file.mode + })), + 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: 4096, + maximumPromptOutputBytes: 1024 * 1024 + } + } + const manifest = { + ...initial, + bundleDigest: await digestRemoteRuntimeBundleIdentity(initial) + } + for (const file of files) { + const destination = join( + directory, + ...file.path.split('/') + ) + await mkdir(join(destination, '..'), { recursive: true }) + await writeFile(destination, file.contents) + } + await writeFile( + join(directory, 'manifest.json'), + canonical(manifest) + ) + await writeFile( + join(directory, 'manifest.sig'), + Buffer.alloc(64, 7) + ) + return { + bundleDirectory: directory, + manifest, + manifestDigest: + await digestRemoteRuntimeBundleManifest(manifest), + canonicalReleaseKeyRegistryBytes: canonical(registry), + canonicalRemoteRuntimeLockBytes: canonical(runtimeLock) + } +} + +async function harness(options: { + sftp?: MemorySftp + resolverTargets?: SshConnectionPoolTarget[] + bootstrapGate?: Promise + maximumConcurrentHosts?: number +} = {}) { + const bundle = await bundleFixture() + const sftp = options.sftp ?? new MemorySftp() + const release = vi.fn() + const activate = vi.fn(async () => undefined) + const lease = { + identity: { + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + authenticationIdentity: 'b'.repeat(64) + }, + isUsable: () => true, + runAgentBootstrapProbe: vi.fn( + async (signal?: AbortSignal) => { + if (options.bootstrapGate) { + await waitForGate(options.bootstrapGate, signal) + } + return { + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: home, + uid, + shell: '/bin/bash', + procfs: 'ready' + } as const + } + ), + openStagedSftp: vi.fn(async () => sftp), + release + } as unknown as SshConnectionLease + let resolveCount = 0 + const targets = options.resolverTargets ?? [target()] + const resolver = { + resolve: vi.fn(async () => + targets[ + Math.min(resolveCount++, targets.length - 1) + ]! + ) + } + const sshPool = { + acquire: vi.fn(async () => lease) + } + const manager = new RemoteRuntimeInstallationManager({ + resolver, + sshPool, + loadVerifiedBundle: async () => bundle, + activate, + maximumConcurrentHosts: options.maximumConcurrentHosts + }) + const destination = + `.goodbuddy/runtimes/opencode/${ + bundle.manifest.bundleDigest.slice('sha256:'.length) + }` + return { + manager, + bundle, + sftp, + release, + activate, + lease, + resolver, + sshPool, + destination + } +} + +async function waitForGate( + gate: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + await gate + return + } + signal.throwIfAborted() + await new Promise((resolve, reject) => { + const abort = (): void => { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + signal.addEventListener('abort', abort, { once: true }) + gate.then( + () => { + signal.removeEventListener('abort', abort) + resolve() + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} + +function populateBundle( + fixture: Awaited> +): void { + fixture.sftp.add(fixture.destination, { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add(`${fixture.destination}/bin`, { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add(`${fixture.destination}/licenses`, { + type: 'directory', + mode: 0o700, + uid + }) + fixture.sftp.add(`${fixture.destination}/bin/opencode`, { + type: 'file', + mode: 0o755, + uid, + contents: Buffer.from('opencode') + }) + fixture.sftp.add(`${fixture.destination}/licenses/LICENSE`, { + type: 'file', + mode: 0o644, + uid, + contents: Buffer.from('license') + }) + fixture.sftp.add(`${fixture.destination}/manifest.json`, { + type: 'file', + mode: 0o644, + uid, + contents: canonical(fixture.bundle.manifest) + }) + fixture.sftp.add(`${fixture.destination}/manifest.sig`, { + type: 'file', + mode: 0o644, + uid, + contents: Buffer.alloc(64, 7) + }) +} + +async function metadataOnlyHarness(options: { + corruptPath?: string + remoteReleaseKeyBytes?: Buffer + resolverTargets?: SshConnectionPoolTarget[] +} = {}) { + const baseBundle = await bundleFixture() + const keyPair = generateKeyPairSync('ed25519') + const signedRegistry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [{ + keyId: 'test-key', + publicKeySpkiBase64: keyPair.publicKey.export({ + format: 'der', + type: 'spki' + }).toString('base64'), + environment: 'production' + }], + revocations: [] + } + const manifestBytes = canonical(baseBundle.manifest) + const signatureBytes = Buffer.from( + `${sign( + null, + runtimeManifestSignaturePayload(manifestBytes), + keyPair.privateKey + ).toString('base64')}\n` + ) + const releaseKeyRegistryBytes = canonical(signedRegistry) + const remoteRuntimeLockBytes = canonical(runtimeLock) + const bundle: VerifiedRemoteRuntimeInstallationBundle = { + bundleDirectory: baseBundle.bundleDirectory, + manifest: baseBundle.manifest, + manifestDigest: baseBundle.manifestDigest, + canonicalReleaseKeyRegistryBytes: + releaseKeyRegistryBytes, + canonicalRemoteRuntimeLockBytes: + remoteRuntimeLockBytes + } + const sftp = new MemorySftp() + const destination = + `.goodbuddy/runtimes/opencode/${ + bundle.manifest.bundleDigest.slice('sha256:'.length) + }` + for (const path of [ + '.goodbuddy', + '.goodbuddy/runtimes', + '.goodbuddy/runtimes/opencode', + destination, + `${destination}/bin`, + `${destination}/licenses` + ]) { + sftp.add(path, { + type: 'directory', + mode: 0o700, + uid + }) + } + const runtimeRegistryBytes = canonical({ + formatVersion: 1, + current: [{ + runtimeId: 'opencode', + runtimeVersion: bundle.manifest.runtimeVersion, + architecture: bundle.manifest.architecture, + bundleDigest: bundle.manifest.bundleDigest, + manifestDigest: bundle.manifestDigest, + acpCapabilitiesDigest: + bundle.manifest.acpCapabilitiesDigest + }] + }) + for (const [path, contents] of [ + [ + '.goodbuddy/runtimes/registry.json', + runtimeRegistryBytes + ], + [ + '.goodbuddy/runtimes/release-keys.json', + options.remoteReleaseKeyBytes ?? + releaseKeyRegistryBytes + ], + [ + '.goodbuddy/runtimes/remote-runtime-lock.json', + remoteRuntimeLockBytes + ] + ] as const) { + sftp.add(path, { + type: 'file', + mode: 0o600, + uid, + contents + }) + } + for (const file of bundle.manifest.files) { + const contents = await readFile( + join(bundle.bundleDirectory, ...file.path.split('/')) + ) + sftp.add(`${destination}/${file.path}`, { + type: 'file', + mode: file.mode === '0755' ? 0o755 : 0o644, + uid, + contents: + options.corruptPath === file.path + ? Buffer.alloc(contents.byteLength, 0x78) + : contents + }) + } + sftp.add(`${destination}/manifest.json`, { + type: 'file', + mode: 0o644, + uid, + contents: manifestBytes + }) + sftp.add(`${destination}/manifest.sig`, { + type: 'file', + mode: 0o644, + uid, + contents: + options.corruptPath === 'manifest.sig' + ? Buffer.from(`${'A'.repeat(86)}==\n`) + : signatureBytes + }) + + const release = vi.fn() + const activate = vi.fn(async () => { + if ( + options.corruptPath !== undefined && + options.corruptPath !== 'manifest.sig' + ) { + throw new Error('Agent Runtime verification failed') + } + }) + const lease = { + identity: { + hostId: 'host-1', + hostRevision: 1, + hostKeyGeneration: 1, + authenticationIdentity: 'b'.repeat(64) + }, + isUsable: () => true, + runAgentBootstrapProbe: vi.fn(async () => ({ + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: home, + uid, + shell: '/bin/bash', + procfs: 'ready' + }) as const), + openStagedSftp: vi.fn(async () => sftp), + release + } as unknown as SshConnectionLease + let resolveCount = 0 + const targets = options.resolverTargets ?? [target()] + const resolver = { + resolve: vi.fn(async () => + targets[Math.min(resolveCount++, targets.length - 1)]! + ) + } + const manager = new RemoteRuntimeInstallationManager({ + resolver, + sshPool: { acquire: vi.fn(async () => lease) }, + loadVerifiedBundle: async () => { + throw new RemoteRuntimeBundleResourcesUnavailableError() + }, + loadVerificationMetadata: async () => ({ + releaseKeyRegistry: signedRegistry, + runtimeLock, + canonicalReleaseKeyRegistryBytes: + releaseKeyRegistryBytes, + canonicalRemoteRuntimeLockBytes: + remoteRuntimeLockBytes + }), + activate + }) + return { + manager, + bundle, + activate, + destination, + release, + resolver, + sftp + } +} + +describe('RemoteRuntimeInstallationManager', () => { + it('cryptographically reuses an exact current Host Runtime when bundle resources are absent', async () => { + const fixture = await metadataOnlyHarness() + + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ + runtimeId: 'opencode', + runtimeVersion: fixture.bundle.manifest.runtimeVersion, + bundleDigest: fixture.bundle.manifest.bundleDigest, + architecture: 'x64' + }) + expect(fixture.activate).toHaveBeenCalledOnce() + expect( + fixture.sftp.operations.some((operation) => + operation.startsWith('write:') + ) + ).toBe(false) + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('rejects metadata-only Runtime reuse when trust metadata differs', async () => { + const fixture = await metadataOnlyHarness({ + remoteReleaseKeyBytes: canonical({ + formatVersion: 1, + keys: [], + revocations: [] + }) + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'corrupt' }) + expect(fixture.activate).not.toHaveBeenCalled() + }) + + it('rejects metadata-only Runtime reuse when its signature is corrupt', async () => { + const fixture = await metadataOnlyHarness({ + corruptPath: 'manifest.sig' + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'corrupt' }) + expect(fixture.activate).not.toHaveBeenCalled() + }) + + it('delegates exact payload re-verification to Agent activation', async () => { + const fixture = await metadataOnlyHarness({ + corruptPath: 'bin/opencode' + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ reason: 'activation' }) + expect(fixture.activate).toHaveBeenCalledOnce() + }) + + it('rejects metadata-only Runtime reuse after Host identity changes', async () => { + const fixture = await metadataOnlyHarness({ + resolverTargets: [target(), target(2)] + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + expect(fixture.activate).not.toHaveBeenCalled() + }) + + it('installs, atomically publishes, then activates OpenCode', async () => { + const fixture = await harness() + const identity = await fixture.manager.ensureInstalled('host-1') + + expect(identity).toEqual({ + runtimeId: 'opencode', + runtimeVersion: fixture.bundle.manifest.runtimeVersion, + bundleDigest: fixture.bundle.manifest.bundleDigest, + manifestDigest: fixture.bundle.manifestDigest, + runtimeAdapterDigest: + fixture.bundle.manifest.adapterDigest, + acpCapabilitiesDigest: + fixture.bundle.manifest.acpCapabilitiesDigest, + platform: 'linux', + architecture: 'x64' + }) + expect( + fixture.sftp.entries.has(`${home}/${fixture.destination}`) + ).toBe(true) + expect( + fixture.sftp.operations.filter((entry) => + entry.startsWith('replace:.goodbuddy/runtimes/') + ) + ).toHaveLength(2) + const publishIndex = fixture.sftp.operations.findIndex( + (entry) => + entry.startsWith( + 'rename:.goodbuddy/runtimes/staging/op-' + ) + ) + expect(publishIndex).toBeGreaterThan(-1) + expect(fixture.activate).toHaveBeenCalledOnce() + expect(fixture.activate).toHaveBeenCalledWith( + fixture.lease, + 'opencode', + fixture.bundle.manifest.bundleDigest, + 'x64', + expect.any(AbortSignal) + ) + expect(fixture.release).toHaveBeenCalledOnce() + }) + + it('fully verifies and reuses an existing digest destination', async () => { + const fixture = await harness() + populateBundle(fixture) + + const identity = + await fixture.manager.ensureInstalled('host-1') + + expect(identity).toEqual({ + runtimeId: 'opencode', + runtimeVersion: fixture.bundle.manifest.runtimeVersion, + bundleDigest: fixture.bundle.manifest.bundleDigest, + manifestDigest: fixture.bundle.manifestDigest, + runtimeAdapterDigest: + fixture.bundle.manifest.adapterDigest, + acpCapabilitiesDigest: + fixture.bundle.manifest.acpCapabilitiesDigest, + platform: 'linux', + architecture: 'x64' + }) + expect( + fixture.sftp.operations.some((entry) => + entry.startsWith( + 'rename:.goodbuddy/runtimes/staging/op-' + ) + ) + ).toBe(false) + expect(fixture.activate).toHaveBeenCalledOnce() + }) + + it('rejects mutated remote readback, cleans staging, and never activates', async () => { + const fixture = await harness() + fixture.sftp.mutateRead = (path, contents) => + path.endsWith('/bin/opencode') + ? Buffer.from('mutated') + : contents + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'corrupt' + }) + expect(fixture.activate).not.toHaveBeenCalled() + expect( + [...fixture.sftp.entries.keys()].some((path) => + path.includes('/runtimes/staging/op-') + ) + ).toBe(false) + }) + + it('rejects an identity race before publication and activation', async () => { + const fixture = await harness({ + resolverTargets: [target(1), target(2)] + }) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toMatchObject({ + reason: 'host-identity-changed' + }) + expect(fixture.activate).not.toHaveBeenCalled() + expect( + [...fixture.sftp.entries.keys()].some((path) => + path.includes('/runtimes/staging/op-') + ) + ).toBe(false) + }) + + it('cancels the shared operation when its final waiter leaves and cleans up', async () => { + const sftp = new MemorySftp() + let payloadStarted!: () => void + const started = new Promise((resolve) => { + payloadStarted = resolve + }) + const originalWrite = sftp.writeFile.bind(sftp) + sftp.writeFile = vi.fn(async ( + path: string, + contents: Buffer, + signal?: AbortSignal + ) => { + await originalWrite(path, contents) + if (path.endsWith('/bin/opencode')) { + payloadStarted() + await waitForGate( + new Promise(() => undefined), + signal + ) + } + }) + const fixture = await harness({ sftp }) + const controller = new AbortController() + const installation = fixture.manager.ensureInstalled('host-1', { + signal: controller.signal + }) + await started + controller.abort() + + await expect(installation).rejects.toMatchObject({ + name: 'AbortError' + }) + await vi.waitFor(() => + expect(fixture.release).toHaveBeenCalledOnce() + ) + expect( + [...sftp.entries.keys()].some((path) => + path.includes('/runtimes/staging/op-') + ) + ).toBe(false) + }) + + it('deduplicates one host and enforces concurrent host capacity', async () => { + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const fixture = await harness({ + bootstrapGate: gate, + maximumConcurrentHosts: 1, + resolverTargets: [ + target(), + target(), + target(1, 'host-2'), + target() + ] + }) + const first = fixture.manager.ensureInstalled('host-1') + const second = fixture.manager.ensureInstalled('host-1') + await vi.waitFor(() => + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + ) + + await expect( + fixture.manager.ensureInstalled('host-2') + ).rejects.toMatchObject({ + reason: 'capacity' + }) + releaseGate() + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + }) + + it('reuses a successful Runtime installation for the same Host identity', async () => { + const fixture = await harness() + const first = await fixture.manager.ensureInstalled('host-1') + const phases: string[] = [] + const second = await fixture.manager.ensureInstalled('host-1', { + onProgress: (phase) => phases.push(phase) + }) + + expect(second).toEqual(first) + expect(fixture.sshPool.acquire).toHaveBeenCalledOnce() + expect(fixture.activate).toHaveBeenCalledOnce() + expect(phases).toEqual(['inspecting-host', 'complete']) + }) + + it('reports activation errors distinctly after verified publication', async () => { + const fixture = await harness() + fixture.activate.mockRejectedValueOnce( + new Error('fixed activation command failed') + ) + + await expect( + fixture.manager.ensureInstalled('host-1') + ).rejects.toBeInstanceOf(RemoteRuntimeInstallationError) + await expect( + fixture.manager.ensureInstalled('host-1') + ).resolves.toMatchObject({ runtimeId: 'opencode' }) + expect( + fixture.sftp.entries.has(`${home}/${fixture.destination}`) + ).toBe(true) + expect(fixture.activate).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/remote-agent/remote-runtime-installation-manager.ts b/src/main/remote-agent/remote-runtime-installation-manager.ts new file mode 100644 index 00000000..5fc94576 --- /dev/null +++ b/src/main/remote-agent/remote-runtime-installation-manager.ts @@ -0,0 +1,1649 @@ +import { createHash, randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + agentReleaseKeyRegistrySchema, + type AgentArchitecture, + type AgentReleaseKeyRegistry +} from '../../shared/agent-installation-contracts' +import type { AgentBootstrapIncompatibleReason } from '../../shared/ssh-host-contracts' +import { + assertRuntimeManifestMatchesLock, + canonicalRuntimeManifestBytes, + verifyRuntimeManifestSignature +} from '../../shared/node/runtime-bundle-verifier' +import { + parseRuntimeRegistryState +} from '../../shared/remote-environment-registry-contracts' +import { + digestRemoteRuntimeBundleIdentity, + digestRemoteRuntimeBundleManifest, + remoteRuntimeBundleManifestSchema, + remoteRuntimeLockSchema, + type RemoteRuntimeBundleManifest, + type RemoteRuntimeLock +} from '../../shared/remote-runtime-launch-contracts' +import { sha256DigestSchema } from '../../shared/agent-protocol/contracts' +import type { + BoundedSftpLimits, + SftpEntryMetadata, + StagedSftp +} from '../ssh/bounded-sftp' +import type { + SshConnectionLease, + SshConnectionPool, + SshConnectionPoolTarget +} from '../ssh/ssh-connection-pool' + +const MAXIMUM_INSTALLATION_FILES = 200 +const MAXIMUM_INSTALLATION_BYTES = 512 * 1024 * 1024 +const MAXIMUM_RUNTIME_FILE_BYTES = 256 * 1024 * 1024 +const MAXIMUM_METADATA_BYTES = 1024 * 1024 +const MAXIMUM_CLEANUP_ENTRIES = 512 +const METADATA_MODE = 0o644 +const PRIVATE_FILE_MODE = 0o600 +const PRIVATE_DIRECTORY_MODE = 0o700 +const DISPOSE_WAIT_TIMEOUT_MS = 5_000 + +const RUNTIME_ROOT = '.goodbuddy/runtimes' +const RUNTIME_STAGING_ROOT = `${RUNTIME_ROOT}/staging` +const OPENCODE_ROOT = `${RUNTIME_ROOT}/opencode` +const RELEASE_KEYS_PATH = `${RUNTIME_ROOT}/release-keys.json` +const RUNTIME_LOCK_PATH = `${RUNTIME_ROOT}/remote-runtime-lock.json` +const RUNTIME_REGISTRY_PATH = `${RUNTIME_ROOT}/registry.json` + +export class RemoteRuntimeBundleResourcesUnavailableError + extends Error { + constructor(cause?: unknown) { + super( + 'OpenCode Runtime installation resources are not included in this package', + { cause } + ) + this.name = 'RemoteRuntimeBundleResourcesUnavailableError' + } +} + +export type RemoteRuntimeInstallationPhase = + | 'inspecting-host' + | 'verifying-bundle' + | 'preparing-installation' + | 'uploading-bundle' + | 'publishing-bundle' + | 'activating-runtime' + | 'complete' + +export type RemoteRuntimeInstallationIdentity = { + runtimeId: string + runtimeVersion: string + bundleDigest: string + manifestDigest: string + runtimeAdapterDigest: string + acpCapabilitiesDigest: string + platform: 'linux' + architecture: AgentArchitecture +} + +export interface RemoteRuntimeInstallationTargetResolver { + resolve(hostId: string): Promise +} + +export type RemoteRuntimeInstallationRequestOptions = { + signal?: AbortSignal + onProgress?: (phase: RemoteRuntimeInstallationPhase) => void +} + +export class RemoteRuntimeInstallationError extends Error { + constructor( + message: string, + readonly reason: + | 'incompatible' + | 'corrupt' + | 'host-identity-changed' + | 'capacity' + | 'activation', + readonly detail?: AgentBootstrapIncompatibleReason | unknown + ) { + super(message) + this.name = 'RemoteRuntimeInstallationError' + } +} + +type RuntimeBundleBase = { + bundleDirectory: string + manifest: RemoteRuntimeBundleManifest + manifestDigest: string +} + +export type VerifiedRemoteRuntimeInstallationBundle = + RuntimeBundleBase & { + canonicalReleaseKeyRegistryBytes: Uint8Array + canonicalRemoteRuntimeLockBytes: Uint8Array + } + +export type RemoteRuntimeInstallationBundleLoader = ( + architecture: AgentArchitecture +) => Promise + +export type RemoteRuntimeInstallationVerificationMetadata = { + releaseKeyRegistry: AgentReleaseKeyRegistry + runtimeLock: RemoteRuntimeLock + canonicalReleaseKeyRegistryBytes: Uint8Array + canonicalRemoteRuntimeLockBytes: Uint8Array +} + +export type RemoteRuntimeInstallationVerificationMetadataLoader = + () => Promise + +export type RemoteRuntimeActivator = ( + lease: SshConnectionLease, + runtimeId: string, + bundleDigest: string, + architecture: AgentArchitecture, + signal: AbortSignal +) => Promise + +type ActiveInstallation = { + promise: Promise + controller: AbortController + progressCallbacks: Set< + (phase: RemoteRuntimeInstallationPhase) => void + > + waiters: number + settled: boolean +} + +type CleanupEntry = { + path: string + type: 'file' | 'directory' +} + +type LoadedBundle = RuntimeBundleBase & { + releaseKeyRegistryBytes: Buffer + remoteRuntimeLockBytes: Buffer +} + +export class RemoteRuntimeInstallationManager { + readonly #resolver: RemoteRuntimeInstallationTargetResolver + readonly #sshPool: Pick + readonly #loadVerifiedBundle: RemoteRuntimeInstallationBundleLoader + readonly #loadVerificationMetadata?: + RemoteRuntimeInstallationVerificationMetadataLoader + readonly #activate: RemoteRuntimeActivator + readonly #sftpLimits?: BoundedSftpLimits + readonly #maximumConcurrentHosts: number + readonly #active = new Map() + readonly #installed = new Map< + string, + RemoteRuntimeInstallationIdentity + >() + #closed = false + #disposePromise: Promise | undefined + + constructor(options: { + resolver: RemoteRuntimeInstallationTargetResolver + sshPool: Pick + loadVerifiedBundle: RemoteRuntimeInstallationBundleLoader + loadVerificationMetadata?: + RemoteRuntimeInstallationVerificationMetadataLoader + activate: RemoteRuntimeActivator + sftpLimits?: BoundedSftpLimits + maximumConcurrentHosts?: number + }) { + this.#resolver = options.resolver + this.#sshPool = options.sshPool + this.#loadVerifiedBundle = options.loadVerifiedBundle + this.#loadVerificationMetadata = + options.loadVerificationMetadata + this.#activate = options.activate + this.#sftpLimits = options.sftpLimits + this.#maximumConcurrentHosts = + options.maximumConcurrentHosts ?? 8 + if ( + !Number.isSafeInteger(this.#maximumConcurrentHosts) || + this.#maximumConcurrentHosts <= 0 || + this.#maximumConcurrentHosts > 32 + ) { + throw new Error( + 'Remote Runtime installation concurrency limit is invalid' + ) + } + } + + async ensureInstalled( + hostId: string, + options: RemoteRuntimeInstallationRequestOptions = {} + ): Promise { + this.#throwIfClosed() + options.signal?.throwIfAborted() + emitOne(options.onProgress, 'inspecting-host') + const target = await this.#resolver.resolve(hostId) + this.#throwIfClosed() + options.signal?.throwIfAborted() + if (target.host.id !== hostId) { + throw new RemoteRuntimeInstallationError( + 'Resolved SSH host does not match the Runtime installation request', + 'host-identity-changed' + ) + } + + const operationKey = targetIdentityKey(target) + const installed = this.#installed.get(operationKey) + if (installed !== undefined) { + emitOne(options.onProgress, 'complete') + return installed + } + const existing = this.#active.get(operationKey) + if (existing) { + return this.#waitForActive(existing, options) + } + if (this.#active.size >= this.#maximumConcurrentHosts) { + throw new RemoteRuntimeInstallationError( + 'Too many remote Runtime installations are already running', + 'capacity' + ) + } + + const progressCallbacks = new Set< + (phase: RemoteRuntimeInstallationPhase) => void + >() + const controller = new AbortController() + const progress = ( + phase: RemoteRuntimeInstallationPhase + ): void => { + for (const callback of progressCallbacks) { + emitOne(callback, phase) + } + } + const promise = this.#run(target, controller.signal, progress) + .then((identity) => { + this.#installed.set(operationKey, identity) + return identity + }) + const active: ActiveInstallation = { + promise, + controller, + progressCallbacks, + waiters: 0, + settled: false + } + this.#active.set(operationKey, active) + void active.promise.finally(() => { + active.settled = true + if (this.#active.get(operationKey) === active) { + this.#active.delete(operationKey) + } + }).catch(() => undefined) + return this.#waitForActive(active, options) + } + + dispose(): Promise { + if (this.#disposePromise) { + return this.#disposePromise + } + this.#closed = true + const operations = [...this.#active.values()] + const reason = new DOMException( + 'Remote Runtime installation manager was disposed', + 'AbortError' + ) + for (const operation of operations) { + operation.controller.abort(reason) + } + this.#disposePromise = settleBoundedly( + operations.map((operation) => operation.promise), + DISPOSE_WAIT_TIMEOUT_MS + ) + this.#installed.clear() + return this.#disposePromise + } + + #throwIfClosed(): void { + if (this.#closed) { + throw new Error( + 'Remote Runtime installation manager is disposed' + ) + } + } + + async #waitForActive( + active: ActiveInstallation, + options: RemoteRuntimeInstallationRequestOptions + ): Promise { + 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( + 'Remote Runtime installation has no remaining waiters', + 'AbortError' + ) + ) + } + } + } + + async #run( + target: SshConnectionPoolTarget, + signal: AbortSignal, + progress: (phase: RemoteRuntimeInstallationPhase) => void + ): Promise { + let lease: SshConnectionLease | undefined + let sftp: StagedSftp | undefined + let cleanupHomeDirectory: string | undefined + const cleanup: CleanupEntry[] = [] + let stagingPublished = false + try { + lease = await this.#sshPool.acquire(target, signal) + assertLeaseMatchesTarget(lease, target) + const probe = await lease.runAgentBootstrapProbe(signal) + if (!probe.ready) { + throw new RemoteRuntimeInstallationError( + `Remote host cannot install the OpenCode Runtime: ${probe.reason}`, + 'incompatible', + probe.reason + ) + } + if ( + probe.platform !== 'linux' || + (probe.architecture !== 'x64' && + probe.architecture !== 'arm64') + ) { + throw new RemoteRuntimeInstallationError( + 'Remote host platform or architecture is unsupported', + 'incompatible' + ) + } + + progress('verifying-bundle') + let bundle: LoadedBundle + try { + bundle = await this.#loadAndValidateBundle( + probe.architecture + ) + } catch (error) { + if ( + error instanceof RemoteRuntimeBundleResourcesUnavailableError + ) { + if (this.#loadVerificationMetadata === undefined) { + throw new RemoteRuntimeInstallationError( + 'This package does not include Runtime installation resources', + 'incompatible', + safeErrorDetail(error) + ) + } + return await this.#reuseInstalledBundle( + target, + lease, + probe, + signal, + progress + ) + } + throw error + } + assertBundleCapacity(bundle) + const identity = publicIdentity(bundle) + const destination = + `${OPENCODE_ROOT}/${digestDirectoryName(bundle.manifest.bundleDigest)}` + + progress('preparing-installation') + sftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + installationSftpLimits(bundle, this.#sftpLimits), + signal + ) + cleanupHomeDirectory = probe.canonicalHomeDirectory + await ensureManagedHierarchy(sftp, probe.uid, signal) + await installPrivateMetadata( + sftp, + RELEASE_KEYS_PATH, + '.release-keys', + bundle.releaseKeyRegistryBytes, + probe.uid, + cleanup, + signal + ) + await installPrivateMetadata( + sftp, + RUNTIME_LOCK_PATH, + '.remote-runtime-lock', + bundle.remoteRuntimeLockBytes, + probe.uid, + cleanup, + signal + ) + + const existingDestination = await pathMetadata( + sftp, + destination, + signal + ) + if (existingDestination !== undefined) { + await verifyRemoteBundle( + sftp, + destination, + bundle, + probe.uid, + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw new RemoteRuntimeInstallationError( + 'Existing OpenCode Runtime installation is corrupt', + 'corrupt', + safeErrorDetail(error) + ) + }) + await assertCurrentTarget( + this.#resolver, + lease, + target, + signal + ) + progress('activating-runtime') + await activateRuntime( + this.#activate, + lease, + bundle, + signal + ) + progress('complete') + return identity + } + + progress('uploading-bundle') + const staging = `${RUNTIME_STAGING_ROOT}/op-${randomUUID()}` + await sftp.mkdir(staging, signal) + trackCleanup(cleanup, { path: staging, type: 'directory' }) + await assertRemoteMetadata( + sftp, + staging, + 'directory', + probe.uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + await uploadAndVerifyBundle( + sftp, + staging, + bundle, + probe.uid, + cleanup, + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw new RemoteRuntimeInstallationError( + 'OpenCode Runtime bundle upload verification failed', + 'corrupt', + safeErrorDetail(error) + ) + }) + + await assertCurrentTarget( + this.#resolver, + lease, + target, + signal + ) + progress('publishing-bundle') + await sftp.rename(staging, destination, signal) + stagingPublished = true + await verifyRemoteBundle( + sftp, + destination, + bundle, + probe.uid, + signal + ).catch((error: unknown) => { + rethrowAbort(error) + throw new RemoteRuntimeInstallationError( + 'Published OpenCode Runtime bundle verification failed', + 'corrupt', + safeErrorDetail(error) + ) + }) + + progress('activating-runtime') + await activateRuntime( + this.#activate, + lease, + bundle, + signal + ) + progress('complete') + return identity + } catch (error) { + if ( + error instanceof RemoteRuntimeInstallationError || + isAbortError(error) + ) { + throw error + } + throw new RemoteRuntimeInstallationError( + 'Remote Runtime installation data is corrupt or unsafe', + 'corrupt', + safeErrorDetail(error) + ) + } finally { + let cleanupIncomplete = false + if (sftp && !stagingPublished) { + cleanupIncomplete = !(await cleanupOwnedStaging(sftp, cleanup)) + } + sftp?.close() + if ( + cleanupIncomplete && + lease?.isUsable() && + cleanupHomeDirectory + ) { + let cleanupSftp: StagedSftp | undefined + try { + cleanupSftp = await lease.openStagedSftp( + cleanupHomeDirectory, + this.#sftpLimits + ) + await cleanupOwnedStaging(cleanupSftp, cleanup) + } catch { + // Cancellation can make the lease unavailable. Cleanup remains + // bounded to this operation's temporary files and staging tree. + } finally { + cleanupSftp?.close() + } + } + lease?.release() + } + } + + async #loadAndValidateBundle( + architecture: AgentArchitecture + ): Promise { + let loaded: VerifiedRemoteRuntimeInstallationBundle + try { + loaded = await this.#loadVerifiedBundle(architecture) + const manifest = remoteRuntimeBundleManifestSchema.parse( + loaded.manifest + ) + if ( + manifest.runtimeId !== 'opencode' || + manifest.provider !== 'opencode' || + manifest.platform !== 'linux' || + manifest.architecture !== architecture + ) { + throw new Error( + 'Verified Runtime bundle does not match the OpenCode host target' + ) + } + if ( + loaded.manifestDigest !== + await digestRemoteRuntimeBundleManifest(manifest) + ) { + throw new Error('Verified Runtime manifest digest is invalid') + } + if ( + manifest.bundleDigest !== + await digestRemoteRuntimeBundleIdentity(manifest) + ) { + throw new Error('Verified Runtime bundle digest is invalid') + } + const releaseKeyRegistryBytes = canonicalJsonBytes( + loaded.canonicalReleaseKeyRegistryBytes, + agentReleaseKeyRegistrySchema, + 'Runtime release-key registry' + ) + const remoteRuntimeLockBytes = canonicalJsonBytes( + loaded.canonicalRemoteRuntimeLockBytes, + remoteRuntimeLockSchema, + 'Remote Runtime lock' + ) + return { + bundleDirectory: loaded.bundleDirectory, + manifest, + manifestDigest: loaded.manifestDigest, + releaseKeyRegistryBytes, + remoteRuntimeLockBytes + } + } catch (error) { + rethrowAbort(error) + if ( + error instanceof RemoteRuntimeBundleResourcesUnavailableError + ) { + throw error + } + throw new RemoteRuntimeInstallationError( + 'Verified OpenCode Runtime bundle is corrupt', + 'corrupt', + safeErrorDetail(error) + ) + } + } + + async #reuseInstalledBundle( + target: SshConnectionPoolTarget, + lease: SshConnectionLease, + probe: { + architecture: AgentArchitecture + canonicalHomeDirectory: string + uid: number + }, + signal: AbortSignal, + progress: (phase: RemoteRuntimeInstallationPhase) => void + ): Promise { + const loadMetadata = this.#loadVerificationMetadata + if (loadMetadata === undefined) { + throw new RemoteRuntimeInstallationError( + 'This package cannot verify an existing Host Runtime', + 'incompatible' + ) + } + let metadataSftp: StagedSftp | undefined + try { + const metadata = await loadMetadata() + const releaseKeyRegistryBytes = canonicalJsonBytes( + metadata.canonicalReleaseKeyRegistryBytes, + agentReleaseKeyRegistrySchema, + 'Runtime release-key registry' + ) + const remoteRuntimeLockBytes = canonicalJsonBytes( + metadata.canonicalRemoteRuntimeLockBytes, + remoteRuntimeLockSchema, + 'Remote Runtime lock' + ) + const releaseKeyRegistry = + agentReleaseKeyRegistrySchema.parse( + metadata.releaseKeyRegistry + ) + const runtimeLock = remoteRuntimeLockSchema.parse( + metadata.runtimeLock + ) + if ( + !canonicalJsonValueBytes(releaseKeyRegistry).equals( + releaseKeyRegistryBytes + ) || + !canonicalJsonValueBytes(runtimeLock).equals( + remoteRuntimeLockBytes + ) + ) { + throw new Error( + 'Runtime verification metadata values do not match their canonical bytes' + ) + } + + metadataSftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + { + maximumFileBytes: MAXIMUM_METADATA_BYTES, + maximumTotalBytes: MAXIMUM_METADATA_BYTES * 6, + maximumOperations: 48, + operationTimeoutMs: 15_000 + }, + signal + ) + const [ + remoteRegistryBytes, + remoteReleaseKeyBytes, + remoteRuntimeLockFileBytes + ] = await Promise.all([ + metadataSftp.readFile(RUNTIME_REGISTRY_PATH, signal), + metadataSftp.readFile(RELEASE_KEYS_PATH, signal), + metadataSftp.readFile(RUNTIME_LOCK_PATH, signal) + ]) + const remoteReleaseKeyRegistryBytes = canonicalJsonBytes( + remoteReleaseKeyBytes, + agentReleaseKeyRegistrySchema, + 'Installed Runtime release-key registry' + ) + const remoteReleaseKeyRegistry = + agentReleaseKeyRegistrySchema.parse( + parseJsonBytes( + remoteReleaseKeyRegistryBytes, + 'Installed Runtime release-key registry' + ) + ) + if (!remoteRuntimeLockFileBytes.equals(remoteRuntimeLockBytes)) { + throw new Error( + 'Installed Runtime lock does not match this GoodBuddy build' + ) + } + const remoteRegistry = parseRuntimeRegistryState( + parseJsonBytes( + remoteRegistryBytes, + 'Installed Runtime registry' + ) + ) + const current = remoteRegistry.current.find( + (entry) => + entry.runtimeId === 'opencode' && + entry.architecture === probe.architecture + ) + if ( + current === undefined || + current.runtimeVersion !== + runtimeLock.runtimes.opencode.version + ) { + throw new RemoteRuntimeInstallationError( + 'This package does not include Runtime installation resources and the Host has no matching current OpenCode Runtime', + 'incompatible' + ) + } + const destination = + `${OPENCODE_ROOT}/${digestDirectoryName(current.bundleDigest)}` + const manifestBytes = await metadataSftp.readFile( + `${destination}/manifest.json`, + signal + ) + const signatureFile = await metadataSftp.readFile( + `${destination}/manifest.sig`, + signal + ) + const signature = decodeDetachedSignature( + signatureFile, + 'Runtime detached signature' + ) + const manifest = await verifyRuntimeManifestSignature( + manifestBytes, + signature, + releaseKeyRegistry + ) + await verifyRuntimeManifestSignature( + manifestBytes, + signature, + remoteReleaseKeyRegistry + ) + assertRuntimeManifestMatchesLock( + manifest, + runtimeLock, + probe.architecture + ) + const manifestDigest = + await digestRemoteRuntimeBundleManifest(manifest) + if ( + manifest.bundleDigest !== current.bundleDigest || + manifestDigest !== current.manifestDigest || + manifest.acpCapabilitiesDigest !== + current.acpCapabilitiesDigest + ) { + throw new Error( + 'Installed Runtime manifest identity does not match its registry' + ) + } + const bundle: LoadedBundle = { + bundleDirectory: destination, + manifest, + manifestDigest, + releaseKeyRegistryBytes, + remoteRuntimeLockBytes + } + assertBundleCapacity(bundle) + await verifyRemoteFile( + metadataSftp, + RUNTIME_REGISTRY_PATH, + remoteRegistryBytes.byteLength, + sha256(remoteRegistryBytes), + probe.uid, + PRIVATE_FILE_MODE, + signal + ) + await verifyRemoteFile( + metadataSftp, + RELEASE_KEYS_PATH, + remoteReleaseKeyRegistryBytes.byteLength, + sha256(remoteReleaseKeyRegistryBytes), + probe.uid, + PRIVATE_FILE_MODE, + signal + ) + await verifyRemoteFile( + metadataSftp, + RUNTIME_LOCK_PATH, + remoteRuntimeLockBytes.byteLength, + sha256(remoteRuntimeLockBytes), + probe.uid, + PRIVATE_FILE_MODE, + signal + ) + progress('preparing-installation') + await verifyRemoteBundleFromMetadata( + metadataSftp, + destination, + bundle, + manifestBytes, + signatureFile, + probe.uid, + 'metadata', + signal + ) + metadataSftp.close() + metadataSftp = undefined + await assertCurrentTarget( + this.#resolver, + lease, + target, + signal + ) + progress('activating-runtime') + await activateRuntime( + this.#activate, + lease, + bundle, + signal + ) + progress('complete') + return publicIdentity(bundle) + } catch (error) { + rethrowAbort(error) + if (error instanceof RemoteRuntimeInstallationError) { + throw error + } + if (isMissingPathError(error)) { + throw new RemoteRuntimeInstallationError( + 'This package does not include Runtime installation resources and the Host has no matching current OpenCode Runtime', + 'incompatible', + safeErrorDetail(error) + ) + } + throw new RemoteRuntimeInstallationError( + 'The existing Host OpenCode Runtime could not be verified', + 'corrupt', + safeErrorDetail(error) + ) + } finally { + metadataSftp?.close() + } + } +} + +function targetIdentityKey(target: SshConnectionPoolTarget): string { + const hostKey = target.host.hostKey + return createHash('sha256') + .update(JSON.stringify([ + 'goodbuddy-remote-runtime-installation-target-v1', + target.host.id, + target.host.name, + target.host.hostname, + target.host.port, + target.host.username, + target.host.authentication, + target.hostRevision, + target.hostKeyGeneration, + hostKey?.algorithm ?? null, + hostKey?.publicKeyBase64 ?? null, + hostKey?.fingerprintSha256 ?? null, + hostKey?.generation ?? null + ])) + .digest('hex') +} + +function assertLeaseMatchesTarget( + lease: SshConnectionLease, + target: SshConnectionPoolTarget +): void { + if ( + lease.identity.hostId !== target.host.id || + lease.identity.hostRevision !== target.hostRevision || + lease.identity.hostKeyGeneration !== + target.hostKeyGeneration || + !/^[a-f0-9]{64}$/u.test( + lease.identity.authenticationIdentity + ) + ) { + throw new RemoteRuntimeInstallationError( + 'SSH lease identity changed during Runtime installation', + 'host-identity-changed' + ) + } +} + +async function assertCurrentTarget( + resolver: RemoteRuntimeInstallationTargetResolver, + lease: SshConnectionLease, + originalTarget: SshConnectionPoolTarget, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const currentTarget = await resolver.resolve( + originalTarget.host.id + ) + signal.throwIfAborted() + if ( + targetIdentityKey(currentTarget) !== + targetIdentityKey(originalTarget) + ) { + throw new RemoteRuntimeInstallationError( + 'SSH host identity changed before Runtime publication or activation', + 'host-identity-changed' + ) + } + assertLeaseMatchesTarget(lease, currentTarget) +} + +function publicIdentity( + bundle: LoadedBundle +): RemoteRuntimeInstallationIdentity { + return { + runtimeId: bundle.manifest.runtimeId, + runtimeVersion: bundle.manifest.runtimeVersion, + bundleDigest: bundle.manifest.bundleDigest, + manifestDigest: bundle.manifestDigest, + runtimeAdapterDigest: bundle.manifest.adapterDigest, + acpCapabilitiesDigest: + bundle.manifest.acpCapabilitiesDigest, + platform: bundle.manifest.platform, + architecture: bundle.manifest.architecture + } +} + +function digestDirectoryName(bundleDigest: string): string { + if (!sha256DigestSchema.safeParse(bundleDigest).success) { + throw new Error('Runtime bundle digest is invalid') + } + return bundleDigest.slice('sha256:'.length) +} + +function assertBundleCapacity(bundle: LoadedBundle): void { + const metadataBytes = + bundle.releaseKeyRegistryBytes.byteLength + + bundle.remoteRuntimeLockBytes.byteLength + const payloadBytes = bundle.manifest.files.reduce( + (sum, file) => sum + file.size, + 0 + ) + const total = payloadBytes + metadataBytes + if ( + bundle.manifest.files.length > MAXIMUM_INSTALLATION_FILES || + bundle.manifest.files.some( + (file) => file.size > MAXIMUM_RUNTIME_FILE_BYTES + ) || + bundle.releaseKeyRegistryBytes.byteLength > + MAXIMUM_METADATA_BYTES || + bundle.remoteRuntimeLockBytes.byteLength > + MAXIMUM_METADATA_BYTES || + !Number.isSafeInteger(total) || + total > MAXIMUM_INSTALLATION_BYTES + ) { + throw new RemoteRuntimeInstallationError( + 'OpenCode Runtime bundle exceeds installation safety limits', + 'capacity' + ) + } +} + +function installationSftpLimits( + bundle: LoadedBundle, + configured?: BoundedSftpLimits +): BoundedSftpLimits { + const largest = Math.max( + bundle.releaseKeyRegistryBytes.byteLength, + bundle.remoteRuntimeLockBytes.byteLength, + ...bundle.manifest.files.map((file) => file.size), + MAXIMUM_METADATA_BYTES + ) + const payload = + bundle.manifest.files.reduce( + (sum, file) => sum + file.size, + 0 + ) + + bundle.releaseKeyRegistryBytes.byteLength + + bundle.remoteRuntimeLockBytes.byteLength + + 2 * MAXIMUM_METADATA_BYTES + return { + maximumFileBytes: Math.max( + largest, + configured?.maximumFileBytes ?? 0 + ), + maximumTotalBytes: Math.max( + payload * 4, + configured?.maximumTotalBytes ?? 0 + ), + maximumOperations: + configured?.maximumOperations ?? 2_048, + operationTimeoutMs: configured?.operationTimeoutMs + } +} + +async function ensureManagedHierarchy( + sftp: StagedSftp, + uid: number, + signal: AbortSignal +): Promise { + for (const path of [ + '.goodbuddy', + RUNTIME_ROOT, + RUNTIME_STAGING_ROOT, + OPENCODE_ROOT + ]) { + const existing = await pathMetadata(sftp, path, signal) + if (existing === undefined) { + await sftp.mkdir(path, signal) + } else { + assertMetadata( + existing, + path, + 'directory', + uid, + undefined, + undefined + ) + if (existing.mode !== PRIVATE_DIRECTORY_MODE) { + await sftp.chmod(path, PRIVATE_DIRECTORY_MODE, signal) + } + } + await assertRemoteMetadata( + sftp, + path, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } +} + +async function installPrivateMetadata( + sftp: StagedSftp, + destination: string, + temporaryPrefix: string, + contents: Buffer, + uid: number, + cleanup: CleanupEntry[], + signal: AbortSignal +): Promise { + const existing = await pathMetadata(sftp, destination, signal) + if (existing !== undefined) { + assertMetadata( + existing, + destination, + 'file', + uid, + PRIVATE_FILE_MODE, + undefined + ) + if ( + existing.size === contents.byteLength && + (await sftp.readFile(destination, signal)).equals(contents) + ) { + return + } + } + const temporary = + `${RUNTIME_ROOT}/${temporaryPrefix}-${randomUUID()}.tmp` + trackCleanup(cleanup, { path: temporary, type: 'file' }) + await sftp.writeFile(temporary, contents, signal) + await sftp.chmod(temporary, PRIVATE_FILE_MODE, signal) + await verifyRemoteFile( + sftp, + temporary, + contents.byteLength, + sha256(contents), + uid, + PRIVATE_FILE_MODE, + signal + ) + await sftp.replaceFile(temporary, destination, signal) + removeCleanup(cleanup, temporary) + await verifyRemoteFile( + sftp, + destination, + contents.byteLength, + sha256(contents), + uid, + PRIVATE_FILE_MODE, + signal + ) +} + +async function uploadAndVerifyBundle( + sftp: StagedSftp, + staging: string, + bundle: LoadedBundle, + uid: number, + cleanup: CleanupEntry[], + signal: AbortSignal +): Promise { + const createdDirectories = new Set() + for (const file of bundle.manifest.files) { + const parts = file.path.split('/') + let relativeDirectory = '' + for (const part of parts.slice(0, -1)) { + relativeDirectory = relativeDirectory + ? `${relativeDirectory}/${part}` + : part + if (!createdDirectories.has(relativeDirectory)) { + const directory = `${staging}/${relativeDirectory}` + await sftp.mkdir(directory, signal) + trackCleanup(cleanup, { + path: directory, + type: 'directory' + }) + createdDirectories.add(relativeDirectory) + await assertRemoteMetadata( + sftp, + directory, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } + } + + const contents = await readFile( + join(bundle.bundleDirectory, ...file.path.split('/')) + ) + if ( + contents.byteLength !== file.size || + sha256(contents) !== file.sha256 + ) { + throw new Error( + `Runtime bundle changed after local verification: ${file.path}` + ) + } + const destination = `${staging}/${file.path}` + trackCleanup(cleanup, { path: destination, type: 'file' }) + await sftp.writeFile(destination, contents, signal) + const mode = file.mode === '0755' ? 0o755 : 0o644 + await sftp.chmod(destination, mode, signal) + await verifyRemoteFile( + sftp, + destination, + file.size, + file.sha256, + uid, + mode, + signal + ) + } + + const expectedManifestBytes = + canonicalRuntimeManifestBytes(bundle.manifest) + for (const metadataName of ['manifest.json', 'manifest.sig']) { + const contents = await readFile( + join(bundle.bundleDirectory, metadataName) + ) + if ( + contents.byteLength > MAXIMUM_METADATA_BYTES || + ( + metadataName === 'manifest.json' && + !contents.equals(expectedManifestBytes) + ) + ) { + throw new Error( + `Runtime bundle metadata changed after verification: ${metadataName}` + ) + } + const destination = `${staging}/${metadataName}` + trackCleanup(cleanup, { path: destination, type: 'file' }) + await sftp.writeFile(destination, contents, signal) + await sftp.chmod(destination, METADATA_MODE, signal) + await verifyRemoteFile( + sftp, + destination, + contents.byteLength, + sha256(contents), + uid, + METADATA_MODE, + signal + ) + } +} + +async function verifyRemoteBundle( + sftp: StagedSftp, + destination: string, + bundle: LoadedBundle, + uid: number, + signal: AbortSignal +): Promise { + const [manifestBytes, signatureBytes] = await Promise.all([ + readFile(join(bundle.bundleDirectory, 'manifest.json')), + readFile(join(bundle.bundleDirectory, 'manifest.sig')) + ]) + await verifyRemoteBundleFromMetadata( + sftp, + destination, + bundle, + manifestBytes, + signatureBytes, + uid, + 'contents', + signal + ) +} + +async function verifyRemoteBundleFromMetadata( + sftp: StagedSftp, + destination: string, + bundle: LoadedBundle, + manifestBytes: Buffer, + signatureBytes: Buffer, + uid: number, + verification: 'metadata' | 'contents', + signal: AbortSignal +): Promise { + await assertRemoteMetadata( + sftp, + destination, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + const checkedDirectories = new Set() + for (const file of bundle.manifest.files) { + const parts = file.path.split('/') + let directory = destination + for (const part of parts.slice(0, -1)) { + directory = `${directory}/${part}` + if (!checkedDirectories.has(directory)) { + checkedDirectories.add(directory) + await assertRemoteMetadata( + sftp, + directory, + 'directory', + uid, + PRIVATE_DIRECTORY_MODE, + undefined, + signal + ) + } + } + const path = `${destination}/${file.path}` + const mode = file.mode === '0755' ? 0o755 : 0o644 + if (verification === 'contents') { + await verifyRemoteFile( + sftp, + path, + file.size, + file.sha256, + uid, + mode, + signal + ) + } else { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + file.size, + signal + ) + } + } + for (const [metadataName, contents] of [ + ['manifest.json', manifestBytes], + ['manifest.sig', signatureBytes] + ] as const) { + if (contents.byteLength > MAXIMUM_METADATA_BYTES) { + throw new Error('Runtime metadata exceeds its safety limit') + } + const path = `${destination}/${metadataName}` + if (verification === 'contents') { + await verifyRemoteFile( + sftp, + path, + contents.byteLength, + sha256(contents), + uid, + METADATA_MODE, + signal + ) + } else { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + METADATA_MODE, + contents.byteLength, + signal + ) + } + } +} + +async function verifyRemoteFile( + sftp: StagedSftp, + path: string, + size: number, + digest: string, + uid: number, + mode: number, + signal: AbortSignal +): Promise { + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + size, + signal + ) + const contents = await sftp.readFile(path, signal) + if (contents.byteLength !== size || sha256(contents) !== digest) { + throw new Error(`Remote Runtime file readback mismatch: ${path}`) + } + await assertRemoteMetadata( + sftp, + path, + 'file', + uid, + mode, + size, + signal + ) +} + +async function assertRemoteMetadata( + sftp: StagedSftp, + path: string, + type: 'file' | 'directory', + uid: number, + mode: number, + size: number | undefined, + signal: AbortSignal +): Promise { + assertMetadata( + await sftp.stat(path, signal), + path, + type, + uid, + mode, + size + ) +} + +function assertMetadata( + metadata: SftpEntryMetadata, + path: string, + type: 'file' | 'directory', + uid: number, + mode: number | undefined, + size: number | undefined +): void { + if ( + metadata.type !== type || + metadata.uid !== uid || + (mode !== undefined && metadata.mode !== mode) || + (size !== undefined && metadata.size !== size) + ) { + throw new Error(`Remote Runtime metadata mismatch: ${path}`) + } +} + +async function pathMetadata( + sftp: StagedSftp, + path: string, + signal?: AbortSignal +): Promise { + try { + return await sftp.lstat(path, signal) + } catch (error) { + if (isMissingPathError(error)) { + return undefined + } + throw error + } +} + +async function activateRuntime( + activate: RemoteRuntimeActivator, + lease: SshConnectionLease, + bundle: LoadedBundle, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + try { + await activate( + lease, + bundle.manifest.runtimeId, + bundle.manifest.bundleDigest, + bundle.manifest.architecture, + signal + ) + } catch (error) { + rethrowAbort(error) + throw new RemoteRuntimeInstallationError( + 'OpenCode Runtime activation failed', + 'activation', + safeErrorDetail(error) + ) + } +} + +function canonicalJsonBytes( + input: Uint8Array | undefined, + schema: { parse(value: unknown): T }, + label: string +): Buffer { + if (input === undefined) { + throw new Error(`${label} bytes are missing`) + } + const bytes = Buffer.from(input) + if ( + bytes.byteLength === 0 || + bytes.byteLength > MAXIMUM_METADATA_BYTES + ) { + throw new Error(`${label} exceeds its safety limit`) + } + let parsedJson: unknown + try { + parsedJson = JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error(`${label} is invalid JSON`, { cause: error }) + } + const parsed = schema.parse(parsedJson) + const canonical = Buffer.from( + `${JSON.stringify(parsed, null, 2)}\n`, + 'utf8' + ) + if (!canonical.equals(bytes)) { + throw new Error(`${label} bytes are not canonical`) + } + return canonical +} + +function canonicalJsonValueBytes(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +function parseJsonBytes( + value: Buffer, + description: string +): unknown { + try { + return JSON.parse(value.toString('utf8')) as unknown + } catch (error) { + throw new Error(`${description} is not valid JSON`, { + cause: error + }) + } +} + +function decodeDetachedSignature( + value: Buffer, + description: string +): Buffer { + const text = value.toString('ascii') + if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) { + throw new Error(`${description} is not canonical Base64`) + } + const signature = Buffer.from(text.slice(0, -1), 'base64') + if (signature.byteLength !== 64) { + throw new Error(`${description} is not an Ed25519 signature`) + } + return signature +} + +function trackCleanup( + cleanup: CleanupEntry[], + entry: CleanupEntry +): void { + if (cleanup.length >= MAXIMUM_CLEANUP_ENTRIES) { + throw new RemoteRuntimeInstallationError( + 'Runtime cleanup inventory exceeds its safety limit', + 'capacity' + ) + } + cleanup.push(entry) +} + +function removeCleanup( + cleanup: CleanupEntry[], + path: string +): void { + const index = cleanup.findIndex((entry) => entry.path === path) + if (index >= 0) { + cleanup.splice(index, 1) + } +} + +async function cleanupOwnedStaging( + sftp: StagedSftp, + entries: readonly CleanupEntry[] +): Promise { + let complete = true + for (const entry of [...entries] + .slice(0, MAXIMUM_CLEANUP_ENTRIES) + .reverse()) { + try { + if (entry.type === 'file') { + await sftp.unlink(entry.path) + } else { + await sftp.rmdir(entry.path) + } + } catch (error) { + if (!isMissingPathError(error)) { + complete = false + // Cleanup never widens beyond operation-owned temporary paths. + } + } + } + return complete +} + +function waitForOperation( + promise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return promise + } + if (signal.aborted) { + return Promise.reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + return new Promise((resolve, reject) => { + const abort = (): void => { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + signal.addEventListener('abort', abort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', abort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} + +async function settleBoundedly( + promises: readonly Promise[], + timeoutMs: number +): Promise { + if (promises.length === 0) { + return + } + let timeout: ReturnType | undefined + try { + await Promise.race([ + Promise.allSettled(promises), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function emitOne( + callback: + | ((phase: RemoteRuntimeInstallationPhase) => void) + | undefined, + phase: RemoteRuntimeInstallationPhase +): void { + try { + callback?.(phase) + } catch { + // Progress observers cannot alter installation outcomes. + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + ( + (error as { code?: unknown }).code === 2 || + (error as { code?: unknown }).code === 'ENOENT' + ) + ) +} + +function isAbortError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + (error as { name?: unknown }).name === 'AbortError' + ) +} + +function rethrowAbort(error: unknown): void { + if (isAbortError(error)) { + throw error + } +} + +function safeErrorDetail(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message + } + } + return undefined +} + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} diff --git a/src/main/remote-agent/remote-runtime-resource-loader.test.ts b/src/main/remote-agent/remote-runtime-resource-loader.test.ts new file mode 100644 index 00000000..1a6c5011 --- /dev/null +++ b/src/main/remote-agent/remote-runtime-resource-loader.test.ts @@ -0,0 +1,384 @@ +import { createHash } from 'node:crypto' +import { + mkdir, + mkdtemp, + rm, + symlink, + 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 type { BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources' +import { + createRemoteRuntimeResourceLoader, + loadRemoteRuntimeVerificationMetadata, + loadVerifiedRemoteRuntimeResourceBundle +} from './remote-runtime-resource-loader' +import { + RemoteRuntimeBundleResourcesUnavailableError +} from './remote-runtime-installation-manager' + +const temporaryDirectories: string[] = [] +const registry: AgentReleaseKeyRegistry = { + formatVersion: 1, + keys: [], + revocations: [] +} +const runtimeLock: RemoteRuntimeLock = { + formatVersion: 1, + runtimes: { + opencode: { + version: '1.18.9', + provider: 'opencode', + entrypoint: 'bin/opencode', + entrypointIdentity: 'opencode-acp', + argvPrefix: ['acp'], + allowedEnvironmentNames: [ + '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==' + }, + arm64: { + package: 'opencode-linux-arm64', + integrity: + 'sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A==' + } + } + } + } +} + +afterEach(async () => { + 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 () => { + 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({ + releaseKeyRegistry: registry, + runtimeLock, + canonicalReleaseKeyRegistryBytes: canonical(registry), + canonicalRemoteRuntimeLockBytes: canonical(runtimeLock) + }) + }) + + 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([ + { + name: 'malformed', + bytes: Buffer.from('not json\n'), + error: 'invalid JSON' + }, + { + name: 'noncanonical', + bytes: Buffer.from(JSON.stringify(registry)), + error: 'not canonical' + }, + { + name: 'oversize', + bytes: Buffer.alloc(1024 * 1024 + 1, 0x20), + error: 'exceeds its safety limit' + } + ])('rejects $name metadata', async ({ bytes, error }) => { + const fixture = await createFixture() + await writeFile(fixture.paths.keyRegistryPath, bytes) + + await expect(loadWithTestVerifier(fixture.paths, vi.fn())) + .rejects.toThrow(error) + }) +}) + +async function createFixture(options: { + digestDirectories?: number +} = {}): Promise<{ + root: string + paths: BundledRemoteRuntimeResourcePaths + bundleDirectory: string + manifest: RemoteRuntimeBundleManifest +}> { + const root = await mkdtemp(join(tmpdir(), 'runtime-loader-')) + temporaryDirectories.push(root) + const paths: BundledRemoteRuntimeResourcePaths = { + keyRegistryPath: join(root, 'agent-release-keys.json'), + runtimeLockPath: join(root, 'remote-runtime-lock.json'), + runtimeRoots: { + x64: join(root, 'linux-x64', 'opencode'), + arm64: join(root, 'linux-arm64', 'opencode') + } + } + await mkdir(paths.runtimeRoots.x64, { recursive: true }) + await mkdir(paths.runtimeRoots.arm64, { recursive: true }) + await writeFile(paths.keyRegistryPath, canonical(registry)) + await writeFile(paths.runtimeLockPath, canonical(runtimeLock)) + const manifest = await createManifest('x64') + const digest = manifest.bundleDigest.slice('sha256:'.length) + const directoryCount = options.digestDirectories ?? 1 + for (let index = 0; index < directoryCount; index += 1) { + await mkdir( + join( + paths.runtimeRoots.x64, + index === 0 ? digest : 'e'.repeat(64) + ) + ) + } + return { + root, + paths, + bundleDirectory: join(paths.runtimeRoots.x64, digest), + manifest + } +} + +async function createManifest( + architecture: AgentArchitecture +): Promise { + const executable = Buffer.from('opencode') + const license = Buffer.from('license') + const initial: RemoteRuntimeBundleManifest = { + formatVersion: 2, + product: 'GoodBuddy', + runtimeId: 'opencode', + runtimeVersion: '1.18.9', + provider: 'opencode', + platform: 'linux', + architecture, + signingKeyId: 'test-key', + bundleDigest: `sha256:${'0'.repeat(64)}`, + adapterDigest: `sha256:${'1'.repeat(64)}`, + sourcePackage: { + name: + runtimeLock.runtimes.opencode.targets[architecture].package, + integrity: + runtimeLock.runtimes.opencode.targets[architecture].integrity + }, + entrypoint: { + identity: 'opencode-acp', + path: 'bin/opencode', + sha256: sha256(executable), + argvPrefix: ['acp'] + }, + files: [ + { + path: 'bin/opencode', + size: executable.byteLength, + sha256: sha256(executable), + mode: '0755' + }, + { + path: 'licenses/LICENSE', + size: license.byteLength, + sha256: sha256(license), + mode: '0644' + } + ], + licenses: [ + { + package: 'opencode', + version: '1.18.9', + spdx: 'MIT', + path: 'licenses/LICENSE' + } + ], + allowedEnvironmentNames: [ + 'HOME', + 'LANG', + 'LC_ALL', + 'PATH', + 'TMPDIR', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME' + ], + protocol: { major: 1, minor: 0 }, + acpCapabilitiesDigest: `sha256:${'3'.repeat(64)}`, + limits: { + maximumPromptRuntimeMilliseconds: 60_000, + maximumPromptInputBytes: 1024 * 1024, + maximumPromptOutputBytes: 1024 * 1024 + } + } + return { + ...initial, + bundleDigest: await digestRemoteRuntimeBundleIdentity(initial) + } +} + +function loadWithTestVerifier( + paths: BundledRemoteRuntimeResourcePaths, + verifyRuntimeBundle: NonNullable< + Parameters< + typeof loadVerifiedRemoteRuntimeResourceBundle + >[2] + >['verifyRuntimeBundle'] +) { + return loadVerifiedRemoteRuntimeResourceBundle(paths, 'x64', { + verificationEnvironment: 'test', + verifyRuntimeBundle + }) +} + +function canonical(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') +} + +function sha256(contents: Uint8Array): string { + return createHash('sha256').update(contents).digest('hex') +} diff --git a/src/main/remote-agent/remote-runtime-resource-loader.ts b/src/main/remote-agent/remote-runtime-resource-loader.ts new file mode 100644 index 00000000..7dd821c4 --- /dev/null +++ b/src/main/remote-agent/remote-runtime-resource-loader.ts @@ -0,0 +1,314 @@ +import { constants } from 'node:fs' +import { + lstat, + open, + readdir +} from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { + verifyRuntimeBundle, + type VerifiedRuntimeBundle, + type VerifyRuntimeBundleOptions +} from '../../shared/node/runtime-bundle-verifier' +import { + agentReleaseKeyRegistrySchema, + type AgentArchitecture, + type AgentReleaseKeyRegistry +} from '../../shared/agent-installation-contracts' +import { + digestRemoteRuntimeBundleManifest, + remoteRuntimeBundleManifestSchema, + remoteRuntimeLockSchema, + type RemoteRuntimeLock +} from '../../shared/remote-runtime-launch-contracts' +import { + RemoteRuntimeBundleResourcesUnavailableError, + type RemoteRuntimeInstallationBundleLoader, + type VerifiedRemoteRuntimeInstallationBundle +} from './remote-runtime-installation-manager' +import { + getBundledRemoteRuntimeRoot, + type BundledRemoteRuntimeResourcePaths +} from './bundled-remote-runtime-resources' + +const MAXIMUM_METADATA_BYTES = 1024 * 1024 +const digestDirectoryPattern = /^[a-f0-9]{64}$/u + +type RuntimeBundleVerifier = ( + bundleDirectory: string, + options: VerifyRuntimeBundleOptions +) => Promise + +export type RemoteRuntimeResourceLoaderOptions = { + verificationEnvironment?: 'production' | 'test' + verifyRuntimeBundle?: RuntimeBundleVerifier +} + +type CanonicalMetadata = { + bytes: Buffer + value: T +} + +export type RemoteRuntimeVerificationMetadata = { + releaseKeyRegistry: AgentReleaseKeyRegistry + runtimeLock: RemoteRuntimeLock + canonicalReleaseKeyRegistryBytes: Buffer + canonicalRemoteRuntimeLockBytes: Buffer +} + +export function createRemoteRuntimeResourceLoader( + paths: BundledRemoteRuntimeResourcePaths, + options: RemoteRuntimeResourceLoaderOptions = {} +): RemoteRuntimeInstallationBundleLoader { + assertSafeVerificationInjection(options) + return (architecture) => + loadVerifiedRemoteRuntimeResourceBundle( + paths, + architecture, + options + ) +} + +export async function loadVerifiedRemoteRuntimeResourceBundle( + paths: BundledRemoteRuntimeResourcePaths, + architecture: AgentArchitecture, + options: RemoteRuntimeResourceLoaderOptions = {} +): Promise { + assertSafeVerificationInjection(options) + const verificationEnvironment = + options.verificationEnvironment ?? 'production' + const metadata = + await loadRemoteRuntimeVerificationMetadata(paths) + const bundleDirectory = await resolveSingleDigestDirectory( + getBundledRemoteRuntimeRoot(paths, architecture) + ) + const verifier = options.verifyRuntimeBundle ?? verifyRuntimeBundle + const verified = await verifier(bundleDirectory, { + architecture, + releaseKeyRegistry: metadata.releaseKeyRegistry, + runtimeLock: metadata.runtimeLock, + verificationEnvironment + }) + + return validateVerifiedBundle( + verified, + bundleDirectory, + architecture, + metadata.canonicalReleaseKeyRegistryBytes, + metadata.canonicalRemoteRuntimeLockBytes + ) +} + +export async function loadRemoteRuntimeVerificationMetadata( + paths: BundledRemoteRuntimeResourcePaths +): Promise { + const [keyRegistry, runtimeLock] = await Promise.all([ + readCanonicalMetadata( + paths.keyRegistryPath, + agentReleaseKeyRegistrySchema, + 'Runtime release-key registry' + ), + readCanonicalMetadata( + paths.runtimeLockPath, + remoteRuntimeLockSchema, + 'Remote Runtime lock' + ) + ]) + return { + releaseKeyRegistry: keyRegistry.value, + runtimeLock: runtimeLock.value, + canonicalReleaseKeyRegistryBytes: keyRegistry.bytes, + canonicalRemoteRuntimeLockBytes: runtimeLock.bytes + } +} + +function assertSafeVerificationInjection( + options: RemoteRuntimeResourceLoaderOptions +): void { + if ( + options.verifyRuntimeBundle !== undefined && + options.verificationEnvironment !== 'test' + ) { + throw new Error( + 'Injected Runtime bundle verification is allowed only in test verification' + ) + } +} + +async function readCanonicalMetadata( + filePathInput: string, + schema: { parse(value: unknown): T }, + label: string +): Promise> { + const filePath = resolve(filePathInput) + const pathStat = await lstat(filePath) + if (!pathStat.isFile() || pathStat.isSymbolicLink()) { + throw new Error(`${label} is not a regular file`) + } + if ( + pathStat.size === 0 || + pathStat.size > MAXIMUM_METADATA_BYTES + ) { + throw new Error(`${label} exceeds its safety limit`) + } + + const handle = await open( + filePath, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ) + let bytes: Buffer + try { + const openedStat = await handle.stat() + if ( + !openedStat.isFile() || + openedStat.dev !== pathStat.dev || + openedStat.ino !== pathStat.ino || + openedStat.size === 0 || + openedStat.size > MAXIMUM_METADATA_BYTES + ) { + throw new Error(`${label} changed while it was being opened`) + } + const bounded = Buffer.alloc(MAXIMUM_METADATA_BYTES + 1) + let offset = 0 + while (offset < bounded.byteLength) { + const { bytesRead } = await handle.read( + bounded, + offset, + bounded.byteLength - offset, + offset + ) + if (bytesRead === 0) { + break + } + offset += bytesRead + } + bytes = bounded.subarray(0, offset) + } finally { + await handle.close() + } + if ( + bytes.byteLength === 0 || + bytes.byteLength > MAXIMUM_METADATA_BYTES + ) { + throw new Error(`${label} exceeds its safety limit`) + } + + let parsedJson: unknown + try { + parsedJson = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) + } catch (error) { + throw new Error(`${label} is invalid JSON`, { cause: error }) + } + let value: T + try { + value = schema.parse(parsedJson) + } catch (error) { + throw new Error(`${label} contract is invalid`, { cause: error }) + } + const canonicalBytes = Buffer.from( + `${JSON.stringify(value, null, 2)}\n`, + 'utf8' + ) + if (!canonicalBytes.equals(bytes)) { + throw new Error(`${label} is not canonical`) + } + return { bytes: canonicalBytes, value } +} + +async function resolveSingleDigestDirectory( + runtimeRootInput: string +): Promise { + const runtimeRoot = resolve(runtimeRootInput) + let rootStat + try { + rootStat = await lstat(runtimeRoot) + } catch (error) { + if (isMissingPathError(error)) { + throw new RemoteRuntimeBundleResourcesUnavailableError(error) + } + throw error + } + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error('OpenCode Runtime resource root is not a real directory') + } + const entries = await readdir(runtimeRoot, { withFileTypes: true }) + if (entries.length !== 1) { + throw new Error( + 'OpenCode Runtime resource root must contain exactly one digest directory' + ) + } + const entry = entries[0] + if (entry === undefined || !digestDirectoryPattern.test(entry.name)) { + throw new Error( + 'OpenCode Runtime resource directory name is not a SHA-256 digest' + ) + } + const bundleDirectory = join(runtimeRoot, entry.name) + const bundleStat = await lstat(bundleDirectory) + if ( + entry.isSymbolicLink() || + !bundleStat.isDirectory() || + bundleStat.isSymbolicLink() + ) { + throw new Error( + 'OpenCode Runtime digest entry is not a real directory' + ) + } + return bundleDirectory +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ) +} + +async function validateVerifiedBundle( + verified: VerifiedRuntimeBundle, + bundleDirectory: string, + architecture: AgentArchitecture, + keyRegistryBytes: Buffer, + runtimeLockBytes: Buffer +): Promise { + const manifest = remoteRuntimeBundleManifestSchema.parse( + verified.manifest + ) + if ( + verified.bundleDirectory !== bundleDirectory || + manifest.runtimeId !== 'opencode' || + manifest.provider !== 'opencode' || + manifest.platform !== 'linux' || + manifest.architecture !== architecture + ) { + throw new Error( + 'Verified Runtime bundle does not match the requested OpenCode target' + ) + } + if ( + manifest.bundleDigest !== + `sha256:${basename(bundleDirectory)}` + ) { + throw new Error( + 'Runtime bundle digest does not match its resource directory' + ) + } + if ( + verified.manifestDigest !== + await digestRemoteRuntimeBundleManifest(manifest) + ) { + throw new Error('Verified Runtime manifest digest is invalid') + } + + return { + bundleDirectory, + manifest, + manifestDigest: verified.manifestDigest, + canonicalReleaseKeyRegistryBytes: keyRegistryBytes, + canonicalRemoteRuntimeLockBytes: runtimeLockBytes + } +} diff --git a/src/main/ssh/bounded-directory-sftp.test.ts b/src/main/ssh/bounded-directory-sftp.test.ts new file mode 100644 index 00000000..c34348fc --- /dev/null +++ b/src/main/ssh/bounded-directory-sftp.test.ts @@ -0,0 +1,379 @@ +import { EventEmitter } from 'node:events' +import type { FileEntryWithStats } from 'ssh2' +import { describe, expect, it, vi } from 'vitest' +import { + listBoundedSftpDirectories, + type DirectorySftp, + type DirectorySftpOpener +} from './bounded-directory-sftp' + +type TestSftp = DirectorySftp + +function entry( + filename: string, + type: 'directory' | 'file' | 'symbolic-link' = 'directory' +): FileEntryWithStats { + return { + filename, + longname: filename, + attrs: { + size: 0, + uid: 1, + gid: 1, + mode: 0o755, + atime: 0, + mtime: 0, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symbolic-link', + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false + } + } +} + +function createSftp(options: { + home?: string + canonicalPath?: string + batches?: FileEntryWithStats[][] +} = {}): TestSftp { + const emitter = new EventEmitter() + const batches = [...(options.batches ?? [[]])] + const sftp = Object.assign(emitter, { + realpath: vi.fn( + ( + path: string, + callback: ( + error: Error | undefined, + result: string + ) => void + ) => { + callback( + undefined, + path === '.' + ? (options.home ?? '/home/builder') + : (options.canonicalPath ?? path) + ) + } + ), + opendir: vi.fn( + ( + _path: string, + callback: ( + error: Error | undefined, + handle: Buffer + ) => void + ) => callback(undefined, Buffer.from('directory-handle')) + ), + readdir: vi.fn( + ( + _handle: Buffer, + callback: ( + error: Error | undefined, + entries: FileEntryWithStats[] + ) => void + ) => callback(undefined, batches.shift() ?? []) + ), + close: vi.fn( + ( + _handle: Buffer, + callback: (error?: Error | null) => void + ) => callback() + ), + end: vi.fn() + }) + return sftp as unknown as TestSftp +} + +function opener(sftp: TestSftp): DirectorySftpOpener { + return (callback) => callback(undefined, sftp) +} + +describe('bounded directory SFTP', () => { + it('resolves home and requested paths, filters entries, and closes every resource', async () => { + const sftp = createSftp({ + home: '/home/builder', + canonicalPath: '/srv/projects', + batches: [ + [ + entry('zeta'), + entry('alpha'), + entry('file.txt', 'file'), + entry('link', 'symbolic-link'), + entry('.'), + entry('..'), + entry('nested/name'), + entry('back\\slash'), + entry('control\u0001'), + entry('\ufffd'), + entry('x'.repeat(256)), + entry('alpha') + ], + [] + ] + }) + + await expect( + listBoundedSftpDirectories( + opener(sftp), + '/srv/requested' + ) + ).resolves.toEqual({ + path: '/srv/projects', + homeDirectory: '/home/builder', + parentPath: '/srv', + entries: [ + { name: 'alpha', path: '/srv/projects/alpha' }, + { name: 'zeta', path: '/srv/projects/zeta' } + ], + truncated: false + }) + expect(sftp.realpath).toHaveBeenNthCalledWith( + 1, + '.', + expect.any(Function) + ) + expect(sftp.realpath).toHaveBeenNthCalledWith( + 2, + '/srv/requested', + expect.any(Function) + ) + expect(sftp.opendir).toHaveBeenCalledWith( + '/srv/projects', + expect.any(Function) + ) + expect(sftp.readdir).toHaveBeenCalledTimes(2) + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('uses canonical home by default and reports root without a parent', async () => { + const sftp = createSftp({ + home: '/', + canonicalPath: '/', + batches: [[entry('tmp')], []] + }) + + await expect( + listBoundedSftpDirectories(opener(sftp)) + ).resolves.toMatchObject({ + path: '/', + homeDirectory: '/', + parentPath: null, + entries: [{ name: 'tmp', path: '/tmp' }] + }) + expect(sftp.realpath).toHaveBeenNthCalledWith( + 2, + '/', + expect.any(Function) + ) + }) + + it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => { + const sftp = createSftp({ + batches: [[entry('\u{10000}'), entry('\ue000')], []] + }) + + const result = await listBoundedSftpDirectories(opener(sftp)) + + expect(result.entries.map(({ name }) => name)).toEqual([ + '\ue000', + '\u{10000}' + ]) + }) + + it('returns at most 500 directories after scanning at most 2000 entries', async () => { + const entries = Array.from({ length: 2_100 }, (_, index) => + entry(`dir-${index.toString().padStart(4, '0')}`) + ) + const sftp = createSftp({ batches: [entries] }) + + const result = await listBoundedSftpDirectories(opener(sftp)) + + expect(result.entries).toHaveLength(500) + expect(result.entries[0]?.name).toBe('dir-0000') + expect(result.entries[499]?.name).toBe('dir-0499') + expect(result.truncated).toBe(true) + expect(sftp.readdir).toHaveBeenCalledOnce() + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('stops after 64 handle-based READDIR callbacks', async () => { + const sftp = createSftp({ + batches: Array.from({ length: 65 }, (_, index) => [ + entry(`dir-${index.toString().padStart(2, '0')}`) + ]) + }) + + const result = await listBoundedSftpDirectories(opener(sftp)) + + expect(sftp.readdir).toHaveBeenCalledTimes(64) + expect(result.entries).toHaveLength(64) + expect(result.truncated).toBe(true) + expect(sftp.readdir).toHaveBeenCalledWith( + Buffer.from('directory-handle'), + expect.any(Function) + ) + }) + + it('times out the whole operation and ends a pending channel', async () => { + vi.useFakeTimers() + try { + const sftp = createSftp() + sftp.realpath = vi.fn() + const pending = listBoundedSftpDirectories(opener(sftp)) + const expectation = expect(pending).rejects.toThrow( + '目录浏览超时' + ) + + await vi.advanceTimersByTimeAsync(30_000) + + await expectation + expect(sftp.close).not.toHaveBeenCalled() + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('cancels a pending read and ignores its late callback', async () => { + const sftp = createSftp() + let callback: + | (( + error: Error | undefined, + entries: FileEntryWithStats[] + ) => void) + | undefined + sftp.readdir = vi.fn((_handle, next) => { + callback = next + }) + const controller = new AbortController() + const pending = listBoundedSftpDirectories( + opener(sftp), + undefined, + controller.signal + ) + controller.abort() + + await expect(pending).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + + callback?.(undefined, [entry('late')]) + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('closes the handle and channel when READDIR fails', async () => { + const sftp = createSftp() + sftp.readdir = vi.fn((_handle, callback) => { + callback(new Error('READDIR failed'), []) + }) + + await expect( + listBoundedSftpDirectories(opener(sftp)) + ).rejects.toThrow('READDIR failed') + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('treats SFTP status code 1 as the end of the directory', async () => { + const sftp = createSftp({ + batches: [[entry('project')]] + }) + let reads = 0 + sftp.readdir = vi.fn((_handle, callback) => { + reads += 1 + if (reads === 1) { + callback(undefined, [entry('project')]) + return + } + callback( + Object.assign(new Error('End of file'), { code: 1 }), + [] + ) + }) + + await expect( + listBoundedSftpDirectories(opener(sftp)) + ).resolves.toMatchObject({ + entries: [{ + name: 'project', + path: '/home/builder/project' + }], + truncated: false + }) + expect(sftp.readdir).toHaveBeenCalledTimes(2) + expect(sftp.close).toHaveBeenCalledOnce() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('closes a handle returned after cancellation', async () => { + const sftp = createSftp() + let callback: + | ((error: Error | undefined, handle: Buffer) => void) + | undefined + sftp.opendir = vi.fn((_path, next) => { + callback = next + }) + const controller = new AbortController() + const pending = listBoundedSftpDirectories( + opener(sftp), + undefined, + controller.signal + ) + controller.abort() + await expect(pending).rejects.toMatchObject({ + name: 'AbortError' + }) + + callback?.(undefined, Buffer.from('late-handle')) + expect(sftp.close).toHaveBeenCalledWith( + Buffer.from('late-handle'), + expect.any(Function) + ) + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('rejects non-canonical inputs and provider paths without opening directories', async () => { + const sftp = createSftp({ canonicalPath: '/bad/../path' }) + + expect(() => + listBoundedSftpDirectories(opener(sftp), 'relative') + ).toThrow('目录路径无效') + await expect( + listBoundedSftpDirectories(opener(sftp), '/requested') + ).rejects.toThrow('目录路径无效') + expect(sftp.opendir).not.toHaveBeenCalled() + expect(sftp.end).toHaveBeenCalledOnce() + }) + + it('ends an SFTP channel delivered after an open timeout', async () => { + vi.useFakeTimers() + try { + let callback: + | Parameters[0] + | undefined + const pending = listBoundedSftpDirectories((next) => { + callback = next + }) + const expectation = expect(pending).rejects.toThrow( + '目录浏览超时' + ) + await vi.advanceTimersByTimeAsync(30_000) + await expectation + + const late = createSftp() + callback?.(undefined, late) + expect(late.end).toHaveBeenCalledOnce() + expect(late.realpath).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/bounded-directory-sftp.ts b/src/main/ssh/bounded-directory-sftp.ts new file mode 100644 index 00000000..16f08f25 --- /dev/null +++ b/src/main/ssh/bounded-directory-sftp.ts @@ -0,0 +1,383 @@ +import type { + FileEntryWithStats, + SFTPWrapper +} from 'ssh2' +import type { + SshHostDirectoryBrowseResult +} from '../../shared/ssh-host-contracts' + +const MAXIMUM_RETURNED_DIRECTORIES = 500 +const MAXIMUM_SCANNED_ENTRIES = 2_000 +const MAXIMUM_READDIR_CALLBACKS = 64 +const OPERATION_TIMEOUT_MS = 30_000 +const MAXIMUM_REMOTE_PATH_BYTES = 4_096 +const MAXIMUM_NAME_BYTES = 255 + +export type DirectorySftp = Pick< + SFTPWrapper, + | 'realpath' + | 'opendir' + | 'readdir' + | 'close' + | 'end' + | 'on' + | 'removeListener' +> + +export type DirectorySftpOpener = ( + callback: ( + error: Error | undefined, + sftp?: DirectorySftp + ) => void +) => void + +function abortError(signal?: AbortSignal): unknown { + return ( + signal?.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) +} + +function isCanonicalAbsolutePosixPath(value: unknown): value is string { + return ( + typeof value === 'string' && + Buffer.byteLength(value, 'utf8') <= MAXIMUM_REMOTE_PATH_BYTES && + value.startsWith('/') && + (value === '/' || !value.endsWith('/')) && + !value.includes('//') && + !value.includes('\\') && + !value + .split('/') + .some((part) => part === '.' || part === '..') && + !/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value) + ) +} + +function validateRequestedPath(path: string | undefined): void { + if (path !== undefined && !isCanonicalAbsolutePosixPath(path)) { + throw new Error('SSH 目录路径无效') + } +} + +function parentPath(path: string): string | null { + if (path === '/') { + return null + } + const separator = path.lastIndexOf('/') + return separator === 0 ? '/' : path.slice(0, separator) +} + +function validDirectoryName(entry: FileEntryWithStats): boolean { + const name = entry?.filename + if ( + typeof name !== 'string' || + name === '.' || + name === '..' || + name.length === 0 || + Buffer.byteLength(name, 'utf8') > MAXIMUM_NAME_BYTES || + name.includes('/') || + name.includes('\\') || + /[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(name) + ) { + return false + } + try { + return ( + typeof entry.attrs?.isDirectory === 'function' && + entry.attrs.isDirectory() === true && + typeof entry.attrs.isSymbolicLink === 'function' && + entry.attrs.isSymbolicLink() === false + ) + } catch { + return false + } +} + +function joinPath(parent: string, name: string): string { + return parent === '/' ? `/${name}` : `${parent}/${name}` +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare( + Buffer.from(left, 'utf8'), + Buffer.from(right, 'utf8') + ) +} + +function isSftpEndOfDirectory(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + error.code === 1 + ) +} + +export function listBoundedSftpDirectories( + openSftp: DirectorySftpOpener, + requestedPath?: string, + signal?: AbortSignal +): Promise { + validateRequestedPath(requestedPath) + if (signal?.aborted) { + return Promise.reject(abortError(signal)) + } + + return new Promise((resolve, reject) => { + let sftp: DirectorySftp | undefined + let handle: Buffer | undefined + let handleCloseRequested = false + let channelEnded = false + let settled = false + let scannedEntries = 0 + let readdirCallbacks = 0 + let truncated = false + let homeDirectory: string | undefined + let listingPath: string | undefined + const directories = new Set() + + const endChannel = (): void => { + if (channelEnded || !sftp) { + return + } + channelEnded = true + sftp.removeListener('error', onChannelError) + sftp.end() + } + + const closeHandleBestEffort = ( + candidate: Buffer | undefined = handle, + afterClose?: (error?: Error | null) => void + ): void => { + if (!candidate || !sftp) { + afterClose?.() + return + } + if (candidate === handle) { + if (handleCloseRequested) { + afterClose?.() + return + } + handleCloseRequested = true + } + try { + sftp.close(candidate, (error) => { + afterClose?.(error) + }) + } catch (error) { + afterClose?.( + error instanceof Error + ? error + : new Error('SFTP 目录句柄关闭失败') + ) + } + } + + const finishError = (error: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + closeHandleBestEffort() + endChannel() + reject(error) + } + + const finishSuccess = (): void => { + if (settled || !listingPath) { + return + } + const sorted = [...directories].sort(compareUtf8) + const result: SshHostDirectoryBrowseResult = { + path: listingPath, + homeDirectory: homeDirectory!, + parentPath: parentPath(listingPath), + entries: sorted + .slice(0, MAXIMUM_RETURNED_DIRECTORIES) + .map((name) => ({ + name, + path: joinPath(listingPath!, name) + })), + truncated: + truncated || + sorted.length > MAXIMUM_RETURNED_DIRECTORIES + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + closeHandleBestEffort() + endChannel() + resolve(result) + } + + const onAbort = (): void => { + finishError(abortError(signal)) + } + const onChannelError = (error: Error): void => { + finishError(error) + } + const timeout = setTimeout(() => { + finishError(new Error('SSH 目录浏览超时')) + }, OPERATION_TIMEOUT_MS) + + const readNextBatch = (): void => { + if (settled || !sftp || !handle) { + return + } + if ( + scannedEntries >= MAXIMUM_SCANNED_ENTRIES || + readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS + ) { + truncated = true + finishSuccess() + return + } + readdirCallbacks += 1 + try { + sftp.readdir(handle, (error, rawList) => { + if (settled) { + return + } + if (isSftpEndOfDirectory(error)) { + finishSuccess() + return + } + if (error) { + finishError(error) + return + } + const list = rawList as + | FileEntryWithStats[] + | false + | undefined + if (list === false || list === undefined) { + finishSuccess() + return + } + if (!Array.isArray(list)) { + finishError(new Error('SFTP 目录列表无效')) + return + } + if (list.length === 0) { + finishSuccess() + return + } + const remaining = + MAXIMUM_SCANNED_ENTRIES - scannedEntries + const inspected = list.slice(0, remaining) + scannedEntries += inspected.length + for (const entry of inspected) { + if ( + validDirectoryName(entry) && + isCanonicalAbsolutePosixPath( + joinPath(listingPath!, entry.filename) + ) + ) { + directories.add(entry.filename) + } + } + if ( + list.length > inspected.length || + scannedEntries >= MAXIMUM_SCANNED_ENTRIES || + readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS + ) { + truncated = true + finishSuccess() + } else { + readNextBatch() + } + }) + } catch (error) { + finishError(error) + } + } + + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + return + } + + try { + openSftp((openError, openedSftp) => { + if (settled) { + openedSftp?.end() + return + } + if (openError || !openedSftp) { + finishError(openError ?? new Error('无法打开 SFTP 通道')) + return + } + sftp = openedSftp + sftp.on('error', onChannelError) + try { + sftp.realpath('.', (homeError, homePath) => { + if (settled) { + return + } + if ( + homeError || + !isCanonicalAbsolutePosixPath(homePath) + ) { + finishError( + homeError ?? new Error('SSH 主目录路径无效') + ) + return + } + homeDirectory = homePath + try { + sftp!.realpath( + requestedPath ?? homePath, + (pathError, canonicalPath) => { + if (settled) { + return + } + if ( + pathError || + !isCanonicalAbsolutePosixPath(canonicalPath) + ) { + finishError( + pathError ?? new Error('SSH 目录路径无效') + ) + return + } + listingPath = canonicalPath + try { + sftp!.opendir( + canonicalPath, + (directoryError, openedHandle) => { + if (settled) { + closeHandleBestEffort(openedHandle) + endChannel() + return + } + if (directoryError || !openedHandle) { + finishError( + directoryError ?? + new Error('无法打开 SSH 目录') + ) + return + } + handle = openedHandle + readNextBatch() + } + ) + } catch (error) { + finishError(error) + } + } + ) + } catch (error) { + finishError(error) + } + }) + } catch (error) { + finishError(error) + } + }) + } catch (error) { + finishError(error) + } + }) +} diff --git a/src/main/ssh/bounded-sftp.test.ts b/src/main/ssh/bounded-sftp.test.ts new file mode 100644 index 00000000..b5031b3e --- /dev/null +++ b/src/main/ssh/bounded-sftp.test.ts @@ -0,0 +1,900 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { BoundedStagedSftp } from './bounded-sftp' + +type Entry = { + type: 'file' | 'directory' | 'symbolic-link' + contents?: Buffer + reportedSize?: number +} + +function stats(entry: Entry) { + return { + mode: + entry.type === 'directory' + ? 0o40700 + : entry.type === 'symbolic-link' + ? 0o120777 + : 0o100600, + uid: 1000, + gid: 1000, + size: entry.reportedSize ?? entry.contents?.byteLength ?? 0, + atime: 1, + mtime: 2, + isDirectory: () => entry.type === 'directory', + isFile: () => entry.type === 'file', + isSymbolicLink: () => entry.type === 'symbolic-link', + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false + } +} + +function missingError(): Error & { code: number } { + return Object.assign(new Error('not found'), { code: 2 }) +} + +function createSftp(root = '/safe/staging') { + const emitter = new EventEmitter() + const entries = new Map([ + [root, { type: 'directory' }] + ]) + const handles = new Map() + let nextHandle = 0 + const lookup = ( + path: string, + callback: (error?: Error, value?: ReturnType) => void + ): void => { + const entry = entries.get(path) + if (!entry) { + callback(missingError()) + return + } + callback(undefined, stats(entry)) + } + const sftp = Object.assign(emitter, { + mkdir: vi.fn( + ( + path: string, + _attributes: unknown, + callback: (error?: Error) => void + ) => { + entries.set(path, { type: 'directory' }) + callback() + } + ), + writeFile: vi.fn( + ( + path: string, + contents: Buffer, + _options: unknown, + callback: (error?: Error) => void + ) => { + entries.set(path, { + type: 'file', + contents: Buffer.from(contents) + }) + callback() + } + ), + chmod: vi.fn( + ( + _path: string, + _mode: number, + callback: (error?: Error) => void + ) => callback() + ), + rename: vi.fn( + ( + source: string, + destination: string, + callback: (error?: Error) => void + ) => { + const entry = entries.get(source) + if (!entry) { + callback(missingError()) + return + } + entries.delete(source) + entries.set(destination, entry) + callback() + } + ), + ext_openssh_rename: vi.fn( + ( + source: string, + destination: string, + callback: (error?: Error) => void + ) => { + const entry = entries.get(source) + if (!entry) { + callback(missingError()) + return + } + entries.delete(source) + entries.set(destination, entry) + callback() + } + ), + ext_openssh_hardlink: vi.fn( + ( + source: string, + destination: string, + callback: (error?: Error) => void + ) => { + const entry = entries.get(source) + if (!entry) { + callback(missingError()) + return + } + if (entries.has(destination)) { + callback(new Error('destination exists')) + return + } + entries.set(destination, entry) + callback() + } + ), + unlink: vi.fn( + (path: string, callback: (error?: Error) => void) => { + entries.delete(path) + callback() + } + ), + rmdir: vi.fn( + (path: string, callback: (error?: Error) => void) => { + entries.delete(path) + callback() + } + ), + lstat: vi.fn(lookup), + stat: vi.fn(lookup), + open: vi.fn( + ( + path: string, + _mode: string, + callback: (error?: Error, handle?: Buffer) => void + ) => { + const entry = entries.get(path) + if (!entry) { + callback(missingError()) + return + } + const handle = Buffer.from(`handle-${nextHandle++}`) + handles.set(handle.toString(), entry) + callback(undefined, handle) + } + ), + fstat: vi.fn( + ( + handle: Buffer, + callback: ( + error?: Error, + value?: ReturnType + ) => void + ) => { + const entry = handles.get(handle.toString()) + if (!entry) { + callback(missingError()) + return + } + callback(undefined, stats(entry)) + } + ), + read: vi.fn( + ( + handle: Buffer, + buffer: Buffer, + offset: number, + length: number, + position: number, + callback: ( + error: Error | undefined, + bytesRead: number + ) => void + ) => { + const contents = + handles.get(handle.toString())?.contents ?? Buffer.alloc(0) + const bytesRead = Math.min( + length, + Math.max(0, contents.byteLength - position) + ) + contents.copy( + buffer, + offset, + position, + position + bytesRead + ) + callback(undefined, bytesRead) + } + ), + close: vi.fn( + (handle: Buffer, callback: (error?: Error) => void) => { + handles.delete(handle.toString()) + callback() + } + ), + end: vi.fn() + }) + return { sftp, entries } +} + +describe('bounded staged SFTP', () => { + it('keeps writes inside staging and supports bounded metadata and reads', async () => { + const { sftp } = createSftp( + '/home/builder/.goodbuddy/staging/random' + ) + const staged = new BoundedStagedSftp( + sftp as never, + '/home/builder/.goodbuddy/staging/random' + ) + + await staged.mkdir('bin') + await staged.writeFile( + 'bin/goodbuddy-agent', + Buffer.from('agent') + ) + await expect( + staged.readFile('bin/goodbuddy-agent') + ).resolves.toEqual(Buffer.from('agent')) + await expect( + staged.lstat('bin/goodbuddy-agent') + ).resolves.toMatchObject({ + type: 'file', + size: 5, + mode: 0o600 + }) + await expect( + staged.stat('bin/goodbuddy-agent') + ).resolves.toMatchObject({ type: 'file', size: 5 }) + await staged.setExecutable('bin/goodbuddy-agent') + await staged.rename( + 'bin/goodbuddy-agent', + 'bin/agent-ready' + ) + + expect(sftp.mkdir).toHaveBeenCalledWith( + '/home/builder/.goodbuddy/staging/random/bin', + { mode: 0o700 }, + expect.any(Function) + ) + expect(sftp.writeFile).toHaveBeenCalledWith( + '/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent', + Buffer.from('agent'), + { mode: 0o600, flag: 'wx' }, + expect.any(Function) + ) + expect(sftp.chmod).toHaveBeenCalledWith( + '/home/builder/.goodbuddy/staging/random/bin/goodbuddy-agent', + 0o700, + expect.any(Function) + ) + }) + + it('pipelines bounded reads for large files', async () => { + const { sftp, entries } = createSftp() + const contents = Buffer.alloc(4 * 64 * 1024, 0x5a) + entries.set('/safe/staging/large', { + type: 'file', + contents + }) + const pending: Array<{ + buffer: Buffer + offset: number + length: number + position: number + callback: ( + error: Error | undefined, + bytesRead: number + ) => void + }> = [] + sftp.read.mockImplementation( + ( + _handle, + buffer, + offset, + length, + position, + callback + ) => { + pending.push({ + buffer, + offset, + length, + position, + callback + }) + } + ) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: contents.byteLength, + maximumTotalBytes: contents.byteLength + } + ) + + const read = staged.readFile('large') + await vi.waitFor(() => expect(pending).toHaveLength(4)) + for (const request of pending.splice(0)) { + contents.copy( + request.buffer, + request.offset, + request.position, + request.position + request.length + ) + request.callback(undefined, request.length) + } + await vi.waitFor(() => expect(pending).toHaveLength(1)) + pending[0]!.callback(undefined, 0) + + await expect(read).resolves.toEqual(contents) + }) + + it('allows only exact safe modes and removes only real directories', async () => { + const { sftp } = createSftp() + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging' + ) + await staged.mkdir('empty') + await staged.chmod('empty', 0o755) + await staged.writeFile('public-file', Buffer.from('ok')) + await staged.chmod('public-file', 0o644) + + await expect( + staged.chmod('public-file', 0o666 as never) + ).rejects.toThrow('允许列表') + await expect( + staged.chmod('empty', 0o600) + ).rejects.toThrow('目录权限') + await staged.rmdir('empty') + + expect(sftp.chmod).toHaveBeenCalledWith( + '/safe/staging/empty', + 0o755, + expect.any(Function) + ) + expect(sftp.rmdir).toHaveBeenCalledWith( + '/safe/staging/empty', + expect.any(Function) + ) + }) + + it('atomically replaces only a regular managed file', async () => { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/current', { + type: 'file', + contents: Buffer.from('old') + }) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging' + ) + await staged.writeFile('replacement', Buffer.from('new')) + await staged.replaceFile('replacement', 'current') + + expect(entries.get('/safe/staging/current')?.contents).toEqual( + Buffer.from('new') + ) + expect(sftp.ext_openssh_rename).toHaveBeenCalledWith( + '/safe/staging/replacement', + '/safe/staging/current', + expect.any(Function) + ) + expect(sftp.rename).not.toHaveBeenCalled() + + entries.set('/safe/staging/link', { + type: 'symbolic-link' + }) + await staged.writeFile('other', Buffer.from('other')) + await expect( + staged.replaceFile('other', 'link') + ).rejects.toThrow('符号链接') + }) + + it('hard-links only regular files to absent managed destinations', async () => { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/current', { + type: 'directory' + }) + entries.set('/safe/staging/current/node', { + type: 'file', + contents: Buffer.from('node') + }) + entries.set('/safe/staging/current/link', { + type: 'symbolic-link' + }) + entries.set('/safe/staging/linked-source', { + type: 'symbolic-link' + }) + entries.set('/safe/staging/linked-source/node', { + type: 'file', + contents: Buffer.from('escaped') + }) + entries.set('/safe/staging/candidate', { + type: 'directory' + }) + entries.set('/safe/staging/linked-destination', { + type: 'symbolic-link' + }) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: 1, + maximumTotalBytes: 1, + maximumOperations: 6 + } + ) + + await staged.hardLink( + 'current/node', + 'candidate/node' + ) + + expect(entries.get('/safe/staging/candidate/node')).toBe( + entries.get('/safe/staging/current/node') + ) + expect(sftp.ext_openssh_hardlink).toHaveBeenCalledWith( + '/safe/staging/current/node', + '/safe/staging/candidate/node', + expect.any(Function) + ) + await expect( + staged.hardLink('current/link', 'candidate/other') + ).rejects.toThrow('符号链接') + await expect( + staged.hardLink( + 'linked-source/node', + 'candidate/escaped-source' + ) + ).rejects.toThrow('符号链接') + await expect( + staged.hardLink( + 'current/node', + 'linked-destination/escaped-destination' + ) + ).rejects.toThrow('符号链接') + await expect( + staged.hardLink('current/node', 'candidate/node') + ).rejects.toThrow('目标已存在') + await expect( + staged.hardLink('../current/node', 'candidate/escape') + ).rejects.toThrow('相对路径无效') + expect(sftp.ext_openssh_hardlink).toHaveBeenCalledOnce() + }) + + it('propagates hard-link extension errors without transferring bytes', async () => { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/current', { + type: 'file', + contents: Buffer.alloc(8) + }) + sftp.ext_openssh_hardlink.mockImplementation( + ( + _source, + _destination, + callback + ) => callback(new Error('unsupported extension')) + ) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: 1, + maximumTotalBytes: 1, + maximumOperations: 2 + } + ) + + await expect( + staged.hardLink('current', 'candidate') + ).rejects.toThrow('unsupported extension') + await expect(staged.mkdir('still-available')).resolves + .toBeUndefined() + await expect(staged.mkdir('operation-limit')).rejects.toThrow( + '操作数量' + ) + expect(sftp.writeFile).not.toHaveBeenCalled() + expect(sftp.open).not.toHaveBeenCalled() + }) + + it('fails closed for symlinks and unsupported remote types', async () => { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/link', { + type: 'symbolic-link' + }) + entries.set('/safe/staging/link/agent', { + type: 'file', + contents: Buffer.from('escaped') + }) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging' + ) + + await expect(staged.stat('link')).rejects.toThrow( + '符号链接' + ) + await expect(staged.lstat('link')).resolves.toMatchObject({ + type: 'symbolic-link' + }) + await expect(staged.readFile('link')).rejects.toThrow( + '符号链接' + ) + await expect(staged.unlink('link')).rejects.toThrow( + '符号链接' + ) + await expect(staged.readFile('link/agent')).rejects.toThrow( + '符号链接' + ) + expect(sftp.open).not.toHaveBeenCalled() + expect(sftp.unlink).not.toHaveBeenCalled() + }) + + it('rejects reads that exceed metadata or grow during transfer', async () => { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/large', { + type: 'file', + contents: Buffer.alloc(5) + }) + entries.set('/safe/staging/growing', { + type: 'file', + contents: Buffer.alloc(5), + reportedSize: 4 + }) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: 4, + maximumTotalBytes: 8 + } + ) + + await expect(staged.readFile('large')).rejects.toThrow( + '读取大小' + ) + await expect(staged.readFile('growing')).rejects.toThrow( + '读取大小' + ) + expect(sftp.close).toHaveBeenCalledOnce() + }) + + it('rejects traversal, absolute relative paths, and unsafe staging roots', async () => { + const { sftp } = createSftp() + expect( + () => + new BoundedStagedSftp( + sftp as never, + 'relative/staging' + ) + ).toThrow('暂存目录无效') + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging' + ) + + await expect(staged.mkdir('../escape')).rejects.toThrow( + '相对路径无效' + ) + await expect( + staged.writeFile('/escape', Buffer.alloc(1)) + ).rejects.toThrow('相对路径无效') + expect(sftp.mkdir).not.toHaveBeenCalled() + expect(sftp.writeFile).not.toHaveBeenCalled() + }) + + it('bounds total bytes, operation count, timeout, and cancellation', async () => { + const firstHarness = createSftp() + const staged = new BoundedStagedSftp( + firstHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: 4, + maximumTotalBytes: 5, + maximumOperations: 2 + } + ) + + await staged.writeFile('first', Buffer.alloc(3)) + await expect( + staged.writeFile('total', Buffer.alloc(3)) + ).rejects.toThrow('传输总量') + await expect(staged.mkdir('third')).rejects.toThrow( + '操作数量' + ) + + const pendingHarness = createSftp() + pendingHarness.sftp.writeFile.mockImplementation( + () => undefined + ) + const cancellable = new BoundedStagedSftp( + pendingHarness.sftp as never, + '/safe/staging' + ) + const controller = new AbortController() + const write = cancellable.writeFile( + 'agent', + Buffer.alloc(1), + controller.signal + ) + await vi.waitFor(() => + expect( + pendingHarness.sftp.writeFile + ).toHaveBeenCalledOnce() + ) + controller.abort() + + await expect(write).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(pendingHarness.sftp.end).toHaveBeenCalledOnce() + + vi.useFakeTimers() + try { + const timeoutHarness = createSftp() + timeoutHarness.sftp.writeFile.mockImplementation( + () => undefined + ) + const timed = new BoundedStagedSftp( + timeoutHarness.sftp as never, + '/safe/staging', + { operationTimeoutMs: 1 } + ) + const timedWrite = timed.writeFile( + 'agent', + Buffer.alloc(1) + ) + const timedRejection = expect(timedWrite).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + expect( + timeoutHarness.sftp.writeFile + ).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + await timedRejection + expect(timeoutHarness.sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('scales large transfer timeouts by the bounded file size', async () => { + vi.useFakeTimers() + try { + const writeHarness = createSftp() + writeHarness.sftp.writeFile.mockImplementation( + () => undefined + ) + const writeSftp = new BoundedStagedSftp( + writeHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: 64 * 1024, + operationTimeoutMs: 1 + } + ) + const write = writeSftp.writeFile( + 'large', + Buffer.alloc(64 * 1024) + ) + const writeRejection = expect(write).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(999) + expect(writeHarness.sftp.end).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await writeRejection + expect(writeHarness.sftp.end).toHaveBeenCalledOnce() + + const readHarness = createSftp() + readHarness.entries.set('/safe/staging/large', { + type: 'file', + contents: Buffer.alloc(64 * 1024) + }) + readHarness.sftp.read.mockImplementation( + () => undefined + ) + const readSftp = new BoundedStagedSftp( + readHarness.sftp as never, + '/safe/staging', + { + maximumFileBytes: 64 * 1024, + maximumTotalBytes: 64 * 1024, + operationTimeoutMs: 1 + } + ) + const read = readSftp.readFile('large') + const readRejection = expect(read).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(999) + expect(readHarness.sftp.end).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await readRejection + expect(readHarness.sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('starts operation deadlines only after queued work reaches the head', async () => { + vi.useFakeTimers() + try { + const { sftp } = createSftp() + let finishWrite: ((error?: Error) => void) | undefined + sftp.writeFile.mockImplementation( + ( + _path: string, + _contents: Buffer, + _options: unknown, + callback: (error?: Error) => void + ) => { + finishWrite = callback + } + ) + sftp.mkdir.mockImplementation(() => undefined) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { operationTimeoutMs: 10 } + ) + + const first = staged.writeFile('first', Buffer.alloc(1)) + const second = staged.mkdir('second') + const secondRejection = expect(second).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + expect(sftp.writeFile).toHaveBeenCalledOnce() + expect(sftp.mkdir).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(9) + finishWrite?.() + await expect(first).resolves.toBeUndefined() + await vi.advanceTimersByTimeAsync(0) + expect(sftp.mkdir).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(1) + expect(sftp.end).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(9) + await secondRejection + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('cancels queued work promptly without waiting for the active operation', async () => { + vi.useFakeTimers() + try { + const { sftp } = createSftp() + sftp.writeFile.mockImplementation(() => undefined) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { operationTimeoutMs: 10 } + ) + const active = staged.writeFile('active', Buffer.alloc(1)) + const activeRejection = expect(active).rejects.toThrow( + '操作超时' + ) + const controller = new AbortController() + const queued = staged.mkdir('queued', controller.signal) + await vi.advanceTimersByTimeAsync(0) + + controller.abort() + + await expect(queued).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(sftp.end).toHaveBeenCalledOnce() + expect(sftp.mkdir).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10) + await activeRejection + } finally { + vi.useRealTimers() + } + }) + + it('uses the opened file size instead of the configured read maximum', async () => { + vi.useFakeTimers() + try { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/small', { + type: 'file', + contents: Buffer.alloc(64 * 1024) + }) + sftp.read.mockImplementation(() => undefined) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: 128 * 1024 * 1024, + operationTimeoutMs: 1 + } + ) + + const read = staged.readFile('small') + const rejection = expect(read).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + expect(sftp.read).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(999) + expect(sftp.end).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('keeps stalled read metadata checks on the fixed operation timeout', async () => { + vi.useFakeTimers() + try { + const { sftp } = createSftp() + sftp.lstat.mockImplementation(() => undefined) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { + maximumFileBytes: 128 * 1024 * 1024, + operationTimeoutMs: 1 + } + ) + + const read = staged.readFile('small') + const rejection = expect(read).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + expect(sftp.lstat).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(sftp.end).toHaveBeenCalledOnce() + expect(sftp.open).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('caps transfer deadlines at the revised 60-minute size boundary', async () => { + vi.useFakeTimers() + try { + const { sftp, entries } = createSftp() + entries.set('/safe/staging/capped', { + type: 'file', + reportedSize: 64 * 1024 * 60 * 60 + 1 + }) + sftp.read.mockImplementation(() => undefined) + const staged = new BoundedStagedSftp( + sftp as never, + '/safe/staging', + { operationTimeoutMs: 1 } + ) + + const read = staged.readFile('capped') + const rejection = expect(read).rejects.toThrow( + '操作超时' + ) + await vi.advanceTimersByTimeAsync(0) + expect(sftp.read).toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(60 * 60_000 - 1) + expect(sftp.end).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(sftp.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/bounded-sftp.ts b/src/main/ssh/bounded-sftp.ts new file mode 100644 index 00000000..b68265d2 --- /dev/null +++ b/src/main/ssh/bounded-sftp.ts @@ -0,0 +1,846 @@ +import type { SFTPWrapper, Stats } from 'ssh2' + +const DEFAULT_OPERATION_TIMEOUT_MS = 30_000 +const MAX_OPERATION_TIMEOUT_MS = 60_000 +const MINIMUM_TRANSFER_BYTES_PER_SECOND = 64 * 1024 +const MAX_TRANSFER_TIMEOUT_MS = 60 * 60_000 +const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024 +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024 +const DEFAULT_MAX_OPERATIONS = 2_048 +const MAX_REMOTE_PATH_BYTES = 4_096 +const MAX_REMOTE_PATH_SEGMENTS = 128 +const READ_CHUNK_BYTES = 64 * 1024 +const MAXIMUM_CONCURRENT_READS = 32 +const ALLOWED_MODES = [0o600, 0o644, 0o700, 0o755] as const + +export type BoundedSftpLimits = { + maximumFileBytes?: number + maximumTotalBytes?: number + maximumOperations?: number + operationTimeoutMs?: number +} + +export type SftpEntryType = + | 'file' + | 'directory' + | 'symbolic-link' + +export type SftpEntryMetadata = { + type: SftpEntryType + size: number + mode: number + uid: number + gid: number + atime: number + mtime: number +} + +export type AllowedSftpMode = (typeof ALLOWED_MODES)[number] + +export type StagedSftp = { + readonly stagingDirectory: string + mkdir(relativePath: string, signal?: AbortSignal): Promise + writeFile( + relativePath: string, + contents: Buffer, + signal?: AbortSignal + ): Promise + readFile( + relativePath: string, + signal?: AbortSignal + ): Promise + lstat( + relativePath: string, + signal?: AbortSignal + ): Promise + stat( + relativePath: string, + signal?: AbortSignal + ): Promise + chmod( + relativePath: string, + mode: AllowedSftpMode, + signal?: AbortSignal + ): Promise + setExecutable( + relativePath: string, + signal?: AbortSignal + ): Promise + rename( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise + replaceFile( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise + hardLink?( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise + unlink(relativePath: string, signal?: AbortSignal): Promise + rmdir(relativePath: string, signal?: AbortSignal): Promise + close(): void +} + +type SftpLike = Pick< + SFTPWrapper, + | 'mkdir' + | 'writeFile' + | 'chmod' + | 'rename' + | 'ext_openssh_rename' + | 'ext_openssh_hardlink' + | 'unlink' + | 'rmdir' + | 'lstat' + | 'stat' + | 'open' + | 'read' + | 'close' + | 'fstat' + | 'end' + | 'on' +> + +function validatePositiveLimit( + value: number | undefined, + fallback: number, + maximum: number +): number { + const resolved = value ?? fallback + if ( + !Number.isSafeInteger(resolved) || + resolved <= 0 || + resolved > maximum + ) { + throw new Error('SFTP 安全限制无效') + } + return resolved +} + +function validateStagingDirectory(value: string): string { + if ( + Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES || + value.includes('\0') || + value.includes('\\') || + value.includes('//') || + !(value.startsWith('/') || /^[A-Za-z]:\//u.test(value)) + ) { + throw new Error('SFTP 暂存目录无效') + } + const normalized = value.replace(/\/+$/u, '') + const pathWithoutRoot = normalized.replace( + /^(?:\/|[A-Za-z]:\/)/u, + '' + ) + const parts = pathWithoutRoot.split('/') + if ( + !pathWithoutRoot || + parts.length > MAX_REMOTE_PATH_SEGMENTS || + parts.some((part) => !part || part === '.' || part === '..') + ) { + throw new Error('SFTP 暂存目录无效') + } + return normalized +} + +function validateRelativePath(value: string): string { + if ( + !value || + Buffer.byteLength(value, 'utf8') > MAX_REMOTE_PATH_BYTES || + value.includes('\0') || + value.includes('\\') || + value.startsWith('/') || + /^[A-Za-z]:/u.test(value) + ) { + throw new Error('SFTP 相对路径无效') + } + const parts = value.split('/') + if ( + parts.length > MAX_REMOTE_PATH_SEGMENTS || + parts.some((part) => !part || part === '.' || part === '..') + ) { + throw new Error('SFTP 相对路径无效') + } + return value +} + +function abortError(signal?: AbortSignal): unknown { + return ( + signal?.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + ('code' in error && + ((error as { code?: unknown }).code === 2 || + (error as { code?: unknown }).code === 'ENOENT')) + ) +} + +function metadataFromStats(stats: Stats): SftpEntryMetadata { + if ( + !Number.isSafeInteger(stats.size) || + stats.size < 0 || + !Number.isSafeInteger(stats.mode) || + !Number.isSafeInteger(stats.uid) || + !Number.isSafeInteger(stats.gid) || + !Number.isFinite(stats.atime) || + !Number.isFinite(stats.mtime) + ) { + throw new Error('SFTP 文件元数据无效') + } + const predicates = [ + ['file', stats.isFile], + ['directory', stats.isDirectory], + ['symbolic-link', stats.isSymbolicLink] + ] as const + const types = predicates + .filter(([, predicate]) => { + if (typeof predicate !== 'function') { + throw new Error('SFTP 文件类型元数据无效') + } + return predicate.call(stats) + }) + .map(([type]) => type) + if (types.length !== 1) { + throw new Error('SFTP 文件类型不受支持') + } + return { + type: types[0]!, + size: stats.size, + mode: stats.mode & 0o7777, + uid: stats.uid, + gid: stats.gid, + atime: stats.atime, + mtime: stats.mtime + } +} + +export class BoundedStagedSftp implements StagedSftp { + readonly stagingDirectory: string + private readonly maximumFileBytes: number + private readonly maximumTotalBytes: number + private readonly maximumOperations: number + private readonly operationTimeoutMs: number + private totalBytes = 0 + private operations = 0 + private closed = false + private queue: Promise = Promise.resolve() + + constructor( + private readonly sftp: SftpLike, + stagingDirectory: string, + limits: BoundedSftpLimits = {} + ) { + this.stagingDirectory = + validateStagingDirectory(stagingDirectory) + this.maximumFileBytes = validatePositiveLimit( + limits.maximumFileBytes, + DEFAULT_MAX_FILE_BYTES, + DEFAULT_MAX_FILE_BYTES + ) + this.maximumTotalBytes = validatePositiveLimit( + limits.maximumTotalBytes, + DEFAULT_MAX_TOTAL_BYTES, + DEFAULT_MAX_TOTAL_BYTES + ) + this.maximumOperations = validatePositiveLimit( + limits.maximumOperations, + DEFAULT_MAX_OPERATIONS, + DEFAULT_MAX_OPERATIONS + ) + this.operationTimeoutMs = validatePositiveLimit( + limits.operationTimeoutMs, + DEFAULT_OPERATION_TIMEOUT_MS, + MAX_OPERATION_TIMEOUT_MS + ) + this.sftp.on('error', () => { + this.closed = true + }) + } + + async mkdir( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(validated) + await this.call((done) => { + this.sftp.mkdir(remotePath, { mode: 0o700 }, done) + }) + }) + } + + async writeFile( + relativePath: string, + contents: Buffer, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + if (contents.byteLength > this.maximumFileBytes) { + throw new Error('SFTP 文件大小超过安全限制') + } + return this.enqueue( + signal, + async () => { + if ( + this.totalBytes + contents.byteLength > + this.maximumTotalBytes + ) { + throw new Error('SFTP 传输总量超过安全限制') + } + await this.assertSafeAncestors(validated) + await this.call((done) => { + this.sftp.writeFile( + remotePath, + contents, + { mode: 0o600, flag: 'wx' }, + done + ) + }) + this.totalBytes += contents.byteLength + }, + this.transferTimeoutMs(contents.byteLength) + ) + } + + async readFile( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + return this.enqueue( + signal, + async (resetTimeout) => { + const metadata = await this.safeStat(validated) + this.assertType(metadata, 'file') + const remainingTotal = + this.maximumTotalBytes - this.totalBytes + const maximumReadBytes = Math.min( + this.maximumFileBytes, + remainingTotal + ) + if ( + maximumReadBytes <= 0 || + metadata.size > maximumReadBytes + ) { + throw new Error('SFTP 读取大小超过安全限制') + } + const handle = await this.call((done) => { + this.sftp.open(remotePath, 'r', done) + }) + try { + const openedMetadata = metadataFromStats( + await this.call((done) => { + this.sftp.fstat(handle, done) + }) + ) + this.assertType(openedMetadata, 'file') + if (openedMetadata.size > maximumReadBytes) { + throw new Error('SFTP 读取大小超过安全限制') + } + resetTimeout( + this.transferTimeoutMs(openedMetadata.size) + ) + const contents = await this.readHandle( + handle, + openedMetadata.size, + maximumReadBytes + ) + this.totalBytes += contents.byteLength + return contents + } finally { + await this.closeHandle(handle) + } + } + ) + } + + async lstat( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(validated) + return this.getLstat(this.resolveValidated(validated)) + }) + } + + async stat( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + return this.enqueue(signal, () => this.safeStat(validated)) + } + + async chmod( + relativePath: string, + mode: AllowedSftpMode, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + if (!(ALLOWED_MODES as readonly number[]).includes(mode)) { + throw new Error('SFTP 文件权限不在安全允许列表中') + } + return this.enqueue(signal, async () => { + const metadata = await this.safeStat(validated) + if ( + metadata.type === 'directory' && + mode !== 0o700 && + mode !== 0o755 + ) { + throw new Error('SFTP 目录权限必须保留执行权限') + } + await this.call((done) => { + this.sftp.chmod(remotePath, mode, done) + }) + }) + } + + setExecutable( + relativePath: string, + signal?: AbortSignal + ): Promise { + return this.chmod(relativePath, 0o700, signal) + } + + async rename( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise { + const sourceValidated = validateRelativePath(sourceRelativePath) + const destinationValidated = validateRelativePath( + destinationRelativePath + ) + const source = this.resolveValidated(sourceValidated) + const destination = this.resolveValidated(destinationValidated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(sourceValidated) + const sourceMetadata = await this.getLstat(source) + this.assertNotLink(sourceMetadata) + await this.assertSafeAncestors(destinationValidated) + try { + await this.getLstat(destination) + throw new Error('SFTP 重命名目标已存在') + } catch (error) { + if (!isMissingPathError(error)) { + throw error + } + } + await this.call((done) => { + this.sftp.rename(source, destination, done) + }) + }) + } + + async replaceFile( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise { + const sourceValidated = validateRelativePath(sourceRelativePath) + const destinationValidated = validateRelativePath( + destinationRelativePath + ) + const source = this.resolveValidated(sourceValidated) + const destination = this.resolveValidated(destinationValidated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(sourceValidated) + this.assertType(await this.getLstat(source), 'file') + await this.assertSafeAncestors(destinationValidated) + try { + this.assertType(await this.getLstat(destination), 'file') + } catch (error) { + if (!isMissingPathError(error)) { + throw error + } + } + try { + await this.call((done) => { + this.sftp.ext_openssh_rename( + source, + destination, + done + ) + }) + } catch (error) { + throw new Error('SFTP 原子文件替换失败', { + cause: error + }) + } + }) + } + + async hardLink( + sourceRelativePath: string, + destinationRelativePath: string, + signal?: AbortSignal + ): Promise { + const sourceValidated = validateRelativePath(sourceRelativePath) + const destinationValidated = validateRelativePath( + destinationRelativePath + ) + const source = this.resolveValidated(sourceValidated) + const destination = this.resolveValidated(destinationValidated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(sourceValidated) + this.assertType(await this.getLstat(source), 'file') + await this.assertSafeAncestors(destinationValidated) + try { + await this.getLstat(destination) + throw new Error('SFTP 硬链接目标已存在') + } catch (error) { + if (!isMissingPathError(error)) { + throw error + } + } + await this.call((done) => { + this.sftp.ext_openssh_hardlink( + source, + destination, + done + ) + }) + this.assertType(await this.getLstat(destination), 'file') + }) + } + + async unlink( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(validated) + this.assertType(await this.getLstat(remotePath), 'file') + await this.call((done) => { + this.sftp.unlink(remotePath, done) + }) + }) + } + + async rmdir( + relativePath: string, + signal?: AbortSignal + ): Promise { + const validated = validateRelativePath(relativePath) + const remotePath = this.resolveValidated(validated) + return this.enqueue(signal, async () => { + await this.assertSafeAncestors(validated) + this.assertType( + await this.getLstat(remotePath), + 'directory' + ) + await this.call((done) => { + this.sftp.rmdir(remotePath, done) + }) + }) + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + this.sftp.end() + } + + private resolveValidated(relativePath: string): string { + return `${this.stagingDirectory}/${relativePath}` + } + + private async assertSafeAncestors( + relativePath: string + ): Promise { + const parts = relativePath.split('/') + const paths = [this.stagingDirectory] + let current = this.stagingDirectory + for (const part of parts.slice(0, -1)) { + current = `${current}/${part}` + paths.push(current) + } + for (const path of paths) { + this.assertType(await this.getLstat(path), 'directory') + } + } + + private async safeStat( + relativePath: string + ): Promise { + await this.assertSafeAncestors(relativePath) + const remotePath = this.resolveValidated(relativePath) + const linkMetadata = await this.getLstat(remotePath) + this.assertNotLink(linkMetadata) + const metadata = metadataFromStats( + await this.call((done) => { + this.sftp.stat(remotePath, done) + }) + ) + this.assertNotLink(metadata) + if (metadata.type !== linkMetadata.type) { + throw new Error('SFTP 文件类型在操作期间发生变化') + } + return metadata + } + + private getLstat(remotePath: string): Promise { + return this.call((done) => { + this.sftp.lstat(remotePath, done) + }).then(metadataFromStats) + } + + private assertNotLink(metadata: SftpEntryMetadata): void { + if (metadata.type === 'symbolic-link') { + throw new Error('SFTP 不允许操作符号链接') + } + } + + private assertType( + metadata: SftpEntryMetadata, + expected: Exclude + ): void { + this.assertNotLink(metadata) + if (metadata.type !== expected) { + throw new Error( + expected === 'file' + ? 'SFTP 目标不是普通文件' + : 'SFTP 目标不是目录' + ) + } + } + + private async readHandle( + handle: Buffer, + expectedSize: number, + maximumReadBytes: number + ): Promise { + const contents = Buffer.allocUnsafe(expectedSize) + let nextPosition = 0 + let discoveredSize = expectedSize + const readSegment = async (): Promise => { + while (true) { + const position = nextPosition + nextPosition += READ_CHUNK_BYTES + if ( + position >= expectedSize || + position >= discoveredSize + ) { + return + } + const segmentLength = Math.min( + READ_CHUNK_BYTES, + expectedSize - position + ) + let segmentOffset = 0 + while ( + segmentOffset < segmentLength && + position + segmentOffset < discoveredSize + ) { + const length = segmentLength - segmentOffset + const bytesRead = await this.call((done) => { + this.sftp.read( + handle, + contents, + position + segmentOffset, + length, + position + segmentOffset, + (error, count) => done(error, count) + ) + }) + if ( + !Number.isSafeInteger(bytesRead) || + bytesRead < 0 || + bytesRead > length + ) { + throw new Error('SFTP 读取结果无效') + } + if (bytesRead === 0) { + discoveredSize = Math.min( + discoveredSize, + position + segmentOffset + ) + return + } + segmentOffset += bytesRead + } + } + } + const workerCount = Math.min( + MAXIMUM_CONCURRENT_READS, + Math.ceil(expectedSize / READ_CHUNK_BYTES) + ) + await Promise.all( + Array.from({ length: workerCount }, readSegment) + ) + const extra = Buffer.allocUnsafe(1) + const extraBytes = await this.call((done) => { + this.sftp.read( + handle, + extra, + 0, + 1, + expectedSize, + (error, count) => done(error, count) + ) + }) + if (extraBytes !== 0) { + throw new Error('SFTP 读取大小超过安全限制') + } + const actualSize = Math.min(expectedSize, discoveredSize) + if (actualSize > maximumReadBytes) { + throw new Error('SFTP 读取大小超过安全限制') + } + return actualSize === contents.byteLength + ? contents + : contents.subarray(0, actualSize) + } + + private async closeHandle(handle: Buffer): Promise { + try { + await this.call((done) => { + this.sftp.close(handle, done) + }) + } catch { + this.close() + } + } + + private call( + begin: ( + callback: (error?: Error | null, value?: T) => void + ) => void + ): Promise { + if (this.closed) { + return Promise.reject(new Error('SFTP 通道已关闭')) + } + return new Promise((resolve, reject) => { + try { + begin((error, value) => { + if (error) { + reject(error) + } else { + resolve(value as T) + } + }) + } catch (error) { + reject(error) + } + }) + } + + private transferTimeoutMs(byteLength: number): number { + const scaled = Math.ceil( + (byteLength / MINIMUM_TRANSFER_BYTES_PER_SECOND) * 1_000 + ) + return Math.min( + MAX_TRANSFER_TIMEOUT_MS, + Math.max(this.operationTimeoutMs, scaled) + ) + } + + private enqueue( + signal: AbortSignal | undefined, + operation: ( + resetTimeout: (timeoutMs: number) => void + ) => Promise, + timeoutMs = this.operationTimeoutMs + ): Promise { + if (this.closed) { + return Promise.reject(new Error('SFTP 通道已关闭')) + } + if (signal?.aborted) { + return Promise.reject(abortError(signal)) + } + this.operations += 1 + if (this.operations > this.maximumOperations) { + return Promise.reject( + new Error('SFTP 操作数量超过安全限制') + ) + } + const previous = this.queue + let releaseQueue!: () => void + this.queue = new Promise((resolve) => { + releaseQueue = resolve + }) + return new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | undefined + const finish = (value?: T, error?: unknown): void => { + if (settled) { + return + } + settled = true + if (timeout !== undefined) { + clearTimeout(timeout) + } + signal?.removeEventListener('abort', abort) + releaseQueue() + if (error) { + reject(error) + } else { + resolve(value as T) + } + } + const abort = (): void => { + this.close() + finish(undefined, abortError(signal)) + } + const resetTimeout = (nextTimeoutMs: number): void => { + if (settled) { + return + } + if (timeout !== undefined) { + clearTimeout(timeout) + } + timeout = setTimeout(() => { + this.close() + finish(undefined, new Error('SFTP 操作超时')) + }, nextTimeoutMs) + } + const start = (): void => { + if (settled) { + return + } + if (this.closed) { + finish(undefined, new Error('SFTP 通道已关闭')) + return + } + if (signal?.aborted) { + abort() + return + } + resetTimeout(timeoutMs) + Promise.resolve() + .then(() => operation(resetTimeout)) + .then( + (value) => finish(value), + (error: unknown) => finish(undefined, error) + ) + } + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + abort() + return + } + previous.then(start, start) + }) + } +} diff --git a/src/main/ssh/ssh-agent-command.test.ts b/src/main/ssh/ssh-agent-command.test.ts new file mode 100644 index 00000000..0d8e8609 --- /dev/null +++ b/src/main/ssh/ssh-agent-command.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_BOOTSTRAP_PROBE_COMMAND, + buildFixedAgentCliArgv, + buildFixedAgentSshCommand, + parseAgentBootstrapProbeOutput, + verifyAgentInstallationId +} from './ssh-agent-command' + +function bootstrapOutput( + overrides: Partial> = {} +): string { + const values = { + home: '/home/builder', + uid: '1001', + os: 'Linux', + arch: 'x86_64', + shell: '/bin/bash', + procfs: 'ready', + ...overrides + } + return [ + 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1', + ...Object.entries(values).map( + ([key, value]) => `${key}=${value}` + ), + '' + ].join('\n') +} + +describe('fixed Agent SSH commands', () => { + it('uses one exact, fixed, non-mutating bootstrap probe command', () => { + expect(AGENT_BOOTSTRAP_PROBE_COMMAND).toBe( + `printf 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1\\n'; ` + + `if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi; ` + + `if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi; ` + + `if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi; ` + + `if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi; ` + + `printf 'shell=%s\\n' "\${SHELL:-unknown}"; ` + + `if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi` + ) + expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch( + /\b(?:sudo|env|printenv|set|export|install|mkdir|touch|rm)\b/u + ) + expect(AGENT_BOOTSTRAP_PROBE_COMMAND).not.toMatch( + /\b(?:systemctl|loginctl)\b|Linger/u + ) + }) + + it('builds only fixed commands from a separately verified installation ID', () => { + const installationId = verifyAgentInstallationId( + 'agent_2026-08-20_abcdef012345' + ) + + expect( + buildFixedAgentSshCommand(installationId, { kind: 'attach' }) + ).toBe( + 'exec "$HOME/.goodbuddy/agent/installations/' + + 'agent_2026-08-20_abcdef012345/goodbuddy-agent" ' + + 'attach-or-bootstrap --installation-id ' + + 'agent_2026-08-20_abcdef012345' + ) + expect( + buildFixedAgentSshCommand(installationId, { kind: 'doctor' }) + ).toContain( + '/goodbuddy-agent" doctor --installation-id ' + + 'agent_2026-08-20_abcdef012345' + ) + expect( + buildFixedAgentSshCommand(installationId, { + kind: 'lifecycle', + action: 'bootstrap' + }) + ).toContain( + '/goodbuddy-agent" bootstrap --installation-id ' + + 'agent_2026-08-20_abcdef012345' + ) + expect( + buildFixedAgentCliArgv(installationId, { + kind: 'lifecycle', + action: 'bootstrap' + }) + ).toEqual([ + 'bootstrap', + '--installation-id', + 'agent_2026-08-20_abcdef012345' + ]) + expect( + buildFixedAgentSshCommand(installationId, { + kind: 'lifecycle', + action: 'health' + }) + ).toContain( + '/goodbuddy-agent" health --installation-id ' + + 'agent_2026-08-20_abcdef012345' + ) + }) + + it('rejects shell syntax and path traversal in installation IDs', () => { + for (const value of [ + '../current', + 'agent/latest', + 'agent; shutdown', + '$(whoami)', + 'agent.v1', + '', + 'a'.repeat(129) + ]) { + expect(() => verifyAgentInstallationId(value)).toThrow( + 'installation ID 无效' + ) + } + }) + + it('builds a closed digest-addressed OpenCode activation command', () => { + const installationId = verifyAgentInstallationId('agent-v1') + const bundleDigest = `sha256:${'a'.repeat(64)}` + + expect( + buildFixedAgentCliArgv(installationId, { + kind: 'runtime-activate', + runtimeId: 'opencode', + bundleDigest, + architecture: 'arm64' + }) + ).toEqual([ + 'runtime', + 'activate', + '--installation-id', + 'agent-v1', + '--runtime-id', + 'opencode', + '--bundle-digest', + bundleDigest, + '--architecture', + 'arm64' + ]) + expect( + buildFixedAgentSshCommand(installationId, { + kind: 'runtime-activate', + runtimeId: 'opencode', + bundleDigest, + architecture: 'arm64' + }) + ).toBe( + 'exec "$HOME/.goodbuddy/agent/installations/agent-v1/' + + 'goodbuddy-agent" runtime activate --installation-id agent-v1 ' + + `--runtime-id opencode --bundle-digest ${bundleDigest} ` + + '--architecture arm64' + ) + }) + + it('rejects unchecked Runtime activation argv before command construction', () => { + const installationId = verifyAgentInstallationId('agent-v1') + const valid = { + kind: 'runtime-activate', + runtimeId: 'opencode', + bundleDigest: `sha256:${'a'.repeat(64)}`, + architecture: 'x64' + } as const + + for (const action of [ + { ...valid, runtimeId: 'opencode; id' }, + { ...valid, bundleDigest: `sha256:${'a'.repeat(63)};id` }, + { ...valid, architecture: 'x64;id' } + ]) { + expect(() => + buildFixedAgentSshCommand( + installationId, + action as never + ) + ).toThrow(/Runtime/iu) + } + }) + + it('fails closed if an untyped caller supplies an unknown lifecycle action', () => { + const installationId = verifyAgentInstallationId('agent-v1') + + expect(() => + buildFixedAgentSshCommand(installationId, { + kind: 'lifecycle', + action: 'arbitrary command' + } as never) + ).toThrow('lifecycle action 无效') + expect(() => + buildFixedAgentSshCommand( + '../unverified' as typeof installationId, + { kind: 'attach' } + ) + ).toThrow('installation ID 无效') + }) + + it('parses supported Linux x64 and arm64 bootstrap results', () => { + expect(parseAgentBootstrapProbeOutput(bootstrapOutput())).toEqual({ + ready: true, + platform: 'linux', + architecture: 'x64', + canonicalHomeDirectory: '/home/builder', + uid: 1_001, + shell: '/bin/bash', + procfs: 'ready' + }) + expect( + parseAgentBootstrapProbeOutput( + bootstrapOutput({ arch: 'aarch64' }) + ) + ).toMatchObject({ + ready: true, + architecture: 'arm64' + }) + }) + + it('returns explicit incompatible bootstrap reasons', () => { + const cases: Array< + [Partial>, string] + > = [ + [{ os: 'Darwin' }, 'non-linux'], + [{ arch: 'riscv64' }, 'unsupported-architecture'], + [{ home: 'unknown' }, 'home-directory-unavailable'], + [{ uid: 'unknown' }, 'uid-unavailable'], + [{ shell: 'unknown' }, 'shell-unavailable'], + [{ procfs: 'unavailable' }, 'procfs-unavailable'] + ] + for (const [overrides, reason] of cases) { + expect( + parseAgentBootstrapProbeOutput(bootstrapOutput(overrides)) + ).toEqual({ ready: false, reason }) + } + }) + + it('rejects malformed, reordered, incomplete, and unsafe output', () => { + const valid = bootstrapOutput() + for (const output of [ + valid.slice(0, -1), + valid.replace('home=', 'uid='), + valid.replace('home=/home/builder', 'home=relative/path'), + valid.replace('/home/builder', '/home/../root'), + valid.replace('/bin/bash', '/bin/ba\u0000sh'), + valid.replace('/bin/bash', '/bin/ba\u0085sh'), + bootstrapOutput({ uid: '1001x' }), + bootstrapOutput({ procfs: 'maybe' }), + valid.replace('uid=1001', 'uid=1001\nuid=1001'), + `noise\n${valid}`, + valid.replace('\n', '\r\n'), + 'x'.repeat(16 * 1024 + 1) + ]) { + expect(() => + parseAgentBootstrapProbeOutput(output) + ).toThrow('无效') + } + }) +}) diff --git a/src/main/ssh/ssh-agent-command.ts b/src/main/ssh/ssh-agent-command.ts new file mode 100644 index 00000000..eee265ae --- /dev/null +++ b/src/main/ssh/ssh-agent-command.ts @@ -0,0 +1,252 @@ +import { + agentBootstrapProbeResultSchema, + canonicalRemotePathSchema, + type AgentBootstrapIncompatibleReason, + type AgentBootstrapProbeResult +} from '../../shared/ssh-host-contracts' +import { sha256DigestSchema } from '../../shared/agent-protocol/contracts' + +const INSTALLATION_ID_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u +const AGENT_BOOTSTRAP_PROBE_MARKER = + 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1' +const MAX_PROBE_FIELD_LENGTH = 4_096 +const MAX_LINUX_UID = 4_294_967_294 + +export const AGENT_BOOTSTRAP_PROBE_COMMAND = [ + `printf '${AGENT_BOOTSTRAP_PROBE_MARKER}\\n'`, + `if home=$(cd "$HOME" 2>/dev/null && pwd -P); then printf 'home=%s\\n' "$home"; else printf 'home=unknown\\n'; fi`, + `if uid=$(id -u 2>/dev/null); then printf 'uid=%s\\n' "$uid"; else uid=unknown; printf 'uid=unknown\\n'; fi`, + `if os=$(uname -s 2>/dev/null); then printf 'os=%s\\n' "$os"; else printf 'os=unknown\\n'; fi`, + `if arch=$(uname -m 2>/dev/null); then printf 'arch=%s\\n' "$arch"; else printf 'arch=unknown\\n'; fi`, + `printf 'shell=%s\\n' "\${SHELL:-unknown}"`, + `if [ -r /proc/self/stat ] && [ -r "/proc/$$/stat" ]; then printf 'procfs=ready\\n'; else printf 'procfs=unavailable\\n'; fi` +].join('; ') + +const lifecycleActions = [ + 'bootstrap', + 'health', + 'status', + 'retire', + 'stop' +] as const +const runtimeArchitectures = ['x64', 'arm64'] as const +const RUNTIME_ID = 'opencode' as const + +declare const verifiedInstallationIdBrand: unique symbol + +export type VerifiedAgentInstallationId = string & { + readonly [verifiedInstallationIdBrand]: true +} + +export type AgentLifecycleAction = (typeof lifecycleActions)[number] +export type AgentRuntimeArchitecture = + (typeof runtimeArchitectures)[number] + +export type AgentRuntimeActivationAction = { + kind: 'runtime-activate' + runtimeId: typeof RUNTIME_ID + bundleDigest: string + architecture: AgentRuntimeArchitecture +} + +export type FixedAgentSshAction = + | { kind: 'attach' } + | { kind: 'doctor' } + | { kind: 'lifecycle'; action: AgentLifecycleAction } + | AgentRuntimeActivationAction + +export type FixedAgentCliArgv = readonly string[] + +const probeFields = [ + 'home', + 'uid', + 'os', + 'arch', + 'shell', + 'procfs' +] as const + +function incompatible( + reason: AgentBootstrapIncompatibleReason +): AgentBootstrapProbeResult { + return agentBootstrapProbeResultSchema.parse({ + ready: false, + reason + }) +} + +function isSafeProbeField(value: string): boolean { + return ( + value.length > 0 && + value.length <= MAX_PROBE_FIELD_LENGTH && + !/[\p{Cc}\p{Cs}\u2028\u2029\ufffd]/u.test(value) + ) +} + +function parseLinuxUid(value: string): number | undefined { + if (!/^(?:0|[1-9][0-9]{0,9})$/u.test(value)) { + return undefined + } + const uid = Number(value) + return Number.isSafeInteger(uid) && uid <= MAX_LINUX_UID + ? uid + : undefined +} + +export function parseAgentBootstrapProbeOutput( + stdout: string +): AgentBootstrapProbeResult { + if ( + !stdout.endsWith('\n') || + stdout.length > 16 * 1024 || + stdout.includes('\r') || + stdout.includes('\0') + ) { + throw new Error('Agent 启动探针返回了无效结果') + } + const lines = stdout.slice(0, -1).split('\n') + if ( + lines.length !== probeFields.length + 1 || + lines[0] !== AGENT_BOOTSTRAP_PROBE_MARKER + ) { + throw new Error('Agent 启动探针返回了无效结果') + } + const values = new Map() + for (const [index, field] of probeFields.entries()) { + const prefix = `${field}=` + const line = lines[index + 1] + if (!line?.startsWith(prefix)) { + throw new Error('Agent 启动探针返回了无效结果') + } + const value = line.slice(prefix.length) + if (!isSafeProbeField(value)) { + throw new Error('Agent 启动探针返回了无效结果') + } + values.set(field, value) + } + + const os = values.get('os') + if (os !== 'Linux') { + return incompatible('non-linux') + } + const rawArchitecture = values.get('arch') + const architecture = + rawArchitecture === 'x86_64' + ? 'x64' + : rawArchitecture === 'aarch64' + ? 'arm64' + : undefined + if (!architecture) { + return incompatible('unsupported-architecture') + } + + const home = values.get('home') ?? '' + if (home === 'unknown') { + return incompatible('home-directory-unavailable') + } + if (!canonicalRemotePathSchema.safeParse(home).success) { + throw new Error('Agent 启动探针返回了无效路径') + } + const rawUid = values.get('uid') + if (rawUid === 'unknown') { + return incompatible('uid-unavailable') + } + const uid = parseLinuxUid(rawUid ?? '') + if (uid === undefined) { + throw new Error('Agent 启动探针返回了无效 UID') + } + const shell = values.get('shell') ?? '' + if (shell === 'unknown') { + return incompatible('shell-unavailable') + } + if (!canonicalRemotePathSchema.safeParse(shell).success) { + throw new Error('Agent 启动探针返回了无效路径') + } + const procfs = values.get('procfs') + if (procfs !== 'ready' && procfs !== 'unavailable') { + throw new Error('Agent 启动探针返回了无效结果') + } + if (procfs === 'unavailable') { + return incompatible('procfs-unavailable') + } + return agentBootstrapProbeResultSchema.parse({ + ready: true, + platform: 'linux', + architecture, + canonicalHomeDirectory: home, + uid, + shell, + procfs: 'ready' + }) +} + +export function verifyAgentInstallationId( + value: string +): VerifiedAgentInstallationId { + if ( + value.length > 128 || + !INSTALLATION_ID_PATTERN.test(value) + ) { + throw new Error('GoodBuddy Agent installation ID 无效') + } + return value as VerifiedAgentInstallationId +} + +export function buildFixedAgentCliArgv( + installationId: VerifiedAgentInstallationId, + action: FixedAgentSshAction +): FixedAgentCliArgv { + const verifiedInstallationId = + verifyAgentInstallationId(installationId) + const installationOption = [ + '--installation-id', + verifiedInstallationId + ] as const + switch (action.kind) { + case 'attach': + return ['attach-or-bootstrap', ...installationOption] + case 'doctor': + return ['doctor', ...installationOption] + case 'lifecycle': + if (!lifecycleActions.includes(action.action)) { + throw new Error('GoodBuddy Agent lifecycle action 无效') + } + return [action.action, ...installationOption] + case 'runtime-activate': { + if (action.runtimeId !== RUNTIME_ID) { + throw new Error('GoodBuddy Agent Runtime ID 无效') + } + if (!sha256DigestSchema.safeParse(action.bundleDigest).success) { + throw new Error('GoodBuddy Agent Runtime bundle digest 无效') + } + if (!runtimeArchitectures.includes(action.architecture)) { + throw new Error('GoodBuddy Agent Runtime architecture 无效') + } + return [ + 'runtime', + 'activate', + ...installationOption, + '--runtime-id', + action.runtimeId, + '--bundle-digest', + action.bundleDigest, + '--architecture', + action.architecture + ] + } + } +} + +export function buildFixedAgentSshCommand( + installationId: VerifiedAgentInstallationId, + action: FixedAgentSshAction +): string { + const verifiedInstallationId = + verifyAgentInstallationId(installationId) + const argv = buildFixedAgentCliArgv(verifiedInstallationId, action) + const executable = + `"$HOME/.goodbuddy/agent/installations/${verifiedInstallationId}` + + '/goodbuddy-agent"' + return `exec ${executable} ${argv.join(' ')}` +} diff --git a/src/main/ssh/ssh-connection-pool.test.ts b/src/main/ssh/ssh-connection-pool.test.ts new file mode 100644 index 00000000..30e69dc2 --- /dev/null +++ b/src/main/ssh/ssh-connection-pool.test.ts @@ -0,0 +1,610 @@ +import { EventEmitter } from 'node:events' +import type { ConnectConfig } from 'ssh2' +import { describe, expect, it, vi } from 'vitest' +import type { ResolvedSshHost } from './ssh-host-store' +import { + AGENT_BOOTSTRAP_PROBE_COMMAND, + verifyAgentInstallationId +} from './ssh-agent-command' +import { + Ssh2AuthenticatedConnection, + SshConnectionPool, + type AuthenticatedSshConnection, + type SshConnectionFactory, + type SshConnectionPoolTarget, + type SshPoolConnectionIdentity +} from './ssh-connection-pool' + +const sshEd25519Algorithm = Buffer.from('ssh-ed25519') +const pinnedHostKey = Buffer.concat([ + Buffer.from([0, 0, 0, sshEd25519Algorithm.byteLength]), + sshEd25519Algorithm, + Buffer.from([0, 0, 0, 32]), + Buffer.alloc(32, 7) +]) + +const hostKey = { + algorithm: 'ssh-ed25519', + publicKeyBase64: pinnedHostKey.toString('base64'), + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + generation: 3 +} + +function createTarget( + overrides: Partial = {}, + identityOverrides: Partial< + Pick< + SshConnectionPoolTarget, + 'hostRevision' | 'hostKeyGeneration' + > + > = {} +): SshConnectionPoolTarget { + return { + host: { + id: '00000000-0000-4000-8000-000000000201', + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'password', + password: 'private password', + hostKey, + ...overrides + }, + hostRevision: 7, + hostKeyGeneration: 3, + ...identityOverrides + } +} + +function createConnection(identity: SshPoolConnectionIdentity) { + return { + identity, + isUsable: vi.fn(() => true), + openAgentAttach: vi.fn(), + runAgentDoctor: vi.fn(), + runAgentBootstrapProbe: vi.fn(), + runAgentLifecycleAction: vi.fn(), + runAgentRuntimeAction: vi.fn(), + openStagedSftp: vi.fn(), + listDirectories: vi.fn(), + dispose: vi.fn() + } satisfies AuthenticatedSshConnection +} + +describe('SSH connection pool', () => { + it('reuses one authenticated connection for matching identities and refcounts leases', async () => { + vi.useFakeTimers() + try { + const connections: ReturnType[] = [] + const factory: SshConnectionFactory = vi.fn( + async (_target, identity) => { + const connection = createConnection(identity) + connections.push(connection) + return connection + } + ) + const pool = new SshConnectionPool(factory, { + idleTimeoutMs: 100 + }) + + const [first, second] = await Promise.all([ + pool.acquire(createTarget()), + pool.acquire(createTarget()) + ]) + + expect(factory).toHaveBeenCalledOnce() + expect(first.identity).toEqual(second.identity) + first.release() + await vi.advanceTimersByTimeAsync(100) + expect(connections[0]?.dispose).not.toHaveBeenCalled() + second.release() + await vi.advanceTimersByTimeAsync(99) + expect(connections[0]?.dispose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(connections[0]?.dispose).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('isolates revision, host-key generation, credential, and host identities', async () => { + const factory: SshConnectionFactory = vi.fn( + async (_target, identity) => createConnection(identity) + ) + const pool = new SshConnectionPool(factory) + const targets = [ + createTarget(), + createTarget({}, { hostRevision: 8 }), + createTarget( + { hostKey: { ...hostKey, generation: 4 } }, + { hostKeyGeneration: 4 } + ), + createTarget({ password: 'rotated password' }), + createTarget({ + id: '00000000-0000-4000-8000-000000000202' + }) + ] + + const leases = await Promise.all( + targets.map((target) => pool.acquire(target)) + ) + + expect(factory).toHaveBeenCalledTimes(5) + expect( + new Set( + leases.map( + (lease) => + `${lease.identity.hostId}:${lease.identity.hostRevision}:` + + `${lease.identity.hostKeyGeneration}:` + + lease.identity.authenticationIdentity + ) + ).size + ).toBe(5) + for (const lease of leases) { + lease.release() + } + pool.dispose() + }) + + it('lets one canceled waiter leave without canceling a shared connection', async () => { + let resolveConnection: + | ((connection: AuthenticatedSshConnection) => void) + | undefined + const factory: SshConnectionFactory = vi.fn( + (_target, identity) => + new Promise((resolve) => { + resolveConnection = resolve + expect(identity.authenticationIdentity).toMatch( + /^[a-f0-9]{64}$/u + ) + }) + ) + const pool = new SshConnectionPool(factory) + const controller = new AbortController() + const canceled = pool.acquire(createTarget(), controller.signal) + const surviving = pool.acquire(createTarget()) + controller.abort() + + await expect(canceled).rejects.toMatchObject({ + name: 'AbortError' + }) + const identity = ( + factory as ReturnType + ).mock.calls[0]?.[1] as SshPoolConnectionIdentity + resolveConnection?.(createConnection(identity)) + const lease = await surviving + expect(lease.isUsable()).toBe(true) + lease.release() + pool.dispose() + }) + + it('disposes all host entries and prevents use after release', async () => { + const connection = createConnection({ + hostId: createTarget().host.id, + hostRevision: 7, + hostKeyGeneration: 3, + authenticationIdentity: 'a'.repeat(64) + }) + const pool = new SshConnectionPool(async () => connection) + const lease = await pool.acquire(createTarget()) + const signal = new AbortController().signal + void lease.runAgentBootstrapProbe(signal) + expect( + connection.runAgentBootstrapProbe + ).toHaveBeenCalledWith(signal) + void lease.listDirectories('/srv', signal) + expect(connection.listDirectories).toHaveBeenCalledWith( + '/srv', + signal + ) + void lease.runAgentRuntimeAction( + verifyAgentInstallationId('agent-v1'), + { + kind: 'runtime-activate', + runtimeId: 'opencode', + bundleDigest: `sha256:${'a'.repeat(64)}`, + architecture: 'x64' + }, + signal + ) + expect(connection.runAgentRuntimeAction).toHaveBeenCalledWith( + 'agent-v1', + expect.objectContaining({ kind: 'runtime-activate' }), + signal + ) + lease.release() + + expect(() => + lease.openAgentAttach( + verifyAgentInstallationId('agent-v1') + ) + ).toThrow('租约已释放') + expect(() => + lease.runAgentBootstrapProbe() + ).toThrow('租约已释放') + expect(() => + lease.listDirectories() + ).toThrow('租约已释放') + expect(lease).not.toHaveProperty('exec') + expect(lease).not.toHaveProperty('sftp') + pool.disposeHost(createTarget().host.id) + expect(connection.dispose).toHaveBeenCalledOnce() + }) +}) + +describe('authenticated ssh2 connection', () => { + it('uses pinned algorithms, keepalive, no forwarding, and only fixed Agent exec', async () => { + let connectConfig: ConnectConfig | undefined + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn() + }) + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect( + this: EventEmitter, + config: ConnectConfig + ) { + connectConfig = config + expect( + ( + config.hostVerifier as + | ((key: Buffer) => boolean) + | undefined + )?.(pinnedHostKey) + ).toBe(true) + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + _options: unknown, + callback: ( + error: Error | undefined, + stream: typeof channel + ) => void + ) => callback(undefined, channel) + ), + sftp: vi.fn() + }) + const target = createTarget() + const identity: SshPoolConnectionIdentity = { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + } + const connection = await Ssh2AuthenticatedConnection.connect( + target, + identity, + new AbortController().signal, + { + createClient: () => client as never, + supportedOpenSslCiphers: () => ['aes-256-gcm'] + } + ) + + await connection.openAgentAttach( + verifyAgentInstallationId('agent-v1') + ) + + expect(connectConfig).toMatchObject({ + host: 'build.example.com', + port: 22, + username: 'builder', + password: 'private password', + agentForward: false, + keepaliveInterval: 5_000, + keepaliveCountMax: 2, + algorithms: { + cipher: ['aes256-gcm@openssh.com'] + } + }) + expect(client.exec).toHaveBeenCalledWith( + 'exec "$HOME/.goodbuddy/agent/installations/' + + 'agent-v1/goodbuddy-agent" attach-or-bootstrap ' + + '--installation-id agent-v1', + { + env: {}, + pty: false, + x11: false, + allowHalfOpen: false + }, + expect.any(Function) + ) + expect(connection).not.toHaveProperty('exec') + }) + + it('hard-fails a host-key mismatch and aborts pending authentication', async () => { + let authenticationAttempted = false + const mismatchedClient = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect( + this: EventEmitter, + config: ConnectConfig + ) { + const accepted = ( + config.hostVerifier as + | ((key: Buffer) => boolean) + | undefined + )?.(Buffer.from('different-key')) + if (accepted) { + authenticationAttempted = true + } + this.emit( + 'error', + Object.assign(new Error('key rejected'), { + level: 'handshake' + }) + ) + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn(), + sftp: vi.fn() + }) + const target = createTarget() + const identity = { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + } + + expect(() => + Ssh2AuthenticatedConnection.connect( + target, + { ...identity, hostRevision: identity.hostRevision + 1 }, + new AbortController().signal, + { createClient: () => mismatchedClient as never } + ) + ).toThrow('认证目标不匹配') + await expect( + Ssh2AuthenticatedConnection.connect( + target, + identity, + new AbortController().signal, + { createClient: () => mismatchedClient as never } + ) + ).rejects.toThrow('主机密钥已变化') + expect(authenticationAttempted).toBe(false) + expect(mismatchedClient.destroy).toHaveBeenCalledOnce() + + const pendingClient = Object.assign(new EventEmitter(), { + connect: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn(), + sftp: vi.fn() + }) + const controller = new AbortController() + const pending = Ssh2AuthenticatedConnection.connect( + target, + identity, + controller.signal, + { createClient: () => pendingClient as never } + ) + controller.abort() + await expect(pending).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(pendingClient.destroy).toHaveBeenCalledOnce() + }) + + it('bounds fixed diagnostic output and destroys an overflowing channel', async () => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn() + }) + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect(this: EventEmitter) { + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + _options: unknown, + callback: ( + error: Error | undefined, + stream: typeof channel + ) => void + ) => { + callback(undefined, channel) + setTimeout(() => { + channel.emit('data', Buffer.alloc(64 * 1024 + 1)) + }, 0) + } + ), + sftp: vi.fn() + }) + const target = createTarget() + const connection = await Ssh2AuthenticatedConnection.connect( + target, + { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + }, + new AbortController().signal, + { createClient: () => client as never } + ) + + await expect( + connection.runAgentDoctor( + verifyAgentInstallationId('agent-v1') + ) + ).rejects.toThrow('输出超过安全限制') + expect(client.exec.mock.calls[0]?.[0]).toContain( + '/goodbuddy-agent" doctor' + ) + expect(channel.destroy).toHaveBeenCalledOnce() + }) + + it('bounds bootstrap probe output and destroys an overflowing channel', async () => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn() + }) + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect(this: EventEmitter) { + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + _options: unknown, + callback: ( + error: Error | undefined, + stream: typeof channel + ) => void + ) => { + callback(undefined, channel) + setTimeout(() => { + channel.emit('data', Buffer.alloc(64 * 1024 + 1)) + }, 0) + } + ), + sftp: vi.fn() + }) + const target = createTarget() + const connection = await Ssh2AuthenticatedConnection.connect( + target, + { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + }, + new AbortController().signal, + { createClient: () => client as never } + ) + + await expect( + connection.runAgentBootstrapProbe() + ).rejects.toThrow('输出超过安全限制') + expect(client.exec.mock.calls[0]?.[0]).toBe( + AGENT_BOOTSTRAP_PROBE_COMMAND + ) + expect(channel.destroy).toHaveBeenCalledOnce() + }) + + it('runs the fixed bootstrap probe and collects split output', async () => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn() + }) + const output = [ + 'GOODBUDDY_AGENT_BOOTSTRAP_PROBE_V1', + 'home=/home/builder', + 'uid=1001', + 'os=Linux', + 'arch=aarch64', + 'shell=/bin/bash', + 'procfs=ready', + '' + ].join('\n') + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect(this: EventEmitter) { + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + _options: unknown, + callback: ( + error: Error | undefined, + stream: typeof channel + ) => void + ) => { + callback(undefined, channel) + setTimeout(() => { + channel.emit('data', output.slice(0, 17)) + channel.emit('data', Buffer.from(output.slice(17))) + channel.emit('close', 0) + }, 0) + } + ), + sftp: vi.fn() + }) + const target = createTarget() + const connection = await Ssh2AuthenticatedConnection.connect( + target, + { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + }, + new AbortController().signal, + { createClient: () => client as never } + ) + + await expect(connection.runAgentBootstrapProbe()).resolves.toMatchObject({ + ready: true, + architecture: 'arm64' + }) + expect(client.exec).toHaveBeenCalledWith( + AGENT_BOOTSTRAP_PROBE_COMMAND, + { + env: {}, + pty: false, + x11: false, + allowHalfOpen: false + }, + expect.any(Function) + ) + }) + + it('cancels a running bootstrap probe and destroys its channel', async () => { + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + destroy: vi.fn() + }) + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect(this: EventEmitter) { + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + _options: unknown, + callback: ( + error: Error | undefined, + stream: typeof channel + ) => void + ) => callback(undefined, channel) + ), + sftp: vi.fn() + }) + const target = createTarget() + const connection = await Ssh2AuthenticatedConnection.connect( + target, + { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: 'a'.repeat(64) + }, + new AbortController().signal, + { createClient: () => client as never } + ) + const controller = new AbortController() + const result = connection.runAgentBootstrapProbe(controller.signal) + controller.abort() + + await expect(result).rejects.toMatchObject({ + name: 'AbortError' + }) + expect(channel.destroy).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-connection-pool.ts b/src/main/ssh/ssh-connection-pool.ts new file mode 100644 index 00000000..376a7f54 --- /dev/null +++ b/src/main/ssh/ssh-connection-pool.ts @@ -0,0 +1,878 @@ +import { createHmac, getCiphers, randomBytes } from 'node:crypto' +import { Client } from 'ssh2' +import type { + ClientChannel, + ConnectConfig, + SFTPWrapper +} from 'ssh2' +import { + BoundedStagedSftp, + type BoundedSftpLimits, + type StagedSftp +} from './bounded-sftp' +import { listBoundedSftpDirectories } from './bounded-directory-sftp' +import type { ResolvedSshHost } from './ssh-host-store' +import { + AGENT_BOOTSTRAP_PROBE_COMMAND, + buildFixedAgentSshCommand, + parseAgentBootstrapProbeOutput, + type AgentLifecycleAction, + type AgentRuntimeActivationAction, + type VerifiedAgentInstallationId +} from './ssh-agent-command' +import type { + AgentBootstrapProbeResult, + SshHostDirectoryBrowseResult +} from '../../shared/ssh-host-contracts' +import { + buildAuthenticatedSshConnectConfig, + createStrongSshAlgorithms, + defaultSystemAgent, + safeSshError, + SSH_CONNECTION_TIMEOUT_MS +} from './ssh-transport' + +const FIXED_COMMAND_TIMEOUT_MS = 15_000 +const CHANNEL_OPEN_TIMEOUT_MS = 10_000 +const MAX_DIAGNOSTIC_OUTPUT_BYTES = 64 * 1024 +const DEFAULT_IDLE_TIMEOUT_MS = 60_000 +const MAX_POOL_ENTRIES = 32 + +export type SshConnectionPoolTarget = { + host: ResolvedSshHost + hostRevision: number + hostKeyGeneration: number +} + +export type SshPoolConnectionIdentity = { + hostId: string + hostRevision: number + hostKeyGeneration: number + authenticationIdentity: string +} + +export type AgentDiagnosticResult = { + exitCode: number | null + stdout: string + stderr: string +} + +export interface AuthenticatedSshConnection { + readonly identity: SshPoolConnectionIdentity + isUsable(): boolean + openAgentAttach( + installationId: VerifiedAgentInstallationId, + signal?: AbortSignal + ): Promise + runAgentDoctor( + installationId: VerifiedAgentInstallationId, + signal?: AbortSignal + ): Promise + runAgentBootstrapProbe( + signal?: AbortSignal + ): Promise + runAgentLifecycleAction( + installationId: VerifiedAgentInstallationId, + action: AgentLifecycleAction, + signal?: AbortSignal + ): Promise + runAgentRuntimeAction( + installationId: VerifiedAgentInstallationId, + action: AgentRuntimeActivationAction, + signal?: AbortSignal + ): Promise + openStagedSftp( + stagingDirectory: string, + limits?: BoundedSftpLimits, + signal?: AbortSignal + ): Promise + listDirectories( + path?: string, + signal?: AbortSignal + ): Promise + dispose(): void +} + +export interface SshConnectionLease + extends Omit { + release(): void +} + +type ClientLike = Pick< + Client, + | 'connect' + | 'end' + | 'destroy' + | 'exec' + | 'sftp' + | 'on' + | 'once' +> + +type SshConnectionDependencies = { + createClient: () => ClientLike + supportedOpenSslCiphers: () => readonly string[] + systemAgent: () => string | undefined +} + +export type SshConnectionFactory = ( + target: SshConnectionPoolTarget, + identity: SshPoolConnectionIdentity, + signal: AbortSignal +) => Promise + +type PoolEntry = { + key: string + identity: SshPoolConnectionIdentity + controller: AbortController + connectionPromise: Promise + connection?: AuthenticatedSshConnection + leases: number + waiters: number + idleTimer?: ReturnType +} + +function createAbortError(signal?: AbortSignal): unknown { + return ( + signal?.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) +} + +function validatePoolTarget(target: SshConnectionPoolTarget): void { + if ( + !target.host.id || + !Number.isSafeInteger(target.hostRevision) || + target.hostRevision <= 0 || + !Number.isSafeInteger(target.hostKeyGeneration) || + target.hostKeyGeneration <= 0 + ) { + throw new Error('SSH 连接身份无效') + } + if (!target.host.hostKey) { + throw new Error('请先验证并接受 SSH 主机密钥') + } + if ( + target.host.hostKey.generation !== + target.hostKeyGeneration + ) { + throw new Error('SSH 主机密钥代际不匹配') + } +} + +function validateConnectionIdentity( + target: SshConnectionPoolTarget, + identity: SshPoolConnectionIdentity +): void { + if ( + identity.hostId !== target.host.id || + identity.hostRevision !== target.hostRevision || + identity.hostKeyGeneration !== target.hostKeyGeneration || + !/^[a-f0-9]{64}$/u.test(identity.authenticationIdentity) + ) { + throw new Error('SSH 连接身份与认证目标不匹配') + } +} + +function createSshAuthenticationIdentity( + host: ResolvedSshHost, + systemAgent: string | undefined, + identityKey: Buffer +): string { + const credentialIdentity = + host.authentication === 'password' + ? host.password + : systemAgent + if (!credentialIdentity) { + throw new Error( + host.authentication === 'password' + ? 'SSH 主机尚未配置密码' + : '当前系统未检测到可用的 SSH Agent' + ) + } + return createHmac('sha256', identityKey) + .update( + JSON.stringify([ + 'goodbuddy-ssh-auth-v1', + host.id, + host.hostname, + host.port, + host.username, + host.authentication, + credentialIdentity + ]) + ) + .digest('hex') +} + +function poolKey(identity: SshPoolConnectionIdentity): string { + return [ + identity.hostId, + identity.hostRevision, + identity.hostKeyGeneration, + identity.authenticationIdentity + ].join('\0') +} + +function waitForSharedConnection( + promise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return promise + } + if (signal.aborted) { + return Promise.reject(createAbortError(signal)) + } + return new Promise((resolve, reject) => { + const abort = (): void => { + reject(createAbortError(signal)) + } + signal.addEventListener('abort', abort, { once: true }) + promise.then( + (connection) => { + signal.removeEventListener('abort', abort) + resolve(connection) + }, + (error: unknown) => { + signal.removeEventListener('abort', abort) + reject(error) + } + ) + }) +} + +export class Ssh2AuthenticatedConnection + implements AuthenticatedSshConnection +{ + private usable = true + private disposed = false + + private constructor( + readonly identity: SshPoolConnectionIdentity, + private readonly client: ClientLike + ) { + const disconnected = (): void => { + this.usable = false + } + this.client.on('close', disconnected) + this.client.on('end', disconnected) + this.client.on('error', disconnected) + } + + static connect( + target: SshConnectionPoolTarget, + identity: SshPoolConnectionIdentity, + signal: AbortSignal, + dependencies: Partial = {} + ): Promise { + validatePoolTarget(target) + validateConnectionIdentity(target, identity) + const resolvedDependencies: SshConnectionDependencies = { + createClient: () => new Client(), + supportedOpenSslCiphers: getCiphers, + systemAgent: defaultSystemAgent, + ...dependencies + } + const algorithms = createStrongSshAlgorithms( + resolvedDependencies.supportedOpenSslCiphers() + ) + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(createAbortError(signal)) + return + } + const client = resolvedDependencies.createClient() + let settled = false + let hostKeyMismatch = false + const settle = ( + connection?: Ssh2AuthenticatedConnection, + error?: unknown + ): void => { + if (settled) { + connection?.dispose() + return + } + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', abort) + if (error) { + client.destroy() + reject(error) + } else if (connection) { + resolve(connection) + } + } + const abort = (): void => { + settle(undefined, createAbortError(signal)) + } + const timeout = setTimeout(() => { + settle(undefined, new Error('SSH 连接超时')) + }, SSH_CONNECTION_TIMEOUT_MS) + signal.addEventListener('abort', abort, { once: true }) + client.once('error', (error) => { + settle( + undefined, + hostKeyMismatch + ? new Error( + 'SSH 主机密钥已变化,请重新验证主机身份' + ) + : safeSshError(error) + ) + }) + client.once('ready', () => { + settle(new Ssh2AuthenticatedConnection(identity, client)) + }) + let config: ConnectConfig + try { + config = buildAuthenticatedSshConnectConfig( + target.host, + algorithms, + resolvedDependencies.systemAgent(), + () => { + hostKeyMismatch = true + } + ) + } catch (error) { + settle(undefined, error) + return + } + client.connect(config) + }) + } + + isUsable(): boolean { + return this.usable + } + + async openAgentAttach( + installationId: VerifiedAgentInstallationId, + signal?: AbortSignal + ): Promise { + const channel = await this.openFixedChannel( + buildFixedAgentSshCommand(installationId, { + kind: 'attach' + }), + signal + ) + if (signal?.aborted) { + channel.destroy() + throw createAbortError(signal) + } + if (signal) { + const abort = (): void => { + channel.destroy() + } + signal.addEventListener('abort', abort, { once: true }) + channel.once('close', () => { + signal.removeEventListener('abort', abort) + }) + } + return channel + } + + async runAgentBootstrapProbe( + signal?: AbortSignal + ): Promise { + const result = await this.runBoundedCommand( + AGENT_BOOTSTRAP_PROBE_COMMAND, + signal + ) + if ( + result.exitCode !== 0 || + result.stderr.length !== 0 + ) { + throw new Error('Agent 启动探针执行失败') + } + return parseAgentBootstrapProbeOutput(result.stdout) + } + + runAgentDoctor( + installationId: VerifiedAgentInstallationId, + signal?: AbortSignal + ): Promise { + return this.runBoundedCommand( + buildFixedAgentSshCommand(installationId, { + kind: 'doctor' + }), + signal + ) + } + + runAgentLifecycleAction( + installationId: VerifiedAgentInstallationId, + action: AgentLifecycleAction, + signal?: AbortSignal + ): Promise { + return this.runBoundedCommand( + buildFixedAgentSshCommand(installationId, { + kind: 'lifecycle', + action + }), + signal + ) + } + + runAgentRuntimeAction( + installationId: VerifiedAgentInstallationId, + action: AgentRuntimeActivationAction, + signal?: AbortSignal + ): Promise { + return this.runBoundedCommand( + buildFixedAgentSshCommand(installationId, action), + signal + ) + } + + openStagedSftp( + stagingDirectory: string, + limits?: BoundedSftpLimits, + signal?: AbortSignal + ): Promise { + this.assertUsable() + if (signal?.aborted) { + return Promise.reject(createAbortError(signal)) + } + return new Promise((resolve, reject) => { + let settled = false + const finish = ( + error?: unknown, + sftp?: SFTPWrapper + ): void => { + if (settled) { + sftp?.end() + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + if (error || !sftp) { + reject(error ?? new Error('无法打开 SFTP 通道')) + return + } + try { + resolve( + new BoundedStagedSftp( + sftp, + stagingDirectory, + limits + ) + ) + } catch (validationError) { + sftp.end() + reject(validationError) + } + } + const abort = (): void => { + finish(createAbortError(signal)) + } + const timeout = setTimeout(() => { + finish(new Error('SFTP 通道打开超时')) + }, CHANNEL_OPEN_TIMEOUT_MS) + signal?.addEventListener('abort', abort, { once: true }) + this.client.sftp((error, sftp) => { + finish(error, sftp) + }) + }) + } + + listDirectories( + path?: string, + signal?: AbortSignal + ): Promise { + this.assertUsable() + return listBoundedSftpDirectories( + (callback) => { + this.client.sftp((error, sftp) => { + callback(error, sftp) + }) + }, + path, + signal + ) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.usable = false + this.client.end() + this.client.destroy() + } + + private assertUsable(): void { + if (!this.usable) { + throw new Error('SSH 连接已关闭') + } + } + + private openFixedChannel( + command: string, + signal?: AbortSignal + ): Promise { + this.assertUsable() + if (signal?.aborted) { + return Promise.reject(createAbortError(signal)) + } + return new Promise((resolve, reject) => { + let settled = false + const finish = ( + error?: unknown, + channel?: ClientChannel + ): void => { + if (settled) { + channel?.destroy() + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + if (error || !channel) { + reject(error ?? new Error('无法打开 Agent SSH 通道')) + } else { + resolve(channel) + } + } + const abort = (): void => { + finish(createAbortError(signal)) + } + const timeout = setTimeout(() => { + finish(new Error('Agent SSH 通道打开超时')) + }, CHANNEL_OPEN_TIMEOUT_MS) + signal?.addEventListener('abort', abort, { once: true }) + this.client.exec( + command, + { + env: {}, + pty: false, + x11: false, + allowHalfOpen: false + }, + (error, channel) => finish(error, channel) + ) + }) + } + + private async runBoundedCommand( + command: string, + signal?: AbortSignal + ): Promise { + const channel = await this.openFixedChannel(command, signal) + if (signal?.aborted) { + channel.destroy() + throw createAbortError(signal) + } + return new Promise((resolve, reject) => { + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let outputBytes = 0 + let settled = false + const finish = ( + result?: AgentDiagnosticResult, + error?: unknown + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + if (error) { + channel.destroy() + reject(error) + } else if (result) { + resolve(result) + } + } + const collect = ( + destination: Buffer[], + chunk: Buffer | string + ): void => { + const buffer = Buffer.from(chunk) + outputBytes += buffer.byteLength + if (outputBytes > MAX_DIAGNOSTIC_OUTPUT_BYTES) { + finish( + undefined, + new Error('Agent 诊断输出超过安全限制') + ) + return + } + destination.push(buffer) + } + const abort = (): void => { + finish(undefined, createAbortError(signal)) + } + const timeout = setTimeout(() => { + finish(undefined, new Error('Agent 诊断超时')) + }, FIXED_COMMAND_TIMEOUT_MS) + signal?.addEventListener('abort', abort, { once: true }) + channel.on('data', (chunk: Buffer | string) => { + collect(stdout, chunk) + }) + channel.stderr.on('data', (chunk: Buffer | string) => { + collect(stderr, chunk) + }) + channel.once('error', (error: Error) => { + finish(undefined, error) + }) + channel.once( + 'close', + (code: number | null) => { + finish({ + exitCode: typeof code === 'number' ? code : null, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8') + }) + } + ) + }) + } +} + +export class SshConnectionPool { + private readonly entries = new Map() + private readonly factory: SshConnectionFactory + private readonly systemAgent: () => string | undefined + private readonly idleTimeoutMs: number + private readonly authenticationIdentityKey = randomBytes(32) + private disposed = false + + constructor( + factory?: SshConnectionFactory, + options: { + systemAgent?: () => string | undefined + idleTimeoutMs?: number + } = {} + ) { + this.systemAgent = options.systemAgent ?? defaultSystemAgent + this.factory = + factory ?? + ((target, identity, signal) => + Ssh2AuthenticatedConnection.connect( + target, + identity, + signal, + { systemAgent: this.systemAgent } + )) + this.idleTimeoutMs = + options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + if ( + !Number.isSafeInteger(this.idleTimeoutMs) || + this.idleTimeoutMs < 0 + ) { + throw new Error('SSH 连接池空闲超时无效') + } + } + + async acquire( + target: SshConnectionPoolTarget, + signal?: AbortSignal + ): Promise { + if (this.disposed) { + throw new Error('SSH 连接池已关闭') + } + signal?.throwIfAborted() + validatePoolTarget(target) + const identity: SshPoolConnectionIdentity = { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + authenticationIdentity: createSshAuthenticationIdentity( + target.host, + this.systemAgent(), + this.authenticationIdentityKey + ) + } + const key = poolKey(identity) + let entry = this.entries.get(key) + if (entry?.connection && !entry.connection.isUsable()) { + this.disposeEntry(entry) + entry = undefined + } + if (!entry) { + if (this.entries.size >= MAX_POOL_ENTRIES) { + throw new Error('SSH 连接池已达到安全上限') + } + const controller = new AbortController() + const newEntry: PoolEntry = { + key, + identity, + controller, + connectionPromise: Promise.resolve(undefined as never), + leases: 0, + waiters: 0 + } + newEntry.connectionPromise = this.factory( + target, + identity, + controller.signal + ).then( + (connection) => { + if ( + this.disposed || + this.entries.get(key) !== newEntry + ) { + connection.dispose() + throw new Error('SSH 连接请求已失效') + } + newEntry.connection = connection + return connection + }, + (error: unknown) => { + if (this.entries.get(key) === newEntry) { + this.entries.delete(key) + } + throw error + } + ) + this.entries.set(key, newEntry) + entry = newEntry + } + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + entry.waiters += 1 + try { + const connection = await waitForSharedConnection( + entry.connectionPromise, + signal + ) + if (!connection.isUsable()) { + this.disposeEntry(entry) + throw new Error('SSH 连接已关闭') + } + entry.leases += 1 + return this.createLease(entry, connection) + } finally { + entry.waiters -= 1 + if (entry.waiters === 0 && entry.leases === 0) { + this.scheduleIdleDisposal(entry) + } + } + } + + disposeHost(hostId: string): void { + for (const entry of this.entries.values()) { + if (entry.identity.hostId === hostId) { + this.disposeEntry(entry) + } + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const entry of [...this.entries.values()]) { + this.disposeEntry(entry) + } + } + + private createLease( + entry: PoolEntry, + connection: AuthenticatedSshConnection + ): SshConnectionLease { + let released = false + const assertActive = (): void => { + if (released) { + throw new Error('SSH 连接租约已释放') + } + } + return { + identity: connection.identity, + isUsable: () => !released && connection.isUsable(), + openAgentAttach: (installationId, signal) => { + assertActive() + return connection.openAgentAttach(installationId, signal) + }, + runAgentDoctor: (installationId, signal) => { + assertActive() + return connection.runAgentDoctor(installationId, signal) + }, + runAgentBootstrapProbe: (signal) => { + assertActive() + return connection.runAgentBootstrapProbe(signal) + }, + runAgentLifecycleAction: ( + installationId, + action, + signal + ) => { + assertActive() + return connection.runAgentLifecycleAction( + installationId, + action, + signal + ) + }, + runAgentRuntimeAction: ( + installationId, + action, + signal + ) => { + assertActive() + return connection.runAgentRuntimeAction( + installationId, + action, + signal + ) + }, + openStagedSftp: (stagingDirectory, limits, signal) => { + assertActive() + return connection.openStagedSftp( + stagingDirectory, + limits, + signal + ) + }, + listDirectories: (path, signal) => { + assertActive() + return connection.listDirectories(path, signal) + }, + release: () => { + if (released) { + return + } + released = true + entry.leases -= 1 + if (entry.leases === 0 && entry.waiters === 0) { + this.scheduleIdleDisposal(entry) + } + } + } + } + + private scheduleIdleDisposal(entry: PoolEntry): void { + if ( + this.entries.get(entry.key) !== entry || + entry.idleTimer + ) { + return + } + if (!entry.connection || this.idleTimeoutMs === 0) { + this.disposeEntry(entry) + return + } + entry.idleTimer = setTimeout(() => { + if (entry.leases === 0 && entry.waiters === 0) { + this.disposeEntry(entry) + } + }, this.idleTimeoutMs) + } + + private disposeEntry(entry: PoolEntry): void { + if (this.entries.get(entry.key) === entry) { + this.entries.delete(entry.key) + } + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + entry.controller.abort() + entry.connection?.dispose() + } +} diff --git a/src/main/ssh/ssh-host-directory-browser.test.ts b/src/main/ssh/ssh-host-directory-browser.test.ts new file mode 100644 index 00000000..3f32e1c0 --- /dev/null +++ b/src/main/ssh/ssh-host-directory-browser.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshConnectionTarget } from './ssh-host-store' +import type { + SshConnectionLease, + SshPoolConnectionIdentity +} from './ssh-connection-pool' +import { SshHostDirectoryBrowser } from './ssh-host-directory-browser' + +const hostId = '00000000-0000-4000-8000-000000000301' + +function createTarget( + overrides: Partial = {} +): SshConnectionTarget { + return { + host: { + id: hostId, + name: 'Browse host', + hostname: 'browse.example.com', + port: 22, + username: 'browser', + authentication: 'password', + password: 'secret', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'key', + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + generation: 4 + } + }, + hostRevision: 9, + hostKeyGeneration: 4, + ...overrides + } +} + +function createLease( + identity: Partial = {}, + listDirectories = vi.fn(async () => ({ + path: '/home/browser', + homeDirectory: '/home/browser', + parentPath: '/home', + entries: [], + truncated: false + })) +): SshConnectionLease { + return { + identity: { + hostId, + hostRevision: 9, + hostKeyGeneration: 4, + authenticationIdentity: 'a'.repeat(64), + ...identity + }, + isUsable: vi.fn(() => true), + openAgentAttach: vi.fn(), + runAgentDoctor: vi.fn(), + runAgentBootstrapProbe: vi.fn(), + runAgentLifecycleAction: vi.fn(), + runAgentRuntimeAction: vi.fn(), + openStagedSftp: vi.fn(), + listDirectories, + release: vi.fn() + } +} + +function createBrowser(options: { + target?: SshConnectionTarget + lease?: ReturnType + events?: string[] +}) { + const events = options.events ?? [] + const target = options.target ?? createTarget() + const lease = options.lease ?? createLease() + const resolveConnectionTarget = vi.fn(async () => { + events.push('resolve') + return target + }) + const assertConnectionTargetCurrent = vi.fn(() => { + events.push('current') + }) + const acquire = vi.fn(async () => { + events.push('acquire') + return lease + }) + const originalList = lease.listDirectories + lease.listDirectories = vi.fn(async (path, signal) => { + events.push('list') + return originalList(path, signal) + }) + const originalRelease = lease.release + lease.release = vi.fn(() => { + events.push('release') + originalRelease() + }) + const browser = new SshHostDirectoryBrowser({ + sshHosts: { + resolveConnectionTarget, + assertConnectionTargetCurrent + }, + sshPool: { acquire } + }) + return { + browser, + events, + target, + lease, + resolveConnectionTarget, + assertConnectionTargetCurrent, + acquire + } +} + +describe('SSH host directory browser', () => { + it('checks the current target before and after browsing and always releases', async () => { + const test = createBrowser({}) + const signal = new AbortController().signal + + await expect( + test.browser.listDirectories( + hostId, + '/srv/projects', + signal + ) + ).resolves.toMatchObject({ + path: '/home/browser', + homeDirectory: '/home/browser' + }) + + expect(test.events).toEqual([ + 'resolve', + 'current', + 'acquire', + 'list', + 'current', + 'release' + ]) + expect( + test.assertConnectionTargetCurrent + ).toHaveBeenNthCalledWith(1, { + hostId, + hostRevision: 9, + hostKeyGeneration: 4, + username: 'browser' + }) + expect( + test.assertConnectionTargetCurrent + ).toHaveBeenNthCalledWith(2, { + hostId, + hostRevision: 9, + hostKeyGeneration: 4, + username: 'browser' + }) + expect(test.acquire).toHaveBeenCalledWith(test.target, signal) + expect(test.lease.listDirectories).toHaveBeenCalledWith( + '/srv/projects', + signal + ) + expect(test.lease.release).toHaveBeenCalledOnce() + }) + + it.each([ + ['host id', { hostId: 'other-host' }], + ['host revision', { hostRevision: 10 }], + ['host-key generation', { hostKeyGeneration: 5 }] + ])( + 'rejects a lease with a mismatched %s and releases it', + async (_label, identity) => { + const test = createBrowser({ + lease: createLease(identity) + }) + + await expect( + test.browser.listDirectories(hostId) + ).rejects.toThrow('连接身份不匹配') + expect(test.lease.listDirectories).not.toHaveBeenCalled() + expect(test.lease.release).toHaveBeenCalledOnce() + expect( + test.assertConnectionTargetCurrent + ).toHaveBeenCalledOnce() + } + ) + + it('rejects a target resolved for another host before acquiring', async () => { + const target = createTarget() + target.host.id = '00000000-0000-4000-8000-000000000302' + const test = createBrowser({ target }) + + await expect( + test.browser.listDirectories(hostId) + ).rejects.toThrow('连接目标无效') + expect(test.acquire).not.toHaveBeenCalled() + expect( + test.assertConnectionTargetCurrent + ).not.toHaveBeenCalled() + }) + + it('rejects a host change after listing and releases the lease', async () => { + const test = createBrowser({}) + test.assertConnectionTargetCurrent + .mockImplementationOnce(() => { + test.events.push('current') + }) + .mockImplementationOnce(() => { + test.events.push('current') + throw new Error('SSH 主机配置已变化,请重新验证') + }) + + await expect( + test.browser.listDirectories(hostId) + ).rejects.toThrow('主机配置已变化') + expect(test.events.at(-1)).toBe('release') + expect(test.lease.release).toHaveBeenCalledOnce() + }) + + it('releases the lease when directory browsing fails', async () => { + const test = createBrowser({ + lease: createLease( + {}, + vi.fn(async () => { + throw new Error('SFTP unavailable') + }) + ) + }) + + await expect( + test.browser.listDirectories(hostId) + ).rejects.toThrow('SFTP unavailable') + expect(test.lease.release).toHaveBeenCalledOnce() + expect( + test.assertConnectionTargetCurrent + ).toHaveBeenCalledOnce() + }) + + it('honors cancellation before target resolution and after acquisition', async () => { + const alreadyCanceled = new AbortController() + alreadyCanceled.abort() + const early = createBrowser({}) + await expect( + early.browser.listDirectories( + hostId, + undefined, + alreadyCanceled.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(early.resolveConnectionTarget).not.toHaveBeenCalled() + + const controller = new AbortController() + const late = createBrowser({}) + late.acquire.mockImplementationOnce(async () => { + late.events.push('acquire') + controller.abort() + return late.lease + }) + await expect( + late.browser.listDirectories( + hostId, + undefined, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(late.lease.listDirectories).not.toHaveBeenCalled() + expect(late.lease.release).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-host-directory-browser.ts b/src/main/ssh/ssh-host-directory-browser.ts new file mode 100644 index 00000000..f2ac9e97 --- /dev/null +++ b/src/main/ssh/ssh-host-directory-browser.ts @@ -0,0 +1,99 @@ +import type { + SshHostDirectoryBrowseResult +} from '../../shared/ssh-host-contracts' +import type { + CurrentSshConnectionTarget, + SshConnectionTarget, + SshHostStore +} from './ssh-host-store' +import type { + SshConnectionLease, + SshConnectionPool +} from './ssh-connection-pool' + +export type SshHostDirectoryBrowserOptions = { + sshHosts: Pick< + SshHostStore, + 'resolveConnectionTarget' | 'assertConnectionTargetCurrent' + > + sshPool: Pick +} + +function currentTarget( + target: SshConnectionTarget +): CurrentSshConnectionTarget { + return { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + username: target.host.username + } +} + +function assertTarget( + requestedHostId: string, + target: SshConnectionTarget +): void { + if ( + target.host.id !== requestedHostId || + target.hostRevision < 1 || + target.hostKeyGeneration < 1 || + !target.host.hostKey || + target.host.hostKey.generation !== + target.hostKeyGeneration + ) { + throw new Error('SSH 主机连接目标无效') + } +} + +function assertLeaseIdentity( + target: SshConnectionTarget, + lease: SshConnectionLease +): void { + if ( + lease.identity.hostId !== target.host.id || + lease.identity.hostRevision !== target.hostRevision || + lease.identity.hostKeyGeneration !== + target.hostKeyGeneration + ) { + throw new Error('SSH 目录浏览连接身份不匹配') + } +} + +export class SshHostDirectoryBrowser { + readonly #sshHosts: SshHostDirectoryBrowserOptions['sshHosts'] + readonly #sshPool: SshHostDirectoryBrowserOptions['sshPool'] + + constructor(options: SshHostDirectoryBrowserOptions) { + this.#sshHosts = options.sshHosts + this.#sshPool = options.sshPool + } + + async listDirectories( + hostId: string, + path?: string, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + const target = + await this.#sshHosts.resolveConnectionTarget(hostId) + signal?.throwIfAborted() + assertTarget(hostId, target) + const expected = currentTarget(target) + this.#sshHosts.assertConnectionTargetCurrent(expected) + + let lease: SshConnectionLease | undefined + try { + lease = await this.#sshPool.acquire(target, signal) + signal?.throwIfAborted() + assertLeaseIdentity(target, lease) + const listing = await lease.listDirectories(path, signal) + signal?.throwIfAborted() + assertLeaseIdentity(target, lease) + this.#sshHosts.assertConnectionTargetCurrent(expected) + return listing + } finally { + lease?.release() + } + } +} diff --git a/src/main/ssh/ssh-host-remote-environment.test.ts b/src/main/ssh/ssh-host-remote-environment.test.ts new file mode 100644 index 00000000..96507a35 --- /dev/null +++ b/src/main/ssh/ssh-host-remote-environment.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StagedSftp } from './bounded-sftp' +import type { + SshConnectionLease, + SshConnectionPoolTarget +} from './ssh-connection-pool' +import { + SshHostRemoteEnvironmentInspector +} from './ssh-host-remote-environment' + +const hostId = '00000000-0000-4000-8000-000000000401' +const digest = `sha256:${'a'.repeat(64)}` + +function target(): SshConnectionPoolTarget { + return { + host: { + id: hostId, + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: 'AAAA', + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + generation: 2 + } + }, + hostRevision: 3, + hostKeyGeneration: 2 + } +} + +function agentEntry(version = '0.11.1', digestCharacter = 'a') { + const manifestSha256 = digestCharacter.repeat(64) + return { + installationId: `agent-${manifestSha256}`, + agentVersion: version, + manifestSha256, + arch: 'x64' as const + } +} + +function runtimeEntry(version = '1.18.9') { + return { + runtimeId: 'opencode', + runtimeVersion: version, + architecture: 'x64' as const, + bundleDigest: digest, + manifestDigest: digest, + acpCapabilitiesDigest: digest + } +} + +function missing(): Error & { code: number } { + return Object.assign(new Error('missing'), { code: 2 }) +} + +function harness(registries: { + agent?: unknown + runtime?: unknown +} = {}) { + const release = vi.fn() + const close = vi.fn() + const readFile = vi.fn(async (path: string) => { + const value = + path === '.goodbuddy/agent/registry.json' + ? registries.agent + : path === '.goodbuddy/runtimes/registry.json' + ? registries.runtime + : undefined + if (value === undefined) { + throw missing() + } + return Buffer.from(JSON.stringify(value)) + }) + const sftp = { readFile, close } as unknown as StagedSftp + const currentTarget = target() + const lease = { + identity: { + hostId, + hostRevision: 3, + hostKeyGeneration: 2, + authenticationIdentity: 'b'.repeat(64) + }, + runAgentBootstrapProbe: vi.fn(async () => ({ + ready: true as const, + platform: 'linux' as const, + architecture: 'x64' as const, + canonicalHomeDirectory: '/home/builder', + uid: 1_000, + shell: '/bin/bash', + procfs: 'ready' as const + })), + openStagedSftp: vi.fn(async () => sftp), + release + } as unknown as SshConnectionLease + const assertConnectionTargetCurrent = vi.fn() + const inspector = new SshHostRemoteEnvironmentInspector({ + sshHosts: { + resolveConnectionTarget: vi.fn(async () => currentTarget), + assertConnectionTargetCurrent + }, + sshPool: { + acquire: vi.fn(async () => lease) + }, + agentRuntimeLockPath: 'unused', + remoteRuntimeLockPath: 'unused', + loadExpectedCatalog: async () => ({ + agent: { version: '0.11.1' }, + runtimes: [{ + runtimeId: 'opencode', + provider: 'opencode', + version: '1.18.9' + }] + }), + now: () => new Date('2030-01-01T00:00:00.000Z') + }) + return { + inspector, + lease, + close, + release, + assertConnectionTargetCurrent + } +} + +describe('SshHostRemoteEnvironmentInspector', () => { + it('reports installed Agent and Runtime versions against the desktop catalog', async () => { + const value = harness({ + agent: { + formatVersion: 1, + minimumTrustedReleaseSequence: 1, + current: { + ...agentEntry(), + productVersion: '0.11.0', + releaseSequence: 4, + binaryDigest: `sha256:${'a'.repeat(64)}`, + protocol: { major: 1, minor: 0 }, + signingKeyId: 'test-key', + previouslyVerified: true + }, + draining: [] + }, + runtime: { + formatVersion: 1, + minimumTrustedReleaseSequence: 1, + current: [{ + ...runtimeEntry(), + provider: 'opencode', + releaseSequence: 3, + signingKeyId: 'test-key' + }] + } + }) + + await expect(value.inspector.inspect(hostId)).resolves.toEqual({ + hostId, + checkedAt: '2030-01-01T00:00:00.000Z', + architecture: 'x64', + agent: { + state: 'current', + expected: { + version: '0.11.1', + architecture: 'x64' + }, + installed: { + version: '0.11.1', + architecture: 'x64' + } + }, + runtimes: [{ + runtimeId: 'opencode', + provider: 'opencode', + state: 'current', + expected: { + version: '1.18.9', + architecture: 'x64' + }, + installed: { + version: '1.18.9', + architecture: 'x64' + } + }] + }) + expect(value.close).toHaveBeenCalledOnce() + expect(value.release).toHaveBeenCalledOnce() + expect(value.assertConnectionTargetCurrent).toHaveBeenCalledTimes(2) + }) + + it('reports an older Agent and a missing Runtime without installing either', async () => { + const value = harness({ + agent: { + formatVersion: 1, + current: agentEntry('0.11.0', 'b') + } + }) + + await expect(value.inspector.inspect(hostId)).resolves.toMatchObject({ + agent: { + state: 'update-available', + installed: { version: '0.11.0' } + }, + runtimes: [{ + runtimeId: 'opencode', + state: 'not-installed', + installed: null + }] + }) + expect(value.lease.runAgentBootstrapProbe).toHaveBeenCalledOnce() + expect(value.lease.openStagedSftp).toHaveBeenCalledOnce() + }) + + it('rejects corrupt remote metadata and still closes every lease', async () => { + const value = harness({ + agent: { + formatVersion: 1, + current: { command: 'untrusted' } + } + }) + + await expect(value.inspector.inspect(hostId)).rejects.toThrow( + '元数据已损坏' + ) + expect(value.close).toHaveBeenCalledOnce() + expect(value.release).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ssh/ssh-host-remote-environment.ts b/src/main/ssh/ssh-host-remote-environment.ts new file mode 100644 index 00000000..73a2970b --- /dev/null +++ b/src/main/ssh/ssh-host-remote-environment.ts @@ -0,0 +1,341 @@ +import { readFile } from 'node:fs/promises' +import { + agentRuntimeLockSchema, + type AgentRuntimeLock +} from '../../shared/agent-installation-contracts' +import { + remoteRuntimeLockSchema, + type RemoteRuntimeLock +} from '../../shared/remote-runtime-launch-contracts' +import { + sshHostRemoteEnvironmentSchema, + type SshHostRemoteEnvironment +} from '../../shared/ssh-host-contracts' +import { + parseInstallationRegistryState, + parseRuntimeRegistryState, + type InstallationRegistryEntry, + type RuntimeRegistryEntry +} from '../../shared/remote-environment-registry-contracts' +import type { StagedSftp } from './bounded-sftp' +import type { + CurrentSshConnectionTarget, + SshConnectionTarget, + SshHostStore +} from './ssh-host-store' +import type { + SshConnectionLease, + SshConnectionPool +} from './ssh-connection-pool' + +const AGENT_REGISTRY_PATH = '.goodbuddy/agent/registry.json' +const RUNTIME_REGISTRY_PATH = '.goodbuddy/runtimes/registry.json' +const MAXIMUM_REGISTRY_BYTES = 256 * 1024 + +type ExpectedCatalog = { + agent: { + version: string + } + runtimes: Array<{ + runtimeId: 'opencode' + provider: 'opencode' + version: string + }> +} + +export type SshHostRemoteEnvironmentInspectorOptions = { + sshHosts: Pick< + SshHostStore, + 'resolveConnectionTarget' | 'assertConnectionTargetCurrent' + > + sshPool: Pick + agentRuntimeLockPath: string + remoteRuntimeLockPath: string + loadExpectedCatalog?: () => Promise + now?: () => Date +} + +export class SshHostRemoteEnvironmentInspector { + readonly #sshHosts: SshHostRemoteEnvironmentInspectorOptions['sshHosts'] + readonly #sshPool: SshHostRemoteEnvironmentInspectorOptions['sshPool'] + readonly #loadExpectedCatalog: () => Promise + readonly #now: () => Date + #expectedCatalog?: Promise + + constructor(options: SshHostRemoteEnvironmentInspectorOptions) { + this.#sshHosts = options.sshHosts + this.#sshPool = options.sshPool + this.#loadExpectedCatalog = + options.loadExpectedCatalog ?? + (() => + loadExpectedCatalog( + options.agentRuntimeLockPath, + options.remoteRuntimeLockPath + )) + this.#now = options.now ?? (() => new Date()) + } + + async inspect( + hostId: string, + signal?: AbortSignal + ): Promise { + signal?.throwIfAborted() + const target = + await this.#sshHosts.resolveConnectionTarget(hostId) + signal?.throwIfAborted() + assertTarget(hostId, target) + const expectedTarget = currentTarget(target) + this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) + + let lease: SshConnectionLease | undefined + let sftp: StagedSftp | undefined + try { + lease = await this.#sshPool.acquire(target, signal) + assertLeaseIdentity(target, lease) + const probe = await lease.runAgentBootstrapProbe(signal) + signal?.throwIfAborted() + if (!probe.ready) { + throw new Error( + `远端系统不能运行 GoodBuddy Agent:${probe.reason}` + ) + } + sftp = await lease.openStagedSftp( + probe.canonicalHomeDirectory, + { + maximumFileBytes: MAXIMUM_REGISTRY_BYTES, + maximumTotalBytes: MAXIMUM_REGISTRY_BYTES * 2, + maximumOperations: 8, + operationTimeoutMs: 15_000 + }, + signal + ) + const [catalog, agentRegistry, runtimeRegistry] = + await Promise.all([ + this.#expected(), + readOptionalRegistry( + sftp, + AGENT_REGISTRY_PATH, + { parse: parseInstallationRegistryState }, + signal + ), + readOptionalRegistry( + sftp, + RUNTIME_REGISTRY_PATH, + { parse: parseRuntimeRegistryState }, + signal + ) + ]) + signal?.throwIfAborted() + assertLeaseIdentity(target, lease) + this.#sshHosts.assertConnectionTargetCurrent(expectedTarget) + return sshHostRemoteEnvironmentSchema.parse({ + hostId, + checkedAt: this.#now().toISOString(), + architecture: probe.architecture, + agent: agentStatus( + catalog.agent, + agentRegistry?.current, + probe.architecture + ), + runtimes: catalog.runtimes.map((runtime) => + runtimeStatus( + runtime, + runtimeRegistry?.current.find( + (installed) => + installed.runtimeId === runtime.runtimeId && + installed.architecture === probe.architecture + ), + probe.architecture + ) + ) + }) + } finally { + sftp?.close() + lease?.release() + } + } + + #expected(): Promise { + this.#expectedCatalog ??= this.#loadExpectedCatalog() + return this.#expectedCatalog + } +} + +function currentTarget( + target: SshConnectionTarget +): CurrentSshConnectionTarget { + return { + hostId: target.host.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + username: target.host.username + } +} + +function assertTarget( + requestedHostId: string, + target: SshConnectionTarget +): void { + if ( + target.host.id !== requestedHostId || + target.hostRevision < 1 || + target.hostKeyGeneration < 1 || + !target.host.hostKey || + target.host.hostKey.generation !== target.hostKeyGeneration + ) { + throw new Error('SSH 主机连接目标无效') + } +} + +function assertLeaseIdentity( + target: SshConnectionTarget, + lease: SshConnectionLease +): void { + if ( + lease.identity.hostId !== target.host.id || + lease.identity.hostRevision !== target.hostRevision || + lease.identity.hostKeyGeneration !== target.hostKeyGeneration + ) { + throw new Error('SSH 远端运行环境连接身份不匹配') + } +} + +async function readOptionalRegistry( + sftp: StagedSftp, + path: string, + schema: { parse(value: unknown): T }, + signal?: AbortSignal +): Promise { + let bytes: Buffer + try { + bytes = await sftp.readFile(path, signal) + } catch (error) { + if (isMissingPathError(error)) { + return undefined + } + throw error + } + let value: unknown + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) + } catch (error) { + throw new Error('远端 GoodBuddy 运行环境元数据无效', { + cause: error + }) + } + try { + return schema.parse(value) + } catch (error) { + throw new Error('远端 GoodBuddy 运行环境元数据已损坏', { + cause: error + }) + } +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + ( + (error as { code?: unknown }).code === 2 || + (error as { code?: unknown }).code === 'ENOENT' + ) + ) +} + +function environmentState( + installedVersion: string | undefined, + expectedVersion: string +): 'current' | 'update-available' | 'not-installed' { + if (!installedVersion) { + return 'not-installed' + } + return installedVersion === expectedVersion + ? 'current' + : 'update-available' +} + +function agentStatus( + expected: ExpectedCatalog['agent'], + installed: InstallationRegistryEntry | undefined, + architecture: 'x64' | 'arm64' +) { + if (installed && installed.arch !== architecture) { + throw new Error('远端 Agent registry 架构与主机不匹配') + } + const installedVersion = installed + ? { + version: installed.agentVersion, + architecture: installed.arch + } + : null + return { + state: environmentState(installed?.agentVersion, expected.version), + expected: { ...expected, architecture }, + installed: installedVersion + } +} + +function runtimeStatus( + expected: ExpectedCatalog['runtimes'][number], + installed: RuntimeRegistryEntry | undefined, + architecture: 'x64' | 'arm64' +) { + const installedVersion = installed + ? { + version: installed.runtimeVersion, + architecture: installed.architecture + } + : null + return { + runtimeId: expected.runtimeId, + provider: expected.provider, + state: environmentState(installed?.runtimeVersion, expected.version), + expected: { + version: expected.version, + architecture + }, + installed: installedVersion + } +} + +async function loadExpectedCatalog( + agentRuntimeLockPath: string, + remoteRuntimeLockPath: string +): Promise { + const [agentBytes, runtimeBytes] = await Promise.all([ + readFile(agentRuntimeLockPath), + readFile(remoteRuntimeLockPath) + ]) + let agentLock: AgentRuntimeLock + let runtimeLock: RemoteRuntimeLock + try { + agentLock = agentRuntimeLockSchema.parse( + JSON.parse(agentBytes.toString('utf8')) + ) + runtimeLock = remoteRuntimeLockSchema.parse( + JSON.parse(runtimeBytes.toString('utf8')) + ) + } catch (error) { + throw new Error('GoodBuddy 内置远端运行环境版本元数据无效', { + cause: error + }) + } + return { + agent: { + version: agentLock.agentVersion + }, + runtimes: Object.entries(runtimeLock.runtimes) + .map(([runtimeId, runtime]) => ({ + runtimeId: runtimeId as 'opencode', + provider: runtime.provider, + version: runtime.version + })) + .sort((left, right) => + left.runtimeId.localeCompare(right.runtimeId, 'en') + ) + } +} diff --git a/src/main/ssh/ssh-host-service.test.ts b/src/main/ssh/ssh-host-service.test.ts new file mode 100644 index 00000000..28488816 --- /dev/null +++ b/src/main/ssh/ssh-host-service.test.ts @@ -0,0 +1,736 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SettingsCredentialCipher } from '../settings-credential-cipher' +import { + SshHostService, + type SshHostLifecycleHooks +} from './ssh-host-service' +import { SshHostStore } from './ssh-host-store' +import type { + SshHostKeyCandidate, + SshTransport +} from './ssh-transport' + +const roots: string[] = [] +const keyA: SshHostKeyCandidate = { + algorithm: 'ssh-ed25519', + publicKeyBase64: Buffer.from('key-a').toString('base64'), + fingerprintSha256: `SHA256:${'A'.repeat(43)}` +} +const keyB: SshHostKeyCandidate = { + algorithm: 'ssh-ed25519', + publicKeyBase64: Buffer.from('key-b').toString('base64'), + fingerprintSha256: `SHA256:${'B'.repeat(43)}` +} +const decrypt = vi.fn((value: Buffer) => value.toString()) +const cipher: SettingsCredentialCipher = { + isAvailable: () => true, + encrypt: (value) => Buffer.from(value), + decrypt +} +const newPasswordHost = { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'password' as const, + password: { + action: 'replace' as const, + value: 'private password' + } +} + +async function createHarness( + now = 1_000, + lifecycleHooks: SshHostLifecycleHooks = {} +): Promise<{ + service: SshHostService + store: SshHostStore + inspectHostKey: ReturnType + testConnection: ReturnType + setNow: (value: number) => void +}> { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-service-')) + roots.push(root) + const store = new SshHostStore(join(root, 'ssh-hosts.json'), cipher) + const inspectHostKey = vi.fn(async () => keyA) + const testConnection = vi.fn(async () => ({ + connected: true as const, + latencyMs: 12, + platform: 'linux' as const, + architecture: 'x64' as const, + shell: '/bin/bash', + homeDirectory: '/home/builder', + detail: 'SSH 已连接,远端系统为 linux/x64' + })) + const transport: SshTransport = { + inspectHostKey, + testConnection + } + return { + service: new SshHostService( + store, + transport, + () => now, + lifecycleHooks + ), + store, + inspectHostKey, + testConnection, + setNow: (value) => { + now = value + } + } +} + +afterEach(async () => { + vi.clearAllMocks() + await Promise.all( + roots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('SshHostService', () => { + it('inspects a new draft before authentication without persisting it', async () => { + const harness = await createHarness() + + const inspection = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + + expect(inspection).toMatchObject({ + state: 'unverified', + algorithm: keyA.algorithm, + fingerprintSha256: keyA.fingerprintSha256 + }) + expect(inspection.candidateId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u + ) + expect(harness.inspectHostKey).toHaveBeenCalledWith({ + hostname: 'build.example.com', + port: 22, + username: 'builder' + }) + expect(harness.testConnection).not.toHaveBeenCalled() + await expect(harness.service.getSnapshot()).resolves.toMatchObject({ + hosts: [] + }) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyB.fingerprintSha256, + input: newPasswordHost + }) + ).rejects.toThrow('指纹不匹配') + await expect(harness.service.getSnapshot()).resolves.toMatchObject({ + hosts: [] + }) + + harness.setNow(301_000) + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + }) + ).rejects.toThrow('已过期') + }) + + it('persists a host only after authentication and the probe succeed', async () => { + const harness = await createHarness() + const inspection = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + harness.testConnection + .mockRejectedValueOnce(new Error('SSH 认证失败')) + .mockResolvedValueOnce({ + connected: true, + latencyMs: 12, + platform: 'linux', + architecture: 'x64', + shell: '/bin/bash', + homeDirectory: '/home/builder', + detail: 'SSH 已连接,远端系统为 linux/x64' + }) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + }) + ).rejects.toThrow('SSH 认证失败') + await expect(harness.service.getSnapshot()).resolves.toMatchObject({ + hosts: [] + }) + + const result = await harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + password: { + action: 'replace', + value: 'corrected password' + } + } + }) + + expect(result).toMatchObject({ + host: { + name: 'Build host', + hostKey: { + state: 'verified', + fingerprintSha256: keyA.fingerprintSha256, + generation: 1 + } + }, + connection: { + connected: true, + platform: 'linux' + } + }) + expect(result.connection.hostId).toBe(result.host.id) + expect(harness.testConnection).toHaveBeenLastCalledWith( + expect.objectContaining({ + password: 'corrected password', + hostKey: keyA + }) + ) + expect(JSON.stringify(await harness.service.getSnapshot())).not.toContain( + 'corrected password' + ) + }) + + it('binds candidates to the inspected target and discards them on request', async () => { + const harness = await createHarness() + const inspection = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + hostname: 'other.example.com' + } + }) + ).rejects.toThrow('配置已变化') + expect(harness.testConnection).not.toHaveBeenCalled() + + harness.service.discardCandidate(inspection.candidateId) + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + }) + ).rejects.toThrow('重新检查') + }) + + it('supersedes older candidates for the same draft target', async () => { + const harness = await createHarness() + const first = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + const second = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + + await expect( + harness.service.validateAndSave({ + candidateId: first.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + }) + ).rejects.toThrow('重新检查') + await expect( + harness.service.validateAndSave({ + candidateId: second.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + }) + ).resolves.toMatchObject({ + host: { name: 'Build host' } + }) + expect(harness.testConnection).toHaveBeenCalledOnce() + }) + + it('rejects duplicate in-flight inspections for the same target', async () => { + const harness = await createHarness() + let finishInspection: + | ((candidate: SshHostKeyCandidate) => void) + | undefined + harness.inspectHostKey.mockImplementationOnce( + () => + new Promise((resolve) => { + finishInspection = resolve + }) + ) + const request = { + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + } + const first = harness.service.inspectDraftHostKey(request) + await vi.waitFor(() => + expect(harness.inspectHostKey).toHaveBeenCalledOnce() + ) + + await expect( + harness.service.inspectDraftHostKey(request) + ).rejects.toThrow('正在检查') + finishInspection?.(keyA) + await expect(first).resolves.toMatchObject({ + fingerprintSha256: keyA.fingerprintSha256 + }) + }) + + it('classifies changed keys and atomically replaces a validated edit', async () => { + const harness = await createHarness() + const host = await harness.store.commitValidated({ + input: { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'password', + password: { + action: 'replace', + value: 'private password' + } + }, + hostKey: keyA + }) + const decryptionsBeforeInspection = decrypt.mock.calls.length + harness.inspectHostKey.mockResolvedValueOnce(keyB) + + const inspection = await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: 'build.example.com', + port: 22, + username: 'builder' + }) + + expect(inspection).toMatchObject({ + hostId: host.id, + state: 'changed', + fingerprintSha256: keyB.fingerprintSha256, + previousHostKey: { + algorithm: keyA.algorithm, + fingerprintSha256: keyA.fingerprintSha256 + } + }) + expect(decrypt).toHaveBeenCalledTimes(decryptionsBeforeInspection) + const resolveConnectionTarget = vi.spyOn( + harness.store, + 'resolveConnectionTarget' + ) + const result = await harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyB.fingerprintSha256, + input: { + ...newPasswordHost, + name: 'Renamed host', + password: { action: 'keep' } + } + }) + + expect(result.host).toMatchObject({ + id: host.id, + name: 'Renamed host', + hostKey: { + state: 'verified', + fingerprintSha256: keyB.fingerprintSha256, + generation: 2 + } + }) + expect(harness.testConnection).toHaveBeenCalledWith( + expect.objectContaining({ + id: host.id, + password: 'private password', + hostKey: keyB + }) + ) + expect(resolveConnectionTarget).toHaveBeenCalledOnce() + }) + + it.each([ + [ + 'hostname', + { + hostname: 'moved.example.com' + } + ], + [ + 'port', + { + port: 2222 + } + ], + [ + 'username', + { + username: 'deployer' + } + ], + [ + 'authentication', + { + authentication: 'system-agent' as const + } + ] + ])( + 'rejects credential keep before resolution when %s changes', + async (_field, change) => { + const harness = await createHarness() + const host = await harness.store.commitValidated({ + input: newPasswordHost, + hostKey: keyA + }) + const draft = { + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username, + ...change + } + const inspection = + await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: draft.hostname, + port: draft.port, + username: draft.username + }) + const resolveConnectionTarget = vi.spyOn( + harness.store, + 'resolveConnectionTarget' + ) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + ...change, + password: { action: 'keep' } + } + }) + ).rejects.toThrow( + '地址、端口、用户名或认证方式已变化' + ) + expect(resolveConnectionTarget).toHaveBeenCalledOnce() + expect(harness.testConnection).not.toHaveBeenCalled() + } + ) + + it('allows credential keep when target and authentication are unchanged', async () => { + const harness = await createHarness() + const host = await harness.store.commitValidated({ + input: newPasswordHost, + hostKey: keyA + }) + const inspection = await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: host.hostname, + port: host.port, + username: host.username + }) + const resolveConnectionTarget = vi.spyOn( + harness.store, + 'resolveConnectionTarget' + ) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + name: 'Renamed host', + password: { action: 'keep' } + } + }) + ).resolves.toMatchObject({ + host: { + id: host.id, + name: 'Renamed host' + } + }) + expect(resolveConnectionTarget).toHaveBeenCalledOnce() + expect(harness.testConnection).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: 'password', + password: 'private password' + }) + ) + }) + + it('rejects an edit when the durable host changes after inspection', async () => { + const harness = await createHarness() + const host = await harness.store.commitValidated({ + input: { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + const inspection = await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: host.hostname, + port: host.port, + username: host.username + }) + const identity = await harness.store.getHostIdentity(host.id) + await harness.store.commitValidated({ + hostId: host.id, + expectedRevision: identity.revision, + input: { + name: 'Changed elsewhere', + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + name: 'Stale edit', + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: 'system-agent', + password: { action: 'clear' } + } + }) + ).rejects.toThrow('配置已变化') + expect(harness.testConnection).not.toHaveBeenCalled() + }) + + it('validates target edits against the draft before replacing the durable host', async () => { + const harness = await createHarness() + const host = await harness.store.commitValidated({ + input: { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'password', + password: { + action: 'replace', + value: 'old password' + } + }, + hostKey: keyA + }) + harness.inspectHostKey.mockResolvedValueOnce(keyB) + const inspection = await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: 'moved.example.com', + port: 2222, + username: 'deployer' + }) + + expect(inspection).toMatchObject({ + state: 'unverified', + fingerprintSha256: keyB.fingerprintSha256 + }) + const result = await harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyB.fingerprintSha256, + input: { + name: 'Moved host', + hostname: 'moved.example.com', + port: 2222, + username: 'deployer', + authentication: 'password', + password: { + action: 'replace', + value: 'new password' + } + } + }) + + expect(result.host).toMatchObject({ + id: host.id, + hostname: 'moved.example.com', + port: 2222, + username: 'deployer', + hostKey: { + fingerprintSha256: keyB.fingerprintSha256, + generation: 2 + } + }) + expect(harness.testConnection).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: 'moved.example.com', + port: 2222, + username: 'deployer', + password: 'new password' + }) + ) + }) + + it('fires credential-free lifecycle hooks only after successful edit and removal commits', async () => { + const onHostEdited = vi.fn() + const onHostRemoved = vi.fn() + const harness = await createHarness(1_000, { + onHostEdited, + onHostRemoved + }) + const host = await harness.store.commitValidated({ + input: newPasswordHost, + hostKey: keyA + }) + const inspection = await harness.service.inspectDraftHostKey({ + hostId: host.id, + hostname: host.hostname, + port: host.port, + username: host.username + }) + harness.testConnection.mockRejectedValueOnce( + new Error('SSH 认证失败') + ) + + await expect( + harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + name: 'Renamed host', + password: { action: 'keep' } + } + }) + ).rejects.toThrow('SSH 认证失败') + expect(onHostEdited).not.toHaveBeenCalled() + expect(onHostRemoved).not.toHaveBeenCalled() + + await harness.service.validateAndSave({ + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: { + ...newPasswordHost, + name: 'Renamed host', + password: { action: 'keep' } + } + }) + + expect(onHostEdited).toHaveBeenCalledWith(host.id) + expect(onHostEdited.mock.calls[0]).toEqual([host.id]) + expect(onHostRemoved).not.toHaveBeenCalled() + + await harness.service.remove(host.id) + + expect(onHostRemoved).toHaveBeenCalledWith(host.id) + expect(onHostRemoved.mock.calls[0]).toEqual([host.id]) + await expect( + harness.service.remove(host.id) + ).rejects.toThrow('不存在') + expect(onHostRemoved).toHaveBeenCalledOnce() + }) + + it('bounds the number of pending host-key candidates', async () => { + const harness = await createHarness() + for (let index = 0; index < 100; index += 1) { + await harness.service.inspectDraftHostKey({ + hostname: `build-${index}.example.com`, + port: 22, + username: 'builder' + }) + } + + await expect( + harness.service.inspectDraftHostKey({ + hostname: 'one-too-many.example.com', + port: 22, + username: 'builder' + }) + ).rejects.toThrow('检查过多') + expect(harness.inspectHostKey).toHaveBeenCalledTimes(100) + }) + + it('allows only one validation to consume a candidate', async () => { + const harness = await createHarness() + const inspection = await harness.service.inspectDraftHostKey({ + hostname: newPasswordHost.hostname, + port: newPasswordHost.port, + username: newPasswordHost.username + }) + let finishConnection: + | ((result: { + connected: true + latencyMs: number + platform: 'linux' + architecture: 'x64' + shell: string + homeDirectory: string + detail: string + }) => void) + | undefined + harness.testConnection.mockImplementationOnce( + () => + new Promise((resolve) => { + finishConnection = resolve + }) + ) + const request = { + candidateId: inspection.candidateId, + fingerprintSha256: keyA.fingerprintSha256, + input: newPasswordHost + } + const first = harness.service.validateAndSave(request) + await vi.waitFor(() => + expect(harness.testConnection).toHaveBeenCalledOnce() + ) + + await expect( + harness.service.validateAndSave(request) + ).rejects.toThrow('正在验证') + finishConnection?.({ + connected: true, + latencyMs: 12, + platform: 'linux', + architecture: 'x64', + shell: '/bin/bash', + homeDirectory: '/home/builder', + detail: 'SSH 已连接,远端系统为 linux/x64' + }) + await expect(first).resolves.toMatchObject({ + host: { name: 'Build host' } + }) + await expect(harness.service.getSnapshot()).resolves.toMatchObject({ + hosts: [{ name: 'Build host' }] + }) + }) +}) diff --git a/src/main/ssh/ssh-host-service.ts b/src/main/ssh/ssh-host-service.ts new file mode 100644 index 00000000..9e6c4b2f --- /dev/null +++ b/src/main/ssh/ssh-host-service.ts @@ -0,0 +1,346 @@ +import { randomUUID } from 'node:crypto' +import type { + SshHostDraftInspectionRequest, + SshHostKeyInspection, + SshHostsSnapshot, + SshHostValidationRequest, + SshHostValidationResult +} from '../../shared/ssh-host-contracts' +import { + sshHostCreateInputSchema, + sshHostDraftInspectionRequestSchema, + sshHostUpdateInputSchema, + sshHostValidationRequestSchema +} from '../../shared/ssh-host-contracts' +import type { SshHostStore } from './ssh-host-store' +import type { + SshHostKeyCandidate, + SshTransport +} from './ssh-transport' + +const CANDIDATE_TTL_MS = 5 * 60_000 +const MAX_PENDING_CANDIDATES = 100 + +type PendingHostKey = { + candidate: SshHostKeyCandidate + hostId?: string + expectedRevision?: number + sourceTargetKey?: string + targetKey: string + expiresAt: number +} + +function targetKey(host: { + hostname: string + port: number + username: string +}): string { + return `${host.hostname}\0${host.port}\0${host.username}` +} + +export type SshHostLifecycleHooks = { + onHostEdited?: (hostId: string) => void + onHostRemoved?: (hostId: string) => void +} + +export class SshHostService { + private readonly pendingHostKeys = new Map< + string, + PendingHostKey + >() + private activeInspections = 0 + private readonly activeInspectionKeys = new Set() + private readonly activeValidations = new Set() + private readonly activeValidationKeys = new Set() + private readonly now: () => number + private readonly lifecycleHooks: SshHostLifecycleHooks + + constructor( + private readonly store: SshHostStore, + private readonly transport: SshTransport, + nowOrHooks: (() => number) | SshHostLifecycleHooks = Date.now, + lifecycleHooks: SshHostLifecycleHooks = {} + ) { + this.now = + typeof nowOrHooks === 'function' ? nowOrHooks : Date.now + this.lifecycleHooks = + typeof nowOrHooks === 'function' + ? lifecycleHooks + : nowOrHooks + } + + getSnapshot(): Promise { + return this.store.getSnapshot() + } + + async remove(hostId: string): Promise { + await this.store.remove(hostId) + for (const [candidateId, pending] of this.pendingHostKeys) { + if (pending.hostId === hostId) { + this.pendingHostKeys.delete(candidateId) + } + } + this.lifecycleHooks.onHostRemoved?.(hostId) + } + + async inspectDraftHostKey( + request: SshHostDraftInspectionRequest + ): Promise { + const parsed = sshHostDraftInspectionRequestSchema.parse(request) + const requestedTargetKey = targetKey(parsed) + const inspectionKeys = [ + `target:${requestedTargetKey}`, + ...(parsed.hostId ? [`host:${parsed.hostId}`] : []) + ] + if ( + inspectionKeys.some((key) => + this.activeValidationKeys.has(key) + ) + ) { + throw new Error('SSH 主机正在验证,请等待当前操作完成') + } + if ( + inspectionKeys.some((key) => + this.activeInspectionKeys.has(key) + ) + ) { + throw new Error('SSH 主机密钥正在检查,请等待当前操作完成') + } + for (const [candidateId, pending] of this.pendingHostKeys) { + if (pending.expiresAt <= this.now()) { + this.pendingHostKeys.delete(candidateId) + } + } + if ( + this.pendingHostKeys.size + this.activeInspections >= + MAX_PENDING_CANDIDATES + ) { + throw new Error('SSH 主机密钥检查过多,请稍后重试') + } + this.activeInspections += 1 + for (const key of inspectionKeys) { + this.activeInspectionKeys.add(key) + } + let existing: + | Awaited> + | undefined + let candidate: SshHostKeyCandidate + try { + existing = parsed.hostId + ? await this.store.getHostIdentity(parsed.hostId) + : undefined + candidate = await this.transport.inspectHostKey({ + hostname: parsed.hostname, + port: parsed.port, + username: parsed.username + }) + } finally { + this.activeInspections -= 1 + for (const key of inspectionKeys) { + this.activeInspectionKeys.delete(key) + } + } + const sameEndpoint = + existing !== undefined && + existing.hostname === parsed.hostname && + existing.port === parsed.port + const existingHostKey = sameEndpoint + ? existing?.hostKey + : undefined + const state = + !existingHostKey + ? 'unverified' + : existingHostKey.fingerprintSha256 === + candidate.fingerprintSha256 + ? 'verified' + : 'changed' + const candidateId = randomUUID() + for (const [existingId, pending] of this.pendingHostKeys) { + if ( + !this.activeValidations.has(existingId) && + (pending.targetKey === requestedTargetKey || + (existing && pending.hostId === existing.id)) + ) { + this.pendingHostKeys.delete(existingId) + } + } + this.pendingHostKeys.set(candidateId, { + candidate, + ...(existing + ? { + hostId: existing.id, + expectedRevision: existing.revision, + sourceTargetKey: targetKey(existing) + } + : {}), + targetKey: requestedTargetKey, + expiresAt: this.now() + CANDIDATE_TTL_MS + }) + return { + candidateId, + ...(existing ? { hostId: existing.id } : {}), + state, + algorithm: candidate.algorithm, + fingerprintSha256: candidate.fingerprintSha256, + ...(state === 'changed' && existing?.hostKey + ? { + previousHostKey: { + algorithm: existing.hostKey.algorithm, + fingerprintSha256: + existing.hostKey.fingerprintSha256 + } + } + : {}) + } + } + + discardCandidate(candidateId: string): void { + if (!this.activeValidations.has(candidateId)) { + this.pendingHostKeys.delete(candidateId) + } + } + + async validateAndSave( + request: SshHostValidationRequest + ): Promise { + const parsed = sshHostValidationRequestSchema.parse(request) + const pending = this.pendingHostKeys.get(parsed.candidateId) + if (!pending || pending.expiresAt <= this.now()) { + this.pendingHostKeys.delete(parsed.candidateId) + throw new Error('SSH 主机密钥检查已过期,请重新检查') + } + if (pending.targetKey !== targetKey(parsed.input)) { + throw new Error('SSH 主机配置已变化,请重新检查主机密钥') + } + if ( + pending.candidate.fingerprintSha256 !== + parsed.fingerprintSha256 + ) { + throw new Error('SSH 主机密钥指纹不匹配') + } + if (this.activeValidations.has(parsed.candidateId)) { + throw new Error('SSH 主机正在验证,请等待当前操作完成') + } + const validationKeys = [ + `target:${pending.targetKey}`, + ...(pending.hostId ? [`host:${pending.hostId}`] : []) + ] + if ( + validationKeys.some((key) => + this.activeValidationKeys.has(key) + ) + ) { + throw new Error('SSH 主机正在验证,请等待当前操作完成') + } + this.activeValidations.add(parsed.candidateId) + for (const key of validationKeys) { + this.activeValidationKeys.add(key) + } + + try { + let resolvedExisting: + | Awaited< + ReturnType + > + | undefined + if (pending.hostId) { + resolvedExisting = + await this.store.resolveConnectionTarget( + pending.hostId + ) + const existing = { + id: resolvedExisting.host.id, + hostname: resolvedExisting.host.hostname, + port: resolvedExisting.host.port, + username: resolvedExisting.host.username, + revision: resolvedExisting.hostRevision + } + if ( + existing.revision !== pending.expectedRevision || + targetKey(existing) !== pending.sourceTargetKey + ) { + this.pendingHostKeys.delete(parsed.candidateId) + throw new Error( + 'SSH 主机配置已变化,请重新检查主机密钥' + ) + } + sshHostUpdateInputSchema.parse(parsed.input) + if ( + parsed.input.password.action === 'keep' && + (existing.hostname !== parsed.input.hostname || + existing.port !== parsed.input.port || + existing.username !== parsed.input.username || + parsed.input.authentication !== 'password') + ) { + throw new Error( + '主机地址、端口、用户名或认证方式已变化,请重新输入密码' + ) + } + } else { + sshHostCreateInputSchema.parse(parsed.input) + } + + let password: string | undefined + if (parsed.input.authentication === 'password') { + if (parsed.input.password.action === 'replace') { + if (!this.store.isSecureStorageAvailable()) { + throw new Error( + '系统安全存储不可用,无法保存 SSH 密码' + ) + } + password = parsed.input.password.value + } else if ( + parsed.input.password.action === 'keep' && + pending.hostId + ) { + password = resolvedExisting?.host.password + } else { + throw new Error('SSH 密码不能为空') + } + } + + const connection = await this.transport.testConnection({ + id: pending.hostId ?? parsed.candidateId, + name: parsed.input.name, + hostname: parsed.input.hostname, + port: parsed.input.port, + username: parsed.input.username, + authentication: parsed.input.authentication, + ...(password ? { password } : {}), + hostKey: pending.candidate + }) + const host = await this.store.commitValidated({ + ...(pending.hostId ? { hostId: pending.hostId } : {}), + ...(pending.expectedRevision !== undefined + ? { expectedRevision: pending.expectedRevision } + : {}), + input: parsed.input, + hostKey: pending.candidate + }) + if (pending.hostId) { + this.lifecycleHooks.onHostEdited?.(host.id) + } + for (const [candidateId, candidate] of this.pendingHostKeys) { + if ( + candidateId === parsed.candidateId || + candidate.targetKey === pending.targetKey || + (pending.hostId && candidate.hostId === pending.hostId) + ) { + this.pendingHostKeys.delete(candidateId) + } + } + return { + host, + connection: { + hostId: host.id, + ...connection + } + } + } finally { + this.activeValidations.delete(parsed.candidateId) + for (const key of validationKeys) { + this.activeValidationKeys.delete(key) + } + } + } +} diff --git a/src/main/ssh/ssh-host-store.test.ts b/src/main/ssh/ssh-host-store.test.ts new file mode 100644 index 00000000..dc8aa151 --- /dev/null +++ b/src/main/ssh/ssh-host-store.test.ts @@ -0,0 +1,554 @@ +import { + mkdtemp, + readFile, + readdir, + 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 { + SshHost, + SshHostUpdateInput +} from '../../shared/ssh-host-contracts' +import type { SettingsCredentialCipher } from '../settings-credential-cipher' +import { + SshHostStore, + type ValidatedSshHostCommit +} from './ssh-host-store' + +const roots: string[] = [] +const fingerprintA = `SHA256:${'A'.repeat(43)}` +const fingerprintB = `SHA256:${'B'.repeat(43)}` +const keyA = { + algorithm: 'ssh-ed25519', + publicKeyBase64: Buffer.from('first-key').toString('base64'), + fingerprintSha256: fingerprintA +} +const keyB = { + algorithm: 'ssh-ed25519', + publicKeyBase64: Buffer.from('second-key').toString('base64'), + fingerprintSha256: fingerprintB +} + +function createCipher(available = true): SettingsCredentialCipher { + return { + isAvailable: () => available, + encrypt: (value) => + Buffer.from( + `protected:${Buffer.from(value).toString('base64')}` + ), + decrypt: (value) => + Buffer.from( + value.toString().replace(/^protected:/u, ''), + 'base64' + ).toString() + } +} + +async function createHarness( + cipher = createCipher() +): Promise<{ + filePath: string + store: SshHostStore +}> { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ssh-hosts-')) + roots.push(root) + const filePath = join(root, 'ssh-hosts.json') + return { + filePath, + store: new SshHostStore(filePath, cipher) + } +} + +function passwordHostInput( + overrides: Partial = {} +): SshHostUpdateInput { + return { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'password', + password: { + action: 'replace', + value: 'private password' + }, + ...overrides + } +} + +async function updateValidated( + store: SshHostStore, + host: SshHost, + input: SshHostUpdateInput, + hostKey: ValidatedSshHostCommit['hostKey'] = keyA +): Promise { + const identity = await store.getHostIdentity(host.id) + return store.commitValidated({ + hostId: host.id, + expectedRevision: identity.revision, + input, + hostKey + }) +} + +afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('SshHostStore', () => { + it('atomically creates a validated host with an encrypted credential and pinned key', async () => { + const { filePath, store } = await createHarness() + + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + + expect(created).toMatchObject({ + credentialConfigured: true, + credentialSource: 'encrypted', + hostKey: { + state: 'verified', + fingerprintSha256: fingerprintA, + generation: 1 + }, + lastValidatedAt: expect.any(String) + }) + const persisted = await readFile(filePath, 'utf8') + expect(persisted).not.toContain('private password') + expect(persisted).toContain(fingerprintA) + expect(JSON.stringify(created)).not.toContain('ciphertextBase64') + await expect(store.resolveHost(created.id)).resolves.toMatchObject({ + password: 'private password', + hostKey: { + fingerprintSha256: fingerprintA + } + }) + }) + + it('resolves one atomic connection target snapshot with credentials and generations', async () => { + const { store } = await createHarness() + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + const identity = await store.getHostIdentity(created.id) + + const targetPromise = store.resolveConnectionTarget(created.id) + const editPromise = store.commitValidated({ + hostId: created.id, + expectedRevision: identity.revision, + input: passwordHostInput({ + password: { + action: 'replace', + value: 'rotated password' + } + }), + hostKey: keyB + }) + + await expect(targetPromise).resolves.toMatchObject({ + host: { + id: created.id, + password: 'private password', + hostKey: { + fingerprintSha256: fingerprintA, + generation: 1 + } + }, + hostRevision: 1, + hostKeyGeneration: 1 + }) + await expect(editPromise).resolves.toMatchObject({ + hostKey: { + fingerprintSha256: fingerprintB, + generation: 2 + } + }) + await expect( + store.resolveConnectionTarget(created.id) + ).resolves.toMatchObject({ + host: { + password: 'rotated password', + hostKey: { + fingerprintSha256: fingerprintB, + generation: 2 + } + }, + hostRevision: 2, + hostKeyGeneration: 2 + }) + }) + + it('reuses credential validation across unchanged public snapshots', async () => { + const baseCipher = createCipher() + const decrypt = vi.fn(baseCipher.decrypt) + const { store } = await createHarness({ + ...baseCipher, + decrypt + }) + await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + const decryptionsAfterCommit = decrypt.mock.calls.length + + await store.getSnapshot() + await store.getSnapshot() + + expect(decrypt).toHaveBeenCalledTimes(decryptionsAfterCommit) + }) + + it('keeps or replaces credentials only through validated edits', async () => { + const { store } = await createHarness() + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + const renamed = await updateValidated( + store, + created, + passwordHostInput({ + name: 'Renamed host', + password: { action: 'keep' } + }) + ) + + expect(renamed).toMatchObject({ + name: 'Renamed host', + hostKey: { + fingerprintSha256: fingerprintA, + generation: 1 + } + }) + await expect(store.resolveHost(created.id)).resolves.toMatchObject({ + password: 'private password' + }) + + const identity = await store.getHostIdentity(created.id) + await expect( + store.commitValidated({ + hostId: created.id, + expectedRevision: identity.revision, + input: passwordHostInput({ + hostname: 'moved.example.com', + password: { action: 'keep' } + }), + hostKey: keyB + }) + ).rejects.toThrow('重新输入密码') + + const moved = await updateValidated( + store, + renamed, + passwordHostInput({ + name: 'Moved host', + hostname: 'moved.example.com', + password: { + action: 'replace', + value: 'second password' + } + }), + keyB + ) + expect(moved).toMatchObject({ + hostname: 'moved.example.com', + hostKey: { + fingerprintSha256: fingerprintB, + generation: 2 + } + }) + await expect(store.resolveHost(created.id)).resolves.toMatchObject({ + password: 'second password' + }) + }) + + it('rejects stale validated edits with a monotonic revision', async () => { + const { store } = await createHarness() + const created = await store.commitValidated({ + input: { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + const identity = await store.getHostIdentity(created.id) + await store.commitValidated({ + hostId: created.id, + expectedRevision: identity.revision, + input: { + name: 'First edit', + hostname: created.hostname, + port: created.port, + username: created.username, + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + + await expect( + store.commitValidated({ + hostId: created.id, + expectedRevision: identity.revision, + input: { + name: 'Stale edit', + hostname: created.hostname, + port: created.port, + username: created.username, + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + ).rejects.toThrow('配置已变化') + }) + + it('binds encrypted credentials to the host id', async () => { + const { filePath, store } = await createHarness() + const first = await store.commitValidated({ + input: passwordHostInput({ + name: 'First', + hostname: 'first.example.com', + password: { action: 'replace', value: 'first secret' } + }), + hostKey: keyA + }) + const second = await store.commitValidated({ + input: passwordHostInput({ + name: 'Second', + hostname: 'second.example.com', + password: { action: 'replace', value: 'second secret' } + }), + hostKey: keyA + }) + const persisted = JSON.parse( + await readFile(filePath, 'utf8') + ) as { + hosts: Array<{ + id: string + credential?: unknown + }> + } + persisted.hosts[1]!.credential = persisted.hosts[0]!.credential + await writeFile(filePath, JSON.stringify(persisted), 'utf8') + + const reloaded = new SshHostStore(filePath, createCipher()) + const snapshot = await reloaded.getSnapshot() + expect( + snapshot.hosts.find((host) => host.id === first.id) + ).toMatchObject({ credentialSource: 'encrypted' }) + expect( + snapshot.hosts.find((host) => host.id === second.id) + ).toMatchObject({ + credentialConfigured: false, + credentialSource: 'unreadable' + }) + await expect(reloaded.resolveHost(second.id)).rejects.toThrow( + '不匹配' + ) + }) + + it('binds encrypted credentials to the target address and username', async () => { + const { filePath, store } = await createHarness() + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + const persisted = JSON.parse( + await readFile(filePath, 'utf8') + ) as { + hosts: Array<{ + hostname: string + }> + } + persisted.hosts[0]!.hostname = 'attacker.example.com' + await writeFile(filePath, JSON.stringify(persisted), 'utf8') + + const reloaded = new SshHostStore(filePath, createCipher()) + await expect(reloaded.getSnapshot()).resolves.toMatchObject({ + hosts: [ + expect.objectContaining({ + id: created.id, + credentialConfigured: false, + credentialSource: 'unreadable' + }) + ] + }) + await expect(reloaded.resolveHost(created.id)).rejects.toThrow( + '不匹配' + ) + }) + + it('increments host-key generations only for new key material or targets', async () => { + const { store } = await createHarness() + const created = await store.commitValidated({ + input: { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + hostKey: keyA + }) + const same = await updateValidated( + store, + created, + { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + keyA + ) + const changed = await updateValidated( + store, + same, + { + name: 'Build host', + hostname: 'build.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + keyB + ) + const moved = await updateValidated( + store, + changed, + { + name: 'Build host', + hostname: 'moved.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + password: { action: 'clear' } + }, + keyB + ) + + expect(created.hostKey.generation).toBe(1) + expect(same.hostKey.generation).toBe(1) + expect(changed.hostKey.generation).toBe(2) + expect(moved.hostKey.generation).toBe(3) + }) + + it('loads legacy revision-zero hosts and requires guided revalidation', async () => { + const { filePath } = await createHarness() + await writeFile( + filePath, + JSON.stringify({ + version: 1, + hosts: [ + { + id: '00000000-0000-4000-8000-000000000121', + name: 'Legacy host', + hostname: 'legacy.example.com', + port: 22, + username: 'builder', + authentication: 'system-agent', + hostKey: { + ...keyA, + acceptedAt: '2026-08-01T00:00:00.000Z', + generation: 1 + }, + hostKeyGeneration: 1, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z' + } + ] + }), + 'utf8' + ) + const store = new SshHostStore(filePath, createCipher()) + + const snapshot = await store.getSnapshot() + expect(snapshot).toMatchObject({ + hosts: [{ name: 'Legacy host', hostKey: { state: 'verified' } }] + }) + expect(snapshot.hosts[0]).not.toHaveProperty('lastValidatedAt') + await expect( + store.getHostIdentity('00000000-0000-4000-8000-000000000121') + ).resolves.toMatchObject({ revision: 0 }) + }) + + it('requires secure storage for passwords and isolates corrupt settings', async () => { + const unavailable = await createHarness(createCipher(false)) + await expect( + unavailable.store.commitValidated({ + input: passwordHostInput({ + password: { action: 'replace', value: 'secret' } + }), + hostKey: keyA + }) + ).rejects.toThrow('安全存储不可用') + + const { filePath } = await createHarness() + await writeFile(filePath, '{invalid-json', 'utf8') + const recovered = new SshHostStore(filePath, createCipher()) + await expect(recovered.getSnapshot()).resolves.toMatchObject({ + hosts: [] + }) + expect( + (await readdir(join(filePath, '..'))).some((name) => + name.startsWith('ssh-hosts.json.corrupt-') + ) + ).toBe(true) + }) + + it('asserts a loaded current target synchronously without resolving credentials', async () => { + const { store } = await createHarness() + expect(() => + store.assertConnectionTargetCurrent({ + hostId: '00000000-0000-4000-8000-000000000001', + hostRevision: 1, + hostKeyGeneration: 1, + username: 'builder' + }) + ).toThrow('尚未加载') + + const created = await store.commitValidated({ + input: passwordHostInput(), + hostKey: keyA + }) + const target = await store.resolveConnectionTarget(created.id) + const current = { + hostId: created.id, + hostRevision: target.hostRevision, + hostKeyGeneration: target.hostKeyGeneration, + username: target.host.username + } + expect(() => + store.assertConnectionTargetCurrent(current) + ).not.toThrow() + expect(() => + store.assertConnectionTargetCurrent({ + ...current, + hostRevision: current.hostRevision + 1 + }) + ).toThrow('配置已变化') + expect(() => + store.assertConnectionTargetCurrent({ + ...current, + username: 'other-user' + }) + ).toThrow('配置已变化') + }) +}) diff --git a/src/main/ssh/ssh-host-store.ts b/src/main/ssh/ssh-host-store.ts new file mode 100644 index 00000000..001daa81 --- /dev/null +++ b/src/main/ssh/ssh-host-store.ts @@ -0,0 +1,669 @@ +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { z } from 'zod' +import { + SSH_HOST_LIMITS, + sshAuthenticationKindSchema, + sshHostCreateInputSchema, + sshHostUpdateInputSchema, + type SshHost, + type SshHostsSnapshot, + type SshHostUpdateInput +} from '../../shared/ssh-host-contracts' +import { + decryptSettingsCredential, + encryptedSettingsCredentialSchema, + encryptSettingsCredential, + type SettingsCredentialCipher +} from '../settings-credential-cipher' +import { + assertSupportedSettingsVersion, + isolateCorruptSettingsFile, + isMissingFileError, + UnsupportedSettingsVersionError, + writeJsonFileAtomically +} from '../settings-file-utils' + +const CURRENT_SETTINGS_VERSION = 1 + +const storedHostKeySchema = z + .object({ + algorithm: z.string().min(1).max(64), + publicKeyBase64: z + .string() + .min(1) + .max(SSH_HOST_LIMITS.maximumHostKeyLength * 2) + .regex(/^[A-Za-z0-9+/]+={0,2}$/u), + fingerprintSha256: z + .string() + .regex(/^SHA256:[A-Za-z0-9+/]{43}$/u), + acceptedAt: z.string().datetime({ offset: true }), + generation: z.number().int().positive() + }) + .strict() + +const encryptedCredentialSchema = encryptedSettingsCredentialSchema + .extend({ + ciphertextBase64: z + .string() + .min(1) + .max(SSH_HOST_LIMITS.maximumPasswordLength * 8) + .regex(/^[A-Za-z0-9+/]+={0,2}$/u) + }) + .strict() + +const storedHostSchema = z + .object({ + id: z.string().uuid(), + name: sshHostCreateInputSchema.shape.name, + hostname: sshHostCreateInputSchema.shape.hostname, + port: sshHostCreateInputSchema.shape.port, + username: sshHostCreateInputSchema.shape.username, + authentication: sshAuthenticationKindSchema, + credential: encryptedCredentialSchema.optional(), + hostKey: storedHostKeySchema.optional(), + hostKeyGeneration: z.number().int().nonnegative(), + revision: z.number().int().nonnegative().default(0), + lastValidatedAt: z + .string() + .datetime({ offset: true }) + .optional(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }) + }) + .strict() + .superRefine((host, context) => { + if ( + host.hostKey && + host.hostKey.generation !== host.hostKeyGeneration + ) { + context.addIssue({ + code: 'custom', + path: ['hostKeyGeneration'], + message: 'SSH 主机密钥代际不一致' + }) + } + }) + +const storedSettingsSchema = z + .object({ + version: z.literal(CURRENT_SETTINGS_VERSION), + hosts: z.array(storedHostSchema).max(SSH_HOST_LIMITS.maximumHosts) + }) + .strict() + +const credentialPayloadSchema = z + .object({ + version: z.literal(1), + hostId: z.string().uuid(), + authentication: z.literal('password'), + hostname: sshHostCreateInputSchema.shape.hostname, + port: sshHostCreateInputSchema.shape.port, + username: sshHostCreateInputSchema.shape.username, + password: z + .string() + .min(1) + .max(SSH_HOST_LIMITS.maximumPasswordLength) + }) + .strict() + +type StoredSettings = z.infer +type StoredHost = z.infer +type StoredHostKey = z.infer +type CredentialPayload = z.infer + +export type ResolvedSshHost = { + id: string + name: string + hostname: string + port: number + username: string + authentication: SshHost['authentication'] + password?: string + hostKey?: Pick< + StoredHostKey, + 'algorithm' | 'publicKeyBase64' | 'fingerprintSha256' + > & + Partial> +} + +export type SshHostIdentity = { + id: string + hostname: string + port: number + username: string + revision: number + hostKey?: { + algorithm: string + publicKeyBase64: string + fingerprintSha256: string + generation: number + } +} + +export type SshConnectionTarget = { + host: ResolvedSshHost + hostRevision: number + hostKeyGeneration: number +} + +export type CurrentSshConnectionTarget = Readonly<{ + hostId: string + hostRevision: number + hostKeyGeneration: number + username: string +}> + +export type ValidatedSshHostCommit = { + hostId?: string + expectedRevision?: number + input: SshHostUpdateInput + hostKey: { + algorithm: string + publicKeyBase64: string + fingerprintSha256: string + } +} + +const defaultSettings: StoredSettings = { + version: CURRENT_SETTINGS_VERSION, + hosts: [] +} + +function cloneSettings(settings: StoredSettings): StoredSettings { + return structuredClone(settings) +} + +function targetChanged( + current: StoredHost, + input: SshHostUpdateInput +): boolean { + return ( + current.hostname !== input.hostname || + current.port !== input.port || + current.username !== input.username || + current.authentication !== input.authentication + ) +} + +function credentialMatchesHost( + payload: CredentialPayload, + host: StoredHost +): boolean { + return ( + payload.hostId === host.id && + payload.authentication === host.authentication && + payload.hostname === host.hostname && + payload.port === host.port && + payload.username === host.username + ) +} + +export class SshHostStore { + private settings?: StoredSettings + private settingsLoad?: Promise + private updateQueue: Promise = Promise.resolve() + private readonly credentialSourceCache = new Map< + string, + { + credential: NonNullable + secureStorageAvailable: boolean + hostname: string + port: number + username: string + source: Extract< + SshHost['credentialSource'], + 'encrypted' | 'unreadable' + > + } + >() + + constructor( + private readonly filePath: string, + private readonly cipher: SettingsCredentialCipher, + private readonly now: () => Date = () => new Date() + ) {} + + async getSnapshot(): Promise { + const settings = await this.load() + return { + hosts: settings.hosts.map((host) => this.toPublicHost(host)), + secureStorageAvailable: this.cipher.isAvailable() + } + } + + isSecureStorageAvailable(): boolean { + return this.cipher.isAvailable() + } + + commitValidated( + commit: ValidatedSshHostCommit + ): Promise { + const parsed = commit.hostId + ? sshHostUpdateInputSchema.parse(commit.input) + : sshHostCreateInputSchema.parse(commit.input) + return this.queue(async () => { + const settings = cloneSettings(await this.load()) + const timestamp = this.now().toISOString() + + if (!commit.hostId) { + if (settings.hosts.length >= SSH_HOST_LIMITS.maximumHosts) { + throw new Error( + `SSH 主机数量不能超过 ${SSH_HOST_LIMITS.maximumHosts}` + ) + } + const id = randomUUID() + const host: StoredHost = storedHostSchema.parse({ + id, + name: parsed.name, + hostname: parsed.hostname, + port: parsed.port, + username: parsed.username, + authentication: parsed.authentication, + ...(parsed.authentication === 'password' && + parsed.password.action === 'replace' + ? { + credential: this.encryptPassword( + id, + { + hostname: parsed.hostname, + port: parsed.port, + username: parsed.username + }, + parsed.password.value + ) + } + : {}), + hostKey: { + ...commit.hostKey, + acceptedAt: timestamp, + generation: 1 + }, + hostKeyGeneration: 1, + revision: 1, + lastValidatedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp + }) + settings.hosts.push(host) + await this.persist(settings) + this.settings = settings + return this.toPublicHost(host) + } + + const index = settings.hosts.findIndex( + (host) => host.id === commit.hostId + ) + const current = settings.hosts[index] + if (!current) { + throw new Error('SSH 主机不存在') + } + if ( + commit.expectedRevision === undefined || + current.revision !== commit.expectedRevision + ) { + throw new Error('SSH 主机配置已变化,请重新验证') + } + const changedTarget = targetChanged(current, parsed) + if ( + current.credential && + changedTarget && + parsed.password.action === 'keep' + ) { + throw new Error( + '主机地址、端口、用户名或认证方式已变化,请重新输入密码' + ) + } + let credential = + parsed.password.action === 'keep' + ? current.credential + : undefined + if ( + parsed.authentication === 'password' && + parsed.password.action === 'replace' + ) { + credential = this.encryptPassword( + current.id, + { + hostname: parsed.hostname, + port: parsed.port, + username: parsed.username + }, + parsed.password.value + ) + } + if (parsed.authentication === 'password' && !credential) { + throw new Error('SSH 密码不能为空') + } + const generation = + !changedTarget && + current.hostKey?.publicKeyBase64 === + commit.hostKey.publicKeyBase64 + ? current.hostKey.generation + : current.hostKeyGeneration + 1 + const next: StoredHost = storedHostSchema.parse({ + ...current, + name: parsed.name, + hostname: parsed.hostname, + port: parsed.port, + username: parsed.username, + authentication: parsed.authentication, + ...(credential ? { credential } : { credential: undefined }), + hostKey: { + ...commit.hostKey, + acceptedAt: timestamp, + generation + }, + hostKeyGeneration: generation, + revision: current.revision + 1, + lastValidatedAt: timestamp, + updatedAt: timestamp + }) + settings.hosts[index] = next + await this.persist(settings) + this.settings = settings + return this.toPublicHost(next) + }) + } + + remove(hostId: string): Promise { + return this.queue(async () => { + const settings = cloneSettings(await this.load()) + const index = settings.hosts.findIndex( + (host) => host.id === hostId + ) + if (index === -1) { + throw new Error('SSH 主机不存在') + } + settings.hosts.splice(index, 1) + await this.persist(settings) + this.settings = settings + this.credentialSourceCache.delete(hostId) + }) + } + + async resolveHost(hostId: string): Promise { + const host = await this.getStoredHost(hostId) + return this.resolveStoredHost(host) + } + + resolveConnectionTarget( + hostId: string + ): Promise { + return this.queue(async () => { + const host = (await this.load()).hosts.find( + (candidate) => candidate.id === hostId + ) + if (!host) { + throw new Error('SSH 主机不存在') + } + const snapshot = structuredClone(host) + return { + host: this.resolveStoredHost(snapshot), + hostRevision: snapshot.revision, + hostKeyGeneration: snapshot.hostKeyGeneration + } + }) + } + + /** + * Synchronous transaction precondition for callers that already resolved a + * target. It deliberately consults only loaded, credential-free metadata so + * it is safe to call from inside a SQLite transaction. + */ + assertConnectionTargetCurrent( + expected: CurrentSshConnectionTarget + ): void { + const settings = this.settings + if (!settings) { + throw new Error('SSH 主机当前状态尚未加载') + } + const host = settings.hosts.find( + (candidate) => candidate.id === expected.hostId + ) + if ( + !host || + host.revision !== expected.hostRevision || + host.hostKeyGeneration !== expected.hostKeyGeneration || + host.username !== expected.username || + !host.hostKey || + host.hostKey.generation !== expected.hostKeyGeneration + ) { + throw new Error('SSH 主机配置已变化,请重新验证') + } + } + + private resolveStoredHost(host: StoredHost): ResolvedSshHost { + if (host.authentication === 'system-agent') { + return { + id: host.id, + name: host.name, + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: host.authentication, + hostKey: host.hostKey + } + } + if (!host.credential) { + throw new Error('SSH 主机尚未配置密码') + } + const payload = credentialPayloadSchema.parse( + decryptSettingsCredential(this.cipher, host.credential) + ) + if (!credentialMatchesHost(payload, host)) { + throw new Error('SSH 主机凭据与当前主机不匹配') + } + return { + id: host.id, + name: host.name, + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: host.authentication, + password: payload.password, + hostKey: host.hostKey + } + } + + private async getStoredHost(hostId: string): Promise { + const host = (await this.load()).hosts.find( + (candidate) => candidate.id === hostId + ) + if (!host) { + throw new Error('SSH 主机不存在') + } + return structuredClone(host) + } + + async getHostIdentity(hostId: string): Promise { + const host = await this.getStoredHost(hostId) + return { + id: host.id, + hostname: host.hostname, + port: host.port, + username: host.username, + revision: host.revision, + ...(host.hostKey + ? { + hostKey: { + algorithm: host.hostKey.algorithm, + publicKeyBase64: host.hostKey.publicKeyBase64, + fingerprintSha256: + host.hostKey.fingerprintSha256, + generation: host.hostKeyGeneration + } + } + : {}) + } + } + + private encryptPassword( + hostId: string, + target: { + hostname: string + port: number + username: string + }, + password: string + ): StoredHost['credential'] { + if (!password) { + throw new Error('SSH 密码不能为空') + } + if (!this.cipher.isAvailable()) { + throw new Error('系统安全存储不可用,无法保存 SSH 密码') + } + return encryptSettingsCredential(this.cipher, { + version: 1, + hostId, + authentication: 'password', + ...target, + password + }) + } + + private toPublicHost(host: StoredHost): SshHost { + let credentialSource: SshHost['credentialSource'] + if (host.authentication === 'system-agent') { + this.credentialSourceCache.delete(host.id) + credentialSource = 'system-agent' + } else if (!host.credential) { + this.credentialSourceCache.delete(host.id) + credentialSource = 'none' + } else { + const secureStorageAvailable = this.cipher.isAvailable() + const cached = this.credentialSourceCache.get(host.id) + if ( + cached?.credential === host.credential && + cached.secureStorageAvailable === secureStorageAvailable && + cached.hostname === host.hostname && + cached.port === host.port && + cached.username === host.username + ) { + credentialSource = cached.source + } else { + try { + const payload = credentialPayloadSchema.parse( + decryptSettingsCredential(this.cipher, host.credential) + ) + credentialSource = + credentialMatchesHost(payload, host) + ? 'encrypted' + : 'unreadable' + } catch { + credentialSource = 'unreadable' + } + this.credentialSourceCache.set(host.id, { + credential: host.credential, + secureStorageAvailable, + hostname: host.hostname, + port: host.port, + username: host.username, + source: credentialSource + }) + } + } + return { + id: host.id, + name: host.name, + hostname: host.hostname, + port: host.port, + username: host.username, + authentication: host.authentication, + credentialConfigured: + credentialSource === 'encrypted' || + credentialSource === 'system-agent', + credentialSource, + hostKey: host.hostKey + ? { + state: 'verified', + algorithm: host.hostKey.algorithm, + fingerprintSha256: + host.hostKey.fingerprintSha256, + generation: host.hostKeyGeneration + } + : { + state: 'unverified', + generation: host.hostKeyGeneration + }, + ...(host.lastValidatedAt + ? { lastValidatedAt: host.lastValidatedAt } + : {}), + createdAt: host.createdAt, + updatedAt: host.updatedAt + } + } + + private queue(operation: () => Promise): Promise { + const result = this.updateQueue.then(operation, operation) + this.updateQueue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private async load(): Promise { + if (this.settings) { + return this.settings + } + if (!this.settingsLoad) { + this.settingsLoad = this.readStored().finally(() => { + this.settingsLoad = undefined + }) + } + return this.settingsLoad + } + + private async readStored(): Promise { + this.credentialSourceCache.clear() + try { + const contents = await readFile(this.filePath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(contents) as unknown + } catch { + await this.isolateCorruptFile() + this.settings = cloneSettings(defaultSettings) + return this.settings + } + assertSupportedSettingsVersion( + parsed, + CURRENT_SETTINGS_VERSION, + (version) => + `当前 GoodBuddy 不支持 SSH 主机设置版本 ${version},请升级应用后重试` + ) + const result = storedSettingsSchema.safeParse(parsed) + if (!result.success) { + await this.isolateCorruptFile() + this.settings = cloneSettings(defaultSettings) + return this.settings + } + this.settings = result.data + return this.settings + } catch (error) { + if (error instanceof UnsupportedSettingsVersionError) { + throw error + } + if (!isMissingFileError(error)) { + throw new Error('SSH 主机设置读取失败', { cause: error }) + } + this.settings = cloneSettings(defaultSettings) + return this.settings + } + } + + private async isolateCorruptFile(): Promise { + await isolateCorruptSettingsFile( + this.filePath, + 'SSH 主机设置损坏且无法隔离' + ) + } + + private async persist(settings: StoredSettings): Promise { + await writeJsonFileAtomically( + this.filePath, + storedSettingsSchema.parse(settings) + ) + } +} diff --git a/src/main/ssh/ssh-transport.test.ts b/src/main/ssh/ssh-transport.test.ts new file mode 100644 index 00000000..22535195 --- /dev/null +++ b/src/main/ssh/ssh-transport.test.ts @@ -0,0 +1,432 @@ +import { generateKeyPairSync } from 'node:crypto' +import { EventEmitter } from 'node:events' +import type { AddressInfo } from 'node:net' +import { + Server, + type AuthContext, + type Connection +} from 'ssh2' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ResolvedSshHost } from './ssh-host-store' +import { + buildAuthenticatedSshConnectConfig, + createStrongSshAlgorithms, + describeSshHostKey, + selectStrongSshCiphers, + Ssh2Transport +} from './ssh-transport' + +type ServerHarness = { + server: Server + port: number + authenticationMethods: string[] + passwords: string[] + commands: string[] +} + +const servers: Server[] = [] +const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2_048, + privateKeyEncoding: { + type: 'pkcs1', + format: 'pem' + }, + publicKeyEncoding: { + type: 'pkcs1', + format: 'pem' + } +}) + +function createHostKeyBlob(algorithm: string): Buffer { + const name = Buffer.from(algorithm) + const length = Buffer.alloc(4) + length.writeUInt32BE(name.length) + return Buffer.concat([length, name, Buffer.alloc(32, 7)]) +} + +function createResolvedHost( + publicKey: Buffer, + algorithm = 'metadata-is-not-trusted' +): ResolvedSshHost { + return { + id: '00000000-0000-4000-8000-000000000101', + name: 'Pinned test host', + hostname: '127.0.0.1', + port: 22, + username: 'builder', + authentication: 'password', + password: 'private password', + hostKey: { + algorithm, + publicKeyBase64: publicKey.toString('base64'), + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + acceptedAt: '2026-08-01T00:00:00.000Z', + generation: 1 + } + } +} + +const testAlgorithms = createStrongSshAlgorithms([ + 'aes-256-gcm' +]) + +async function createServer(): Promise { + const authenticationMethods: string[] = [] + const passwords: string[] = [] + const commands: string[] = [] + const server = new Server( + { hostKeys: [privateKey] }, + (client: Connection) => { + client.on('error', () => undefined) + client.on('authentication', (context: AuthContext) => { + authenticationMethods.push(context.method) + if (context.method === 'password') { + passwords.push(context.password) + if ( + context.username === 'builder' && + context.password === 'private password' + ) { + context.accept() + return + } + } + context.reject(['password']) + }) + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept() + session.on('exec', (acceptExec, _reject, info) => { + commands.push(info.command) + const stream = acceptExec() + stream.write( + [ + 'GOODBUDDY_SSH_PROBE_V1', + 'Linux', + 'x86_64', + '/bin/bash', + '/home/builder', + '' + ].join('\n') + ) + stream.exit(0) + stream.end() + }) + }) + }) + } + ) + servers.push(server) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + return { + server, + port: (server.address() as AddressInfo).port, + authenticationMethods, + passwords, + commands + } +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + }) + ) + ) +}) + +describe('SSH transport', () => { + it('removes ChaCha20 when the current OpenSSL build cannot provide it', () => { + expect( + selectStrongSshCiphers([ + 'aes-256-gcm', + 'aes-128-gcm', + 'aes-256-ctr', + 'aes-192-ctr', + 'aes-128-ctr' + ]) + ).toEqual([ + 'aes256-gcm@openssh.com', + 'aes128-gcm@openssh.com', + 'aes256-ctr', + 'aes192-ctr', + 'aes128-ctr' + ]) + }) + + it('fails closed when no approved cipher is available', () => { + expect(() => selectStrongSshCiphers(['des-cbc'])).toThrow( + '不支持 GoodBuddy 要求的安全 SSH 加密算法' + ) + }) + + it('describes complete SSH key blobs with SHA-256 fingerprints', () => { + const blob = createHostKeyBlob('ssh-ed25519') + + expect(describeSshHostKey(blob)).toMatchObject({ + algorithm: 'ssh-ed25519', + publicKeyBase64: blob.toString('base64'), + fingerprintSha256: expect.stringMatching( + /^SHA256:[A-Za-z0-9+/]{43}$/u + ) + }) + expect(() => describeSshHostKey(Buffer.alloc(0))).toThrow( + '大小无效' + ) + }) + + it('negotiates only the algorithm represented by a pinned ECDSA key blob', () => { + const config = buildAuthenticatedSshConnectConfig( + createResolvedHost( + createHostKeyBlob('ecdsa-sha2-nistp384'), + 'ssh-ed25519' + ), + testAlgorithms, + undefined, + vi.fn() + ) + + expect(config.algorithms?.serverHostKey).toEqual([ + 'ecdsa-sha2-nistp384' + ]) + expect(testAlgorithms.serverHostKey).toContain('ssh-ed25519') + }) + + it('maps a pinned RSA key blob to RSA SHA-2 negotiation only', () => { + const config = buildAuthenticatedSshConnectConfig( + createResolvedHost(createHostKeyBlob('ssh-rsa')), + testAlgorithms, + undefined, + vi.fn() + ) + + expect(config.algorithms?.serverHostKey).toEqual([ + 'rsa-sha2-512', + 'rsa-sha2-256' + ]) + expect(config.algorithms?.serverHostKey).not.toContain('ssh-rsa') + }) + + it('fails closed for malformed or unsupported pinned key algorithms', () => { + const malformed = Buffer.alloc(4) + malformed.writeUInt32BE(12) + + expect(() => + buildAuthenticatedSshConnectConfig( + createResolvedHost(malformed), + testAlgorithms, + undefined, + vi.fn() + ) + ).toThrow('主机密钥格式无效') + expect(() => + buildAuthenticatedSshConnectConfig( + createResolvedHost(createHostKeyBlob('ssh-dss')), + testAlgorithms, + undefined, + vi.fn() + ) + ).toThrow('主机密钥算法不受支持') + }) + + it('keeps broad strong Host Key negotiation while inspecting draft hosts', async () => { + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn() + }) + const transport = new Ssh2Transport({ + createClient: () => client as never, + supportedOpenSslCiphers: () => ['aes-256-gcm'] + }) + + const inspection = transport.inspectHostKey({ + hostname: '127.0.0.1', + port: 22, + username: 'builder' + }) + + expect(client.connect).toHaveBeenCalledWith( + expect.objectContaining({ + algorithms: expect.objectContaining({ + serverHostKey: [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'rsa-sha2-512', + 'rsa-sha2-256' + ] + }) + }) + ) + client.emit('error', new Error('stop inspection')) + await expect(inspection).rejects.toThrow('无法连接 SSH 主机') + }) + + it('captures the host key and terminates before sending any authentication method', async () => { + const harness = await createServer() + const transport = new Ssh2Transport() + + const candidate = await transport.inspectHostKey({ + hostname: '127.0.0.1', + port: harness.port, + username: 'builder' + }) + + expect(candidate).toMatchObject({ + algorithm: 'ssh-rsa', + fingerprintSha256: expect.stringMatching( + /^SHA256:[A-Za-z0-9+/]{43}$/u + ) + }) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(harness.authenticationMethods).toEqual([]) + expect(harness.passwords).toEqual([]) + }) + + it('pins the full key blob, authenticates, and executes only the bounded probe', async () => { + const harness = await createServer() + let now = 1_000 + const transport = new Ssh2Transport({ + now: () => { + now += 6 + return now + } + }) + const candidate = await transport.inspectHostKey({ + hostname: '127.0.0.1', + port: harness.port, + username: 'builder' + }) + const host: ResolvedSshHost = { + id: '00000000-0000-4000-8000-000000000102', + name: 'Local test host', + hostname: '127.0.0.1', + port: harness.port, + username: 'builder', + authentication: 'password', + password: 'private password', + hostKey: { + ...candidate, + acceptedAt: '2026-08-01T00:00:00.000Z', + generation: 1 + } + } + + await expect(transport.testConnection(host)).resolves.toEqual({ + connected: true, + latencyMs: 6, + platform: 'linux', + architecture: 'x64', + shell: '/bin/bash', + homeDirectory: '/home/builder', + detail: 'SSH 已连接,远端系统为 linux/x64' + }) + expect(harness.passwords).toEqual(['private password']) + expect(harness.commands).toHaveLength(1) + expect(harness.commands[0]).toContain( + "printf 'GOODBUDDY_SSH_PROBE_V1\\n'" + ) + expect(harness.commands[0]).not.toContain('private password') + }) + + it('rejects a changed host key before authentication', async () => { + const harness = await createServer() + const transport = new Ssh2Transport() + const candidate = await transport.inspectHostKey({ + hostname: '127.0.0.1', + port: harness.port, + username: 'builder' + }) + const wrongKey = Buffer.from( + candidate.publicKeyBase64, + 'base64' + ) + const finalByte = wrongKey.readUInt8(wrongKey.length - 1) + wrongKey.writeUInt8(finalByte ^ 0xff, wrongKey.length - 1) + + await expect( + transport.testConnection({ + id: '00000000-0000-4000-8000-000000000103', + name: 'Local test host', + hostname: '127.0.0.1', + port: harness.port, + username: 'builder', + authentication: 'password', + password: 'private password', + hostKey: { + ...candidate, + publicKeyBase64: wrongKey.toString('base64'), + acceptedAt: '2026-08-01T00:00:00.000Z', + generation: 1 + } + }) + ).rejects.toThrow('主机密钥已变化') + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(harness.passwords).toEqual([]) + }) + + it('starts a separate bounded probe deadline after authentication', async () => { + vi.useFakeTimers() + try { + const stream = Object.assign(new EventEmitter(), { + destroy: vi.fn() + }) + const client = Object.assign(new EventEmitter(), { + connect: vi.fn(function connect(this: EventEmitter) { + this.emit('ready') + }), + end: vi.fn(), + destroy: vi.fn(), + exec: vi.fn( + ( + _command: string, + callback: ( + error: Error | undefined, + output: typeof stream + ) => void + ) => callback(undefined, stream) + ) + }) + const transport = new Ssh2Transport({ + createClient: () => client as never, + now: () => 0 + }) + const connection = transport.testConnection({ + id: '00000000-0000-4000-8000-000000000106', + name: 'Timeout test host', + hostname: '127.0.0.1', + port: 22, + username: 'builder', + authentication: 'password', + password: 'private password', + hostKey: { + algorithm: 'ssh-ed25519', + publicKeyBase64: + createHostKeyBlob('ssh-ed25519').toString('base64'), + fingerprintSha256: `SHA256:${'A'.repeat(43)}`, + acceptedAt: '2026-08-01T00:00:00.000Z', + generation: 1 + } + }) + const rejected = vi.fn() + void connection.catch(rejected) + + await vi.advanceTimersByTimeAsync(9_999) + expect(rejected).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(rejected).toHaveBeenCalledOnce() + await expect(connection).rejects.toThrow('远端系统探测超时') + expect(client.end).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/ssh-transport.ts b/src/main/ssh/ssh-transport.ts new file mode 100644 index 00000000..dd1947a2 --- /dev/null +++ b/src/main/ssh/ssh-transport.ts @@ -0,0 +1,551 @@ +import { createHash, getCiphers } from 'node:crypto' +import { + Client, + type Algorithms, + type CipherAlgorithm, + type ConnectConfig, + type ServerHostKeyAlgorithm +} from 'ssh2' +import type { + SshHostConnectionTestResult +} from '../../shared/ssh-host-contracts' +import type { ResolvedSshHost } from './ssh-host-store' + +export const SSH_CONNECTION_TIMEOUT_MS = 10_000 +export const SSH_KEEPALIVE_INTERVAL_MS = 5_000 +export const SSH_KEEPALIVE_COUNT_MAX = 2 +const PROBE_TIMEOUT_MS = 10_000 +const MAX_PROBE_OUTPUT_BYTES = 16 * 1024 +const MAX_HOST_KEY_BYTES = 32 * 1024 +const PROBE_MARKER = 'GOODBUDDY_SSH_PROBE_V1' +const PROBE_COMMAND = [ + `printf '${PROBE_MARKER}\\n'`, + "uname -s 2>/dev/null || printf 'unknown\\n'", + "uname -m 2>/dev/null || printf 'unknown\\n'", + "printf '%s\\n' \"${SHELL:-unknown}\"", + "printf '%s\\n' \"${HOME:-unknown}\"" +].join('; ') + +const strongAlgorithms: Omit = { + kex: [ + 'curve25519-sha256', + 'curve25519-sha256@libssh.org', + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group14-sha256', + 'diffie-hellman-group15-sha512', + 'diffie-hellman-group16-sha512', + 'diffie-hellman-group17-sha512', + 'diffie-hellman-group18-sha512' + ], + serverHostKey: [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'rsa-sha2-512', + 'rsa-sha2-256' + ], + hmac: [ + 'hmac-sha2-512-etm@openssh.com', + 'hmac-sha2-256-etm@openssh.com', + 'hmac-sha2-512', + 'hmac-sha2-256' + ] +} + +const strongCipherCandidates = [ + { + sshName: 'chacha20-poly1305@openssh.com', + openSslName: 'chacha20' + }, + { + sshName: 'aes256-gcm@openssh.com', + openSslName: 'aes-256-gcm' + }, + { + sshName: 'aes128-gcm@openssh.com', + openSslName: 'aes-128-gcm' + }, + { sshName: 'aes256-ctr', openSslName: 'aes-256-ctr' }, + { sshName: 'aes192-ctr', openSslName: 'aes-192-ctr' }, + { sshName: 'aes128-ctr', openSslName: 'aes-128-ctr' } +] as const + +export function selectStrongSshCiphers( + supportedOpenSslCiphers: readonly string[] +): CipherAlgorithm[] { + const supported = new Set(supportedOpenSslCiphers) + const selected = strongCipherCandidates + .filter((candidate) => supported.has(candidate.openSslName)) + .map((candidate) => candidate.sshName) + if (selected.length === 0) { + throw new Error('当前运行环境不支持 GoodBuddy 要求的安全 SSH 加密算法') + } + return selected +} + +export function createStrongSshAlgorithms( + supportedOpenSslCiphers: readonly string[] +): Algorithms { + return { + ...strongAlgorithms, + cipher: selectStrongSshCiphers(supportedOpenSslCiphers) + } +} + +export type SshConnectionTarget = { + hostname: string + port: number + username: string +} + +export type SshHostKeyCandidate = { + algorithm: string + publicKeyBase64: string + fingerprintSha256: string +} + +export interface SshTransport { + inspectHostKey( + target: SshConnectionTarget + ): Promise + testConnection( + host: ResolvedSshHost + ): Promise> +} + +type ClientLike = Pick & { + once: Client['once'] + on: Client['on'] +} + +type SshTransportDependencies = { + createClient: () => ClientLike + now: () => number + supportedOpenSslCiphers: () => readonly string[] + systemAgent: () => string | undefined +} + +function readSshString( + value: Buffer, + offset: number +): { value: string; offset: number } { + if (offset + 4 > value.length) { + throw new Error('SSH 主机密钥格式无效') + } + const length = value.readUInt32BE(offset) + const start = offset + 4 + const end = start + length + if (length <= 0 || end > value.length) { + throw new Error('SSH 主机密钥格式无效') + } + return { + value: value.subarray(start, end).toString('ascii'), + offset: end + } +} + +export function describeSshHostKey( + publicKey: Buffer +): SshHostKeyCandidate { + if ( + publicKey.byteLength === 0 || + publicKey.byteLength > MAX_HOST_KEY_BYTES + ) { + throw new Error('SSH 主机密钥大小无效') + } + const algorithm = readSshString(publicKey, 0).value + if ( + ![ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp384', + 'ecdsa-sha2-nistp521', + 'ssh-rsa' + ].includes(algorithm) + ) { + throw new Error('SSH 主机密钥算法不受支持') + } + return { + algorithm, + publicKeyBase64: publicKey.toString('base64'), + fingerprintSha256: `SHA256:${createHash('sha256') + .update(publicKey) + .digest('base64') + .replace(/=+$/u, '')}` + } +} + +export function defaultSystemAgent(): string | undefined { + const configured = process.env.SSH_AUTH_SOCK?.trim() + if (configured) { + return configured + } + return process.platform === 'win32' + ? '\\\\.\\pipe\\openssh-ssh-agent' + : undefined +} + +export function safeSshError(error: unknown): Error { + if ( + error !== null && + typeof error === 'object' && + 'level' in error && + typeof error.level === 'string' + ) { + if (error.level.includes('client-authentication')) { + return new Error('SSH 认证失败,请检查用户名和认证凭据') + } + if (error.level.includes('client-timeout')) { + return new Error('SSH 连接超时') + } + } + const code = + error !== null && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' + ? error.code + : '' + if (code === 'ENOTFOUND') { + return new Error('无法解析 SSH 主机地址') + } + if (code === 'ECONNREFUSED') { + return new Error('SSH 主机拒绝连接') + } + if (code === 'ETIMEDOUT') { + return new Error('SSH 连接超时') + } + return new Error('无法连接 SSH 主机') +} + +function selectPinnedServerHostKeyAlgorithms( + algorithm: string +): ServerHostKeyAlgorithm[] { + switch (algorithm) { + case 'ssh-ed25519': + case 'ecdsa-sha2-nistp256': + case 'ecdsa-sha2-nistp384': + case 'ecdsa-sha2-nistp521': + return [algorithm] + case 'ssh-rsa': + return ['rsa-sha2-512', 'rsa-sha2-256'] + default: + throw new Error('SSH 主机密钥算法不受支持') + } +} + +export function buildAuthenticatedSshConnectConfig( + host: ResolvedSshHost, + algorithms: Algorithms, + systemAgent: string | undefined, + onHostKeyMismatch: () => void +): ConnectConfig { + if (!host.hostKey) { + throw new Error('请先验证并接受 SSH 主机密钥') + } + const publicKey = Buffer.from( + host.hostKey.publicKeyBase64, + 'base64' + ) + if (publicKey.byteLength === 0) { + throw new Error('SSH 主机密钥无效') + } + const pinnedAlgorithm = describeSshHostKey(publicKey).algorithm + const pinnedServerHostKeyAlgorithms = + selectPinnedServerHostKeyAlgorithms(pinnedAlgorithm) + const config: ConnectConfig = { + host: host.hostname, + port: host.port, + username: host.username, + readyTimeout: SSH_CONNECTION_TIMEOUT_MS, + keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS, + keepaliveCountMax: SSH_KEEPALIVE_COUNT_MAX, + algorithms: { + ...algorithms, + serverHostKey: [...pinnedServerHostKeyAlgorithms] + }, + agentForward: false, + hostVerifier: (key: Buffer) => { + const matches = key.equals(publicKey) + if (!matches) { + onHostKeyMismatch() + } + return matches + } + } + if (host.authentication === 'password') { + if (!host.password) { + throw new Error('SSH 主机尚未配置密码') + } + config.password = host.password + } else { + if (!systemAgent) { + throw new Error('当前系统未检测到可用的 SSH Agent') + } + config.agent = systemAgent + } + return config +} + +function parseProbeOutput( + stdout: Buffer, + latencyMs: number +): Omit { + const lines = stdout + .toString('utf8') + .replaceAll('\r\n', '\n') + .trimEnd() + .split('\n') + if (lines[0] !== PROBE_MARKER || lines.length < 5) { + throw new Error('SSH 远端系统探测返回了无效结果') + } + const platform = + lines[1] === 'Linux' + ? 'linux' + : lines[1] === 'Darwin' + ? 'darwin' + : /^MINGW|^MSYS|^CYGWIN|^Windows_NT$/u.test(lines[1] ?? '') + ? 'win32' + : 'unknown' + const architecture = + /^(x86_64|amd64)$/iu.test(lines[2] ?? '') + ? 'x64' + : /^(aarch64|arm64)$/iu.test(lines[2] ?? '') + ? 'arm64' + : 'unknown' + return { + connected: true, + latencyMs, + platform, + architecture, + shell: (lines[3] ?? 'unknown').slice(0, 256), + homeDirectory: (lines[4] ?? 'unknown').slice(0, 4_096), + detail: `SSH 已连接,远端系统为 ${platform}/${architecture}` + } +} + +export class Ssh2Transport implements SshTransport { + private readonly dependencies: SshTransportDependencies + private readonly algorithms: Algorithms + + constructor( + dependencies: Partial = {} + ) { + this.dependencies = { + createClient: () => new Client(), + now: Date.now, + supportedOpenSslCiphers: getCiphers, + systemAgent: defaultSystemAgent, + ...dependencies + } + this.algorithms = createStrongSshAlgorithms( + this.dependencies.supportedOpenSslCiphers() + ) + } + + inspectHostKey( + target: SshConnectionTarget + ): Promise { + return new Promise((resolve, reject) => { + const client = this.dependencies.createClient() + let settled = false + const settle = ( + outcome: + | { ok: true; value: SshHostKeyCandidate } + | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + client.destroy() + if (outcome.ok) { + resolve(outcome.value) + } else { + reject(outcome.error) + } + } + const timeout = setTimeout( + () => + settle({ + ok: false, + error: new Error('SSH 主机身份检查超时') + }), + SSH_CONNECTION_TIMEOUT_MS + ) + client.once('error', (error) => { + settle({ ok: false, error: safeSshError(error) }) + }) + client.connect({ + host: target.hostname, + port: target.port, + username: target.username, + readyTimeout: SSH_CONNECTION_TIMEOUT_MS, + algorithms: this.algorithms, + agentForward: false, + hostVerifier: (key: Buffer) => { + try { + settle({ + ok: true, + value: describeSshHostKey(key) + }) + } catch (error) { + settle({ + ok: false, + error: + error instanceof Error + ? error + : new Error('SSH 主机密钥无效') + }) + } + return false + } + }) + }) + } + + testConnection( + host: ResolvedSshHost + ): Promise> { + if (!host.hostKey) { + return Promise.reject( + new Error('请先验证并接受 SSH 主机密钥') + ) + } + return new Promise((resolve, reject) => { + const client = this.dependencies.createClient() + const startedAt = this.dependencies.now() + let settled = false + let hostKeyMismatch = false + const settle = ( + outcome: + | { + ok: true + value: Omit + } + | { ok: false; error: Error } + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + client.end() + if (outcome.ok) { + resolve(outcome.value) + } else { + reject(outcome.error) + } + } + let timeout = setTimeout( + () => + settle({ + ok: false, + error: new Error('SSH 连接超时') + }), + SSH_CONNECTION_TIMEOUT_MS + ) + client.once('error', (error) => { + settle({ + ok: false, + error: hostKeyMismatch + ? new Error('SSH 主机密钥已变化,请重新验证主机身份') + : safeSshError(error) + }) + }) + client.once('ready', () => { + clearTimeout(timeout) + timeout = setTimeout( + () => + settle({ + ok: false, + error: new Error('SSH 远端系统探测超时') + }), + PROBE_TIMEOUT_MS + ) + client.exec(PROBE_COMMAND, (error, stream) => { + if (error) { + settle({ + ok: false, + error: new Error('无法启动 SSH 远端系统探测') + }) + return + } + const chunks: Buffer[] = [] + let outputBytes = 0 + stream.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.from(chunk) + outputBytes += buffer.byteLength + if (outputBytes > MAX_PROBE_OUTPUT_BYTES) { + stream.destroy() + settle({ + ok: false, + error: new Error('SSH 远端系统探测输出超过安全限制') + }) + return + } + chunks.push(buffer) + }) + stream.once('error', () => { + settle({ + ok: false, + error: new Error('SSH 远端系统探测失败') + }) + }) + stream.once('close', (code: number | null) => { + if (code !== 0) { + settle({ + ok: false, + error: new Error('SSH 远端系统探测失败') + }) + return + } + try { + settle({ + ok: true, + value: parseProbeOutput( + Buffer.concat(chunks), + Math.max(0, this.dependencies.now() - startedAt) + ) + }) + } catch (error) { + settle({ + ok: false, + error: + error instanceof Error + ? error + : new Error('SSH 远端系统探测失败') + }) + } + }) + }) + }) + let config: ConnectConfig + try { + config = buildAuthenticatedSshConnectConfig( + host, + this.algorithms, + this.dependencies.systemAgent(), + () => { + hostKeyMismatch = true + } + ) + } catch (error) { + settle({ + ok: false, + error: + error instanceof Error + ? error + : new Error('SSH 认证配置无效') + }) + return + } + client.connect(config) + }) + } +} diff --git a/src/main/workspace/index.ts b/src/main/workspace/index.ts new file mode 100644 index 00000000..73f62e11 --- /dev/null +++ b/src/main/workspace/index.ts @@ -0,0 +1,31 @@ +export { LocalWorkspaceAccess } from './local-workspace-access' +export { RemoteWorkspaceAccess } from './remote-workspace-access' +export { + UnsupportedRemoteWorkspaceAccess +} from './unsupported-remote-workspace-access' +export type { + RemoteWorkspaceAccessFactory, + RemoteWorkspaceProjectBinding, + RemoteWorkspaceTransport, + RemoteWorkspaceTransportBinding, + RemoteWorkspaceTransportLease +} from './remote-workspace-access' +export type { + DirectoryPage, + FilePreview, + SearchPage, + WorkspaceAccess, + WorkspaceChangedFile, + WorkspaceChanges, + WorkspaceChangesInput, + WorkspaceDirectoryEntry, + WorkspaceDirectoryInput, + WorkspaceEntry, + WorkspaceIdentity, + WorkspacePathInput, + WorkspaceReadInput, + WorkspaceSearchInput, + WorkspaceSearchMatch, + WorkspaceWriteInput, + WriteResult +} from './workspace-access' diff --git a/src/main/workspace/local-workspace-access.ts b/src/main/workspace/local-workspace-access.ts new file mode 100644 index 00000000..7b52fd50 --- /dev/null +++ b/src/main/workspace/local-workspace-access.ts @@ -0,0 +1,817 @@ +import spawn from 'cross-spawn' +import { randomUUID } from 'node:crypto' +import { constants as fileSystemConstants } from 'node:fs' +import { + access, + lstat, + open, + realpath, + rename, + rm, + stat +} from 'node:fs/promises' +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + resolve +} from 'node:path' +import { + getCanonicalWorkspace, + isPathInside, + listBoundedDirectoryEntries, + readBoundedUtf8File, + resolveExistingWorkspacePath +} from '../workspace-file-access' +import type { + DirectoryPage, + FilePreview, + SearchPage, + WorkspaceAccess, + WorkspaceChangedFile, + WorkspaceChanges, + WorkspaceChangesInput, + WorkspaceDirectoryInput, + WorkspaceEntry, + WorkspaceIdentity, + WorkspacePathInput, + WorkspaceReadInput, + WorkspaceSearchInput, + WorkspaceWriteInput, + WriteResult +} from './workspace-access' + +const MAX_GIT_OUTPUT_BYTES = 512 * 1024 +const GIT_COMMAND_TIMEOUT_MS = 10_000 +const MAX_GIT_FILTER_DRIVERS = 256 +const MAX_DIRECTORY_ENTRIES = 500 +const MAX_CHANGED_FILES = 2_000 +const DEFAULT_READ_BYTES = 256 * 1024 +const DEFAULT_WRITE_BYTES = 512 * 1024 +const DEFAULT_SEARCH_RESULTS = 100 +const MAX_SEARCH_RESULTS = 1_000 +const DEFAULT_SEARCH_SCAN_BYTES = 2 * 1024 * 1024 +const MAX_SEARCH_SCAN_BYTES = 8 * 1024 * 1024 +const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null' + +const gitEnvironmentAllowlist = [ + 'PATHEXT', + 'SystemRoot', + 'WINDIR', + 'COMSPEC', + 'TEMP', + 'TMP', + 'TMPDIR', + 'LANG', + 'LC_ALL', + 'LC_CTYPE' +] as const + +const safeGitArguments = [ + '--no-pager', + '--no-optional-locks', + '-c', + `core.hooksPath=${NULL_DEVICE}`, + '-c', + 'core.fsmonitor=false', + '-c', + 'core.askPass=', + '-c', + 'credential.helper=', + '-c', + 'diff.external=', + '-c', + 'interactive.diffFilter=' +] as const + +let gitExecutable: Promise | undefined + +type CommandResult = { + code: number | null + stdout: string + stderr: string + truncated: boolean +} + +type GitConfigOverride = readonly [key: string, value: string] + +/** + * Git runs inside user-controlled repositories, so build a fresh environment + * rather than filtering Electron Main's environment in place. + */ +export function buildWorkspaceGitEnvironment( + source: NodeJS.ProcessEnv = process.env, + configOverrides: readonly GitConfigOverride[] = [] +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { + GIT_ASKPASS: '', + GIT_CONFIG_GLOBAL: NULL_DEVICE, + GIT_CONFIG_NOSYSTEM: '1', + GIT_OPTIONAL_LOCKS: '0', + GIT_PAGER: '', + GIT_TERMINAL_PROMPT: '0', + GCM_INTERACTIVE: 'Never', + SSH_ASKPASS: '' + } + const path = source.PATH ?? source.Path ?? source.path + if (path !== undefined) { + environment.PATH = path + } + for (const name of gitEnvironmentAllowlist) { + if (source[name] !== undefined) { + environment[name] = source[name] + } + } + if (configOverrides.length > 0) { + environment.GIT_CONFIG_COUNT = String(configOverrides.length) + configOverrides.forEach(([key, value], index) => { + environment[`GIT_CONFIG_KEY_${index}`] = key + environment[`GIT_CONFIG_VALUE_${index}`] = value + }) + } + return environment +} + +async function resolveGitExecutable( + source: NodeJS.ProcessEnv = process.env +): Promise { + const path = source.PATH ?? source.Path ?? source.path ?? '' + const executableName = + process.platform === 'win32' ? 'git.exe' : 'git' + for (const rawDirectory of path.split(delimiter)) { + const trimmed = rawDirectory.trim() + const directory = + trimmed.startsWith('"') && trimmed.endsWith('"') + ? trimmed.slice(1, -1) + : trimmed + if (!directory || !isAbsolute(directory)) { + continue + } + const candidate = join(directory, executableName) + try { + await access(candidate, fileSystemConstants.X_OK) + const canonicalPath = await realpath(candidate) + if ( + isAbsolute(canonicalPath) && + (await stat(canonicalPath)).isFile() + ) { + return canonicalPath + } + } catch { + // Continue through the explicit PATH candidates. + } + } + throw new Error('无法找到安全的 Git 可执行文件') +} + +function getGitExecutable(): Promise { + gitExecutable ??= resolveGitExecutable() + return gitExecutable +} + +function getBoundedInteger( + value: number | undefined, + fallback: number, + maximum: number, + message: string +): number { + const resolved = value ?? fallback + if ( + !Number.isSafeInteger(resolved) || + resolved < 1 || + resolved > maximum + ) { + throw new Error(message) + } + return resolved +} + +function normalizeRelativePath( + inputPath: string, + allowRoot: boolean +): { path: string; segments: string[] } { + const normalized = inputPath.replaceAll('\\', '/') + if (allowRoot && (normalized === '' || normalized === '.')) { + return { path: '', segments: [] } + } + if ( + !normalized || + normalized.startsWith('/') || + /^[a-zA-Z]:\//u.test(normalized) || + isAbsolute(normalized) || + normalized.includes('\0') + ) { + throw new Error('路径必须是工作区内的相对路径') + } + const segments = normalized.split('/') + if ( + segments.some( + (segment) => + !segment || segment === '.' || segment === '..' + ) + ) { + throw new Error('路径必须是工作区内的相对路径,不能超出工作区') + } + return { + path: segments.join('/'), + segments + } +} + +async function runGit( + rootPath: string, + args: string[], + signal?: AbortSignal, + configOverrides: readonly GitConfigOverride[] = [] +): Promise { + const executablePath = await getGitExecutable() + return new Promise((resolveResult, reject) => { + signal?.throwIfAborted() + const child = spawn(executablePath, args, { + cwd: rootPath, + env: buildWorkspaceGitEnvironment( + process.env, + configOverrides + ), + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let bytes = 0 + let truncated = false + let settled = false + const capture = (target: Buffer[], chunk: Buffer | string): void => { + const buffer = Buffer.from(chunk) + const remaining = MAX_GIT_OUTPUT_BYTES - bytes + if (remaining <= 0) { + truncated = true + return + } + target.push(buffer.subarray(0, remaining)) + bytes += Math.min(buffer.byteLength, remaining) + truncated ||= buffer.byteLength > remaining + } + const finish = ( + callback: () => void + ): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + callback() + } + const abort = (): void => { + child.kill() + finish(() => reject(signal?.reason)) + } + child.stdout?.on('data', (chunk: Buffer | string) => + capture(stdout, chunk) + ) + child.stderr?.on('data', (chunk: Buffer | string) => + capture(stderr, chunk) + ) + const timeout = setTimeout(() => { + child.kill() + finish(() => reject(new Error('读取文件更改超时'))) + }, GIT_COMMAND_TIMEOUT_MS) + signal?.addEventListener('abort', abort, { once: true }) + child.once('error', (error) => { + finish(() => reject(error)) + }) + child.once('close', (code) => { + finish(() => + resolveResult({ + code, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + truncated + }) + ) + }) + }) +} + +function withSafeGitArguments(args: string[]): string[] { + return [...safeGitArguments, ...args] +} + +async function getFilterOverrides( + rootPath: string, + signal?: AbortSignal +): Promise { + const result = await runGit( + rootPath, + withSafeGitArguments([ + 'config', + '--local', + '--includes', + '--name-only', + '-z', + '--get-regexp', + '^filter\\..*\\.(clean|smudge|process|required)$' + ]), + signal + ) + if (result.truncated) { + throw new Error('Git 过滤器配置超过安全限制') + } + // `git config --get-regexp` returns 1 when no names matched. + if (result.code !== 0 && result.code !== 1) { + throw new Error( + result.stderr.trim().slice(0, 2_000) || + '无法安全读取 Git 过滤器配置' + ) + } + + const drivers = new Set() + for (const name of result.stdout.split('\0')) { + const match = /^(filter\..*)\.(?:clean|smudge|process|required)$/iu.exec( + name + ) + if (match?.[1]) { + drivers.add(match[1]) + if (drivers.size > MAX_GIT_FILTER_DRIVERS) { + throw new Error('Git 过滤器配置超过安全限制') + } + } + } + + return [...drivers].flatMap( + (driver): GitConfigOverride[] => [ + [`${driver}.clean`, ''], + [`${driver}.smudge`, ''], + [`${driver}.process`, ''], + [`${driver}.required`, 'false'] + ] + ) +} + +function parseChangedFiles(status: string): { + files: WorkspaceChangedFile[] + truncated: boolean +} { + const records = status.split('\0') + const files: WorkspaceChangedFile[] = [] + let index = 0 + while (index < records.length && files.length < MAX_CHANGED_FILES) { + const record = records[index] + index += 1 + if (!record) { + continue + } + const statusCode = record.slice(0, 2) + const path = record.slice(3) + if (!path) { + continue + } + const renamed = statusCode.includes('R') || statusCode.includes('C') + const previousPath = renamed ? records[index] : undefined + if (renamed) { + index += 1 + } + files.push({ + path, + status: statusCode, + ...(previousPath ? { previousPath } : {}) + }) + } + return { + files, + truncated: index < records.length - 1 + } +} + +function formatChangedFiles(files: WorkspaceChangedFile[]): string { + return files + .map((file) => + file.previousPath + ? `${file.status} ${file.previousPath} -> ${file.path}` + : `${file.status} ${file.path}` + ) + .join('\n') +} + +export class LocalWorkspaceAccess implements WorkspaceAccess { + private canonicalWorkspace?: Promise + + constructor(private readonly rootPath: string) {} + + private getWorkspace(): Promise { + this.canonicalWorkspace ??= getCanonicalWorkspace( + this.rootPath, + '项目工作区不是目录' + ) + return this.canonicalWorkspace + } + + private async resolveExisting( + inputPath: string, + expected: 'file' | 'directory' + ): Promise<{ canonicalPath: string; path: string }> { + const root = await this.getWorkspace() + const normalized = normalizeRelativePath( + inputPath, + expected === 'directory' + ) + return { + canonicalPath: await resolveExistingWorkspacePath( + root, + normalized.segments, + expected + ), + path: normalized.path + } + } + + async resolveEntryPath( + inputPath: string, + expected: 'file' | 'directory' + ): Promise { + return (await this.resolveExisting(inputPath, expected)).canonicalPath + } + + private async resolveWritable(inputPath: string): Promise<{ + canonicalPath: string + path: string + }> { + const root = await this.getWorkspace() + const normalized = normalizeRelativePath(inputPath, false) + const candidate = resolve(root, ...normalized.segments) + if (!isPathInside(root, candidate) || candidate === root) { + throw new Error('工具路径不能超出工作区') + } + const canonicalParent = await realpath(dirname(candidate)) + if (!isPathInside(root, canonicalParent)) { + throw new Error('工具路径不能通过符号链接超出工作区') + } + const existing = await lstat(candidate).catch((error: unknown) => { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return undefined + } + throw error + }) + if (existing?.isSymbolicLink()) { + throw new Error('工作区写入工具拒绝符号链接') + } + if (existing && !existing.isFile()) { + throw new Error('工作区写入目标不是普通文件') + } + return { canonicalPath: candidate, path: normalized.path } + } + + async getIdentity(): Promise { + const canonicalRoot = await this.getWorkspace() + const metadata = await stat(canonicalRoot) + return { + kind: 'local', + id: `local:${metadata.dev}:${metadata.ino}:${canonicalRoot}`, + canonicalDisplayPath: canonicalRoot, + access: 'read-write' + } + } + + async listDirectory( + input: WorkspaceDirectoryInput + ): Promise { + input.signal?.throwIfAborted() + const maximumEntries = getBoundedInteger( + input.maximumEntries, + MAX_DIRECTORY_ENTRIES, + MAX_DIRECTORY_ENTRIES, + '工作区目录条目上限无效' + ) + const directory = await this.resolveExisting(input.path, 'directory') + const listing = await listBoundedDirectoryEntries( + directory.canonicalPath, + maximumEntries, + (entry) => + (input.includeGit === true || entry.name !== '.git') && + (input.includeOther === true || + entry.isDirectory() || + entry.isFile()) + ) + input.signal?.throwIfAborted() + const entries = listing.entries.sort((left, right) => { + if ( + input.includeOther !== true && + left.isDirectory() !== right.isDirectory() + ) { + return left.isDirectory() ? -1 : 1 + } + return left.name.localeCompare(right.name) + }) + return { + path: directory.path, + entries: entries.map((entry) => ({ + name: entry.name, + path: [directory.path, entry.name].filter(Boolean).join('/'), + type: entry.isDirectory() + ? 'directory' + : entry.isFile() + ? 'file' + : 'other' + })), + truncated: listing.truncated + } + } + + async stat(input: WorkspacePathInput): Promise { + input.signal?.throwIfAborted() + const root = await this.getWorkspace() + const normalized = normalizeRelativePath(input.path, true) + const candidate = resolve(root, ...normalized.segments) + if (!isPathInside(root, candidate)) { + throw new Error('文件路径不能超出项目工作区') + } + const canonicalPath = await realpath(candidate) + if (!isPathInside(root, canonicalPath)) { + throw new Error('文件路径不能通过符号链接超出项目工作区') + } + const metadata = await stat(canonicalPath) + input.signal?.throwIfAborted() + return { + name: basename(canonicalPath), + path: normalized.path, + type: metadata.isDirectory() + ? 'directory' + : metadata.isFile() + ? 'file' + : 'other', + size: metadata.size, + modifiedAt: metadata.mtime.toISOString() + } + } + + async readText(input: WorkspaceReadInput): Promise { + input.signal?.throwIfAborted() + const maximumBytes = getBoundedInteger( + input.maximumBytes, + DEFAULT_READ_BYTES, + DEFAULT_WRITE_BYTES, + '工作区读取上限无效' + ) + const file = await this.resolveExisting(input.path, 'file') + const preview = await readBoundedUtf8File( + file.canonicalPath, + maximumBytes, + input.tooLargeMessage ?? '工作区文本文件超过安全限制', + input.invalidUtf8Message ?? '工作区读取目标不是有效 UTF-8 文本' + ) + input.signal?.throwIfAborted() + return { + path: file.path, + name: basename(file.canonicalPath), + content: preview.content, + size: preview.size + } + } + + async writeTextAtomic( + input: WorkspaceWriteInput + ): Promise { + input.signal?.throwIfAborted() + const maximumBytes = getBoundedInteger( + input.maximumBytes, + DEFAULT_WRITE_BYTES, + DEFAULT_WRITE_BYTES, + '工作区写入上限无效' + ) + const bytesWritten = Buffer.byteLength(input.content) + if (bytesWritten > maximumBytes) { + throw new Error('写入内容超过 512KB 安全限制') + } + const file = await this.resolveWritable(input.path) + const temporaryPath = `${file.canonicalPath}.${randomUUID()}.tmp` + const handle = await open(temporaryPath, 'wx', 0o600) + try { + try { + await handle.writeFile(input.content, 'utf8') + } finally { + await handle.close() + } + input.signal?.throwIfAborted() + await rename(temporaryPath, file.canonicalPath) + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined) + if (input.signal?.aborted) { + throw error + } + throw new Error('无法安全写入工作区文件', { cause: error }) + } + return { path: file.path, bytesWritten } + } + + async search(input: WorkspaceSearchInput): Promise { + input.signal?.throwIfAborted() + if ( + !input.query || + input.query.includes('\0') || + Buffer.byteLength(input.query) > 4_096 + ) { + throw new Error('工作区搜索词无效') + } + const maximumResults = getBoundedInteger( + input.maximumResults, + DEFAULT_SEARCH_RESULTS, + MAX_SEARCH_RESULTS, + '工作区搜索结果上限无效' + ) + const maximumFileBytes = getBoundedInteger( + input.maximumFileBytes, + DEFAULT_READ_BYTES, + DEFAULT_WRITE_BYTES, + '工作区搜索文件上限无效' + ) + const maximumScannedBytes = getBoundedInteger( + input.maximumScannedBytes, + DEFAULT_SEARCH_SCAN_BYTES, + MAX_SEARCH_SCAN_BYTES, + '工作区搜索扫描上限无效' + ) + const queue = [input.path ?? ''] + const matches: SearchPage['matches'] = [] + let scannedBytes = 0 + let truncated = false + while (queue.length > 0 && !truncated) { + input.signal?.throwIfAborted() + const currentPath = queue.shift()! + const listing = await this.listDirectory({ + path: currentPath, + maximumEntries: MAX_DIRECTORY_ENTRIES, + signal: input.signal + }) + truncated ||= listing.truncated + for (const entry of listing.entries) { + if (entry.type === 'directory') { + queue.push(entry.path) + continue + } + if (entry.type !== 'file') { + continue + } + const metadata = await this.stat({ + path: entry.path, + signal: input.signal + }) + if ( + metadata.size > maximumFileBytes || + scannedBytes + metadata.size > maximumScannedBytes + ) { + truncated = true + break + } + scannedBytes += metadata.size + let content: string + try { + content = ( + await this.readText({ + path: entry.path, + maximumBytes: maximumFileBytes, + signal: input.signal + }) + ).content + } catch (error) { + if (input.signal?.aborted) { + throw error + } + continue + } + const lines = content.split(/\r?\n/u) + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const column = line.indexOf(input.query) + if (column < 0) { + continue + } + matches.push({ + path: entry.path, + line: index + 1, + column: column + 1, + preview: line.slice(0, 1_000) + }) + if (matches.length >= maximumResults) { + truncated = true + break + } + } + if (truncated) { + break + } + } + } + return { matches, truncated } + } + + async getChanges( + input: WorkspaceChangesInput + ): Promise { + input.signal?.throwIfAborted() + if (!this.rootPath.trim()) { + return { + rootPath: this.rootPath, + available: false, + status: '', + patch: '', + files: [], + truncated: false, + error: '项目尚未配置工作区目录' + } + } + const gitMetadata = await stat(join(this.rootPath, '.git')).catch( + () => undefined + ) + if (!gitMetadata) { + return { + rootPath: this.rootPath, + available: false, + status: '', + patch: '', + files: [], + truncated: false + } + } + try { + const canonicalRoot = await this.getWorkspace() + const filterOverrides = await getFilterOverrides( + canonicalRoot, + input.signal + ) + const [statusResult, patchResult] = await Promise.all([ + runGit( + canonicalRoot, + withSafeGitArguments([ + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=normal' + ]), + input.signal, + filterOverrides + ), + runGit( + canonicalRoot, + withSafeGitArguments([ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--no-color', + 'HEAD' + ]), + input.signal, + filterOverrides + ) + ]) + if (statusResult.code !== 0 || patchResult.code !== 0) { + const detail = statusResult.stderr || patchResult.stderr + return { + rootPath: this.rootPath, + available: false, + status: '', + patch: '', + files: [], + truncated: statusResult.truncated || patchResult.truncated, + error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区' + } + } + const changedFiles = parseChangedFiles(statusResult.stdout) + return { + rootPath: this.rootPath, + available: true, + status: formatChangedFiles(changedFiles.files), + patch: patchResult.stdout, + files: changedFiles.files, + truncated: + statusResult.truncated || + patchResult.truncated || + changedFiles.truncated + } + } catch (error) { + if (input.signal?.aborted) { + throw error + } + return { + rootPath: this.rootPath, + available: false, + status: '', + patch: '', + files: [], + truncated: false, + error: + error instanceof Error ? error.message : '无法读取 Git 工作区' + } + } + } + + async dispose(): Promise {} +} diff --git a/src/main/workspace/remote-workspace-access.test.ts b/src/main/workspace/remote-workspace-access.test.ts new file mode 100644 index 00000000..62a90bb5 --- /dev/null +++ b/src/main/workspace/remote-workspace-access.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteWorkspaceHandle, + RemoteWorkspaceListRequest +} from '../../shared/remote-agent-contracts' +import { + RemoteWorkspaceAccess, + type RemoteWorkspaceProjectBinding, + type RemoteWorkspaceTransport, + type RemoteWorkspaceTransportLease +} from './remote-workspace-access' + +const digest = `sha256:${'a'.repeat(64)}` +const binding: RemoteWorkspaceProjectBinding = { + hostId: 'host-1', + hostRevision: 2, + hostKeyGeneration: 3, + remoteUsername: 'builder', + remoteRootPath: '/srv/project', + workspaceIdentity: 'identity-1', + agentInstallationId: 'installation-1', + agentBinaryDigest: digest, + agentVersion: '0.11.0', + agentArchitecture: 'x64', + agentProtocolMajor: 1 +} +const handle: RemoteWorkspaceHandle = { + workspaceId: 'workspace-1', + workspaceIdentity: binding.workspaceIdentity, + canonicalDisplayPath: binding.remoteRootPath, + access: 'read-only', + git: 'available', + capabilities: [ + 'list', + 'stat', + 'read-text', + 'search', + 'git-status', + 'git-diff' + ], + generation: 5 +} + +function createLease( + overrides: Partial = {} +): RemoteWorkspaceTransportLease { + return { + binding: { + hostId: binding.hostId, + hostRevision: binding.hostRevision, + hostKeyGeneration: binding.hostKeyGeneration, + remoteUsername: binding.remoteUsername, + agentInstallationId: binding.agentInstallationId, + agentBinaryDigest: binding.agentBinaryDigest, + agentVersion: binding.agentVersion, + agentArchitecture: binding.agentArchitecture, + agentProtocolMajor: binding.agentProtocolMajor, + capabilityGeneration: 6 + }, + validateWorkspace: vi.fn(async () => ({ + handle, + validatedAt: '2026-08-21T00:00:00.000Z' + })), + openWorkspace: vi.fn(async () => handle), + resumeWorkspace: vi.fn(async () => ({ + resumed: true, + handle + })), + closeWorkspace: vi.fn(async (request) => ({ + ...request, + closed: true as const + })), + listWorkspace: vi.fn(async () => ({ + entries: [], + nextCursor: undefined + })), + statWorkspace: vi.fn(async (request) => ({ + relativePath: request.relativePath, + name: request.relativePath.split('/').at(-1) ?? 'project', + kind: 'file' as const, + byteLength: 5, + modifiedAt: '2026-08-21T00:00:00.000Z', + digest, + executable: false + })), + readWorkspaceText: vi.fn(async (request) => ({ + relativePath: request.relativePath, + content: 'hello', + offsetBytes: request.offsetBytes, + bytesRead: 5, + totalBytes: 5, + digest, + truncated: false + })), + searchWorkspace: vi.fn(async () => ({ + matches: [], + truncated: false + })), + getGitStatus: vi.fn(async () => ({ + repositoryIdentity: 'repository-1', + branch: 'main', + entries: [], + truncated: false + })), + getGitDiff: vi.fn(async () => ({ + repositoryIdentity: 'repository-1', + patch: '', + byteLength: 0, + truncated: false + })), + release: vi.fn(), + ...overrides + } +} + +function createAccess(lease: RemoteWorkspaceTransportLease): { + access: RemoteWorkspaceAccess + transport: RemoteWorkspaceTransport +} { + const transport: RemoteWorkspaceTransport = { + acquireLease: vi.fn(async () => lease) + } + return { + access: new RemoteWorkspaceAccess(binding, transport), + transport + } +} + +describe('RemoteWorkspaceAccess', () => { + it('lazily validates once and maps bounded workspace methods', async () => { + const lease = createLease({ + listWorkspace: vi.fn(async () => ({ + entries: [ + { + relativePath: 'src', + name: 'src', + kind: 'directory' as const, + byteLength: 0, + modifiedAt: '2026-08-21T00:00:00.000Z', + executable: true + } + ], + nextCursor: '2' + })), + searchWorkspace: vi.fn(async () => ({ + matches: [ + { + relativePath: 'src/main.ts', + line: 2, + column: 3, + snippet: ' target' + } + ], + truncated: false + })) + }) + const { access, transport } = createAccess(lease) + + await expect(access.getIdentity()).resolves.toEqual({ + kind: 'remote', + id: 'host-1:identity-1', + canonicalDisplayPath: '/srv/project', + access: 'read-only' + }) + await expect( + access.listDirectory({ path: '', maximumEntries: 10 }) + ).resolves.toEqual({ + path: '', + entries: [{ name: 'src', path: 'src', type: 'directory' }], + truncated: true + }) + await expect(access.stat({ path: 'README.md' })).resolves.toEqual({ + name: 'README.md', + path: 'README.md', + type: 'file', + size: 5, + modifiedAt: '2026-08-21T00:00:00.000Z' + }) + await expect( + access.readText({ path: 'README.md', maximumBytes: 10 }) + ).resolves.toEqual({ + path: 'README.md', + name: 'README.md', + content: 'hello', + size: 5 + }) + await expect( + access.search({ + query: 'target', + path: 'src', + maximumResults: 10 + }) + ).resolves.toEqual({ + matches: [ + { + path: 'src/main.ts', + line: 2, + column: 3, + preview: ' target' + } + ], + truncated: false + }) + + expect(transport.acquireLease).toHaveBeenCalledOnce() + expect(lease.validateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + remoteRootPath: '/srv/project', + requestedAccess: 'read-only' + }), + undefined + ) + expect(lease.listWorkspace).toHaveBeenCalledWith( + { + workspaceId: 'workspace-1', + generation: 5, + relativePath: '', + limit: 10 + } satisfies RemoteWorkspaceListRequest, + undefined + ) + expect(lease.searchWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + generation: 5, + query: 'target', + pathPrefix: 'src', + limit: 10 + }), + undefined + ) + }) + + it('lists a readable non-Git workspace without requiring Git capabilities', async () => { + const nonGitHandle: RemoteWorkspaceHandle = { + ...handle, + git: 'not-a-repository', + capabilities: ['list', 'stat', 'read-text', 'search'] + } + const getGitStatus = vi.fn() + const getGitDiff = vi.fn() + const lease = createLease({ + validateWorkspace: vi.fn(async () => ({ + handle: nonGitHandle, + validatedAt: '2026-08-21T00:00:00.000Z' + })), + getGitStatus, + getGitDiff + }) + const { access } = createAccess(lease) + + await expect( + access.listDirectory({ path: '' }) + ).resolves.toEqual({ + path: '', + entries: [], + truncated: false + }) + expect(lease.validateWorkspace).toHaveBeenCalledWith( + { + remoteRootPath: binding.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [ + 'list', + 'stat', + 'read-text', + 'search' + ] + }, + undefined + ) + await expect(access.getChanges({})).resolves.toMatchObject({ + rootPath: binding.remoteRootPath, + available: false, + files: [] + }) + expect(getGitStatus).not.toHaveBeenCalled() + expect(getGitDiff).not.toHaveBeenCalled() + }) + + it('resumes a previous handle and opens a replacement when resume fails', async () => { + const lease = createLease({ + resumableHandle: handle, + resumeWorkspace: vi.fn(async () => ({ + resumed: false, + handle + })) + }) + const { access } = createAccess(lease) + + await access.getIdentity() + + expect(lease.resumeWorkspace).toHaveBeenCalledWith( + { + workspaceId: 'workspace-1', + generation: 5, + workspaceIdentity: 'identity-1' + }, + undefined + ) + expect(lease.openWorkspace).toHaveBeenCalledWith( + { + workspaceIdentity: 'identity-1', + requestedAccess: 'read-only' + }, + undefined + ) + expect(lease.validateWorkspace).not.toHaveBeenCalled() + }) + + it('rejects immutable transport and workspace binding mismatches', async () => { + for (const changedBinding of [ + { hostKeyGeneration: 99 }, + { agentBinaryDigest: `sha256:${'b'.repeat(64)}` }, + { agentVersion: '0.11.1' }, + { agentArchitecture: 'arm64' as const } + ]) { + const release = vi.fn() + const lease = createLease({ + binding: { + ...createLease().binding, + ...changedBinding + }, + release + }) + const { access } = createAccess(lease) + + await expect(access.getIdentity()).rejects.toThrow( + '绑定已失效' + ) + expect(release).toHaveBeenCalledOnce() + } + + const identityMismatch = createLease({ + validateWorkspace: vi.fn(async () => ({ + handle: { + ...handle, + workspaceIdentity: 'identity-2' + }, + validatedAt: '2026-08-21T00:00:00.000Z' + })) + }) + await expect( + createAccess(identityMismatch).access.getIdentity() + ).rejects.toThrow('身份绑定不匹配') + }) + + it('fails when the lease capability generation becomes stale', async () => { + const mutableBinding = { + ...createLease().binding + } + const lease = createLease({ binding: mutableBinding }) + const { access } = createAccess(lease) + await access.getIdentity() + mutableBinding.capabilityGeneration += 1 + + await expect( + access.listDirectory({ path: '' }) + ).rejects.toThrow('能力代际已失效') + }) + + it('enforces protocol limits before dispatching requests', async () => { + const lease = createLease() + const { access, transport } = createAccess(lease) + + await expect( + access.listDirectory({ path: '', maximumEntries: 1001 }) + ).rejects.toThrow('条目上限') + await expect( + access.readText({ + path: 'file.txt', + maximumBytes: 4 * 1024 * 1024 + 1 + }) + ).rejects.toThrow('读取上限') + await expect( + access.search({ query: 'x', maximumResults: 1001 }) + ).rejects.toThrow('搜索结果上限') + await expect( + access.search({ query: 'x', maximumFileBytes: 1 }) + ).rejects.toThrow('不支持本机扫描限额') + await expect( + access.writeTextAtomic({ path: 'x', content: 'x' }) + ).rejects.toThrow('持久 operation transport') + expect(transport.acquireLease).not.toHaveBeenCalled() + }) + + it('retries one inconsistent Git snapshot and maps status and diff', async () => { + const getGitStatus = vi + .fn() + .mockResolvedValueOnce({ + repositoryIdentity: 'repository-1', + branch: 'main', + entries: [], + truncated: false + }) + .mockResolvedValueOnce({ + repositoryIdentity: 'repository-2', + branch: 'main', + entries: [ + { + relativePath: 'new.txt', + index: 'untracked', + worktree: 'untracked' + } + ], + truncated: false + }) + const getGitDiff = vi + .fn() + .mockResolvedValueOnce({ + repositoryIdentity: 'repository-other', + patch: 'old', + byteLength: 3, + truncated: false + }) + .mockResolvedValueOnce({ + repositoryIdentity: 'repository-2', + patch: '+new\n', + byteLength: 5, + truncated: false + }) + const lease = createLease({ getGitStatus, getGitDiff }) + const { access } = createAccess(lease) + + await expect(access.getChanges({})).resolves.toMatchObject({ + available: true, + status: '?? new.txt', + patch: '+new\n', + files: [{ path: 'new.txt', status: '??' }] + }) + expect(getGitStatus).toHaveBeenCalledTimes(2) + expect(getGitDiff).toHaveBeenCalledTimes(2) + }) + + it('fails closed after the bounded Git snapshot retry', async () => { + const lease = createLease({ + getGitStatus: vi.fn(async () => ({ + repositoryIdentity: 'status-snapshot', + branch: null, + entries: [], + truncated: false + })), + getGitDiff: vi.fn(async () => ({ + repositoryIdentity: 'diff-snapshot', + patch: '', + byteLength: 0, + truncated: false + })) + }) + const { access } = createAccess(lease) + + await expect(access.getChanges({})).resolves.toMatchObject({ + available: false, + error: 'Git 快照在读取期间持续变化' + }) + expect(lease.getGitStatus).toHaveBeenCalledTimes(2) + expect(lease.getGitDiff).toHaveBeenCalledTimes(2) + }) + + it('propagates cancellation to the transport', async () => { + const controller = new AbortController() + const listWorkspace = vi.fn( + async ( + _request: RemoteWorkspaceListRequest, + signal?: AbortSignal + ) => + await new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + ) + const lease = createLease({ listWorkspace }) + const { access } = createAccess(lease) + const operation = access.listDirectory({ + path: '', + signal: controller.signal + }) + await vi.waitFor(() => expect(listWorkspace).toHaveBeenCalled()) + controller.abort(new Error('cancelled')) + + await expect(operation).rejects.toThrow('cancelled') + expect(listWorkspace.mock.calls[0]?.[1]).toBe(controller.signal) + }) + + it('closes and releases exactly once even when close fails', async () => { + const release = vi.fn() + const closeWorkspace = vi.fn(async () => { + throw new Error('close failed') + }) + const lease = createLease({ release, closeWorkspace }) + const { access } = createAccess(lease) + await access.getIdentity() + + await expect(access.dispose()).rejects.toThrow('close failed') + await expect(access.dispose()).rejects.toThrow('close failed') + expect(closeWorkspace).toHaveBeenCalledOnce() + expect(release).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/workspace/remote-workspace-access.ts b/src/main/workspace/remote-workspace-access.ts new file mode 100644 index 00000000..a3684b98 --- /dev/null +++ b/src/main/workspace/remote-workspace-access.ts @@ -0,0 +1,712 @@ +import { + REMOTE_WORKSPACE_READ_CAPABILITIES, + REMOTE_WORKSPACE_LIMITS, + remoteGitDiffRequestSchema, + remoteGitDiffResultSchema, + remoteGitStatusRequestSchema, + remoteGitStatusResultSchema, + remoteWorkspaceCloseRequestSchema, + remoteWorkspaceCloseResultSchema, + remoteWorkspaceHandleSchema, + remoteWorkspaceListRequestSchema, + remoteWorkspaceListResultSchema, + remoteWorkspaceOpenRequestSchema, + remoteWorkspaceOpenResultSchema, + remoteWorkspaceReadTextRequestSchema, + remoteWorkspaceReadTextResultSchema, + remoteWorkspaceResumeRequestSchema, + remoteWorkspaceResumeResultSchema, + remoteWorkspaceSearchRequestSchema, + remoteWorkspaceSearchResultSchema, + remoteWorkspaceStatRequestSchema, + remoteWorkspaceStatResultSchema, + remoteWorkspaceValidateRequestSchema, + remoteWorkspaceValidateResultSchema, + type RemoteGitDiffRequest, + type RemoteGitDiffResult, + type RemoteGitStatusRequest, + type RemoteGitStatusResult, + type RemoteWorkspaceCloseRequest, + type RemoteWorkspaceCloseResult, + type RemoteWorkspaceHandle, + type RemoteWorkspaceListRequest, + type RemoteWorkspaceListResult, + type RemoteWorkspaceOpenRequest, + type RemoteWorkspaceOpenResult, + type RemoteWorkspaceReadTextRequest, + type RemoteWorkspaceReadTextResult, + type RemoteWorkspaceResumeRequest, + type RemoteWorkspaceResumeResult, + type RemoteWorkspaceSearchRequest, + type RemoteWorkspaceSearchResult, + type RemoteWorkspaceStatRequest, + type RemoteWorkspaceStatResult, + type RemoteWorkspaceValidateRequest, + type RemoteWorkspaceValidateResult +} from '../../shared/remote-agent-contracts' +import type { + DirectoryPage, + FilePreview, + SearchPage, + WorkspaceAccess, + WorkspaceChangedFile, + WorkspaceChanges, + WorkspaceChangesInput, + WorkspaceDirectoryInput, + WorkspaceEntry, + WorkspaceIdentity, + WorkspacePathInput, + WorkspaceReadInput, + WorkspaceSearchInput, + WorkspaceWriteInput, + WriteResult +} from './workspace-access' + +const DEFAULT_DIRECTORY_ENTRIES = 500 +const DEFAULT_READ_BYTES = 256 * 1024 +const DEFAULT_SEARCH_RESULTS = 100 +export type RemoteWorkspaceProjectBinding = { + hostId: string + hostRevision: number + hostKeyGeneration: number + remoteUsername: string + remoteRootPath: string + workspaceIdentity: string + agentInstallationId: string + agentBinaryDigest: string + agentVersion: string + agentArchitecture: 'x64' | 'arm64' + agentProtocolMajor: number +} + +export type RemoteWorkspaceTransportBinding = { + hostId: string + hostRevision: number + hostKeyGeneration: number + remoteUsername: string + agentInstallationId: string + agentBinaryDigest: string + agentVersion: string + agentArchitecture: 'x64' | 'arm64' + agentProtocolMajor: number + capabilityGeneration: number +} + +/** + * A process-neutral, already-authenticated Agent API lease. It deliberately + * exposes neither an SSH connection nor a generic JSON-RPC method. + */ +export interface RemoteWorkspaceTransportLease { + readonly binding: RemoteWorkspaceTransportBinding + readonly resumableHandle?: RemoteWorkspaceHandle + validateWorkspace( + request: RemoteWorkspaceValidateRequest, + signal?: AbortSignal + ): Promise + openWorkspace( + request: RemoteWorkspaceOpenRequest, + signal?: AbortSignal + ): Promise + resumeWorkspace( + request: RemoteWorkspaceResumeRequest, + signal?: AbortSignal + ): Promise + closeWorkspace( + request: RemoteWorkspaceCloseRequest + ): Promise + listWorkspace( + request: RemoteWorkspaceListRequest, + signal?: AbortSignal + ): Promise + statWorkspace( + request: RemoteWorkspaceStatRequest, + signal?: AbortSignal + ): Promise + readWorkspaceText( + request: RemoteWorkspaceReadTextRequest, + signal?: AbortSignal + ): Promise + searchWorkspace( + request: RemoteWorkspaceSearchRequest, + signal?: AbortSignal + ): Promise + getGitStatus( + request: RemoteGitStatusRequest, + signal?: AbortSignal + ): Promise + getGitDiff( + request: RemoteGitDiffRequest, + signal?: AbortSignal + ): Promise + release(): void | Promise +} + +export interface RemoteWorkspaceTransport { + acquireLease( + binding: RemoteWorkspaceProjectBinding, + signal?: AbortSignal + ): Promise +} + +export interface RemoteWorkspaceAccessFactory { + create(binding: RemoteWorkspaceProjectBinding): WorkspaceAccess +} + +type OpenRemoteWorkspace = { + lease: RemoteWorkspaceTransportLease + handle: RemoteWorkspaceHandle + capabilityGeneration: number +} + +function boundedInteger( + value: number | undefined, + defaultValue: number, + maximum: number, + message: string +): number { + const resolved = value ?? defaultValue + if ( + !Number.isSafeInteger(resolved) || + resolved < 1 || + resolved > maximum + ) { + throw new Error(message) + } + return resolved +} + +function assertTransportBinding( + expected: RemoteWorkspaceProjectBinding, + actual: RemoteWorkspaceTransportBinding +): void { + if ( + !Number.isSafeInteger(actual.capabilityGeneration) || + actual.capabilityGeneration < 1 || + actual.hostId !== expected.hostId || + actual.hostRevision !== expected.hostRevision || + actual.hostKeyGeneration !== expected.hostKeyGeneration || + actual.remoteUsername !== expected.remoteUsername || + actual.agentInstallationId !== expected.agentInstallationId || + actual.agentBinaryDigest !== expected.agentBinaryDigest || + actual.agentVersion !== expected.agentVersion || + actual.agentArchitecture !== expected.agentArchitecture || + actual.agentProtocolMajor !== expected.agentProtocolMajor + ) { + throw new Error('远程工作区连接绑定已失效') + } +} + +function assertHandleBinding( + expected: RemoteWorkspaceProjectBinding, + handleInput: unknown +): RemoteWorkspaceHandle { + const handle = remoteWorkspaceHandleSchema.parse(handleInput) + if ( + handle.workspaceIdentity !== expected.workspaceIdentity || + handle.access !== 'read-only' + ) { + throw new Error('远程工作区身份绑定不匹配') + } + for (const capability of REMOTE_WORKSPACE_READ_CAPABILITIES) { + if (!handle.capabilities.includes(capability)) { + throw new Error(`远程工作区缺少必要能力:${capability}`) + } + } + return handle +} + +function entryType( + kind: 'file' | 'directory' | 'symlink' | 'other' +): 'file' | 'directory' | 'other' { + return kind === 'file' || kind === 'directory' ? kind : 'other' +} + +function gitStatusCharacter( + status: + | 'unmodified' + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'copied' + | 'unmerged' + | 'untracked' + | 'ignored' +): string { + switch (status) { + case 'unmodified': + return ' ' + case 'added': + return 'A' + case 'modified': + return 'M' + case 'deleted': + return 'D' + case 'renamed': + return 'R' + case 'copied': + return 'C' + case 'unmerged': + return 'U' + case 'untracked': + return '?' + case 'ignored': + return '!' + } +} + +function mapChangedFiles( + result: RemoteGitStatusResult +): WorkspaceChangedFile[] { + return result.entries.map((entry) => { + const status = + entry.index === 'untracked' || entry.worktree === 'untracked' + ? '??' + : entry.index === 'ignored' || entry.worktree === 'ignored' + ? '!!' + : `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}` + return { + path: entry.relativePath, + status, + ...(entry.originalRelativePath + ? { previousPath: entry.originalRelativePath } + : {}) + } + }) +} + +function formatChangedFiles(files: WorkspaceChangedFile[]): string { + return files + .map((file) => + file.previousPath + ? `${file.status} ${file.previousPath} -> ${file.path}` + : `${file.status} ${file.path}` + ) + .join('\n') +} + +export class RemoteWorkspaceAccess implements WorkspaceAccess { + private opening?: Promise + private opened?: OpenRemoteWorkspace + private disposed = false + private disposal?: Promise + + constructor( + private readonly binding: RemoteWorkspaceProjectBinding, + private readonly transport: RemoteWorkspaceTransport + ) {} + + private async open(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (this.disposed) { + throw new Error('远程工作区访问已关闭') + } + if (this.opened) { + assertTransportBinding(this.binding, this.opened.lease.binding) + return this.opened + } + this.opening ??= this.openNew(signal) + try { + const opened = await this.opening + if (this.disposed) { + throw new Error('远程工作区访问已关闭') + } + this.opened = opened + signal?.throwIfAborted() + return opened + } catch (error) { + this.opening = undefined + throw error + } + } + + private async openNew( + signal?: AbortSignal + ): Promise { + const lease = await this.transport.acquireLease(this.binding, signal) + let retained = false + try { + assertTransportBinding(this.binding, lease.binding) + signal?.throwIfAborted() + let handle: RemoteWorkspaceHandle + if (lease.resumableHandle) { + const previous = assertHandleBinding( + this.binding, + lease.resumableHandle + ) + const resumed = remoteWorkspaceResumeResultSchema.parse( + await lease.resumeWorkspace( + remoteWorkspaceResumeRequestSchema.parse({ + workspaceId: previous.workspaceId, + generation: previous.generation, + workspaceIdentity: this.binding.workspaceIdentity + }), + signal + ) + ) + handle = resumed.resumed + ? assertHandleBinding(this.binding, resumed.handle) + : assertHandleBinding( + this.binding, + remoteWorkspaceOpenResultSchema.parse( + await lease.openWorkspace( + remoteWorkspaceOpenRequestSchema.parse({ + workspaceIdentity: this.binding.workspaceIdentity, + requestedAccess: 'read-only' + }), + signal + ) + ) + ) + } else { + const validation = remoteWorkspaceValidateResultSchema.parse( + await lease.validateWorkspace( + remoteWorkspaceValidateRequestSchema.parse({ + remoteRootPath: this.binding.remoteRootPath, + requestedAccess: 'read-only', + requiredCapabilities: [ + ...REMOTE_WORKSPACE_READ_CAPABILITIES + ] + }), + signal + ) + ) + handle = assertHandleBinding(this.binding, validation.handle) + } + assertTransportBinding(this.binding, lease.binding) + retained = true + return { + lease, + handle, + capabilityGeneration: lease.binding.capabilityGeneration + } + } finally { + if (!retained) { + await lease.release() + } + } + } + + private reference(opened: OpenRemoteWorkspace): { + workspaceId: string + generation: number + } { + assertTransportBinding(this.binding, opened.lease.binding) + if ( + opened.lease.binding.capabilityGeneration !== + opened.capabilityGeneration + ) { + throw new Error('远程工作区能力代际已失效') + } + return { + workspaceId: opened.handle.workspaceId, + generation: opened.handle.generation + } + } + + async getIdentity(): Promise { + const { handle } = await this.open() + return { + kind: 'remote', + id: `${this.binding.hostId}:${handle.workspaceIdentity}`, + canonicalDisplayPath: handle.canonicalDisplayPath, + access: 'read-only' + } + } + + async listDirectory( + input: WorkspaceDirectoryInput + ): Promise { + const maximumEntries = boundedInteger( + input.maximumEntries, + DEFAULT_DIRECTORY_ENTRIES, + REMOTE_WORKSPACE_LIMITS.maximumDirectoryEntriesPerPage, + '工作区目录条目上限无效' + ) + const opened = await this.open(input.signal) + const request = remoteWorkspaceListRequestSchema.parse({ + ...this.reference(opened), + relativePath: input.path, + limit: maximumEntries + }) + const result = remoteWorkspaceListResultSchema.parse( + await opened.lease.listWorkspace(request, input.signal) + ) + const entries = result.entries + .filter( + (entry) => + (input.includeGit === true || entry.name !== '.git') && + (input.includeOther === true || + entryType(entry.kind) !== 'other') + ) + .map((entry) => ({ + name: entry.name, + path: entry.relativePath, + type: entryType(entry.kind) + })) + return { + path: input.path, + entries, + truncated: result.nextCursor !== undefined + } + } + + async stat(input: WorkspacePathInput): Promise { + const opened = await this.open(input.signal) + const request = remoteWorkspaceStatRequestSchema.parse({ + ...this.reference(opened), + relativePath: input.path + }) + const result = remoteWorkspaceStatResultSchema.parse( + await opened.lease.statWorkspace(request, input.signal) + ) + if ( + result.relativePath !== input.path + ) { + throw new Error('远程工作区返回了不匹配的文件元数据') + } + return { + name: result.name, + path: result.relativePath, + type: entryType(result.kind), + size: result.byteLength ?? 0, + modifiedAt: result.modifiedAt ?? '' + } + } + + async readText(input: WorkspaceReadInput): Promise { + const maximumBytes = boundedInteger( + input.maximumBytes, + DEFAULT_READ_BYTES, + REMOTE_WORKSPACE_LIMITS.maximumReadBytes, + '工作区读取上限无效' + ) + const opened = await this.open(input.signal) + const request = remoteWorkspaceReadTextRequestSchema.parse({ + ...this.reference(opened), + relativePath: input.path, + offsetBytes: 0, + maximumBytes + }) + const rawResult = await opened.lease.readWorkspaceText( + request, + input.signal + ) + let result: RemoteWorkspaceReadTextResult + try { + result = remoteWorkspaceReadTextResultSchema.parse(rawResult) + } catch (error) { + if (input.signal?.aborted) { + throw error + } + throw new Error( + input.invalidUtf8Message ?? + '工作区读取目标不是有效 UTF-8 文本', + { cause: error } + ) + } + if ( + result.relativePath !== input.path || + result.offsetBytes !== 0 + ) { + throw new Error('远程工作区返回了不匹配的文件') + } + if (result.truncated || result.bytesRead !== result.totalBytes) { + throw new Error( + input.tooLargeMessage ?? '工作区文本文件超过安全限制' + ) + } + const name = input.path.split('/').at(-1) ?? input.path + return { + path: input.path, + name, + content: result.content, + size: result.totalBytes + } + } + + async writeTextAtomic( + input: WorkspaceWriteInput + ): Promise { + input.signal?.throwIfAborted() + throw new Error( + '远程工作区写入需要可恢复的持久 operation transport' + ) + } + + async search(input: WorkspaceSearchInput): Promise { + if ( + input.maximumFileBytes !== undefined || + input.maximumScannedBytes !== undefined + ) { + throw new Error('远程工作区搜索不支持本机扫描限额') + } + const maximumResults = boundedInteger( + input.maximumResults, + DEFAULT_SEARCH_RESULTS, + REMOTE_WORKSPACE_LIMITS.maximumSearchMatchesPerPage, + '工作区搜索结果上限无效' + ) + const opened = await this.open(input.signal) + const request = remoteWorkspaceSearchRequestSchema.parse({ + ...this.reference(opened), + query: input.query, + ...(input.path === undefined ? {} : { pathPrefix: input.path }), + caseSensitive: true, + limit: maximumResults + }) + const result = remoteWorkspaceSearchResultSchema.parse( + await opened.lease.searchWorkspace(request, input.signal) + ) + return { + matches: result.matches.map((match) => ({ + path: match.relativePath, + line: match.line, + column: match.column, + preview: match.snippet + })), + truncated: + result.truncated || result.nextCursor !== undefined + } + } + + async getChanges( + input: WorkspaceChangesInput + ): Promise { + try { + const opened = await this.open(input.signal) + if (opened.handle.git !== 'available') { + return { + rootPath: opened.handle.canonicalDisplayPath, + available: false, + status: '', + patch: '', + files: [], + truncated: false + } + } + for (let attempt = 0; attempt < 2; attempt += 1) { + input.signal?.throwIfAborted() + const reference = this.reference(opened) + const [status, diff] = await Promise.all([ + opened.lease + .getGitStatus( + remoteGitStatusRequestSchema.parse({ + ...reference, + includeIgnored: false, + maximumEntries: + REMOTE_WORKSPACE_LIMITS.maximumGitStatusEntries + }), + input.signal + ) + .then((result) => + remoteGitStatusResultSchema.parse(result) + ), + opened.lease + .getGitDiff( + remoteGitDiffRequestSchema.parse({ + ...reference, + staged: false, + maximumBytes: + REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes + }), + input.signal + ) + .then((result) => + remoteGitDiffResultSchema.parse(result) + ) + ]) + if (status.repositoryIdentity !== diff.repositoryIdentity) { + if (attempt === 0) { + continue + } + throw new Error('Git 快照在读取期间持续变化') + } + const files = mapChangedFiles(status) + return { + rootPath: opened.handle.canonicalDisplayPath, + available: true, + status: formatChangedFiles(files), + patch: diff.patch, + files, + truncated: + status.truncated || + diff.truncated || + diff.nextCursor !== undefined + } + } + throw new Error('Git 快照读取失败') + } catch (error) { + if (input.signal?.aborted) { + throw error + } + return { + rootPath: this.binding.remoteRootPath, + available: false, + status: '', + patch: '', + files: [], + truncated: false, + error: + error instanceof Error + ? error.message + : '无法读取远程 Git 工作区' + } + } + } + + async dispose(): Promise { + this.disposal ??= this.disposeOnce() + return await this.disposal + } + + private async disposeOnce(): Promise { + this.disposed = true + let opened = this.opened + if (!opened && this.opening) { + try { + opened = await this.opening + } catch { + return + } + } + if (!opened) { + return + } + let closeError: unknown + try { + const request = remoteWorkspaceCloseRequestSchema.parse( + { + workspaceId: opened.handle.workspaceId, + generation: opened.handle.generation + } + ) + const result = remoteWorkspaceCloseResultSchema.parse( + await opened.lease.closeWorkspace(request) + ) + if ( + result.workspaceId !== request.workspaceId || + result.generation !== request.generation + ) { + throw new Error('远程工作区关闭确认不匹配') + } + } catch (error) { + closeError = error + } + try { + await opened.lease.release() + } catch (releaseError) { + if (closeError === undefined) { + throw releaseError + } + throw new AggregateError( + [closeError, releaseError], + '关闭远程工作区失败', + { cause: releaseError } + ) + } + if (closeError !== undefined) { + throw closeError + } + } +} diff --git a/src/main/workspace/unsupported-remote-workspace-access.ts b/src/main/workspace/unsupported-remote-workspace-access.ts new file mode 100644 index 00000000..0c7f02ac --- /dev/null +++ b/src/main/workspace/unsupported-remote-workspace-access.ts @@ -0,0 +1,73 @@ +import type { + DirectoryPage, + FilePreview, + SearchPage, + WorkspaceAccess, + WorkspaceChanges, + WorkspaceChangesInput, + WorkspaceDirectoryInput, + WorkspaceEntry, + WorkspaceIdentity, + WorkspacePathInput, + WorkspaceReadInput, + WorkspaceSearchInput, + WorkspaceWriteInput, + WriteResult +} from './workspace-access' + +const unsupportedRemoteMessage = '远程工作区访问尚不可用' + +export class UnsupportedRemoteWorkspaceAccess +implements WorkspaceAccess { + constructor( + private readonly message: string = unsupportedRemoteMessage + ) {} + + private unsupported(): never { + throw new Error(this.message) + } + + async getIdentity(): Promise { + return this.unsupported() + } + + async listDirectory( + input: WorkspaceDirectoryInput + ): Promise { + void input + return this.unsupported() + } + + async stat(input: WorkspacePathInput): Promise { + void input + return this.unsupported() + } + + async readText(input: WorkspaceReadInput): Promise { + void input + return this.unsupported() + } + + async writeTextAtomic( + input: WorkspaceWriteInput + ): Promise { + void input + return this.unsupported() + } + + async search(input: WorkspaceSearchInput): Promise { + void input + return this.unsupported() + } + + async getChanges( + input: WorkspaceChangesInput + ): Promise { + void input + return this.unsupported() + } + + async dispose(): Promise { + // Cleanup is always safe and idempotent, even when access is unsupported. + } +} diff --git a/src/main/workspace/workspace-access.test.ts b/src/main/workspace/workspace-access.test.ts new file mode 100644 index 00000000..e907d4f4 --- /dev/null +++ b/src/main/workspace/workspace-access.test.ts @@ -0,0 +1,385 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile +} from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { + LocalWorkspaceAccess, + UnsupportedRemoteWorkspaceAccess +} from './index' +import { buildWorkspaceGitEnvironment } from './local-workspace-access' + +const temporaryDirectories: string[] = [] +const execFileAsync = promisify(execFile) + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +async function createWorkspace(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-workspace-access-') + ) + temporaryDirectories.push(directory) + return directory +} + +async function runGit( + directory: string, + ...args: string[] +): Promise { + await execFileAsync('git', args, { + cwd: directory, + windowsHide: true + }) +} + +async function createGitWorkspace(): Promise { + const directory = await createWorkspace() + await runGit(directory, 'init') + await runGit(directory, 'config', 'user.name', 'GoodBuddy Test') + await runGit( + directory, + 'config', + 'user.email', + 'goodbuddy@example.invalid' + ) + await writeFile(join(directory, 'tracked.txt'), 'original\n', 'utf8') + await runGit(directory, 'add', 'tracked.txt') + await runGit(directory, 'commit', '-m', 'initial') + return directory +} + +async function createMarkerCommand( + directory: string, + name: string, + markerPath: string, + passthrough: boolean +): Promise { + if (process.platform === 'win32') { + const commandPath = join(directory, `${name}.cmd`) + await writeFile( + commandPath, + [ + '@echo off', + `> "${markerPath}" echo invoked`, + ...(passthrough ? ['type "%~1"'] : []), + 'exit /b 0', + '' + ].join('\r\n'), + 'utf8' + ) + return commandPath + } + + const commandPath = join(directory, `${name}.sh`) + await writeFile( + commandPath, + [ + '#!/bin/sh', + `printf invoked > '${markerPath.replaceAll("'", "'\\''")}'`, + ...(passthrough ? ['cat "$1"'] : []), + '' + ].join('\n'), + 'utf8' + ) + await chmod(commandPath, 0o700) + return commandPath +} + +function gitCommandValue(commandPath: string): string { + return `"${commandPath.replaceAll('\\', '/').replaceAll('"', '\\"')}"` +} + +describe('LocalWorkspaceAccess', () => { + it('provides bounded local identity, file, search, and change operations', async () => { + const directory = await createWorkspace() + await mkdir(join(directory, 'docs')) + await writeFile( + join(directory, 'docs', 'guide.txt'), + 'first\nsearch target\n', + 'utf8' + ) + const access = new LocalWorkspaceAccess(directory) + + await expect(access.getIdentity()).resolves.toMatchObject({ + kind: 'local', + canonicalDisplayPath: directory, + access: 'read-write' + }) + await expect( + access.listDirectory({ path: '', maximumEntries: 10 }) + ).resolves.toMatchObject({ + entries: [ + { name: 'docs', path: 'docs', type: 'directory' } + ], + truncated: false + }) + await expect( + access.stat({ path: 'docs/guide.txt' }) + ).resolves.toMatchObject({ + name: 'guide.txt', + path: 'docs/guide.txt', + type: 'file' + }) + await expect( + access.readText({ + path: 'docs/guide.txt', + maximumBytes: 256 + }) + ).resolves.toMatchObject({ + content: 'first\nsearch target\n', + size: 20 + }) + await expect( + access.search({ + query: 'target', + maximumResults: 10 + }) + ).resolves.toEqual({ + matches: [ + { + path: 'docs/guide.txt', + line: 2, + column: 8, + preview: 'search target' + } + ], + truncated: false + }) + await expect( + access.writeTextAtomic({ + path: 'docs/output.txt', + content: 'saved', + maximumBytes: 32 + }) + ).resolves.toEqual({ + path: 'docs/output.txt', + bytesWritten: 5 + }) + await expect( + readFile(join(directory, 'docs', 'output.txt'), 'utf8') + ).resolves.toBe('saved') + await expect(access.getChanges({})).resolves.toMatchObject({ + rootPath: directory, + available: false, + files: [] + }) + }) + + it('rejects traversal and bounded read and write overflows', async () => { + const directory = await createWorkspace() + await writeFile(join(directory, 'file.txt'), '12345', 'utf8') + const access = new LocalWorkspaceAccess(directory) + + await expect( + access.readText({ path: '../file.txt' }) + ).rejects.toThrow('不能超出工作区') + await expect( + access.readText({ path: 'file.txt', maximumBytes: 4 }) + ).rejects.toThrow('安全限制') + await expect( + access.writeTextAtomic({ + path: 'output.txt', + content: '12345', + maximumBytes: 4 + }) + ).rejects.toThrow('512KB') + }) + + it('reports ordinary tracked and untracked Git changes', async () => { + const directory = await createGitWorkspace() + await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8') + await writeFile(join(directory, 'untracked.txt'), 'new\n', 'utf8') + + await expect( + new LocalWorkspaceAccess(directory).getChanges({}) + ).resolves.toMatchObject({ + rootPath: directory, + available: true, + files: [ + { path: 'tracked.txt', status: ' M' }, + { path: 'untracked.txt', status: '??' } + ], + truncated: false + }) + const changes = await new LocalWorkspaceAccess( + directory + ).getChanges({}) + expect(changes.patch).toContain('-original') + expect(changes.patch).toContain('+changed') + }) + + it('does not run a repository-configured fsmonitor command', async () => { + const directory = await createGitWorkspace() + const markerPath = join(directory, 'fsmonitor-invoked') + const commandPath = await createMarkerCommand( + directory, + 'fsmonitor', + markerPath, + false + ) + await runGit( + directory, + 'config', + 'core.fsmonitor', + gitCommandValue(commandPath) + ) + await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8') + await runGit(directory, 'status', '--porcelain') + await expect(readFile(markerPath, 'utf8')).resolves.toContain( + 'invoked' + ) + await rm(markerPath) + + const changes = await new LocalWorkspaceAccess( + directory + ).getChanges({}) + + expect(changes.available).toBe(true) + await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('does not run a repository-configured textconv command', async () => { + const directory = await createGitWorkspace() + const markerPath = join(directory, 'textconv-invoked') + const commandPath = await createMarkerCommand( + directory, + 'textconv', + markerPath, + true + ) + await writeFile( + join(directory, '.gitattributes'), + 'tracked.txt diff=marker\n', + 'utf8' + ) + await runGit( + directory, + 'config', + 'diff.marker.textconv', + gitCommandValue(commandPath) + ) + await runGit(directory, 'add', '.gitattributes') + await runGit(directory, 'commit', '-m', 'configure attributes') + await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8') + await runGit(directory, 'diff', '--textconv', 'HEAD') + await expect(readFile(markerPath, 'utf8')).resolves.toContain( + 'invoked' + ) + await rm(markerPath) + + const changes = await new LocalWorkspaceAccess( + directory + ).getChanges({}) + + expect(changes.available).toBe(true) + expect(changes.patch).toContain('+changed') + await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('neutralizes filter names containing an equals sign', async () => { + const directory = await createGitWorkspace() + const markerPath = join(directory, 'filter-invoked') + const commandPath = await createMarkerCommand( + directory, + 'filter', + markerPath, + true + ) + await writeFile( + join(directory, '.gitattributes'), + 'tracked.txt filter=foo=bar\n', + 'utf8' + ) + await runGit( + directory, + 'config', + 'filter.foo=bar.clean', + gitCommandValue(commandPath) + ) + await runGit(directory, 'add', '.gitattributes') + await runGit(directory, 'commit', '-m', 'configure filter') + await rm(markerPath, { force: true }) + await writeFile(join(directory, 'tracked.txt'), 'changed\n', 'utf8') + + const changes = await new LocalWorkspaceAccess( + directory + ).getChanges({}) + + expect(changes.available).toBe(true) + expect(changes.patch).toContain('+changed') + await expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('builds a fresh Git environment without Main-process secrets', () => { + const environment = buildWorkspaceGitEnvironment({ + PATH: '/safe/bin', + PATHEXT: '.EXE', + SystemRoot: 'C:\\Windows', + LANG: 'en_US.UTF-8', + ANTHROPIC_API_KEY: 'provider-secret', + OPENAI_API_KEY: 'provider-secret', + GIT_TRACE: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.fsmonitor', + GIT_CONFIG_VALUE_0: 'attacker-command' + }) + + expect(environment).toMatchObject({ + PATH: '/safe/bin', + PATHEXT: '.EXE', + SystemRoot: 'C:\\Windows', + LANG: 'en_US.UTF-8', + GIT_CONFIG_NOSYSTEM: '1', + GIT_OPTIONAL_LOCKS: '0', + GIT_TERMINAL_PROMPT: '0', + GCM_INTERACTIVE: 'Never' + }) + expect(environment).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(environment).not.toHaveProperty('OPENAI_API_KEY') + expect(environment).not.toHaveProperty('GIT_TRACE') + expect(environment).not.toHaveProperty('GIT_CONFIG_COUNT') + expect(environment).not.toHaveProperty('GIT_CONFIG_KEY_0') + expect(environment).not.toHaveProperty('GIT_CONFIG_VALUE_0') + }) +}) + +describe('UnsupportedRemoteWorkspaceAccess', () => { + it('fails closed for every operation', async () => { + const access = new UnsupportedRemoteWorkspaceAccess() + const operations = [ + access.getIdentity(), + access.listDirectory({ path: '' }), + access.stat({ path: 'file.txt' }), + access.readText({ path: 'file.txt' }), + access.writeTextAtomic({ path: 'file.txt', content: 'x' }), + access.search({ query: 'x' }), + access.getChanges({}) + ] + + for (const operation of operations) { + await expect(operation).rejects.toThrow('远程工作区访问尚不可用') + } + await expect(access.dispose()).resolves.toBeUndefined() + await expect(access.dispose()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/workspace/workspace-access.ts b/src/main/workspace/workspace-access.ts new file mode 100644 index 00000000..ee5994d7 --- /dev/null +++ b/src/main/workspace/workspace-access.ts @@ -0,0 +1,100 @@ +import type { + WorkspaceChanges, + WorkspaceChangedFile +} from '../../shared/assistant-contracts' + +export type WorkspaceIdentity = { + kind: 'local' | 'remote' + id: string + canonicalDisplayPath: string + access: 'read-only' | 'read-write' +} + +export type WorkspacePathInput = { + path: string + signal?: AbortSignal +} + +export type WorkspaceDirectoryInput = WorkspacePathInput & { + maximumEntries?: number + includeGit?: boolean + includeOther?: boolean +} + +export type WorkspaceDirectoryEntry = { + name: string + path: string + type: 'file' | 'directory' | 'other' +} + +export type DirectoryPage = { + path: string + entries: WorkspaceDirectoryEntry[] + truncated: boolean +} + +export type WorkspaceEntry = WorkspaceDirectoryEntry & { + size: number + modifiedAt: string +} + +export type WorkspaceReadInput = WorkspacePathInput & { + maximumBytes?: number + tooLargeMessage?: string + invalidUtf8Message?: string +} + +export type FilePreview = { + path: string + name: string + content: string + size: number +} + +export type WorkspaceWriteInput = WorkspacePathInput & { + content: string + maximumBytes?: number +} + +export type WriteResult = { + path: string + bytesWritten: number +} + +export type WorkspaceSearchInput = { + query: string + path?: string + maximumResults?: number + maximumFileBytes?: number + maximumScannedBytes?: number + signal?: AbortSignal +} + +export type WorkspaceSearchMatch = { + path: string + line: number + column: number + preview: string +} + +export type SearchPage = { + matches: WorkspaceSearchMatch[] + truncated: boolean +} + +export type WorkspaceChangesInput = { + signal?: AbortSignal +} + +export type { WorkspaceChanges, WorkspaceChangedFile } + +export interface WorkspaceAccess { + getIdentity(): Promise + listDirectory(input: WorkspaceDirectoryInput): Promise + stat(input: WorkspacePathInput): Promise + readText(input: WorkspaceReadInput): Promise + writeTextAtomic(input: WorkspaceWriteInput): Promise + search(input: WorkspaceSearchInput): Promise + getChanges(input: WorkspaceChangesInput): Promise + dispose(): Promise +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 96028aff..ba77138d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,8 @@ import { type RuntimeSettingsInput, type RuntimeConfigActionInput, type RuntimeFileSelectionKind, + type RemoteProjectSaveProgress, + type RemoteProjectSaveRequest, type WindowCaptureOption } from '../shared/contracts' import { ipcChannels } from '../shared/ipc-channels' @@ -102,6 +104,15 @@ import type { import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import type { WeixinBindingSnapshot } from '../shared/weixin-channel-contracts' import type { RemoteChannelActivity } from '../shared/remote-channel-contracts' +import type { + SshHostDraftInspectionRequest, + SshDirectoryBrowseResult, + SshHostKeyInspection, + SshHostRemoteEnvironment, + SshHostsSnapshot, + SshHostValidationRequest, + SshHostValidationResult +} from '../shared/ssh-host-contracts' import type { MagicNoteAnalysisStreamEvent, MagicNoteDraftAnalysis, @@ -311,6 +322,48 @@ const desktopApi: DesktopApi = { selection ) as Promise }, + sshHosts: { + getSnapshot: () => + ipcRenderer.invoke( + ipcChannels.sshHostsGet + ) as Promise, + remove: async (hostId: string) => { + await ipcRenderer.invoke(ipcChannels.sshHostsRemove, { + hostId + }) + }, + inspectDraftHostKey: (input: SshHostDraftInspectionRequest) => + ipcRenderer.invoke( + ipcChannels.sshHostsInspectDraftKey, + input + ) as Promise, + discardCandidate: async (candidateId: string) => { + await ipcRenderer.invoke( + ipcChannels.sshHostsDiscardCandidate, + { candidateId } + ) + }, + validateAndSave: (input: SshHostValidationRequest) => + ipcRenderer.invoke( + ipcChannels.sshHostsValidateAndSave, + input + ) as Promise, + getRemoteEnvironment: (hostId: string) => + ipcRenderer.invoke( + ipcChannels.sshHostsRemoteEnvironment, + { hostId } + ) as Promise, + browseDirectories: (hostId: string, path?: string) => + ipcRenderer.invoke(ipcChannels.sshHostsBrowseDirectories, { + hostId, + ...(path === undefined ? {} : { path }) + }) as Promise, + cancelDirectoryBrowse: async () => { + await ipcRenderer.invoke( + ipcChannels.sshHostsCancelDirectoryBrowse + ) + } + }, channels: { getSnapshot: () => ipcRenderer.invoke( @@ -643,6 +696,36 @@ const desktopApi: DesktopApi = { projectId, confirmation }) + }, + remote: { + activate: (projectId: string) => + ipcRenderer.invoke( + ipcChannels.remoteProjectActivate, + projectId + ) as Promise, + save: (input: RemoteProjectSaveRequest) => + ipcRenderer.invoke( + ipcChannels.remoteProjectSave, + input + ) as Promise, + cancelCurrent: async () => { + await ipcRenderer.invoke(ipcChannels.remoteProjectCancelCurrent) + }, + onSaveProgress: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + progress: RemoteProjectSaveProgress + ): void => listener(progress) + ipcRenderer.on( + ipcChannels.remoteProjectSaveProgress, + handler + ) + return () => + ipcRenderer.removeListener( + ipcChannels.remoteProjectSaveProgress, + handler + ) + } } }, conversations: { diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index dc6a11d5..46ffdece 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -128,6 +128,57 @@ describe('sandboxed preload', () => { expect(source).not.toContain('globalShortcut.') }) + it('exposes only explicit SSH host-management methods', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + expect(source).toContain('sshHosts: {') + expect(source).toContain('getSnapshot:') + expect(source).toContain('inspectDraftHostKey:') + expect(source).toContain('discardCandidate:') + expect(source).toContain('validateAndSave:') + expect(source).toContain( + 'browseDirectories: (hostId: string, path?: string)' + ) + expect(source).toContain('cancelDirectoryBrowse: async ()') + expect(source).toContain('getRemoteEnvironment: (hostId: string)') + expect(source).toContain( + 'ipcChannels.sshHostsRemoteEnvironment' + ) + expect(source).toContain('ipcChannels.sshHostsBrowseDirectories') + expect(source).toContain( + 'ipcChannels.sshHostsCancelDirectoryBrowse' + ) + expect(source).not.toContain('sshHostsCreate') + expect(source).not.toContain('sshHostsAcceptKey') + expect(source).not.toContain('privateKey:') + expect(source).not.toContain('agentForward: true') + const sshHostsSource = + source.match( + /sshHosts: \{(?[\s\S]*?)\n {2}\},\n {2}channels:/u + ) + ?.groups?.body ?? '' + expect(sshHostsSource).not.toMatch( + /\b(?:credential|password|revision|sftp|shell|command)\b/iu + ) + }) + + it('exposes narrow awaited remote project activation and save operations', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + expect(source).toContain('remote: {') + expect(source).toContain('remoteProjectActivate') + expect(source).toContain('remoteProjectSave') + expect(source).toContain('remoteProjectCancelCurrent') + expect(source).toContain('remoteProjectSaveProgress') + expect(source).toContain('RemoteProjectSaveRequest') + expect(source).not.toContain('commitToken') + expect(source).toContain('ipcRenderer.removeListener(') + }) + it('exposes bounded knowledge task actions', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index a4c944fb..b5e7841b 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -416,6 +416,10 @@ describe('ActivityPanel', () => { name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Workspace', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Workspace' + }, defaultWorkMode: 'ask', kind: 'user', builtInDefault: true, @@ -465,6 +469,10 @@ describe('ActivityPanel', () => { name: 'Renamed current project', description: '', rootPath: 'C:\\Renamed', + executionSpace: { + kind: 'local', + rootPath: 'C:\\Renamed' + }, defaultWorkMode: 'ask', kind: 'user', status: 'active', diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 02f6add6..e7749964 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -118,6 +118,11 @@ let conversationQueueChangeListener: DesktopApi['conversationQueue']['onChanged'] >[0] | undefined +let remoteProjectSaveProgressListener: + | Parameters< + DesktopApi['projects']['remote']['onSaveProgress'] + >[0] + | undefined const removeMaximizedChangedListener = vi.fn() const run = vi.fn() const modelProfileId = '00000000-0000-4000-8000-000000000001' @@ -127,6 +132,10 @@ const project = { name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Users\\test', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Users\\test' + }, defaultWorkMode: 'ask' as const, kind: 'user' as const, builtInDefault: true, @@ -372,7 +381,22 @@ const api: DesktopApi = { id: _projectId })), setArchived: vi.fn(async () => {}), - delete: vi.fn(async () => {}) + delete: vi.fn(async () => {}), + remote: { + activate: + vi.fn(), + save: vi.fn(), + cancelCurrent: + vi.fn(), + onSaveProgress: vi.fn((listener) => { + remoteProjectSaveProgressListener = listener + return () => { + if (remoteProjectSaveProgressListener === listener) { + remoteProjectSaveProgressListener = undefined + } + } + }) + } }, conversations: { list: vi.fn(async () => []), @@ -864,12 +888,15 @@ function selectProjectOption(projectName: string): void { function deferred(): { promise: Promise resolve: (value: T) => void + reject: (reason: unknown) => void } { let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise + reject = rejectPromise }) - return { promise, resolve } + return { promise, resolve, reject } } describe('App', () => { @@ -915,6 +942,21 @@ describe('App', () => { vi.mocked(api.conversations.onChanged) .mockReset() .mockReturnValue(() => undefined) + vi.mocked(api.projects.remote.activate).mockReset() + vi.mocked(api.projects.remote.cancelCurrent) + .mockReset() + .mockResolvedValue() + remoteProjectSaveProgressListener = undefined + vi.mocked(api.projects.remote.onSaveProgress) + .mockReset() + .mockImplementation((listener) => { + remoteProjectSaveProgressListener = listener + return () => { + if (remoteProjectSaveProgressListener === listener) { + remoteProjectSaveProgressListener = undefined + } + } + }) conversationQueueChangeListener = undefined conversationQueueDispatchListener = undefined vi.mocked(api.conversationQueue.list) @@ -1103,7 +1145,11 @@ describe('App', () => { id: '00000000-0000-4000-8000-000000000102', name: 'Second project', description: 'Another workspace', - rootPath: 'C:\\Second' + rootPath: 'C:\\Second', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Second' + } } vi.mocked(api.projects.list).mockResolvedValueOnce([ project, @@ -5139,7 +5185,11 @@ describe('App', () => { ...project, id: '00000000-0000-4000-8000-000000000102', name: '第二项目', - rootPath: 'C:\\Second' + rootPath: 'C:\\Second', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Second' + } } vi.mocked(api.projects.list).mockResolvedValueOnce([ project, @@ -5336,13 +5386,15 @@ describe('App', () => { fireEvent.click(screen.getByLabelText('新建项目')) const dialog = screen.getByRole('dialog', { name: '新建项目' }) - const defaultMode = within(dialog).getByRole('combobox', { - name: '默认模式' - }) + const defaultMode = within(dialog) + .getAllByRole('group', { name: '默认模式' }) + .find((candidate) => + candidate.classList.contains('segmented-control') + )! expect( within(defaultMode) - .getAllByRole('option') - .map((option) => option.textContent) + .getAllByRole('button') + .map((button) => button.textContent) ).toEqual(['Ask · 只读问答', 'Execute · 完全权限']) expect(screen.queryByRole('option', { name: /Plan/u })).toBeNull() }) @@ -5438,12 +5490,16 @@ describe('App', () => { ) }) - it('restores and persists the last active project', async () => { + it('starts in the first local project and persists manual selection', async () => { const secondProject = { ...project, id: '00000000-0000-4000-8000-000000000102', name: '第二项目', rootPath: 'C:\\Second', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Second' + }, defaultWorkMode: 'execute' as const } vi.mocked(api.projects.list).mockResolvedValueOnce([ @@ -5459,14 +5515,121 @@ describe('App', () => { expect( await screen.findByRole('button', { name: '当前项目' }) - ).toHaveTextContent(secondProject.name) + ).toHaveTextContent(project.name) expect( screen.getByRole('button', { - name: '工作模式:Execute · 完全权限' + name: '工作模式:Ask · 只读问答' }) ).toBeEnabled() - selectProjectOption(project.name) + selectProjectOption(secondProject.name) + await waitFor(() => + expect( + localStorage.getItem('goodbuddy.active-project.v1') + ).toBe(secondProject.id) + ) + }) + + it('persists a new local conversation before queueing its first message', async () => { + const secondProject = { + ...project, + id: '00000000-0000-4000-8000-000000000105', + name: '新本地项目', + rootPath: 'C:\\NewLocal', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\NewLocal' + } + } + const persistence = deferred() + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + secondProject + ]) + vi.mocked(api.conversations.saveLocal).mockImplementation( + async (batch) => { + if ( + batch.some( + (conversation) => + conversation.header.projectId === secondProject.id + ) + ) { + await persistence.promise + } + } + ) + render() + expect( + await screen.findByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + + selectProjectOption(secondProject.name) + await waitFor(() => + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(secondProject.name) + ) + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '首条消息必须先保存会话' } + }) + await waitFor(() => + expect(screen.getByLabelText('发送')).toBeEnabled() + ) + fireEvent.click(screen.getByLabelText('发送')) + + await waitFor(() => + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + expect.objectContaining({ + header: expect.objectContaining({ + projectId: secondProject.id + }) + }) + ]) + ) + expect(api.conversationQueue.enqueueUser).not.toHaveBeenCalled() + + persistence.resolve(undefined) + await waitFor(() => + expect(api.conversationQueue.enqueueUser).toHaveBeenCalledOnce() + ) + expect( + vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1) + ).toBeLessThan( + vi.mocked( + api.conversationQueue.enqueueUser + ).mock.invocationCallOrder[0]! + ) + }) + + it('starts in the first local project instead of restoring an SSH project', async () => { + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000103', + name: '远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000203', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + remoteProject, + project + ]) + localStorage.setItem( + 'goodbuddy.active-project.v1', + remoteProject.id + ) + + render() + + expect( + await screen.findByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + expect(api.projects.remote.activate).not.toHaveBeenCalled() await waitFor(() => expect( localStorage.getItem('goodbuddy.active-project.v1') @@ -5474,6 +5637,464 @@ describe('App', () => { ) }) + it('keeps the current project active when remote activation fails', async () => { + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000104', + name: '失效远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000204', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockRejectedValueOnce( + new Error('Agent upload failed') + ) + render() + expect( + await screen.findByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + + selectProjectOption(remoteProject.name) + + expect( + await screen.findByText( + '远程项目更新失败:Agent upload failed。请检查 Host 后重试' + ) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + }) + + it('revalidates the current SSH project when it is selected again', async () => { + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000114', + name: '可重新连接的远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000214', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockResolvedValue( + remoteProject + ) + render() + await screen.findByRole('button', { name: '当前项目' }) + + selectProjectOption(remoteProject.name) + await waitFor(() => + expect(api.projects.remote.activate).toHaveBeenCalledTimes(1) + ) + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(remoteProject.name) + + selectProjectOption(remoteProject.name) + + await waitFor(() => + expect(api.projects.remote.activate).toHaveBeenCalledTimes(2) + ) + expect(api.projects.remote.activate).toHaveBeenLastCalledWith( + remoteProject.id + ) + }) + + it('keeps Host-stale projects blocked and preserves queued dispatches until reactivation succeeds', async () => { + const hostId = '00000000-0000-4000-8000-000000000215' + const candidateId = + '00000000-0000-4000-8000-000000000315' + const remoteConversationId = + '00000000-0000-4000-8000-000000000415' + const fingerprint = `SHA256:${'A'.repeat(43)}` + const host = { + id: hostId, + name: 'Build host', + hostname: '192.168.0.23', + port: 22, + username: 'root', + authentication: 'system-agent' as const, + credentialConfigured: true, + credentialSource: 'system-agent' as const, + hostKey: { + state: 'verified' as const, + algorithm: 'ecdsa-sha2-nistp256', + fingerprintSha256: fingerprint, + generation: 1 + }, + lastValidatedAt: '2026-08-24T00:00:00.000Z', + createdAt: '2026-08-24T00:00:00.000Z', + updatedAt: '2026-08-24T00:00:00.000Z' + } + const updatedHost = { + ...host, + hostname: '10.7.0.23', + hostKey: { ...host.hostKey, generation: 2 }, + updatedAt: '2026-08-24T00:01:00.000Z' + } + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000115', + name: 'Host 一致性项目', + rootPath: '/root', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId, + remoteRootPath: '/root' + }, + builtInDefault: false + } + const sshHosts: NonNullable = { + getSnapshot: vi.fn(async () => ({ + hosts: [host], + secureStorageAvailable: true + })), + remove: vi.fn(async () => undefined), + inspectDraftHostKey: vi.fn(async () => ({ + candidateId, + hostId, + state: 'verified' as const, + algorithm: 'ecdsa-sha2-nistp256', + fingerprintSha256: fingerprint + })), + discardCandidate: vi.fn(async () => undefined), + validateAndSave: vi.fn(async () => ({ + host: updatedHost, + connection: { + hostId, + connected: true as const, + latencyMs: 12, + platform: 'linux' as const, + architecture: 'x64' as const, + shell: '/bin/bash', + homeDirectory: '/root', + detail: 'SSH 已连接,远端系统为 linux/x64' + } + })), + getRemoteEnvironment: vi.fn(async () => ({ + hostId, + checkedAt: '2026-08-24T00:00:00.000Z', + architecture: 'x64' as const, + agent: { + state: 'current' as const, + expected: { + version: '0.11.2-e2e.8', + architecture: 'x64' as const + }, + installed: { + version: '0.11.2-e2e.8', + architecture: 'x64' as const + } + }, + runtimes: [{ + runtimeId: 'opencode' as const, + provider: 'opencode' as const, + state: 'current' as const, + expected: { + version: '1.18.9', + architecture: 'x64' as const + }, + installed: { + version: '1.18.9', + architecture: 'x64' as const + } + }] + })), + browseDirectories: vi.fn(async () => ({ + path: '/root', + homeDirectory: '/root', + parentPath: '/', + entries: [], + truncated: false + })), + cancelDirectoryBrowse: vi.fn(async () => undefined) + } + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: remoteConversationId, + projectId: remoteProject.id, + title: 'Host 一致性对话', + updatedAt: Date.now(), + messages: [] + } + ]) + const hostReactivation = deferred() + vi.mocked(api.projects.remote.activate) + .mockResolvedValueOnce(remoteProject) + .mockReturnValueOnce(hostReactivation.promise) + .mockResolvedValueOnce(remoteProject) + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { ...api, sshHosts } + }) + render() + await screen.findByRole('button', { name: '当前项目' }) + selectProjectOption(remoteProject.name) + await waitFor(() => + expect(api.projects.remote.activate).toHaveBeenCalledTimes(1) + ) + + fireEvent.click(await screen.findByText('本地工作区')) + await screen.findByRole('heading', { name: '设置中心' }) + fireEvent.click( + screen.getByRole('tab', { name: '主机与远程执行' }) + ) + fireEvent.click( + await screen.findByRole('button', { + name: '编辑 Build host' + }) + ) + fireEvent.change(screen.getByLabelText('主机地址'), { + target: { value: '10.7.0.23' } + }) + fireEvent.click( + screen.getByRole('button', { name: '检查 Host Key' }) + ) + fireEvent.click( + await screen.findByRole('button', { + name: '确认身份并继续' + }) + ) + fireEvent.click( + screen.getByRole('button', { name: '验证并保存' }) + ) + + await waitFor(() => + expect(sshHosts.validateAndSave).toHaveBeenCalledOnce() + ) + await waitFor(() => + expect(api.projects.remote.activate).toHaveBeenCalledTimes(2) + ) + expect(api.projects.remote.activate).toHaveBeenLastCalledWith( + remoteProject.id + ) + + const queuedDispatch: ConversationQueueDispatch = { + item: { + id: '00000000-0000-4000-8000-000000000515', + conversationId: remoteConversationId, + source: 'user', + label: '等待 Host 恢复的消息', + createdAt: '2026-08-24T00:02:00.000Z' + }, + input: { + conversationId: remoteConversationId, + projectId: remoteProject.id, + runtimeSelection: { provider: 'opencode' }, + workMode: 'ask', + includeMemoryContext: true, + prompt: '等待 Host 恢复的消息', + attachments: [], + knowledgeLibraryIds: [], + knowledgeRetrievalMode: 'auto' + } + } + act(() => { + conversationQueueDispatchListener?.(queuedDispatch) + }) + await waitFor(() => + expect(api.conversationQueue.releaseUser).toHaveBeenCalledWith( + queuedDispatch.item.id + ) + ) + expect(run).not.toHaveBeenCalled() + + await act(async () => { + hostReactivation.reject(new Error('Host reconnect failed')) + await hostReactivation.promise.catch(() => undefined) + }) + expect( + await screen.findByText( + '远程项目更新失败:Host reconnect failed。请检查 Host 后重试' + ) + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '对话' })) + const composer = await screen.findByLabelText('向 GoodBuddy 提问') + fireEvent.change(composer, { + target: { value: '前台消息也必须等待重新连接' } + }) + const send = screen.getByLabelText('发送') + expect(send).toBeDisabled() + expect(send).toHaveAttribute( + 'title', + '远程项目需要重新连接,请重新选择当前项目后再发送' + ) + + selectProjectOption(remoteProject.name) + await waitFor(() => + expect(api.projects.remote.activate).toHaveBeenCalledTimes(3) + ) + await waitFor(() => expect(send).toBeEnabled()) + await waitFor(() => + expect(api.conversationQueue.ready).toHaveBeenCalledWith( + remoteConversationId + ) + ) + + act(() => { + conversationQueueDispatchListener?.(queuedDispatch) + }) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + expect(run.mock.calls[0]![0]).toMatchObject({ + queueItemId: queuedDispatch.item.id, + projectId: remoteProject.id, + prompt: queuedDispatch.input.prompt + }) + expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce() + }) + + it('shows every direct remote activation phase and blocks conflicting project actions', async () => { + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000105', + name: '阶段远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000205', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + const activation = deferred() + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockReturnValueOnce( + activation.promise + ) + render() + expect( + await screen.findByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + + selectProjectOption(remoteProject.name) + + const progress = await screen.findByRole('progressbar', { + name: '远程项目连接进度' + }) + expect(progress).toHaveAttribute('max', '5') + expect(progress).toHaveAttribute('value', '1') + expect( + screen.getByText('正在连接远程项目“阶段远程项目”') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toBeDisabled() + expect(screen.getByLabelText('新建项目')).toBeDisabled() + expect(screen.getByLabelText('项目设置')).toBeDisabled() + + act(() => { + remoteProjectSaveProgressListener?.({ phase: 'workspace' }) + }) + expect(progress).toHaveAttribute('value', '3') + expect( + screen.getByText('当前阶段:远端工作区') + ).toBeInTheDocument() + const stages = screen.getByRole('list', { + name: '远程项目连接阶段' + }) + expect(within(stages).getAllByRole('listitem')).toHaveLength(5) + expect( + within(stages).getByText('远端工作区').closest('li') + ).toHaveAttribute('aria-current', 'step') + + await act(async () => { + activation.resolve(remoteProject) + await activation.promise + }) + await waitFor(() => + expect( + screen.queryByRole('progressbar', { + name: '远程项目连接进度' + }) + ).not.toBeInTheDocument() + ) + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(remoteProject.name) + }) + + it('cancels direct remote activation without switching projects', async () => { + const remoteProject = { + ...project, + id: '00000000-0000-4000-8000-000000000106', + name: '可取消远程项目', + rootPath: '/srv/project', + runtimeSelection: { provider: 'opencode' as const }, + executionSpace: { + kind: 'ssh' as const, + hostId: '00000000-0000-4000-8000-000000000206', + remoteRootPath: '/srv/project' + }, + builtInDefault: false + } + const activation = deferred() + vi.mocked(api.projects.list).mockResolvedValueOnce([ + project, + remoteProject + ]) + vi.mocked(api.projects.remote.activate).mockReturnValueOnce( + activation.promise + ) + render() + expect( + await screen.findByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + selectProjectOption(remoteProject.name) + + fireEvent.click( + await screen.findByRole('button', { name: '取消连接' }) + ) + expect(api.projects.remote.cancelCurrent).toHaveBeenCalledOnce() + expect( + screen.getByRole('button', { name: '取消中…' }) + ).toBeDisabled() + await act(async () => { + activation.reject( + new DOMException('Remote project save cancelled', 'AbortError') + ) + await activation.promise.catch(() => undefined) + }) + + expect( + await screen.findByText('已取消远程项目连接') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '当前项目' }) + ).toHaveTextContent(project.name) + expect( + screen.queryByRole('progressbar', { + name: '远程项目连接进度' + }) + ).not.toBeInTheDocument() + }) + it('shows grouped project details and keeps the rich menu keyboard accessible', async () => { const channelProject = { ...project, @@ -7494,7 +8115,11 @@ describe('App', () => { ...project, id: '00000000-0000-4000-8000-000000000102', name: '第二项目', - rootPath: 'C:\\Second' + rootPath: 'C:\\Second', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Second' + } } vi.mocked(api.projects.list).mockResolvedValueOnce([ project, @@ -7512,13 +8137,25 @@ describe('App', () => { ) expect( within(dialog).getByLabelText('新对话默认 Runtime') - ).toHaveValue('model') + ).toHaveValue( + agentRuntimeSelectionKey({ + provider: 'model', + profileId: modelProfileId + }) + ) fireEvent.change(within(dialog).getByLabelText('说明'), { target: { value: '更新后的说明' } }) fireEvent.change( within(dialog).getByLabelText('新对话默认 Runtime'), - { target: { value: 'continue' } } + { + target: { + value: agentRuntimeSelectionKey({ + provider: 'continue', + profileId: modelProfileId + }) + } + } ) fireEvent.click( within(dialog).getByRole('button', { name: '保存项目' }) @@ -8483,7 +9120,11 @@ describe('App', () => { ...project, id: '00000000-0000-4000-8000-000000000102', name: '第二项目', - rootPath: 'C:\\Second' + rootPath: 'C:\\Second', + executionSpace: { + kind: 'local' as const, + rootPath: 'C:\\Second' + } } vi.mocked(api.projects.list).mockResolvedValueOnce([ project, @@ -8798,6 +9439,7 @@ describe('App', () => { delete api.updates } }) + it('hides Magic Notes when the platform feature is disabled', async () => { api.updates = { getSettings: vi.fn(async () => ({ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 25192490..79ab9112 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -89,6 +89,9 @@ import { agentRuntimeSelectionSchema, type AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' +import type { + RemoteProjectSavePhase +} from '../../shared/remote-project-candidate-contracts' import { getDefaultRuntimeSelection, getRuntimeSelectionForProvider @@ -1154,14 +1157,6 @@ function hasConversationMigrationStorage(): boolean { } } -function loadActiveProjectId(): string | undefined { - try { - return localStorage.getItem(activeProjectStorageKey) || undefined - } catch { - return undefined - } -} - function isConversation(value: unknown): value is Conversation { if (!value || typeof value !== 'object') { return false @@ -1974,6 +1969,25 @@ function App(): React.JSX.Element { const [selectedExpertId, setSelectedExpertId] = useState('') const [activeProjectId, setActiveProjectId] = useState('') const activeProjectIdRef = useRef(activeProjectId) + const remoteProjectActivationRequestRef = useRef(0) + const remoteProjectActivationControlRef = useRef<{ + requestId: number + projectId: string + cancelRequested: boolean + } | undefined>(undefined) + const [remoteProjectActivation, setRemoteProjectActivation] = + useState<{ + projectId: string + phase: RemoteProjectSavePhase + cancelling: boolean + }>() + const remoteProjectsRequiringActivationRef = useRef( + new Set() + ) + const [ + remoteProjectsRequiringActivation, + setRemoteProjectsRequiringActivation + ] = useState>(() => new Set()) const workspaceChangesRequestRef = useRef(0) const runtimeStatusRequestRef = useRef(0) const runtimeSetupPromptedRef = useRef(false) @@ -3345,6 +3359,8 @@ function App(): React.JSX.Element { () => projects.find((project) => project.id === activeProjectId), [activeProjectId, projects] ) + const activeRemoteProjectRequiresActivation = + remoteProjectsRequiringActivation.has(activeProjectId) const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name : undefined @@ -4820,9 +4836,262 @@ function App(): React.JSX.Element { [] ) + const activateRemoteProject = useCallback( + async ( + project: AssistantProject + ): Promise<{ + project: AssistantProject + current: boolean + }> => { + const requestId = remoteProjectActivationRequestRef.current + 1 + remoteProjectActivationRequestRef.current = requestId + const control = { + requestId, + projectId: project.id, + cancelRequested: false + } + remoteProjectActivationControlRef.current = control + setRemoteProjectActivation({ + projectId: project.id, + phase: 'host', + cancelling: false + }) + const removeProgress = + window.goodbuddy.projects.remote.onSaveProgress( + (progress) => { + if ( + remoteProjectActivationControlRef.current !== + control + ) { + return + } + setRemoteProjectActivation({ + projectId: project.id, + phase: progress.phase, + cancelling: control.cancelRequested + }) + } + ) + try { + const activated = + await window.goodbuddy.projects.remote.activate(project.id) + setProjects((current) => + current.map((candidate) => + candidate.id === activated.id ? activated : candidate + ) + ) + const current = + remoteProjectActivationRequestRef.current === requestId && + !control.cancelRequested + if (current) { + notify({ + tone: 'success', + message: tRef.current('notices.remoteProjectActivated'), + dedupeKey: `remote-project-activated:${project.id}` + }) + } else if ( + remoteProjectActivationRequestRef.current === requestId && + control.cancelRequested + ) { + notify({ + tone: 'info', + message: tRef.current( + 'notices.remoteProjectActivationCancelled' + ), + dedupeKey: `remote-project-cancelled:${project.id}` + }) + } + return { project: activated, current } + } catch (reason) { + if ( + remoteProjectActivationRequestRef.current !== requestId + ) { + return { project, current: false } + } + if (control.cancelRequested) { + notify({ + tone: 'info', + message: tRef.current( + 'notices.remoteProjectActivationCancelled' + ), + dedupeKey: `remote-project-cancelled:${project.id}` + }) + return { project, current: false } + } + const message = + reason instanceof Error && reason.message + ? reason.message + : tRef.current('notices.remoteProjectActivationUnknownError') + throw new Error( + tRef.current('notices.remoteProjectActivationFailed', { + message + }), + { cause: reason } + ) + } finally { + removeProgress() + if ( + remoteProjectActivationControlRef.current === control + ) { + remoteProjectActivationControlRef.current = undefined + setRemoteProjectActivation(undefined) + } + } + }, + [notify] + ) + + const cancelRemoteProjectActivation = useCallback((): void => { + const control = remoteProjectActivationControlRef.current + if (!control || control.cancelRequested) { + return + } + control.cancelRequested = true + setRemoteProjectActivation((current) => + current && current.projectId === control.projectId + ? { ...current, cancelling: true } + : current + ) + void window.goodbuddy.projects.remote.cancelCurrent().catch( + (reason: unknown) => { + if ( + remoteProjectActivationControlRef.current !== control + ) { + return + } + control.cancelRequested = false + setRemoteProjectActivation((current) => + current && current.projectId === control.projectId + ? { ...current, cancelling: false } + : current + ) + notify({ + tone: 'error', + message: + reason instanceof Error && reason.message + ? tRef.current( + 'notices.remoteProjectActivationCancelFailed', + { message: reason.message } + ) + : tRef.current( + 'notices.remoteProjectActivationCancelUnknownError' + ) + }) + } + ) + }, [notify]) + + const markRemoteProjectsRequiringActivation = useCallback( + (projectIds: string[]): void => { + const next = new Set(remoteProjectsRequiringActivationRef.current) + let changed = false + for (const projectId of projectIds) { + if (!next.has(projectId)) { + next.add(projectId) + changed = true + } + } + if (!changed) { + return + } + remoteProjectsRequiringActivationRef.current = next + setRemoteProjectsRequiringActivation(next) + }, + [] + ) + + const clearRemoteProjectActivationRequirement = useCallback( + (projectId: string): void => { + if (!remoteProjectsRequiringActivationRef.current.has(projectId)) { + return + } + const next = new Set(remoteProjectsRequiringActivationRef.current) + next.delete(projectId) + remoteProjectsRequiringActivationRef.current = next + setRemoteProjectsRequiringActivation(next) + }, + [] + ) + + const resumeProjectConversationQueues = useCallback( + (projectId: string): void => { + const conversationIds = conversationsRef.current + .filter( + (conversation) => + conversation.projectId === projectId && + !conversation.remote + ) + .map((conversation) => conversation.id) + if (conversationIds.length === 0) { + return + } + void Promise.all( + conversationIds.map((conversationId) => + window.goodbuddy.conversationQueue.ready(conversationId) + ) + ).catch(() => { + notify({ + tone: 'error', + message: tRef.current( + 'notices.conversationQueueResumeFailed' + ), + dedupeKey: `conversation-queue-resume:${projectId}` + }) + }) + }, + [notify] + ) + + const handleSshHostUpdated = useCallback( + (hostId: string): void => { + const affectedProjects = projects.filter( + (candidate) => + candidate.executionSpace.kind === 'ssh' && + candidate.executionSpace.hostId === hostId + ) + markRemoteProjectsRequiringActivation( + affectedProjects.map((project) => project.id) + ) + const project = affectedProjects.find( + (candidate) => candidate.id === activeProjectIdRef.current + ) + if (!project) { + return + } + void activateRemoteProject(project).then( + (activated) => { + if (!activated.current) { + return + } + clearRemoteProjectActivationRequirement(activated.project.id) + resumeProjectConversationQueues(activated.project.id) + }, + (reason: unknown) => { + notify({ + tone: 'error', + message: + reason instanceof Error + ? reason.message + : tRef.current( + 'notices.remoteProjectActivationUnknownError' + ) + }) + } + ) + }, + [ + activateRemoteProject, + clearRemoteProjectActivationRequirement, + markRemoteProjectsRequiringActivation, + notify, + projects, + resumeProjectConversationQueues + ] + ) + useEffect(() => { let active = true - void Promise.all([ + const initialization = Promise.all([ window.goodbuddy.projects.list(false), window.goodbuddy.conversations.list() ]) @@ -4830,12 +5099,16 @@ function App(): React.JSX.Element { if (!active || value.length === 0) { return } - const lastActiveProjectId = loadActiveProjectId() - const project = - value.find( - (candidate) => candidate.id === lastActiveProjectId - ) ?? value[0]! setProjects(value) + const project = value.find( + (candidate) => + candidate.kind === 'user' && + candidate.executionSpace.kind === 'local' + ) + if (!project) { + throw new Error('没有可用的本地项目') + } + remoteProjectActivationRequestRef.current += 1 setActiveProjectId(project.id) setWorkMode( normalizeInteractiveWorkMode(project.defaultWorkMode) @@ -4952,7 +5225,11 @@ function App(): React.JSX.Element { } setConversationStoreReady(true) }) - .catch((reason: unknown) => { + conversationPersistenceQueueRef.current = initialization.then( + () => undefined, + () => undefined + ) + void initialization.catch((reason: unknown) => { if (active) { notify({ tone: 'error', @@ -4966,7 +5243,7 @@ function App(): React.JSX.Element { return () => { active = false } - }, [setActiveId]) + }, [notify, setActiveId]) useEffect(() => { if (!activeProjectId) { @@ -5485,27 +5762,31 @@ function App(): React.JSX.Element { ) }, [startNewConversation]) - const selectProject = (projectId: string): void => { - const project = projects.find((candidate) => candidate.id === projectId) - if (!project) { - return - } - setActiveProjectId(projectId) - setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)) - const conversation = conversations.find( + const commitProjectSelection = (selected: AssistantProject): void => { + setActiveProjectId(selected.id) + setWorkMode( + normalizeInteractiveWorkMode(selected.defaultWorkMode) + ) + const conversation = conversationsRef.current.find( (candidate) => - candidate.projectId === projectId && - (project.kind !== 'channel' || candidate.remote !== undefined) + candidate.projectId === selected.id && + ( + selected.kind !== 'channel' || + candidate.remote !== undefined + ) ) if (conversation) { setActiveId(conversation.id) - } else if (project.kind === 'channel') { + } else if (selected.kind === 'channel') { setActiveId('') } else { const created = createConversation( - projectId, + selected.id, runtimeSettings - ? getProjectDefaultRuntimeSelection(project, runtimeSettings) + ? getProjectDefaultRuntimeSelection( + selected, + runtimeSettings + ) : undefined, t('conversation.greeting') ) @@ -5515,10 +5796,41 @@ function App(): React.JSX.Element { setView('chat') } + const selectProject = (projectId: string): void => { + const project = projects.find((candidate) => candidate.id === projectId) + if (!project) { + return + } + if (project.executionSpace.kind !== 'ssh') { + remoteProjectActivationRequestRef.current += 1 + commitProjectSelection(project) + return + } + void activateRemoteProject(project).then( + (activated) => { + if (activated.current) { + clearRemoteProjectActivationRequirement(activated.project.id) + commitProjectSelection(activated.project) + resumeProjectConversationQueues(activated.project.id) + } + }, + (reason: unknown) => { + notify({ + tone: 'error', + message: + reason instanceof Error + ? reason.message + : t('notices.remoteProjectActivationUnknownError') + }) + } + ) + } + const createProject = async ( input: ProjectCreateInput ): Promise => { const project = await window.goodbuddy.projects.create(input) + remoteProjectActivationRequestRef.current += 1 setProjects((current) => [project, ...current]) setActiveProjectId(project.id) setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)) @@ -5556,8 +5868,25 @@ function App(): React.JSX.Element { return project } + const loadCommittedRemoteProject = async ( + project: AssistantProject + ): Promise => { + setProjects((current) => + current.some((candidate) => candidate.id === project.id) + ? current.map((candidate) => + candidate.id === project.id ? project : candidate + ) + : [project, ...current] + ) + remoteProjectActivationRequestRef.current += 1 + commitProjectSelection(project) + clearRemoteProjectActivationRequirement(project.id) + resumeProjectConversationQueues(project.id) + } + const archiveProject = async (projectId: string): Promise => { await window.goodbuddy.projects.setArchived(projectId, true) + clearRemoteProjectActivationRequirement(projectId) const remaining = projects.filter((project) => project.id !== projectId) setProjects(remaining) const next = remaining[0] @@ -5571,6 +5900,7 @@ function App(): React.JSX.Element { confirmation: string ): Promise => { await window.goodbuddy.projects.delete(projectId, confirmation) + clearRemoteProjectActivationRequirement(projectId) const remainingProjects = projects.filter( (project) => project.id !== projectId ) @@ -6155,6 +6485,39 @@ function App(): React.JSX.Element { await releaseQueuedItem() return } + const dispatchProjectId = + queuedInput?.projectId ?? conversationSnapshot.projectId + const dispatchProjectReactivating = + Boolean( + dispatchProjectId && + remoteProjectActivationControlRef.current?.projectId === + dispatchProjectId + ) + const dispatchProjectRequiresActivation = + Boolean( + dispatchProjectId && + remoteProjectsRequiringActivationRef.current.has( + dispatchProjectId + ) + ) + if ( + dispatchProjectReactivating || + dispatchProjectRequiresActivation + ) { + if (queuedInput) { + await releaseQueuedItem() + } else { + notify({ + tone: 'info', + message: t( + dispatchProjectRequiresActivation + ? 'notices.remoteProjectReconnectRequired' + : 'notices.remoteProjectReactivating' + ) + }) + } + return + } if (!queuedInput && selectingContextFilesRef.current) { notify({ tone: 'info', @@ -6293,6 +6656,17 @@ function App(): React.JSX.Element { knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot } try { + persistLocalConversationChanges() + await conversationPersistenceQueueRef.current + if ( + !persistedLocalConversationsRef.current.has( + conversationSnapshot.id + ) + ) { + throw new Error( + t('notices.conversationPersistenceFailed') + ) + } await window.goodbuddy.conversationQueue.enqueueUser(queueInput) setComposerMenuOpen(undefined) setRuntimeMenuOpen(false) @@ -7452,16 +7826,19 @@ function App(): React.JSX.Element { window.goodbuddy.settings.selectWorkspace() } onUpdate={updateProject} projects={projects} + remoteActivation={remoteProjectActivation} /> {activeProject?.kind !== 'channel' && ( @@ -8981,15 +9358,20 @@ function App(): React.JSX.Element { ) )) || selectingContextFiles || + remoteProjectActivation?.projectId === + activeProjectId || + activeRemoteProjectRequiresActivation || !runtime?.available || runtimeSwitching || runtimeStatusKey !== activeRuntimeSelectionKey } onClick={() => void submit()} title={ - conversationExecutionRunning - ? t('composer.queueMessageTitle') - : t('composer.sendTitle') + activeRemoteProjectRequiresActivation + ? t('notices.remoteProjectReconnectRequired') + : conversationExecutionRunning + ? t('composer.queueMessageTitle') + : t('composer.sendTitle') } >