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:
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ dist/
|
||||
coverage/
|
||||
.vite/
|
||||
.runtime-resources/
|
||||
.remote-runtime-resources/
|
||||
.agent-resources/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 任务。
|
||||
- 选择可信的执行后端,并查看完整执行记录。
|
||||
- 保留通道关闭前后的历史上下文和审计记录。
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ GoodBuddy 应能够:
|
||||
|
||||
1. 持续组织项目、会话、任务、成果和记忆,而不是只保存聊天记录。
|
||||
2. 在明确授权下理解文件、知识库、截图、应用窗口和浏览器上下文。
|
||||
3. 以只读问答、计划审查和受控执行三种模式完成工作。
|
||||
3. 以 Ask 只读问答和 Execute 完整授权两种模式完成工作;计划审查是 Ask 中的交互方式,不是第三种权限模式。
|
||||
4. 在应用级助手工作栏中持续提供任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果。
|
||||
5. 支持后台任务、定时任务、失败恢复和桌面通知。
|
||||
6. 让所有记忆、权限、上下文和远程传输可见、可审查、可撤销。
|
||||
|
||||
Generated
+141
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../shared/agent-protocol/channel-state'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => [])
|
||||
|
||||
@@ -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'
|
||||
@@ -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
Reference in New Issue
Block a user