feat: add managed SSH execution spaces

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 工作和同步未确认输出。
This commit is contained in:
mesalogo
2026-08-24 23:02:18 +08:00
parent 3829a871f5
commit 607bb33249
258 changed files with 99449 additions and 2427 deletions
+263 -8
View File
@@ -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
+2
View File
@@ -4,6 +4,8 @@ dist/
coverage/
.vite/
.runtime-resources/
.remote-runtime-resources/
.agent-resources/
*.log
*.tsbuildinfo
.env
+73 -5
View File
@@ -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
+83 -2
View File
@@ -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/<arch>`。
- 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 <x64|arm64> --runtime-archive <node-runtime.tar.gz>
# 导入由原生 CI 生成的签名归档,导入时会完整校验
npm run agent:import -- --arch x64 --archive <goodbuddy-agent-linux-x64.tar>
npm run agent:import -- --arch arm64 --archive <goodbuddy-agent-linux-arm64.tar>
# 独立重新校验本地工件
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 <windows|macos|linux> --arch <x64|arm64>
```
`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
+4 -3
View File
@@ -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 进程,高风险控制面操作单独确认。
## 规划原则
+29
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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
}
+248 -92
View File
@@ -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()
}
+142 -49
View File
@@ -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
}
+36
View File
@@ -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
]
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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)
@@ -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 不被网络抖动终止。
File diff suppressed because it is too large Load Diff
@@ -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 更新支持所需签名版本的健康切换,失败时保留旧项目绑定。
- [ ] 切换跟随目标后不显示上一对象的过期状态。
- [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。
- [ ] 日志、终端、文件、成果和远程传输均有明确上限和背压。
@@ -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 任务。
- 选择可信的执行后端,并查看完整执行记录。
- 保留通道关闭前后的历史上下文和审计记录。
+1 -1
View File
@@ -23,7 +23,7 @@ GoodBuddy 应能够:
1. 持续组织项目、会话、任务、成果和记忆,而不是只保存聊天记录。
2. 在明确授权下理解文件、知识库、截图、应用窗口和浏览器上下文。
3. 以只读问答、计划审查和受控执行三种模式完成工作。
3. 以 Ask 只读问答和 Execute 完整授权两种模式完成工作;计划审查是 Ask 中的交互方式,不是第三种权限模式。
4. 在应用级助手工作栏中持续提供任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果。
5. 支持后台任务、定时任务、失败恢复和桌面通知。
6. 让所有记忆、权限、上下文和远程传输可见、可审查、可撤销。
+141
View File
@@ -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",
+26
View File
@@ -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",
+39
View File
@@ -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=="
}
}
}
}
}
+21
View File
@@ -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": []
}
+93
View File
@@ -0,0 +1,93 @@
export async function raceWithAbort<T>(
pending: Promise<T>,
signal: AbortSignal
): Promise<T> {
signal.throwIfAborted()
return await new Promise<T>((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<void> {
return new Promise<void>((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<unknown>,
timeoutMs: number
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
pending,
new Promise<void>((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
}
@@ -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<string> {
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<void> {
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
}
+1
View File
@@ -0,0 +1 @@
export * from '../shared/agent-protocol/channel-state'
+566
View File
@@ -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
}
+861
View File
@@ -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<typeof AgentDaemon>[0]) => AgentDaemon
waitForShutdown?: () => Promise<void>
installationPaths?: (
installationId: string
) => ManagedInstallationPaths
attach?: typeof attachRelay
releaseKeyRegistry?: AgentReleaseKeyRegistry
installationRegistry?: InstallationRegistry
verifyInstallation?: (
installationDirectory: string,
options: {
installationId: string
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
}
) => Promise<VerifiedInstalledAgentBundle>
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<number> {
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<number> {
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<void> {
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<void> {
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<typeof AgentDaemon>[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<void> {
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<void> {
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<void> {
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<string, string> {
const allowedSet = new Set(allowed)
const output: Record<string, string> = {}
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<Record<string, string>>,
names: readonly string[]
): void {
for (const name of names) {
if (options[name] === undefined) {
throw new Error(`Missing required --${name}`)
}
}
}
async function waitForShutdownSignal(): Promise<void> {
await new Promise<void>((resolveSignal) => {
const done = (): void => {
process.off('SIGINT', done)
process.off('SIGTERM', done)
resolveSignal()
}
process.once('SIGINT', done)
process.once('SIGTERM', done)
})
}
+371
View File
@@ -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<Record<string, readonly string[]>>
}
type MutableController = {
generation: number
leaseExpiresAt: number
capabilityGeneration: number
connectionId?: string
attached: boolean
previousGeneration?: number
previousConnectionId?: string
ownedObjects: Map<string, Set<string>>
}
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<string, MutableController>()
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<string>()
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<string, unknown>
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<string, Set<string>>()
for (const [kind, objectIds] of Object.entries(
value.ownedObjects as Record<string, unknown>
)) {
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
}
+217
View File
@@ -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<NonNullable<
ConstructorParameters<typeof AgentDaemon>[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<ConstructorParameters<typeof AgentDaemon>[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
})
}
+405
View File
@@ -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<DaemonCapabilities['runtimes']>
methods: Readonly<Record<string, ProtocolMethodHandler>>
onAcpFrame: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['onAcpFrame']>
onBlobFrame?: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['onBlobFrame']>
authorizeBlobFrame?: NonNullable<ConstructorParameters<
typeof AgentProtocolServer
>[0]['authorizeBlobFrame']>
dispose?: () => void | Promise<void>
}
runtimeFactory?: (context: {
events: EventJournal
workspaces: WorkspaceRegistry
outputSink: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
blobSink: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
}) =>
| AgentDaemonOptions['runtimeProtocol']
| undefined
| Promise<AgentDaemonOptions['runtimeProtocol'] | undefined>
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<void> {
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<void> {
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<Record<string, ProtocolMethodHandler>>,
runtimeMethods:
| Readonly<Record<string, ProtocolMethodHandler>>
| undefined
): Readonly<Record<string, ProtocolMethodHandler>> {
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<never> {
throw new AgentUnsupportedError(
'A certified SO_PEERCRED helper is unavailable',
'peer-identity-unavailable'
)
}
}
@@ -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> = {}
): LinuxProcessIdentity => ({
pid: childPid,
starttime: '202',
executablePath: resolve(installationDirectory, 'node'),
...changes
})
const identities = new Map<number, LinuxProcessIdentity>([
[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<DetachedSpawn>(() => {
identities.set(childPid, expectedChildIdentity())
return { pid: childPid, unref }
})
let time = 1_000
let sleepHook: (() => void | Promise<void>) | undefined
let hookActive = false
const sleep = async (milliseconds: number): Promise<void> => {
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<void>) {
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<DetachedAgentLifecycleRecord> {
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: []
}
}
}
@@ -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<NodeJS.ProcessEnv>
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<VerifiedInstalledAgentBundle>
processInspector?: LinuxProcessInspector
socketInspector?: DetachedSocketInspector
spawnDetached?: DetachedSpawn
currentPid?: number
now?: () => number
sleep?: (milliseconds: number) => Promise<void>
readinessTimeoutMs?: number
stopTimeoutMs?: number
environment?: Readonly<NodeJS.ProcessEnv>
probeEndpoint?: (
verified: VerifiedInstalledAgentBundle
) => Promise<Pick<AttachWelcome, 'daemonBootId'>>
retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
}
export class DetachedAgentLifecycle {
readonly #installationId: string
readonly #executablePath: string
readonly #stateDirectory: string
readonly #socketPath: string
readonly #lifecyclePath: string
readonly #lockPath: string
readonly #verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
readonly #processInspector: LinuxProcessInspector
readonly #socketInspector: DetachedSocketInspector
readonly #spawnDetached: DetachedSpawn
readonly #currentPid: number
readonly #now: () => number
readonly #sleep: (milliseconds: number) => Promise<void>
readonly #readinessTimeoutMs: number
readonly #stopTimeoutMs: number
readonly #environment: Readonly<NodeJS.ProcessEnv>
readonly #probeEndpoint?: DetachedAgentLifecycleOptions['probeEndpoint']
readonly #retireLegacyInstallation?: (
verified: VerifiedInstalledAgentBundle
) => void | Promise<void>
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<void>((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<DetachedLifecycleStatus> {
return this.#statusAfterVerification(await this.#verifyAndBind())
}
async health(): Promise<DetachedAgentLifecycleRecord> {
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<DetachedAgentLifecycleRecord> {
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<DetachedAgentLifecycleRecord> {
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<DetachedLifecycleStatus> {
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<DetachedLifecycleStatus> {
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<VerifiedInstalledAgentBundle> {
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<DetachedAgentLifecycleRecord | undefined> {
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<void> {
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<LinuxProcessIdentity> {
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<boolean> {
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<void> {
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<typeof bootstrapLockRecordSchema> | 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<NodeJS.ProcessEnv>
detached: true
shell: false
stdio: 'ignore'
cwd: string
}
): ReturnType<DetachedSpawn> {
return spawn(executable, [...argv], options)
}
function detachedEnvironment(
source: Readonly<NodeJS.ProcessEnv>
): Readonly<NodeJS.ProcessEnv> {
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
}
@@ -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<DirectLinuxStdioSpawn>((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<DirectLinuxStdioSpawn>(
(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<typeof process.kill>(() => 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
}
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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<number> {
return await runAgentCli(argv)
}
const invokedPath = process.argv[1]
if (
invokedPath !== undefined &&
import.meta.url === pathToFileURL(resolve(invokedPath)).href
) {
process.exitCode = await main()
}
+12
View File
@@ -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'
}
}
+842
View File
@@ -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<typeof EventJournal>[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()
})
})
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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'
+113
View File
@@ -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<string, number>()
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')
}
@@ -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'
}
]
}
}
}
+234
View File
@@ -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')
)
}
@@ -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<string, Buffer>([
['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
}
@@ -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<AgentReleaseKeyRegistry> {
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<VerifiedInstalledAgentBundle> {
const installationId = validateInstallationId(options.installationId)
const installationDirectory = resolve(installationDirectoryInput)
assertAbsoluteManagedPath(installationDirectory)
if (basename(installationDirectory) !== installationId) {
throw new Error(
'Agent installation ID does not match its managed directory'
)
}
const 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<Buffer> {
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<Buffer> {
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<string[]> {
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<ReturnType<typeof lstat>>,
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<ReturnType<typeof lstat>>,
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')
}
+300
View File
@@ -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<void>((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<typeof createServer>): Promise<void> {
await new Promise<void>((resolveListen, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolveListen)
})
}
async function onceConnected(socket: Socket): Promise<void> {
await new Promise<void>((resolveConnect, reject) => {
socket.once('connect', resolveConnect)
socket.once('error', reject)
})
}
async function onceClosed(socket: Socket): Promise<void> {
await new Promise<void>((resolveClose) => socket.once('close', resolveClose))
}
async function readFrames(
socket: Socket,
count: number
): Promise<ReturnType<typeof decodeAgentFrame>[]> {
return await new Promise((resolveFrames, reject) => {
let buffer = Buffer.alloc(0)
const frames: ReturnType<typeof decodeAgentFrame>[] = []
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)
})
}
@@ -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<typeof vi.fn<(pid: number, signal: NodeJS.Signals | 0) => 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 })
}
+168
View File
@@ -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
}
+189
View File
@@ -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)
}
+373
View File
@@ -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
}
+805
View File
@@ -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<void>
failDelivery(reason?: unknown): void | Promise<void>
}
export type ModelBridgeBrokerDispatch = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
export type ModelBridgeExchange = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
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<Socket>()
#server?: Server
#socketIdentity?: FileIdentity
#closing = false
#closePromise?: Promise<void>
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-response',
requestId,
ok: false,
error: { code }
})
}
async function readPacket(
socket: Socket,
signal: AbortSignal
): Promise<unknown> {
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<void> {
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<void>((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<Buffer> {
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<void>((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<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
await new Promise<void>((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<void> {
return new Promise<void>((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
}
}
@@ -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<ModelBridgePolicy> = {}) {
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<ModelBridgeRequestMessage> {
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<void> {
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<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for model bridge')
}
await new Promise<void>((resolve) => setTimeout(resolve, 1))
}
}
+519
View File
@@ -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<void>
failDelivery(reason?: unknown): Promise<void>
}
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<void>
readonly #onPoison: (error: ModelBridgeClientError) => void | Promise<void>
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<void>
onPoison: (error: ModelBridgeClientError) => void | Promise<void>
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<ModelBridgeClientExchange> {
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<ModelBridgeResponseMessage>(
(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<void> {
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<void> {
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<void> {
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<void> {
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<string, unknown> | 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<string, unknown>)
: 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
}
+900
View File
@@ -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<NodeJS.ProcessEnv>
}
) => 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<Socket>()
readonly #dispatchWaiters: DispatchWaiter[] = []
#server?: Server
#origin?: string
#dispatchBusy = false
#closing = false
#closePromise?: Promise<void>
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
signal.throwIfAborted()
if (!this.#dispatchBusy) {
this.#dispatchBusy = true
return
}
await new Promise<void>((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<NodeJS.ProcessEnv>
spawn?: ModelBridgeHelperSpawn
}): Promise<number> {
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<number>((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<void> {
return new Promise<void>((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<RemoteModelGatewayRequest> {
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<string, string> = {}
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<string>()
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<Buffer> {
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<ReturnType<ModelBridgeExchange>>
): 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<NodeJS.ProcessEnv>
}
): ModelBridgeHelperChild {
return nodeSpawn(executable, [...args], {
shell: options.shell,
stdio: options.stdio,
env: options.env
})
}
function credentialFreeHelperEnvironment(
source: Readonly<NodeJS.ProcessEnv>,
config: string
): Readonly<NodeJS.ProcessEnv> {
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<void> {
return new Promise<void>((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
}
}
+658
View File
@@ -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<ModelBridgeExchange>(
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<ModelBridgeExchange>(
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<ModelBridgeExchange>(
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<void>((resolveStarted) => {
markStarted = resolveStarted
})
const firstGate = new Promise<void>((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<void>((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<void>((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<string, string>
body?: string
}
): Promise<{
status: number
headers: Record<string, string | string[] | undefined>
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<string, string> | undefined,
expected: string
): boolean {
return Object.keys(headers ?? {}).some(
(name) => name.toLowerCase() === expected
)
}
@@ -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)
}
@@ -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<NodeJS.ProcessEnv>
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<Record<
(typeof OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES)[number],
string
>> = {
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<NodeJS.ProcessEnv>,
workspaceDirectory: string
): Readonly<NodeJS.ProcessEnv> {
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)
)
}
@@ -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
}
+158
View File
@@ -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<PeerIdentity> {
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<Koffi>
} = {}
): Promise<LinuxPeerIdentityProvider> {
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)
}
+576
View File
@@ -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<void>((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<void>((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<void>((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<void>((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<PeerIdentity>((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<PeerIdentity>(() => 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<PeerIdentity>
} {
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<void> {
await new Promise<void>((resolveConnection, reject) => {
socket.once('connect', resolveConnection)
socket.once('error', reject)
})
}
async function onceClosed(socket: Socket): Promise<void> {
await new Promise<void>((resolveClose) => {
socket.once('close', resolveClose)
})
}
async function readPacket(socket: Socket): Promise<Record<string, unknown>> {
const length = await readExactly(socket, 4)
const contents = await readExactly(socket, length.readUInt32BE(0))
return JSON.parse(contents.toString('utf8')) as Record<string, unknown>
}
async function writePacket(
socket: Socket,
value: Record<string, unknown>
): Promise<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)
await new Promise<void>((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<Buffer> {
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<void>((resolveRead, reject) => {
socket.once('readable', resolveRead)
socket.once('error', reject)
})
}
return Buffer.concat(chunks, byteLength)
}
function writeStreamPacket(
output: NodeJS.WritableStream,
value: Record<string, unknown>
): 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<Record<string, unknown>> {
const length = await readExactly(input, 4)
const contents = await readExactly(input, length.readUInt32BE(0))
return JSON.parse(contents.toString('utf8')) as Record<string, unknown>
}
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<AgentFrame> {
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
}
+635
View File
@@ -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<PeerIdentity>
}
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<void>
readonly #installationId: string
readonly #binaryDigest: string
readonly #daemonBootId: string
readonly #protocol: AttachPreface['protocol']
readonly #maximumConnections: number
readonly #sockets = new Set<Socket>()
#server?: Server
#closing = false
#closePromise?: Promise<void>
constructor(options: {
socketPath: string
peerIdentity: UnixPeerIdentityProvider
challenge: InstallationChallengeVerifier
controllers: ControllerRegistry
onAttach: (attach: AuthenticatedAttach) => void | Promise<void>
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<void> {
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<void>((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<void> {
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<void> {
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<void> {
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<void>
onWelcome?: (welcome: AttachWelcome) => void
}): Promise<void> {
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<void>((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<AttachWelcome> {
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<void> {
return new Promise<void>((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<Pick<
AttachPreface,
'protocol' | 'controllerId' | 'clientNonce'
>> {
const parsed = attachPrefaceSchema.parse(await readStreamPacket(input))
return {
protocol: parsed.protocol,
controllerId: parsed.controllerId,
clientNonce: parsed.clientNonce
}
}
async function readStreamPacket(
input: NodeJS.ReadableStream
): Promise<Record<string, unknown>> {
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<string, unknown>
}
async function writeStreamPacket(
output: NodeJS.WritableStream,
value: Record<string, unknown>
): Promise<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)
await new Promise<void>((resolve, reject) => {
output.write(packet, (error?: Error | null) => {
if (error === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readPacket(socket: Socket): Promise<Record<string, unknown>> {
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<string, unknown>
}
async function writePacket(
socket: Socket,
value: Record<string, unknown>
): Promise<void> {
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<void>((resolve, reject) => {
socket.write(packet, (error) => {
if (error === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readExactly(socket: Socket, byteLength: number): Promise<Buffer> {
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<void>((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<Buffer> {
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<void>((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<void> {
if (socket.closed) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
socket.once('close', resolve)
})
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}
+970
View File
@@ -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<void>()
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<void>()
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<string, unknown> }
).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<typeof AgentProtocolServer>[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<string, unknown>
): Uint8Array {
return controlFrame(controller, channelId, channelEpoch, sequence, {
jsonrpc: '2.0',
method,
params
})
}
function controlFrame(
controller: ControllerLease,
channelId: string,
channelEpoch: string,
sequence: number,
value: Record<string, unknown>
): 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<T>(): {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
} {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function waitFor(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 2_000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Timed out waiting for protocol state')
}
await new Promise<void>((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
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -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> = {}
): 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)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<typeof createRegistry>,
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}`
}
}
+343
View File
@@ -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<DaemonCapabilities['runtimes']> {
return async () => await loadVerifiedRuntimeCapabilities(options)
}
async function loadVerifiedRuntimeCapabilities(options: {
registry: RuntimeBundleRegistry
architecture: AgentArchitecture
releaseKeyRegistry: AgentReleaseKeyRegistry
runtimeLock: RemoteRuntimeLock
verificationEnvironment?: InstalledBundleVerificationEnvironment
enforceFilesystemMode?: boolean
filesystemPlatform?: NodeJS.Platform
uid?: number
reportError?: (message: string, error: unknown) => void
}): Promise<DaemonCapabilities['runtimes']> {
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
}
@@ -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')
}
@@ -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<Awaited<
ReturnType<typeof createRuntimeBundleTestFixture>
>> {
const fixture = await createRuntimeBundleTestFixture(overrides)
temporaryPaths.push(fixture.root)
return fixture
}
@@ -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'
@@ -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
}
+474
View File
@@ -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<typeof createVerifiedRuntimeCapabilitySource>
methods: RuntimeAcpBackend['methods']
onAcpFrame: RuntimeAcpBackend['onAcpFrame']
onBlobFrame?: RuntimeAcpBackend['onBlobFrame']
authorizeBlobFrame?: RuntimeAcpBackend['authorizeBlobFrame']
dispose: () => Promise<void>
}
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<void>
blobSink?: (
frame: AgentFrame,
context: {
bindingId: string
controllerId: string
controllerGeneration: number
}
) => void | Promise<void>
agentExecutablePath?: string
installationId?: string
agentServiceUnitName?: string
releaseKeyRegistry?: AgentReleaseKeyRegistry
runtimeLock?: RemoteRuntimeLock
registry?: RuntimeBundleRegistry
prerequisitesAvailable?: () => boolean | Promise<boolean>
launchProcess?: typeof launchDirectLinuxStdioProcessOwner
reconcileOrphanedProcesses?: (
installationId: string
) => Promise<unknown>
ownerRegistry?: RuntimeOwnerRegistry
verificationEnvironment?: 'production' | 'test'
filesystemPlatform?: NodeJS.Platform
reportError?: (message: string, error: unknown) => void
}
export async function createProductionRuntimeProtocol(
options: ProductionRuntimeCompositionOptions
): Promise<ProductionRuntimeProtocol> {
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<boolean> {
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<boolean> {
try {
await access(BWRAP_EXECUTABLE, constants.X_OK)
return true
} catch {
return false
}
}
@@ -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'
}
}
+232
View File
@@ -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
}
}
@@ -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)
})
})
@@ -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<string> {
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<LinuxRuntimeProcessIdentity> {
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<string, unknown>
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
}
@@ -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<NodeJS.ProcessEnv>
signal?: AbortSignal
timeoutMs?: number
terminationGraceMs?: number
}
export interface StructuredCommandRunner {
run(
executable: string,
args: readonly string[],
options?: StructuredCommandOptions
): Promise<SpawnResult>
}
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<SpawnResult> {
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<SpawnResult>((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<SpawnResult['terminationReason']>
): 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'
}
@@ -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'
})
}
File diff suppressed because it is too large Load Diff
@@ -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'
})
}
+765
View File
@@ -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<WorkspaceRootIdentity> {
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<void> {
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<void> {
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<string> {
return await this.#resolveExisting(
relativePathInput,
'directory',
options
)
}
async list(
request: RemoteWorkspaceListRequest,
options: WorkspaceIoOptions = {}
): Promise<RemoteWorkspaceListResult> {
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<RemoteWorkspaceStatResult> {
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<RemoteWorkspaceReadTextResult> {
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<RemoteWorkspaceSearchResult> {
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<string> {
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 ''
}
@@ -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' })
})
})
@@ -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<AbortSignal>()
const invoke = async <T>(
context: ProtocolMethodContext,
action: (io: WorkspaceIoOptions) => Promise<T>
): Promise<T> => {
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<AbortSignal>
): 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<never> {
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<T>(
timeoutMs: number,
action: (options: WorkspaceIoOptions) => Promise<T>,
upstreamSignal?: AbortSignal
): Promise<T> {
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
}
+411
View File
@@ -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<void>((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<ControllerRegistry['attach']>,
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
}
+622
View File
@@ -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<GitAvailability>
now?: () => number
storagePath?: string
}
export class WorkspaceRegistry {
readonly #controllers: ControllerRegistry
readonly #maximumControllerHandles: number
readonly #maximumDaemonHandles: number
readonly #maximumValidatedRoots: number
readonly #inspectGit: NonNullable<WorkspaceRegistryOptions['inspectGit']>
readonly #now: () => number
readonly #storagePath?: string
readonly #validatedRoots = new Map<string, ValidatedRoot>()
readonly #leases = new Map<string, MutableWorkspaceLease>()
readonly #nextGenerationByIdentity = new Map<string, number>()
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<RemoteWorkspaceValidateResult> {
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<RemoteWorkspaceHandle> {
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<RemoteWorkspaceResumeResult> {
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<RemoteWorkspaceCloseResult> {
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<WorkspaceLease> {
const lease = this.#assertLease(
workspaceId,
generation,
controller
)
await lease.access.assertCurrent(options)
return snapshotLease(lease)
}
async getCurrentByIdentity(
workspaceIdentity: string,
controller: ControllerLease,
options: WorkspaceIoOptions = {}
): Promise<WorkspaceLease> {
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<ControllerLease, 'controllerId' | 'generation'>
): void {
const affectedIdentities = new Set<string>()
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<WorkspaceLease> {
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<string, unknown>
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<string>()
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
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -7
View File
@@ -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<string> {
): Promise<Uint8Array> {
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<string> {
return Buffer.from(
await readBoundedResponseBytes(response, options)
).toString('utf8')
}
+29
View File
@@ -7,6 +7,7 @@ import {
createModelProfileRuntime
} from './create-runtime'
import { AgentRuntimeController } from './runtime-controller'
import type { WorkspaceAccess } from '../workspace'
function createBrowserService(): BrowserToolService & {
dispose: ReturnType<typeof vi.fn>
@@ -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()
})
})
+33 -3
View File
@@ -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()
})
}
@@ -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.',
@@ -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<string> {
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' })
})
})
File diff suppressed because it is too large Load Diff
+153 -8
View File
@@ -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<typeof fetch>(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<typeof fetch>()
.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<typeof fetch>(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 () => {
+22 -90
View File
@@ -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<string, unknown> {
return parsed as Record<string, unknown>
}
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<string, unknown>)
.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<string>()
let totalToolCalls = 0
let answer = ''
const identicalCallCounts = new Map<string, number>()
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<Record<string, unknown>> = []
const chatImageCarrierContent: Array<Record<string, unknown>> = []
let roundContextBytes = 0
const roundCallIds = new Set<string>()
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
+211
View File
@@ -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 () => [])
+63 -126
View File
@@ -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<string>(
goodbuddyConfigWriteToolNames
)
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
const builtinModelToolAccessByName = new Map<string, 'read' | 'write'>(
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<typeof scopedDataToolByName.get>[0]
)?.access
if (access !== 'read') {
throw new Error(ASK_TOOL_DENIAL_MESSAGE)
}
}
function parseMcpImage(
content: Record<string, unknown>
): Extract<ModelToolResultPart, { type: 'image' }> {
@@ -472,7 +479,6 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
}
export class ModelToolProvider implements ModelToolProviderLike {
private canonicalWorkspace?: Promise<string>
private mcpConnections?: Promise<ConnectedMcp[]>
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private readonly clients = new Set<Client>()
@@ -480,12 +486,19 @@ export class ModelToolProvider implements ModelToolProviderLike {
private readonly webSearchClients = new Set<Client>()
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<string> {
this.canonicalWorkspace ??= getCanonicalWorkspace(
this.workspace,
'直连模型工作区不是目录'
)
return this.canonicalWorkspace
}
private async resolveExistingPath(
inputPath: string,
expected: 'file' | 'directory'
): Promise<string> {
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<string> {
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<ModelToolResult> {
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<void> {
@@ -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<string> {
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()
}
}
)
})
@@ -0,0 +1,4 @@
export {
PreparedPrivateSqliteDatabaseFile,
openPrivateSqliteDatabase
} from '../../shared/node/private-sqlite-database'
+683
View File
@@ -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<ModelCallOperationStore, string>()
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<ModelCallOperationStore> {
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<string, unknown> | 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<string, unknown>
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<string, unknown> | 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<Response>((_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<Response>((_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')
})
})

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