feat: support macOS arm64 remote Agent hosts
Extend GoodBuddy Agent packaging, installation, SSH attachment, Runtime lifecycle, and Desktop package management to macOS arm64. Linux x64, Linux arm64, and Darwin arm64 share the existing signed catalog. Preserve Ask read-only behavior and Execute account permissions, and stop detached tool descendants during Mac cancellation. Upgrade Desktop before publishing the first mixed-platform catalog because older Linux-only readers reject Darwin entries. Intel Macs are not supported; no launchd service or administrator access is required. This commit does not publish a release. Validation: 3520 tests passed, 57 skipped; typecheck, lint, and development build passed. Real Mac installation, Ask/Execute, cancellation and reconnect passed; final Linux installation, Ask and reconnect passed. Nine real model requests were recorded. Release note: 支持将 Apple Silicon Mac 用作远程项目 Host,可安装 macOS arm64 Agent 并使用 Ask、Execute 和断线重连;与 Linux 共用一个包目录,发布混合目录前需升级 Desktop。
This commit is contained in:
@@ -45,17 +45,22 @@ jobs:
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build Linux ${{ matrix.arch }} compound package
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} compound package
|
||||
needs: authorize
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
@@ -76,11 +81,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native build target
|
||||
run: node -e "if(process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Native Agent target mismatch')"
|
||||
|
||||
- name: Resolve locked native inputs
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
@@ -88,9 +97,11 @@ jobs:
|
||||
const { createHash } = require('node:crypto')
|
||||
const agent = require('./agent-runtime-lock.json')
|
||||
const remote = require('./remote-runtime-lock.json')
|
||||
const node = agent.node?.targets?.[`linux-${process.env.AGENT_ARCH}`]
|
||||
const node = agent.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
const runtime = remote.runtimes?.opencode
|
||||
const opencode = runtime?.targets?.[process.env.AGENT_ARCH]
|
||||
const runtimeKey = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH : `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const opencode = runtime?.targets?.[runtimeKey]
|
||||
if (
|
||||
!node ||
|
||||
!opencode ||
|
||||
@@ -136,7 +147,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-release-inputs
|
||||
key: goodbuddy-agent-release-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
key: goodbuddy-agent-release-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify native inputs
|
||||
shell: bash
|
||||
@@ -162,8 +173,14 @@ jobs:
|
||||
"$NODE_URL" --output "$node_archive.partial"
|
||||
mv "$node_archive.partial" "$node_archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$node_archive" |
|
||||
sha256sum --check --strict -
|
||||
node - "$node_archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
opencode_archive="$directory/$OPENCODE_ARCHIVE"
|
||||
if [[ ! -f "$opencode_archive" ]]; then
|
||||
@@ -201,6 +218,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
@@ -211,11 +229,12 @@ jobs:
|
||||
output="$RUNNER_TEMP/goodbuddy-agent-output"
|
||||
mkdir -p "$output"
|
||||
node build/agent-package.cjs build \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--minimum-desktop-version "$(node -p "require('./package.json').version")" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$NODE_ARCHIVE" \
|
||||
--runtime-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$OPENCODE_ARCHIVE" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-linux-$AGENT_ARCH.gbagent" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" \
|
||||
> "$output/result.json"
|
||||
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
@@ -223,9 +242,10 @@ jobs:
|
||||
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
if (
|
||||
result.descriptor?.architecture !== architecture ||
|
||||
result.descriptor?.platform !== process.env.AGENT_PLATFORM ||
|
||||
result.descriptor?.version !== version ||
|
||||
result.archive !==
|
||||
`goodbuddy-agent-${version}-linux-${architecture}.gbagent` ||
|
||||
`goodbuddy-agent-${version}-${process.env.AGENT_PLATFORM}-${architecture}.gbagent` ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.sha256)
|
||||
) {
|
||||
throw new Error('Production Agent package result is invalid')
|
||||
@@ -235,7 +255,7 @@ jobs:
|
||||
- name: Upload verified compound package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-package-linux-${{ matrix.arch }}
|
||||
name: goodbuddy-agent-package-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-output/goodbuddy-agent-*.gbagent
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
@@ -269,7 +289,7 @@ jobs:
|
||||
- name: Download compound packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: goodbuddy-agent-package-linux-*
|
||||
pattern: goodbuddy-agent-package-*
|
||||
path: dist/agent-release
|
||||
merge-multiple: true
|
||||
|
||||
@@ -353,6 +373,7 @@ jobs:
|
||||
node build/agent-catalog.cjs create \
|
||||
--x64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-x64.gbagent" \
|
||||
--arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-arm64.gbagent" \
|
||||
--darwin-arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-darwin-arm64.gbagent" \
|
||||
"${previous[@]}" \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig \
|
||||
@@ -489,8 +510,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v$AGENT_VERSION"
|
||||
for architecture in x64 arm64; do
|
||||
name="goodbuddy-agent-$AGENT_VERSION-linux-$architecture.gbagent"
|
||||
for target in linux-x64 linux-arm64 darwin-arm64; do
|
||||
name="goodbuddy-agent-$AGENT_VERSION-$target.gbagent"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/$name"
|
||||
cmp "dist/agent-release/$name" "$RUNNER_TEMP/$name"
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
@@ -19,6 +21,7 @@ on:
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
@@ -31,6 +34,8 @@ on:
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
@@ -41,6 +46,7 @@ on:
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
@@ -73,22 +79,28 @@ jobs:
|
||||
run: |
|
||||
npm test -- \
|
||||
tests/agent-bundle.test.ts \
|
||||
tests/agent-darwin-build.test.ts \
|
||||
tests/agent-package.test.ts \
|
||||
tests/remote-runtime-bundle.test.ts \
|
||||
tests/agents-workflow.test.ts
|
||||
npm run typecheck
|
||||
|
||||
build:
|
||||
name: Build Linux ${{ matrix.arch }} Agent
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} Agent
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -103,17 +115,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native runner target
|
||||
run: node -e "if (process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Agent CI requires the native target')"
|
||||
|
||||
- name: Resolve locked official Node runtime
|
||||
id: runtime
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const lock = require('./agent-runtime-lock.json')
|
||||
const target = lock.node?.targets?.[`linux-${process.env.AGENT_ARCH}`]
|
||||
const { lock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const target = lock.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
if (
|
||||
!target ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(target.archive) ||
|
||||
@@ -147,7 +165,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-node
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
|
||||
- name: Acquire and verify locked Node runtime
|
||||
shell: bash
|
||||
@@ -170,22 +188,34 @@ jobs:
|
||||
"$NODE_URL" --output "$archive.partial"
|
||||
mv "$archive.partial" "$archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$archive" |
|
||||
sha256sum --check --strict -
|
||||
node - "$archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Resolve locked OpenCode Runtime
|
||||
id: opencode
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const { createHash: hash } = require('node:crypto')
|
||||
const lock = require('./remote-runtime-lock.json')
|
||||
const runtime = lock.runtimes?.opencode
|
||||
const target = runtime?.targets?.[process.env.AGENT_ARCH]
|
||||
const { runtimeLock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const runtime = runtimeLock.runtimes?.opencode
|
||||
const key = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH
|
||||
: `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const target = runtime?.targets?.[key]
|
||||
if (
|
||||
!runtime ||
|
||||
!target ||
|
||||
@@ -213,7 +243,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-opencode-runtime
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify locked OpenCode Runtime
|
||||
shell: bash
|
||||
@@ -251,16 +281,22 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Run native Agent regressions
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
npm test -- \
|
||||
src/agent-daemon/model-bridge.test.ts \
|
||||
src/agent-daemon/lifecycle.test.ts
|
||||
|
||||
- name: Run native macOS process regressions
|
||||
if: matrix.platform == 'darwin'
|
||||
run: npm test -- src/agent-daemon/darwin-process-identity.test.ts
|
||||
|
||||
- name: Build and verify ephemeral signed compound Agent package twice
|
||||
id: agent
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
run: |
|
||||
@@ -270,11 +306,12 @@ jobs:
|
||||
mkdir -p "$directory"
|
||||
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
node build/agent-ci-bundle.cjs \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" \
|
||||
--opencode-archive "$RUNNER_TEMP/goodbuddy-opencode-runtime/$OPENCODE_ARCHIVE" \
|
||||
--output-directory "$directory/bundle" \
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-linux-$AGENT_ARCH.gbagent" |
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" |
|
||||
tee "$directory/result.json"
|
||||
node - <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require('node:fs')
|
||||
@@ -286,6 +323,7 @@ jobs:
|
||||
)
|
||||
if (
|
||||
result.architecture !== process.env.AGENT_ARCH ||
|
||||
result.platform !== process.env.AGENT_PLATFORM ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.archiveSha256)
|
||||
) {
|
||||
throw new Error('Agent CI result is invalid')
|
||||
@@ -300,14 +338,16 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_SHA256: ${{ steps.agent.outputs.sha256 }}
|
||||
run: |
|
||||
{
|
||||
echo "### Linux $AGENT_ARCH Agent"
|
||||
echo "### $AGENT_PLATFORM $AGENT_ARCH Agent build"
|
||||
echo
|
||||
echo "Native compound Agent, Node, and OpenCode Runtime build, test-signature verification, launch smoke, and deterministic archive comparison passed."
|
||||
echo
|
||||
echo "Archive SHA-256: \`$AGENT_SHA256\`"
|
||||
echo
|
||||
echo "The ephemeral CI key and bundle are not published."
|
||||
echo "Native build checks do not replace real Host installation, Attach, and Ask/Execute acceptance."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -145,7 +145,7 @@ npm run dist:linux:arm64
|
||||
组件,缺少远端包也不能阻塞普通桌面打包或发布。
|
||||
|
||||
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
|
||||
Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
|
||||
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
用户也可导入或导出 `.gbagent` 离线包。
|
||||
@@ -254,16 +254,32 @@ node build/agent-package.cjs build \
|
||||
Agent 源码、共享协议、lock、bundle 工具和测试与桌面应用保持在同一仓库、同一 commit;
|
||||
不维护长期分叉的 Agent 源码分支。`.github/workflows/agents.yml` 在相关 Pull Request、
|
||||
`main` push 和手工触发时,分别使用 `ubuntu-24.04` 与 `ubuntu-24.04-arm` 原生构建
|
||||
Linux x64/arm64 复合包。每个 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
|
||||
Linux x64/arm64 复合包,并使用 `macos-15` 原生构建 Darwin arm64
|
||||
复合包。所有 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
|
||||
地址与 digest,并从 `remote-runtime-lock.json` 解析固定 OpenCode npm 包与 integrity;
|
||||
下载后再次校验,随后使用仅存在于进程内的临时 Ed25519 测试 key 构建两次,完成内外层
|
||||
测试签名验证、Agent 原生启动 smoke 和确定性 `.gbagent` 对比。
|
||||
测试签名验证、Agent CLI 加载 smoke 和确定性 `.gbagent` 对比。所有目标还验证
|
||||
Koffi 原生模块加载与 OpenCode `--version`;Linux job 继续运行 Agent 原生回归。
|
||||
|
||||
Darwin arm64 的 Node 和 OpenCode 固定输入分别由同一份
|
||||
`agent-runtime-lock.json` 与 `remote-runtime-lock.json` 提供,不再维护 CI 专用副本。
|
||||
组包器区分 ELF 与 Mach-O,Darwin CI 产物命名为
|
||||
`goodbuddy-agent-<version>-darwin-arm64.gbagent`,不构建 Intel Mac 目标。
|
||||
macOS Host 继续通过 SSH 按需启动 detached Agent,不注册 launchd 或开机服务。
|
||||
进程身份使用 macOS 原生接口,Unix Socket 使用 `getpeereid`;Host 下载兼容系统自带
|
||||
`shasum -a 256`,不要求安装 GNU coreutils。真实 Host 验证记录见
|
||||
[远程主机技术设计](./docs/features/remote-host/technical-design.md)。
|
||||
|
||||
三个目标共用 `agent-catalog.json` 和现有签名格式,条目按平台与架构区分。旧版 Desktop
|
||||
只接受 Linux catalog 条目;首次发布包含 Darwin 的累计 catalog 前必须先发布新版
|
||||
Desktop,并在发布说明中要求用户升级 Desktop。旧版的在线 Agent 目录检查会失败,
|
||||
本地已验证包不被删除。不得为此改写历史包或复用发布标签。
|
||||
|
||||
该验证 workflow 不读取 production signing secret,不修改公开 key registry,也不上传
|
||||
可安装 Agent 工件。`.github/workflows/agent-release.yml` 是唯一 production Agent
|
||||
发布路径:只接受指向受保护 `main` 历史的 annotated
|
||||
`agent-v${agent-runtime-lock.agentVersion}` 标签,在 `agent-signing` Environment 中
|
||||
原生构建两种架构,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
|
||||
原生构建 Linux x64、Linux arm64、macOS arm64 三个目标,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
|
||||
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
|
||||
|
||||
- `GOODBUDDY_SIGNING_KEY_ID` /
|
||||
|
||||
+9
-3
@@ -123,12 +123,16 @@ otherwise.
|
||||
in the desktop package; switching projects updates only local selection and
|
||||
does not connect or download. Settings reads a small signed catalog and
|
||||
reports the local version, latest compatible online version, and update
|
||||
state for Linux x64/arm64. Downloads occur only after a user action, and
|
||||
state for Linux x64, Linux arm64, and macOS arm64. Intel Macs are not supported.
|
||||
Downloads occur only after a user action, and
|
||||
packages can also be imported or exported offline. Each compound `.gbagent`
|
||||
package contains the Agent, fixed Node, and the compatible OpenCode Runtime
|
||||
maintained by the desktop source. Online sources follow the GitHub/Beijing
|
||||
OSS choice in About and Updates. The cumulative signed catalog binds minimum
|
||||
Desktop version, Agent protocol, architecture, size, SHA-256, and fixed URL.
|
||||
Desktop version, Agent protocol, platform, architecture, size, SHA-256, and fixed URL.
|
||||
All three targets share one catalog. Users must upgrade Desktop before the
|
||||
first mixed-platform catalog is published because older Linux-only readers
|
||||
reject catalogs containing Darwin entries.
|
||||
The public-key registry accepts equivalent JSON whitespace and line endings
|
||||
while still strictly validating schema, Ed25519 keys, environment, and
|
||||
revocation; catalog, package, manifest, payload signatures, and streaming
|
||||
@@ -150,7 +154,9 @@ otherwise.
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.11.18`, while the current Desktop release candidate is `0.12.4`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels.
|
||||
release channels. Current macOS source has passed native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
separate process groups on a real Host; this does not imply publication.
|
||||
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
|
||||
authentication, and system probes succeed, GoodBuddy saves the Host and
|
||||
read-only probes the shared Agent/Runtime. Saving a Host or opening a project
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.18`,Desktop 发布候选为 `0.12.4`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
|
||||
+1
-1
@@ -703,7 +703,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
"binaryPath": "node-v24.19.0-linux-x64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-x64/LICENSE"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"archive": "node-v24.19.0-darwin-arm64.tar.gz",
|
||||
"sha256": "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d",
|
||||
"binaryPath": "node-v24.19.0-darwin-arm64/bin/node",
|
||||
"licensePath": "node-v24.19.0-darwin-arm64/LICENSE"
|
||||
},
|
||||
"linux-arm64": {
|
||||
"archive": "node-v24.19.0-linux-arm64.tar.gz",
|
||||
"sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const {
|
||||
detectBinaryArchitecture,
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
|
||||
function targetName(architecture, platform = 'linux') {
|
||||
if (
|
||||
!(platform === 'linux' && ['x64', 'arm64'].includes(architecture)) &&
|
||||
!(platform === 'darwin' && architecture === 'arm64')
|
||||
) {
|
||||
throw new Error(`Unsupported Agent build target: ${platform}-${architecture}`)
|
||||
}
|
||||
return `${platform}-${architecture}`
|
||||
}
|
||||
|
||||
function assertTargetBinary(filePath, architecture, platform, label) {
|
||||
targetName(architecture, platform)
|
||||
const header = readFileSync(filePath).subarray(0, 64)
|
||||
const isMachO = header.length >= 8 &&
|
||||
(header.readUInt32LE(0) === 0xfeedfacf ||
|
||||
header.readUInt32BE(0) === 0xfeedfacf)
|
||||
const actual = platform === 'linux'
|
||||
? detectElfArchitecture(header)
|
||||
: isMachO ? detectBinaryArchitecture(header) : undefined
|
||||
if (actual !== architecture) {
|
||||
throw new Error(
|
||||
`${label} architecture mismatch: expected ${platform}-${architecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { targetName, assertTargetBinary }
|
||||
+55
-48
@@ -29,9 +29,11 @@ const {
|
||||
sep
|
||||
} = require('node:path')
|
||||
const tar = require('tar')
|
||||
const { detectElfArchitecture } = require('./binary-architecture.cjs')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
targetName,
|
||||
assertTargetBinary
|
||||
} = require('./agent-build-target.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
@@ -132,15 +134,8 @@ function readTrustedKeyRegistry(projectRoot = root) {
|
||||
return registry
|
||||
}
|
||||
|
||||
function targetName(arch) {
|
||||
if (!supportedArchitectures.includes(arch)) {
|
||||
throw new Error(`Unsupported Agent architecture: ${arch}`)
|
||||
}
|
||||
return `linux-${arch}`
|
||||
}
|
||||
|
||||
function lockedRuntimeInput(lock, arch, archivePath) {
|
||||
const target = targetName(arch)
|
||||
function lockedRuntimeInput(lock, arch, archivePath, platform = 'linux') {
|
||||
const target = targetName(arch, platform)
|
||||
const input = lock.node?.targets?.[target]
|
||||
if (
|
||||
!input ||
|
||||
@@ -177,24 +172,23 @@ function lockedRuntimeInput(lock, arch, archivePath) {
|
||||
function assertElfArchitecture(
|
||||
filePath,
|
||||
expectedArchitecture,
|
||||
description = 'Agent Node runtime'
|
||||
description = 'Agent Node runtime',
|
||||
platform = 'linux'
|
||||
) {
|
||||
const actual = detectElfArchitecture(readFileSync(filePath).subarray(0, 64))
|
||||
if (actual !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
`${description} architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
assertTargetBinary(filePath, expectedArchitecture, platform, description)
|
||||
}
|
||||
|
||||
function koffiNativePackageName(arch) {
|
||||
return `@koromix/koffi-linux-${arch}`
|
||||
function koffiNativePackageName(arch, platform = 'linux') {
|
||||
return `@koromix/koffi-${targetName(arch, platform)}`
|
||||
}
|
||||
|
||||
function koffiPayloadPaths(arch) {
|
||||
function koffiPayloadPaths(arch, platform = 'linux') {
|
||||
const packageRoot = `lib/node_modules/${koffiPackageName}`
|
||||
const nativeRoot =
|
||||
`lib/node_modules/${koffiNativePackageName(arch)}`
|
||||
`lib/node_modules/${koffiNativePackageName(arch, platform)}`
|
||||
const native = platform === 'linux'
|
||||
? [`${nativeRoot}/linux_${arch}/koffi.node`, `${nativeRoot}/musl_${arch}/koffi.node`]
|
||||
: [`${nativeRoot}/darwin_${arch}/koffi.node`]
|
||||
return {
|
||||
packageRoot,
|
||||
nativeRoot,
|
||||
@@ -205,13 +199,9 @@ function koffiPayloadPaths(arch) {
|
||||
`${packageRoot}/src/koffi/src/static.js`,
|
||||
`${nativeRoot}/package.json`,
|
||||
`${nativeRoot}/index.js`,
|
||||
`${nativeRoot}/linux_${arch}/koffi.node`,
|
||||
`${nativeRoot}/musl_${arch}/koffi.node`
|
||||
...native
|
||||
],
|
||||
native: [
|
||||
`${nativeRoot}/linux_${arch}/koffi.node`,
|
||||
`${nativeRoot}/musl_${arch}/koffi.node`
|
||||
]
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +349,7 @@ function createManifest(bundleDirectory, metadata) {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: metadata.agentVersion,
|
||||
platform: 'linux',
|
||||
platform: metadata.platform ?? 'linux',
|
||||
arch: metadata.arch,
|
||||
protocol: metadata.protocol,
|
||||
signingKeyId: metadata.signingKeyId,
|
||||
@@ -452,7 +442,10 @@ function validateManifestShape(manifest, expected) {
|
||||
manifest.product !== 'GoodBuddy' ||
|
||||
typeof manifest.agentVersion !== 'string' ||
|
||||
!semanticVersionPattern.test(manifest.agentVersion) ||
|
||||
manifest.platform !== 'linux' ||
|
||||
!(
|
||||
manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.arch === 'arm64')
|
||||
) ||
|
||||
!supportedArchitectures.includes(manifest.arch) ||
|
||||
!Number.isSafeInteger(manifest.protocol?.major) ||
|
||||
!Number.isSafeInteger(manifest.protocol?.minor) ||
|
||||
@@ -617,7 +610,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
throw new Error(`Agent entrypoint payload is missing: ${required}`)
|
||||
}
|
||||
}
|
||||
const koffiPaths = koffiPayloadPaths(manifest.arch)
|
||||
const koffiPaths = koffiPayloadPaths(manifest.arch, manifest.platform)
|
||||
for (const required of koffiPaths.required) {
|
||||
if (!declaredPaths.has(required)) {
|
||||
throw new Error(`Agent Koffi payload is missing: ${required}`)
|
||||
@@ -626,7 +619,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
const koffiLicense = manifest.licenses.find(
|
||||
(license) => license.package === koffiPackageName
|
||||
)
|
||||
const nativePackage = koffiNativePackageName(manifest.arch)
|
||||
const nativePackage = koffiNativePackageName(manifest.arch, manifest.platform)
|
||||
const nativeLicense = manifest.licenses.find(
|
||||
(license) => license.package === nativePackage
|
||||
)
|
||||
@@ -641,13 +634,16 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
}
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch
|
||||
manifest.arch,
|
||||
'Agent Node runtime',
|
||||
manifest.platform
|
||||
)
|
||||
for (const nativePath of koffiPaths.native) {
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, ...nativePath.split('/')),
|
||||
manifest.arch,
|
||||
'Agent Koffi native binding'
|
||||
'Agent Koffi native binding',
|
||||
manifest.platform
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -668,7 +664,8 @@ function assertReplaceableBundle(directory) {
|
||||
if (
|
||||
manifest.formatVersion === 1 &&
|
||||
manifest.product === 'GoodBuddy' &&
|
||||
manifest.platform === 'linux'
|
||||
(manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.arch === 'arm64'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -717,13 +714,13 @@ function copyRegularFile(source, destination) {
|
||||
chmodSync(destination, 0o644)
|
||||
}
|
||||
|
||||
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion, platform = 'linux') {
|
||||
const sourceRoot = join(projectRoot, 'node_modules', koffiPackageName)
|
||||
const packageMetadata = readJson(
|
||||
join(sourceRoot, 'package.json'),
|
||||
'Koffi package metadata'
|
||||
)
|
||||
const nativePackage = koffiNativePackageName(arch)
|
||||
const nativePackage = koffiNativePackageName(arch, platform)
|
||||
const nativeSourceRoot = join(
|
||||
projectRoot,
|
||||
'node_modules',
|
||||
@@ -739,7 +736,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
nativeMetadata.name !== nativePackage ||
|
||||
nativeMetadata.version !== lockedVersion ||
|
||||
!Array.isArray(nativeMetadata.os) ||
|
||||
!nativeMetadata.os.includes('linux') ||
|
||||
!nativeMetadata.os.includes(platform) ||
|
||||
!Array.isArray(nativeMetadata.cpu) ||
|
||||
!nativeMetadata.cpu.includes(arch)
|
||||
) {
|
||||
@@ -748,10 +745,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
)
|
||||
}
|
||||
|
||||
const paths = koffiPayloadPaths(arch)
|
||||
const paths = koffiPayloadPaths(arch, platform)
|
||||
const files = [
|
||||
['package.json', `${paths.packageRoot}/package.json`],
|
||||
['index.js', `${paths.packageRoot}/index.js`],
|
||||
['index.cjs', `${paths.packageRoot}/index.cjs`],
|
||||
['src/koffi/index.cjs', `${paths.packageRoot}/src/koffi/index.cjs`],
|
||||
['src/koffi/src/static.cjs', `${paths.packageRoot}/src/koffi/src/static.cjs`],
|
||||
[
|
||||
'src/koffi/index.js',
|
||||
`${paths.packageRoot}/src/koffi/index.js`
|
||||
@@ -770,8 +770,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
for (const source of [
|
||||
'package.json',
|
||||
'index.js',
|
||||
`linux_${arch}/koffi.node`,
|
||||
`musl_${arch}/koffi.node`
|
||||
...paths.native.map((path) => path.slice(paths.nativeRoot.length + 1))
|
||||
]) {
|
||||
const destination = `${paths.nativeRoot}/${source}`
|
||||
copyRegularFile(
|
||||
@@ -783,7 +782,8 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
assertElfArchitecture(
|
||||
join(staging, ...nativePath.split('/')),
|
||||
arch,
|
||||
'Agent Koffi native binding'
|
||||
'Agent Koffi native binding',
|
||||
platform
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -795,11 +795,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
function buildAgentBundle(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const arch = options.arch
|
||||
const platform = options.platform ?? 'linux'
|
||||
const lock = options.lock ?? readRuntimeLock(projectRoot)
|
||||
const runtimeInput = lockedRuntimeInput(
|
||||
lock,
|
||||
arch,
|
||||
options.runtimeArchive
|
||||
options.runtimeArchive,
|
||||
platform
|
||||
)
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
@@ -843,13 +845,13 @@ function buildAgentBundle(options) {
|
||||
|
||||
const destination =
|
||||
options.outputDirectory ??
|
||||
join(projectRoot, '.agent-resources', targetName(arch))
|
||||
join(projectRoot, '.agent-resources', targetName(arch, platform))
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
const staging = mkdtempSync(
|
||||
join(dirname(destination), `.stage-${targetName(arch)}-`)
|
||||
join(dirname(destination), `.stage-${targetName(arch, platform)}-`)
|
||||
)
|
||||
const runtimeStaging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-node-${targetName(arch)}-`)
|
||||
join(tmpdir(), `goodbuddy-node-${targetName(arch, platform)}-`)
|
||||
)
|
||||
try {
|
||||
tar.x({
|
||||
@@ -872,7 +874,7 @@ function buildAgentBundle(options) {
|
||||
runtimeStaging,
|
||||
...runtimeInput.licensePath.split('/')
|
||||
)
|
||||
assertElfArchitecture(runtimeBinary, arch)
|
||||
assertElfArchitecture(runtimeBinary, arch, 'Agent Node runtime', platform)
|
||||
copyFileSync(runtimeBinary, join(staging, 'node'))
|
||||
chmodSync(join(staging, 'node'), 0o755)
|
||||
|
||||
@@ -912,7 +914,8 @@ function buildAgentBundle(options) {
|
||||
projectRoot,
|
||||
staging,
|
||||
arch,
|
||||
lock.koffi.version
|
||||
lock.koffi.version,
|
||||
platform
|
||||
)
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
@@ -980,6 +983,7 @@ function buildAgentBundle(options) {
|
||||
acpSdkVersion: acpSdkPackage.version,
|
||||
koffiVersion: koffi.version,
|
||||
koffiNativePackage: koffi.nativePackage,
|
||||
platform,
|
||||
arch,
|
||||
protocol: lock.protocol,
|
||||
signingKeyId: signingIdentity.keyId
|
||||
@@ -1004,6 +1008,7 @@ function buildAgentBundle(options) {
|
||||
? 'test'
|
||||
: 'production',
|
||||
expected: {
|
||||
platform,
|
||||
agentVersion: lock.agentVersion,
|
||||
arch,
|
||||
protocol: lock.protocol
|
||||
@@ -1073,6 +1078,7 @@ function verifyLockedBundle(bundleDirectory, arch, options = {}) {
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot),
|
||||
verificationEnvironment: options.verificationEnvironment ?? 'production',
|
||||
expected: {
|
||||
platform: options.platform ?? 'linux',
|
||||
agentVersion: lock.agentVersion,
|
||||
arch,
|
||||
protocol: lock.protocol
|
||||
@@ -1203,6 +1209,7 @@ function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
koffiPayloadPaths,
|
||||
assertElfArchitecture,
|
||||
assertSafeManifestPath,
|
||||
buildAgentBundle,
|
||||
|
||||
+33
-9
@@ -32,6 +32,10 @@ const {
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
function supportedTarget(platform, architecture) {
|
||||
return (platform === 'linux' && ['x64', 'arm64'].includes(architecture)) ||
|
||||
(platform === 'darwin' && architecture === 'arm64')
|
||||
}
|
||||
const catalogName = 'agent-catalog.json'
|
||||
const catalogSignatureName = 'agent-catalog.sig'
|
||||
const catalogSignatureDomain = Buffer.from(
|
||||
@@ -136,7 +140,7 @@ function assertPackageDescriptor(descriptor) {
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
descriptor.platform !== 'linux' ||
|
||||
!supportedTarget(descriptor.platform, descriptor.architecture) ||
|
||||
!['x64', 'arm64'].includes(descriptor.architecture) ||
|
||||
typeof descriptor.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
@@ -214,7 +218,7 @@ function readPackageMetadata(archivePath, registry) {
|
||||
)
|
||||
const expectedArchive =
|
||||
`goodbuddy-agent-${descriptor.version}` +
|
||||
`-linux-${descriptor.architecture}.gbagent`
|
||||
`-${descriptor.platform}-${descriptor.architecture}.gbagent`
|
||||
if (basename(archive) !== expectedArchive) {
|
||||
throw new Error(
|
||||
`Agent package archive must be named ${expectedArchive}`
|
||||
@@ -566,11 +570,11 @@ function assertCatalog(catalog) {
|
||||
!semanticVersionPattern.test(
|
||||
entry.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
entry.platform !== 'linux' ||
|
||||
!supportedTarget(entry.platform, entry.architecture) ||
|
||||
!['x64', 'arm64'].includes(entry.architecture) ||
|
||||
entry.archive !==
|
||||
`goodbuddy-agent-${entry.version}` +
|
||||
`-linux-${entry.architecture}.gbagent` ||
|
||||
`-${entry.platform}-${entry.architecture}.gbagent` ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
!sha256Pattern.test(entry.sha256 ?? '') ||
|
||||
@@ -600,7 +604,7 @@ function assertCatalog(catalog) {
|
||||
entry.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
if (identities.has(identity)) {
|
||||
throw new Error('Agent catalog contains duplicate entries')
|
||||
}
|
||||
@@ -698,7 +702,8 @@ function createCatalog(options) {
|
||||
}
|
||||
const packages = [
|
||||
readPackageMetadata(options.x64Package, registry),
|
||||
readPackageMetadata(options.arm64Package, registry)
|
||||
readPackageMetadata(options.arm64Package, registry),
|
||||
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, registry)] : [])
|
||||
]
|
||||
const [first, second] = packages
|
||||
const {
|
||||
@@ -721,12 +726,26 @@ function createCatalog(options) {
|
||||
canonicalJson(secondRuntimeIdentity) ||
|
||||
new Set(packages.map(
|
||||
(metadata) => metadata.descriptor.architecture
|
||||
)).size !== 2
|
||||
).slice(0, 2)).size !== 2 ||
|
||||
first.descriptor.platform !== 'linux' ||
|
||||
second.descriptor.platform !== 'linux'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package matrix does not describe one coherent release'
|
||||
)
|
||||
}
|
||||
if (packages[2]) {
|
||||
const mac = packages[2].descriptor
|
||||
const { bundleDigest: _digest, ...runtimeIdentity } = mac.remoteRuntime
|
||||
void _digest
|
||||
if (
|
||||
mac.platform !== 'darwin' || mac.architecture !== 'arm64' ||
|
||||
mac.version !== first.descriptor.version ||
|
||||
mac.minimumDesktopVersion !== first.descriptor.minimumDesktopVersion ||
|
||||
canonicalJson(mac.agentProtocol) !== canonicalJson(first.descriptor.agentProtocol) ||
|
||||
canonicalJson(runtimeIdentity) !== canonicalJson(firstRuntimeIdentity)
|
||||
) throw new Error('Agent package matrix does not describe one coherent release')
|
||||
}
|
||||
const previous =
|
||||
options.previousCatalog && options.previousSignature
|
||||
? readVerifiedCatalog(
|
||||
@@ -745,11 +764,11 @@ function createCatalog(options) {
|
||||
}
|
||||
const entries = new Map()
|
||||
for (const entry of previous?.entries ?? []) {
|
||||
entries.set(`${entry.version}:${entry.architecture}`, entry)
|
||||
entries.set(`${entry.version}:${entry.platform}:${entry.architecture}`, entry)
|
||||
}
|
||||
for (const metadata of packages) {
|
||||
const entry = catalogEntry(metadata)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
const existing = entries.get(identity)
|
||||
if (
|
||||
existing &&
|
||||
@@ -764,6 +783,7 @@ function createCatalog(options) {
|
||||
const sortedEntries = [...entries.values()].sort(
|
||||
(left, right) =>
|
||||
compareVersions(right.version, left.version) ||
|
||||
left.platform.localeCompare(right.platform, 'en') ||
|
||||
left.architecture.localeCompare(right.architecture, 'en')
|
||||
)
|
||||
if (sortedEntries.length > maximumCatalogEntries) {
|
||||
@@ -813,6 +833,7 @@ function parseArguments(argv) {
|
||||
![
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--darwin-arm64-package',
|
||||
'--previous-catalog',
|
||||
'--previous-signature',
|
||||
'--catalog',
|
||||
@@ -852,6 +873,9 @@ function parseArguments(argv) {
|
||||
command,
|
||||
x64Package: resolve(options['--x64-package']),
|
||||
arm64Package: resolve(options['--arm64-package']),
|
||||
...(options['--darwin-arm64-package'] ? {
|
||||
darwinArm64Package: resolve(options['--darwin-arm64-package'])
|
||||
} : {}),
|
||||
...(options['--previous-catalog']
|
||||
? {
|
||||
previousCatalog: resolve(
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
assembleAgentPackage
|
||||
} = require('./agent-package.cjs')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
|
||||
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
|
||||
|
||||
@@ -34,6 +35,7 @@ function parseArguments(argv) {
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--output-directory',
|
||||
@@ -56,6 +58,8 @@ function parseArguments(argv) {
|
||||
if (!supportedArchitectures.includes(options.arch)) {
|
||||
throw new Error('--arch must be x64 or arm64')
|
||||
}
|
||||
const platform = options.platform ?? 'linux'
|
||||
targetName(options.arch, platform)
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
@@ -70,6 +74,7 @@ function parseArguments(argv) {
|
||||
}
|
||||
return {
|
||||
arch: options.arch,
|
||||
platform,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
@@ -77,6 +82,14 @@ function parseArguments(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
function readCiLocks(platform = 'linux', architecture = 'x64') {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
targetName(architecture, platform)
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
return { lock, runtimeLock }
|
||||
}
|
||||
|
||||
function ephemeralSigningIdentity() {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
@@ -97,8 +110,12 @@ function ephemeralSigningIdentity() {
|
||||
|
||||
async function buildCiAgentBundle(options) {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(options.arch, platform)
|
||||
if (process.platform !== platform || process.arch !== options.arch) {
|
||||
throw new Error(`Agent CI build requires a native ${target} host`)
|
||||
}
|
||||
const { lock, runtimeLock } = readCiLocks(platform, options.arch)
|
||||
const signing = ephemeralSigningIdentity()
|
||||
const firstArchiveRoot = resolve(
|
||||
join(
|
||||
@@ -124,6 +141,8 @@ async function buildCiAgentBundle(options) {
|
||||
force: true
|
||||
})
|
||||
buildAgentBundle({
|
||||
platform,
|
||||
lock,
|
||||
projectRoot,
|
||||
arch: options.arch,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
@@ -132,6 +151,8 @@ async function buildCiAgentBundle(options) {
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform,
|
||||
lock: runtimeLock,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
runtimeArchive: options.opencodeArchive,
|
||||
@@ -141,6 +162,7 @@ async function buildCiAgentBundle(options) {
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion:
|
||||
@@ -181,7 +203,31 @@ async function buildCiAgentBundle(options) {
|
||||
throw new Error('Built Agent native launch smoke failed')
|
||||
}
|
||||
|
||||
const nativeChecks = [
|
||||
[
|
||||
resolve(options.outputDirectory, 'node'),
|
||||
['-e', `const k = require(${JSON.stringify(resolve(options.outputDirectory, 'lib/node_modules/koffi'))}); if (k.sizeof('int') !== 4) process.exit(1)`]
|
||||
],
|
||||
[join(runtime.bundleDirectory, 'bin', 'opencode'), ['--version']]
|
||||
]
|
||||
for (const [executable, args] of nativeChecks) {
|
||||
const result = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true
|
||||
})
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
(args[0] === '--version' &&
|
||||
result.stdout.trim() !== runtimeLock.runtimes.opencode.version)
|
||||
) {
|
||||
throw new Error(`Built native dependency smoke failed: ${basename(executable)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
agentVersion: lock.agentVersion,
|
||||
remoteRuntimeVersion:
|
||||
runtimeLock.runtimes.opencode.version,
|
||||
@@ -214,5 +260,6 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildCiAgentBundle,
|
||||
parseArguments
|
||||
parseArguments,
|
||||
readCiLocks
|
||||
}
|
||||
|
||||
+13
-3
@@ -19,6 +19,7 @@ const {
|
||||
} = require('node:fs')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
@@ -345,6 +346,8 @@ function createPackageArchive(
|
||||
function assembleAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(architecture, platform)
|
||||
if (
|
||||
!['x64', 'arm64'].includes(architecture) ||
|
||||
!semanticVersionPattern.test(options.minimumDesktopVersion ?? '') ||
|
||||
@@ -386,6 +389,7 @@ function assembleAgentPackage(options) {
|
||||
options.agentBundle,
|
||||
architecture,
|
||||
{
|
||||
platform,
|
||||
projectRoot,
|
||||
registry,
|
||||
lock: agentLock,
|
||||
@@ -395,6 +399,7 @@ function assembleAgentPackage(options) {
|
||||
}
|
||||
)
|
||||
const runtime = verifyRuntimeBundle(options.runtimeBundle, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
@@ -405,14 +410,14 @@ function assembleAgentPackage(options) {
|
||||
})
|
||||
const expectedArchiveName =
|
||||
`goodbuddy-agent-${agent.manifest.agentVersion}` +
|
||||
`-linux-${architecture}.gbagent`
|
||||
`-${target}.gbagent`
|
||||
if (basename(options.output) !== expectedArchiveName) {
|
||||
throw new Error(
|
||||
`Agent package output must be named ${expectedArchiveName}`
|
||||
)
|
||||
}
|
||||
const staging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-package-linux-${architecture}-`)
|
||||
join(tmpdir(), `goodbuddy-agent-package-${target}-`)
|
||||
)
|
||||
try {
|
||||
stagePackagePayload({
|
||||
@@ -431,7 +436,7 @@ function assembleAgentPackage(options) {
|
||||
component: 'agent',
|
||||
version: agent.manifest.agentVersion,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
platform: 'linux',
|
||||
platform,
|
||||
architecture,
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
agentProtocol: agent.manifest.protocol,
|
||||
@@ -493,6 +498,7 @@ function buildAgentPackage(options) {
|
||||
)
|
||||
try {
|
||||
const agentBundle = buildAgentBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
arch: options.architecture,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
@@ -504,6 +510,7 @@ function buildAgentPackage(options) {
|
||||
: { signingIdentity })
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
runtimeArchive: options.runtimeArchive,
|
||||
@@ -544,6 +551,7 @@ function parseArguments(argv) {
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
@@ -571,10 +579,12 @@ function parseArguments(argv) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
platform: options.platform ?? 'linux',
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
targetName(common.architecture, common.platform)
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
throw new Error('Agent package build inputs are incomplete')
|
||||
|
||||
@@ -33,8 +33,9 @@ const {
|
||||
} = require('node:path')
|
||||
const tar = require('tar')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
targetName,
|
||||
assertTargetBinary
|
||||
} = require('./agent-build-target.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
@@ -145,13 +146,6 @@ function readTrustedKeyRegistry(projectRoot = root) {
|
||||
return registry
|
||||
}
|
||||
|
||||
function targetName(architecture) {
|
||||
if (!supportedArchitectures.includes(architecture)) {
|
||||
throw new Error(`Unsupported Runtime architecture: ${architecture}`)
|
||||
}
|
||||
return `linux-${architecture}`
|
||||
}
|
||||
|
||||
function sha256(contents) {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
@@ -390,15 +384,8 @@ function modeString(filePath) {
|
||||
.padStart(4, '0')
|
||||
}
|
||||
|
||||
function assertElfArchitecture(filePath, expectedArchitecture) {
|
||||
const actual = detectElfArchitecture(
|
||||
readFileSync(filePath).subarray(0, 64)
|
||||
)
|
||||
if (actual !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
`Runtime architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
function assertElfArchitecture(filePath, expectedArchitecture, platform = 'linux') {
|
||||
assertTargetBinary(filePath, expectedArchitecture, platform, 'Runtime')
|
||||
}
|
||||
|
||||
function validateManifestShape(manifest) {
|
||||
@@ -408,7 +395,10 @@ function validateManifestShape(manifest) {
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.runtimeVersion !== '1.18.9' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
!(
|
||||
manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.architecture === 'arm64')
|
||||
) ||
|
||||
!supportedArchitectures.includes(manifest.architecture) ||
|
||||
typeof manifest.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(manifest.bundleDigest) ||
|
||||
@@ -478,13 +468,14 @@ function trustedKeyForManifest(
|
||||
return key
|
||||
}
|
||||
|
||||
function assertManifestMatchesLock(manifest, lock, architecture) {
|
||||
function assertManifestMatchesLock(manifest, lock, architecture, platform = 'linux') {
|
||||
const expected = lock.runtimes.opencode
|
||||
const target = expected.targets[architecture]
|
||||
const target = expected.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
if (
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.runtimeVersion !== expected.version ||
|
||||
manifest.provider !== expected.provider ||
|
||||
manifest.platform !== platform ||
|
||||
manifest.architecture !== architecture ||
|
||||
manifest.sourcePackage?.name !== target.package ||
|
||||
manifest.sourcePackage.integrity !== target.integrity ||
|
||||
@@ -512,7 +503,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const architecture = options.architecture
|
||||
targetName(architecture)
|
||||
const platform = options.platform ?? 'linux'
|
||||
targetName(architecture, platform)
|
||||
const manifestBytes = readFileSync(
|
||||
join(bundleDirectory, manifestFileName)
|
||||
)
|
||||
@@ -530,7 +522,7 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
'Runtime manifest is not in canonical deterministic form'
|
||||
)
|
||||
}
|
||||
assertManifestMatchesLock(manifest, lock, architecture)
|
||||
assertManifestMatchesLock(manifest, lock, architecture, platform)
|
||||
const environment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
const key = trustedKeyForManifest(
|
||||
@@ -635,7 +627,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
}
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, ...manifest.entrypoint.path.split('/')),
|
||||
architecture
|
||||
architecture,
|
||||
platform
|
||||
)
|
||||
return {
|
||||
bundleDirectory,
|
||||
@@ -644,8 +637,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function lockedArchive(lock, architecture, archivePath) {
|
||||
const input = resolveLockedRuntimeInput(lock, architecture)
|
||||
function lockedArchive(lock, architecture, archivePath, platform = 'linux') {
|
||||
const input = resolveLockedRuntimeInput(lock, architecture, platform)
|
||||
if (!archivePath || !existsSync(archivePath)) {
|
||||
throw new Error(
|
||||
`Locked OpenCode archive is required: ${input.archive}`
|
||||
@@ -665,13 +658,13 @@ function lockedArchive(lock, architecture, archivePath) {
|
||||
`Runtime archive integrity mismatch for ${targetName(architecture)}`
|
||||
)
|
||||
}
|
||||
return lock.runtimes.opencode.targets[architecture]
|
||||
return lock.runtimes.opencode.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
}
|
||||
|
||||
function resolveLockedRuntimeInput(lock, architecture) {
|
||||
targetName(architecture)
|
||||
function resolveLockedRuntimeInput(lock, architecture, platform = 'linux') {
|
||||
targetName(architecture, platform)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const target = runtime.targets[architecture]
|
||||
const target = runtime.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
return {
|
||||
packageName: target.package,
|
||||
version: runtime.version,
|
||||
@@ -708,7 +701,7 @@ function createManifest(bundleDirectory, metadata) {
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: metadata.runtime.version,
|
||||
provider: metadata.runtime.provider,
|
||||
platform: 'linux',
|
||||
platform: metadata.platform ?? 'linux',
|
||||
architecture: metadata.architecture,
|
||||
signingKeyId: metadata.signingKeyId,
|
||||
bundleDigest: `sha256:${'0'.repeat(64)}`,
|
||||
@@ -745,10 +738,10 @@ function defaultOutputRoot(projectRoot = root) {
|
||||
return join(projectRoot, '.remote-runtime-resources')
|
||||
}
|
||||
|
||||
function bundleDirectory(outputRoot, architecture, digest) {
|
||||
function bundleDirectory(outputRoot, architecture, digest, platform = 'linux') {
|
||||
return join(
|
||||
outputRoot,
|
||||
targetName(architecture),
|
||||
targetName(architecture, platform),
|
||||
'opencode',
|
||||
digest.slice('sha256:'.length)
|
||||
)
|
||||
@@ -757,12 +750,14 @@ function bundleDirectory(outputRoot, architecture, digest) {
|
||||
function buildRuntimeBundle(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
const platform = options.platform ?? 'linux'
|
||||
const lock = options.lock ?? readRemoteRuntimeLock(projectRoot)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const sourcePackage = lockedArchive(
|
||||
lock,
|
||||
architecture,
|
||||
options.runtimeArchive
|
||||
options.runtimeArchive,
|
||||
platform
|
||||
)
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
@@ -810,7 +805,7 @@ function buildRuntimeBundle(options) {
|
||||
// The lock target itself carries the canonical package name.
|
||||
if (
|
||||
sourceMetadata.name !==
|
||||
runtime.targets[architecture].package
|
||||
sourcePackage.package
|
||||
) {
|
||||
throw new Error('OpenCode source package name does not match the lock')
|
||||
}
|
||||
@@ -828,7 +823,8 @@ function buildRuntimeBundle(options) {
|
||||
chmodSync(join(staging, 'bin', 'opencode'), 0o755)
|
||||
assertElfArchitecture(
|
||||
join(staging, 'bin', 'opencode'),
|
||||
architecture
|
||||
architecture,
|
||||
platform
|
||||
)
|
||||
mkdirSync(join(staging, 'licenses'), { mode: 0o700 })
|
||||
copyFileSync(
|
||||
@@ -840,11 +836,12 @@ function buildRuntimeBundle(options) {
|
||||
0o644
|
||||
)
|
||||
const manifest = createManifest(staging, {
|
||||
platform,
|
||||
architecture,
|
||||
runtime,
|
||||
sourcePackage: {
|
||||
name: runtime.targets[architecture].package,
|
||||
integrity: runtime.targets[architecture].integrity
|
||||
name: sourcePackage.package,
|
||||
integrity: sourcePackage.integrity
|
||||
},
|
||||
signingKeyId: signingIdentity.keyId
|
||||
})
|
||||
@@ -869,11 +866,13 @@ function buildRuntimeBundle(options) {
|
||||
const destination = bundleDirectory(
|
||||
outputRoot,
|
||||
architecture,
|
||||
manifest.bundleDigest
|
||||
manifest.bundleDigest,
|
||||
platform
|
||||
)
|
||||
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 })
|
||||
if (existsSync(destination)) {
|
||||
const existing = verifyBundleDirectory(destination, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
lock,
|
||||
@@ -893,6 +892,7 @@ function buildRuntimeBundle(options) {
|
||||
chmodSync(staging, 0o700)
|
||||
renameSync(staging, destination)
|
||||
return verifyBundleDirectory(destination, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
lock,
|
||||
|
||||
@@ -50,9 +50,9 @@ below.
|
||||
optional: users explicitly download a compatible compound `.gbagent` package
|
||||
from Settings or import one offline before GoodBuddy can install or update a
|
||||
Host.
|
||||
- Each independently published Linux x64 or arm64 `.gbagent` contains the
|
||||
- Each independently published Linux x64, Linux arm64, or macOS arm64 `.gbagent` contains the
|
||||
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
|
||||
Runtime. Build both architectures on native GitHub Actions runners from one
|
||||
Runtime. Build all three targets on native GitHub Actions runners from one
|
||||
immutable `agent-v<agentVersion>` tag.
|
||||
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
|
||||
bundle tooling, and tests in this repository so a desktop commit identifies
|
||||
@@ -66,7 +66,9 @@ below.
|
||||
digest, use only an ephemeral in-memory test signing identity, and verify the
|
||||
resulting compound package and deterministic archive. It must not read
|
||||
production signing secrets, publish installable release artifacts, or modify
|
||||
the checked-in public key registry.
|
||||
the checked-in public key registry. The Darwin arm64 target uses `macos-15`
|
||||
and the same production locks. Native CI checks do not replace real Host
|
||||
installation, Attach, Ask/Execute, and lifecycle validation during development.
|
||||
- `.github/workflows/agent-release.yml` is the only production compound Agent
|
||||
publication path. It requires an annotated immutable Agent tag, the protected
|
||||
`agent-signing` Environment, native x64/arm64 builds, one production
|
||||
@@ -98,11 +100,18 @@ below.
|
||||
and are separate from desktop `v${package.version}` releases. Confirm the
|
||||
exact Agent release commit and tag with the user before creating or pushing
|
||||
either.
|
||||
- The tagged commit must be reachable from protected `main`. The two native
|
||||
jobs build the compound x64/arm64 packages from the same source and locks;
|
||||
- The tagged commit must be reachable from protected `main`. The three native
|
||||
jobs build Linux x64, Linux arm64, and Darwin arm64 packages from the same source and locks;
|
||||
the catalog job rejects missing architectures, changed bytes for an existing
|
||||
version/architecture identity, invalid prior signatures, and incompatible
|
||||
version/platform/architecture identity, invalid prior signatures, and incompatible
|
||||
matrix metadata.
|
||||
- All three targets share the existing signed `agent-catalog.json`. Before the
|
||||
first mixed-platform catalog is published, release the Desktop reader that
|
||||
accepts Darwin entries and document the required Desktop upgrade. Older
|
||||
Desktop readers reject the entire mixed catalog and cannot check online Agent
|
||||
updates; preserve their locally verified packages and immutable historical
|
||||
releases. Do not introduce a second catalog or silently weaken package
|
||||
signature and integrity verification.
|
||||
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
|
||||
must use `--latest=false`; they must never replace the desktop release marked
|
||||
Latest. Publish immutable packages and the versioned catalog to
|
||||
|
||||
@@ -102,7 +102,7 @@ bootstrap 能力元数据。公开直连能力仍须完成本文末尾 GitHub/
|
||||
|
||||
探测在 Host Key 已固定且 SSH 认证成功后执行,只检查当前选择的“关于与更新”来源:
|
||||
|
||||
- Host 是受支持的 Linux `x64` 或 `arm64`。
|
||||
- Host 是 Linux `x64`、Linux `arm64` 或 macOS `arm64`;Intel Mac 不在支持范围。
|
||||
- SSH 账号 Home 可写,GoodBuddy-owned staging 可创建。
|
||||
- 可用空间满足签名目录声明的包大小、解包上限和安全余量。
|
||||
- Host 可以通过 HTTPS 访问所选 GitHub 或北京镜像 URL。
|
||||
|
||||
@@ -351,6 +351,53 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
|
||||
## 已完成的 E2E 验收记录
|
||||
|
||||
### macOS arm64 Host 支持
|
||||
|
||||
`agents.yml` 增加 `macos-15` / `darwin-arm64` 的原生 CI 组包任务,使用临时测试
|
||||
签名校验 Agent、固定 Node、Koffi 和 OpenCode,并比较两次组包的字节摘要。
|
||||
构建输入与检查的权威说明见 [BUILD.md 的 Agent 原生 CI 策略](../../../BUILD.md#agent-原生-ci-策略)。
|
||||
此 CI 任务不发布工件。生产 lock、`agent-release.yml`、安装器、Desktop 包管理和
|
||||
SSH Host 链路现支持 `linux-x64`、`linux-arm64`、`darwin-arm64`,三个目标共用
|
||||
原有 catalog JSON 和签名格式,不增加 Mac 专用目录。旧版 Linux-only Desktop
|
||||
读取器不能解析混合目录,首次发布 Darwin 条目前需先升级 Desktop。
|
||||
|
||||
macOS 保持 SSH 按需启动 detached Agent,不要求 launchd、开机服务或管理员权限。
|
||||
私有 socket 使用 `/private/tmp`,通过 `getpeereid` 核对 UID;进程身份、启动标识和
|
||||
进程组通过系统原生 API 获取。取消前枚举 Runtime 的实际后代并核对 PID、启动时间
|
||||
和可执行文件,停止独立进程组中的工具,再停止 Runtime,避免只杀主进程组留下工具。
|
||||
|
||||
2026-09-06 当前源码在真实 macOS arm64 Host 的隔离目录通过生产安装器
|
||||
`preparePackage` / `commitPackage`、adopt、health、Runtime activate、stop、
|
||||
bootstrap 和再次 health。实际 Desktop 协议客户端通过 Attach、只读 Workspace
|
||||
打开、真实 Ask 输出、Execute 写入并读回测试文件、取消工具并验证子进程退出,以及
|
||||
重新连接同一 daemon。两个 Darwin 原生进程测试通过,其中一个覆盖独立进程组子进程。
|
||||
共享 Linux x64 Host 同时通过当前源码组包、生产安装、生命周期、真实 Ask 和重连。
|
||||
这些验证没有发布 catalog 或工件;GitHub Actions 和正式下载渠道的发布验收尚未执行。
|
||||
最终本地 typecheck、lint、开发 build 和 diff whitespace 检查通过。最终全量
|
||||
`npm test` 为 3520 通过、57 跳过、0 失败(326 个测试文件通过、9 个跳过)。
|
||||
此前 ActivityPanel 的批量记录用例超时、App 的实时浏览器用例未观察到预期调用,
|
||||
分别复跑后通过;未修改这两个用例或放宽超时,随后完整测试套件通过。
|
||||
隔离测试 ledger 统计 Mac 模型请求 7 次,Linux 首轮
|
||||
1 次;最终 Linux 源码重装后又完成 1 次最小 Ask,总计 9 次真实模型请求。
|
||||
|
||||
以下为较早的仅构建阶段记录,摘要不代表上述最终源码产物:
|
||||
|
||||
2026-09-06:在真实 macOS 26.5.2 / arm64 Host 的独立测试目录运行当前源码
|
||||
`build/agent-ci-bundle.cjs --platform darwin --arch arm64`,退出码为 0。
|
||||
测试使用固定 Node 24.19.0、OpenCode 1.18.9 和内存临时签名密钥;Node 官方
|
||||
归档 SHA-256、OpenCode integrity、Mach-O 架构、内外层签名和两次组包摘要比较
|
||||
通过,Agent CLI 加载、Koffi 原生模块加载与 OpenCode `--version` 检查通过。
|
||||
生成 `goodbuddy-agent-0.11.18-darwin-arm64.gbagent`,本次测试包 SHA-256 为
|
||||
`9c63ba080def56ebd371a622f2650b89d0c652444077bd6ee19c4bd51b60f6ff`。
|
||||
临时签名每次独立运行都会变化,因此该摘要只标识本次测试产物,不是发布输入。
|
||||
源码压缩传输 529,624 字节,大依赖由 Host 直接下载并复用 npm 缓存。
|
||||
真实模型调用为 0 次;没有安装 Agent 服务,没有修改在线 catalog 或发布工件。
|
||||
|
||||
该仅构建阶段的聚焦测试为 53 项通过、1 项平台跳过,typecheck、lint 和开发生产
|
||||
bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的最终验证。
|
||||
|
||||
### Linux 历史验收
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
"package": "opencode-linux-x64-baseline",
|
||||
"integrity": "sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=="
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"package": "opencode-darwin-arm64",
|
||||
"integrity": "sha512-cTWxW9IFjDdzx9mscQgr5uL478moU64t486JebaSZmUPEGDJVQjvCtXfqbZDzVuoVorRfaytlykqrH3qTSL2VQ=="
|
||||
},
|
||||
"arm64": {
|
||||
"package": "opencode-linux-arm64",
|
||||
"integrity": "sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=="
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { binaryTargetArchitecture } from '../shared/node/binary-target'
|
||||
import type { AgentPlatform } from '../shared/agent-target'
|
||||
import type {
|
||||
AgentArchitecture
|
||||
} from '../shared/agent-installation-contracts'
|
||||
@@ -16,7 +18,8 @@ export async function sha256File(filePath: string): Promise<string> {
|
||||
export async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expected: AgentArchitecture,
|
||||
label: string
|
||||
label: string,
|
||||
platform: AgentPlatform = 'linux'
|
||||
): Promise<void> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
@@ -27,8 +30,8 @@ export async function assertElfArchitecture(
|
||||
header.length,
|
||||
0
|
||||
)
|
||||
const actual = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
const actual = binaryTargetArchitecture(
|
||||
header.subarray(0, bytesRead), platform
|
||||
)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
@@ -39,25 +42,3 @@ export async function assertElfArchitecture(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ describe('Agent CLI fixed command contract', () => {
|
||||
homeDirectory: 'C:\\Users\\tester',
|
||||
uid: 1234
|
||||
})
|
||||
).toThrow('current Linux UID')
|
||||
).toThrow('current Unix UID')
|
||||
})
|
||||
|
||||
it('rejects injected installation IDs before lifecycle dispatch', async () => {
|
||||
|
||||
@@ -842,13 +842,13 @@ export function deriveManagedInstallationPaths(
|
||||
const platform = options.platform ?? process.platform
|
||||
const uid = options.uid ?? process.getuid?.()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
!['linux', 'darwin'].includes(platform) ||
|
||||
uid === undefined ||
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0
|
||||
) {
|
||||
throw new AgentUnsupportedError(
|
||||
'Managed Agent paths require a current Linux UID',
|
||||
'Managed Agent paths require a current Unix UID',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
@@ -860,7 +860,7 @@ export function deriveManagedInstallationPaths(
|
||||
.digest('hex')
|
||||
.slice(0, 20)
|
||||
const socketRoot = posix.join(
|
||||
'/tmp',
|
||||
platform === 'darwin' ? '/private/tmp' : '/tmp',
|
||||
`goodbuddy-${uid}-${homeHash}`
|
||||
)
|
||||
const socketName = createHash('sha256')
|
||||
|
||||
@@ -100,6 +100,7 @@ export class AgentDaemon {
|
||||
readonly #agentVersion: string
|
||||
readonly #protocol: AttachPreface['protocol']
|
||||
readonly #architecture: 'x64' | 'arm64'
|
||||
readonly #platform = process.platform
|
||||
readonly #remoteUserIdentity: string
|
||||
readonly #stateDirectory: string
|
||||
readonly #socketPath: string
|
||||
@@ -171,7 +172,7 @@ export class AgentDaemon {
|
||||
daemonBootId: this.#bootId,
|
||||
agentVersion: this.#agentVersion,
|
||||
protocol: this.#protocol,
|
||||
platform: 'linux',
|
||||
platform: this.#platform,
|
||||
architecture: this.#architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: this.#remoteUserIdentity,
|
||||
@@ -180,9 +181,12 @@ export class AgentDaemon {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
if (
|
||||
this.#platform !== 'linux' &&
|
||||
!(this.#platform === 'darwin' && process.arch === 'arm64')
|
||||
) {
|
||||
throw new AgentUnsupportedError(
|
||||
'GoodBuddy Agent daemon is certified only on Linux',
|
||||
'GoodBuddy Agent daemon requires Linux or macOS arm64',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
darwinBootId,
|
||||
inspectDarwinProcess,
|
||||
listDarwinProcessGroup,
|
||||
stopDarwinDescendants
|
||||
} from './darwin-process-identity'
|
||||
|
||||
describe('Darwin process identity', () => {
|
||||
const native = process.platform === 'darwin' && process.arch === 'arm64' ? it : it.skip
|
||||
native('reads actual process identity and enumerates a dedicated child group', async () => {
|
||||
const current = inspectDarwinProcess(process.pid)
|
||||
expect(current?.pid).toBe(process.pid)
|
||||
expect(current?.executablePath).toBe(realpathSync(process.execPath))
|
||||
expect(BigInt(current!.starttime)).toBeGreaterThan(0n)
|
||||
expect(darwinBootId()).toMatch(/^[a-f0-9-]{36}$/u)
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
detached: true, stdio: 'ignore'
|
||||
})
|
||||
await once(child, 'spawn')
|
||||
try {
|
||||
const identity = inspectDarwinProcess(child.pid!)
|
||||
expect(identity?.processGroupId).toBe(child.pid)
|
||||
expect(listDarwinProcessGroup(child.pid!)).toContain(child.pid)
|
||||
expect(listDarwinProcessGroup(child.pid!)).not.toContain(process.pid)
|
||||
} finally {
|
||||
const exited = once(child, 'exit')
|
||||
child.kill()
|
||||
await exited
|
||||
}
|
||||
expect(inspectDarwinProcess(child.pid!)).toBeUndefined()
|
||||
})
|
||||
native('stops a tool child that starts its own process group', async () => {
|
||||
const parent = spawn(process.execPath, ['-e', `
|
||||
const {spawn}=require('node:child_process');
|
||||
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'ignore'});
|
||||
child.on('spawn',()=>console.log(child.pid));
|
||||
setInterval(()=>{},1000);
|
||||
`], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const [output] = await once(parent.stdout!, 'data')
|
||||
const childPid = Number(String(output).trim())
|
||||
try {
|
||||
expect(inspectDarwinProcess(childPid)?.processGroupId).toBe(childPid)
|
||||
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
|
||||
expect(inspectDarwinProcess(childPid)).toBeUndefined()
|
||||
expect(inspectDarwinProcess(parent.pid!)).toBeDefined()
|
||||
} finally {
|
||||
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
|
||||
const exited = once(parent, 'exit')
|
||||
parent.kill()
|
||||
await exited
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import koffi from 'koffi'
|
||||
import { realpathSync } from 'node:fs'
|
||||
|
||||
export type DarwinProcessIdentity = {
|
||||
pid: number
|
||||
starttime: string
|
||||
executablePath: string
|
||||
processGroupId: number
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
let binding: ReturnType<typeof createBinding> | undefined
|
||||
|
||||
function createBinding() {
|
||||
const library = koffi.load('/usr/lib/libSystem.B.dylib')
|
||||
return {
|
||||
info: library.func('proc_pidinfo', 'int', [
|
||||
'int', 'int', 'uint64_t', 'void *', 'int'
|
||||
]),
|
||||
path: library.func('proc_pidpath', 'int', ['int', 'void *', 'uint32_t']),
|
||||
list: library.func('proc_listpids', 'int', ['uint32_t', 'uint32_t', 'void *', 'int']),
|
||||
sysctl: library.func('sysctlbyname', 'int', [
|
||||
'str', 'void *', 'void *', 'void *', 'size_t'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function native() {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
||||
throw new Error('Darwin process inspection requires macOS arm64')
|
||||
}
|
||||
return binding ??= createBinding()
|
||||
}
|
||||
|
||||
export function inspectDarwinProcess(pid: number): DarwinProcessIdentity | undefined {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('Invalid process ID')
|
||||
const api = native()
|
||||
const info = Buffer.alloc(136)
|
||||
const size = api.info(pid, 3, 0, info, info.length) as number
|
||||
if (size === 0 && [3, 2].includes(koffi.errno())) return undefined
|
||||
if (size !== info.length || info.readUInt32LE(12) !== pid) {
|
||||
throw new Error(`proc_pidinfo failed (${koffi.errno()})`)
|
||||
}
|
||||
const path = Buffer.alloc(4096)
|
||||
if ((api.path(pid, path, path.length) as number) <= 0) {
|
||||
if ([3, 2].includes(koffi.errno())) return undefined
|
||||
throw new Error(`proc_pidpath failed (${koffi.errno()})`)
|
||||
}
|
||||
const starttime = info.readBigUInt64LE(120) * 1_000_000n +
|
||||
info.readBigUInt64LE(128)
|
||||
return {
|
||||
pid,
|
||||
starttime: starttime.toString(),
|
||||
executablePath: realpathSync(path.subarray(0, path.indexOf(0)).toString('utf8')),
|
||||
processGroupId: info.readUInt32LE(100),
|
||||
parentPid: info.readUInt32LE(16)
|
||||
}
|
||||
}
|
||||
|
||||
export function darwinBootId(): string {
|
||||
const bytes = Buffer.alloc(128)
|
||||
const size = Buffer.alloc(8)
|
||||
size.writeBigUInt64LE(BigInt(bytes.length))
|
||||
if (native().sysctl('kern.bootsessionuuid', bytes, size, null, 0) !== 0) {
|
||||
throw new Error(`Boot session lookup failed (${koffi.errno()})`)
|
||||
}
|
||||
const value = bytes.subarray(0, bytes.indexOf(0)).toString('ascii').toLowerCase()
|
||||
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) {
|
||||
throw new Error('Invalid Darwin boot session identity')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function listDarwinProcessGroup(groupId: number): number[] {
|
||||
return listDarwinPids(2, groupId)
|
||||
}
|
||||
|
||||
function listDarwinPids(kind: number, parent: number): number[] {
|
||||
const api = native()
|
||||
if (!Number.isSafeInteger(parent) || parent <= 0) throw new Error('Invalid process group')
|
||||
const required = api.list(kind, parent, null, 0) as number
|
||||
if (required < 0) throw new Error('Process group enumeration failed')
|
||||
const buffer = Buffer.alloc(required + 4096)
|
||||
const size = api.list(kind, parent, buffer, buffer.length) as number
|
||||
if (size < 0 || size >= buffer.length || size % 4 !== 0) {
|
||||
throw new Error('Process group enumeration was incomplete')
|
||||
}
|
||||
const pids: number[] = []
|
||||
for (let offset = 0; offset < size; offset += 4) {
|
||||
const pid = buffer.readInt32LE(offset)
|
||||
if (pid > 0) pids.push(pid)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
export async function stopDarwinDescendants(
|
||||
ownerPid: number,
|
||||
deadline: number
|
||||
): Promise<void> {
|
||||
const descendants: DarwinProcessIdentity[] = []
|
||||
const pending = [ownerPid]
|
||||
const seen = new Set(pending)
|
||||
while (pending.length > 0) {
|
||||
const parentPid = pending.pop()!
|
||||
for (const pid of listDarwinPids(6, parentPid)) {
|
||||
if (seen.has(pid)) continue
|
||||
seen.add(pid)
|
||||
const identity = inspectDarwinProcess(pid)
|
||||
if (!identity || identity.parentPid !== parentPid) continue
|
||||
descendants.push(identity)
|
||||
pending.push(pid)
|
||||
}
|
||||
}
|
||||
const matches = (identity: DarwinProcessIdentity): boolean => {
|
||||
const current = inspectDarwinProcess(identity.pid)
|
||||
return current !== undefined &&
|
||||
current.starttime === identity.starttime &&
|
||||
current.executablePath === identity.executablePath
|
||||
}
|
||||
const signal = (identity: DarwinProcessIdentity, value: NodeJS.Signals): void => {
|
||||
if (!matches(identity)) return
|
||||
try { process.kill(identity.pid, value) }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error
|
||||
}
|
||||
}
|
||||
for (const identity of [...descendants].reverse()) signal(identity, 'SIGTERM')
|
||||
const grace = Math.min(deadline, Date.now() + 500)
|
||||
while (Date.now() < grace && descendants.some(matches)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
for (const identity of [...descendants].reverse()) signal(identity, 'SIGKILL')
|
||||
while (Date.now() < deadline && descendants.some(matches)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
if (descendants.some(matches)) {
|
||||
throw new Error('Runtime child process cleanup could not be verified')
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
sameLinuxRuntimeProcessIdentity,
|
||||
type LinuxRuntimeProcessIdentity
|
||||
} from './runtime-process-identity'
|
||||
import { listDarwinProcessGroup, stopDarwinDescendants } from './darwin-process-identity'
|
||||
|
||||
const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
|
||||
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
||||
@@ -401,6 +402,9 @@ export class DirectLinuxStdioProcessOwner {
|
||||
throw identityError('Runtime PID identity changed before signal')
|
||||
}
|
||||
this.#registry.markStopping(this.ownerId)
|
||||
if (process.platform === 'darwin') {
|
||||
await stopDarwinDescendants(this.processIdentity.pid, Date.parse(deadlineAt))
|
||||
}
|
||||
this.#stdinClosed = true
|
||||
this.#child.stdin.end()
|
||||
this.#sendSignal(-record.processIdentity.processGroupId, 'SIGTERM')
|
||||
@@ -540,9 +544,9 @@ export class DirectLinuxStdioProcessOwner {
|
||||
export async function launchDirectLinuxStdioProcessOwner(
|
||||
options: DirectLinuxStdioProcessOwnerOptions
|
||||
): Promise<DirectLinuxStdioProcessOwner> {
|
||||
if ((options.platform ?? process.platform) !== 'linux') {
|
||||
if (!['linux', 'darwin'].includes(options.platform ?? process.platform)) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Direct Runtime ownership is Linux-only',
|
||||
'Direct Runtime ownership requires Linux or macOS',
|
||||
'platform'
|
||||
)
|
||||
}
|
||||
@@ -786,6 +790,18 @@ export async function listLinuxPidNamespaceMembers(
|
||||
>,
|
||||
options: { procRoot?: string } = {}
|
||||
): Promise<LinuxRuntimeProcessIdentity[]> {
|
||||
if (process.platform === 'darwin') {
|
||||
const identities: LinuxRuntimeProcessIdentity[] = []
|
||||
for (const pid of listDarwinProcessGroup(owner.processGroupId)) {
|
||||
try {
|
||||
const identity = await readLinuxRuntimeProcessIdentity(pid, { bootId: owner.bootId })
|
||||
if (identity.processGroupId === owner.processGroupId) identities.push(identity)
|
||||
} catch (error) {
|
||||
if (!isMissingProcess(error)) throw error
|
||||
}
|
||||
}
|
||||
return identities.sort((left, right) => left.pid - right.pid)
|
||||
}
|
||||
const procRoot = options.procRoot ?? '/proc'
|
||||
const entries = await readdir(procRoot, { withFileTypes: true })
|
||||
const identities: LinuxRuntimeProcessIdentity[] = []
|
||||
|
||||
@@ -230,7 +230,8 @@ export async function verifyPublishedAgentBundle(
|
||||
await assertElfArchitecture(
|
||||
join(installationDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch,
|
||||
'Agent Node runtime'
|
||||
'Agent Node runtime',
|
||||
manifest.platform
|
||||
)
|
||||
const manifestSha256 = sha256Bytes(manifestBytes)
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { assertAbsoluteManagedPath } from './managed-paths'
|
||||
import { inspectDarwinProcess } from './darwin-process-identity'
|
||||
|
||||
const linuxProcessIdentitySchema = z
|
||||
.object({
|
||||
@@ -45,6 +46,14 @@ export class LinuxProcessInspector {
|
||||
|
||||
inspect(pidInput: number): LinuxProcessIdentity | undefined {
|
||||
const pid = positivePid(pidInput)
|
||||
if (process.platform === 'darwin') {
|
||||
const identity = inspectDarwinProcess(pid)
|
||||
return identity === undefined ? undefined : {
|
||||
pid: identity.pid,
|
||||
starttime: identity.starttime,
|
||||
executablePath: identity.executablePath
|
||||
}
|
||||
}
|
||||
try {
|
||||
const starttime = parseStarttime(
|
||||
this.#fileSystem.readText(
|
||||
|
||||
@@ -287,20 +287,20 @@ export function derivePrivateTemporaryRoot(options: {
|
||||
const platform = options.platform ?? process.platform
|
||||
const uid = options.uid ?? process.getuid?.()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
!['linux', 'darwin'].includes(platform) ||
|
||||
uid === undefined ||
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0
|
||||
) {
|
||||
throw new ManagedPathError(
|
||||
'Private Agent endpoint root requires a current Linux UID'
|
||||
'Private Agent endpoint root requires a current Unix UID'
|
||||
)
|
||||
}
|
||||
const canonicalHomeDirectory = resolve(
|
||||
options.homeDirectory ?? homedir()
|
||||
)
|
||||
const temporaryDirectory = resolve(
|
||||
options.temporaryDirectory ?? '/tmp'
|
||||
options.temporaryDirectory ?? (platform === 'darwin' ? '/private/tmp' : '/tmp')
|
||||
)
|
||||
assertAbsoluteManagedPath(canonicalHomeDirectory)
|
||||
assertAbsoluteManagedPath(temporaryDirectory)
|
||||
|
||||
@@ -67,23 +67,39 @@ export async function createLinuxPeerIdentityProvider(
|
||||
byteOrder?: 'BE' | 'LE'
|
||||
loadKoffi?: () => Promise<Koffi>
|
||||
} = {}
|
||||
): Promise<LinuxPeerIdentityProvider> {
|
||||
): Promise<UnixPeerIdentityProvider> {
|
||||
const platform = options.platform ?? process.platform
|
||||
const architecture = options.architecture ?? arch()
|
||||
const byteOrder = options.byteOrder ?? endianness()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
(platform !== 'linux' && !(platform === 'darwin' && architecture === 'arm64')) ||
|
||||
!SUPPORTED_ARCHITECTURES.has(architecture) ||
|
||||
byteOrder !== 'LE'
|
||||
) {
|
||||
throw unavailable(
|
||||
'SO_PEERCRED is certified only on little-endian Linux x64 and arm64'
|
||||
'Unix peer credentials require little-endian Linux or macOS arm64'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const koffi =
|
||||
await (options.loadKoffi?.() ??
|
||||
import('koffi').then((module) => module.default))
|
||||
if (platform === 'darwin') {
|
||||
const library = koffi.load('/usr/lib/libSystem.B.dylib')
|
||||
const getpeereid = library.func('getpeereid', 'int', [
|
||||
'int', 'void *', 'void *'
|
||||
])
|
||||
return {
|
||||
async getPeerIdentity(socket) {
|
||||
const uid = Buffer.alloc(4)
|
||||
const gid = Buffer.alloc(4)
|
||||
if (getpeereid(socketDescriptor(socket), uid, gid) !== 0) {
|
||||
throw new Error(`getpeereid failed with errno ${koffi.errno()}`)
|
||||
}
|
||||
return { uid: uid.readUInt32LE() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return new LinuxPeerIdentityProvider(createNativeBinding(koffi))
|
||||
} catch (error) {
|
||||
if (error instanceof AgentUnsupportedError) {
|
||||
|
||||
@@ -225,7 +225,7 @@ export async function createProductionRuntimeProtocol(
|
||||
try {
|
||||
if (
|
||||
verificationEnvironment === 'production' &&
|
||||
process.platform === 'linux'
|
||||
(process.platform === 'linux' || process.platform === 'darwin')
|
||||
) {
|
||||
await (
|
||||
options.reconcileOrphanedProcesses ??
|
||||
@@ -436,7 +436,7 @@ export function derivePrivateModelBridgeDirectory(
|
||||
controllerGeneration?: number
|
||||
}
|
||||
): string {
|
||||
if (process.platform === 'linux') {
|
||||
if (process.platform === 'linux' || process.platform === 'darwin') {
|
||||
const stateDirectory = resolve(stateDirectoryInput)
|
||||
ensurePrivateDirectory(stateDirectory, { create: false })
|
||||
const userRoot = ensurePrivateTemporaryRoot(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile, readlink } from 'node:fs/promises'
|
||||
import { posix } from 'node:path'
|
||||
import { darwinBootId, inspectDarwinProcess } from './darwin-process-identity'
|
||||
|
||||
export type LinuxRuntimeProcessIdentity = {
|
||||
bootId: string
|
||||
@@ -72,6 +73,19 @@ export async function readLinuxRuntimeProcessIdentity(
|
||||
options: { procRoot?: string; bootId?: string } = {}
|
||||
): Promise<LinuxRuntimeProcessIdentity> {
|
||||
const pid = positiveInteger(pidInput, 'PID')
|
||||
if (process.platform === 'darwin') {
|
||||
const identity = inspectDarwinProcess(pid)
|
||||
if (identity === undefined) {
|
||||
throw Object.assign(new Error('Runtime process no longer exists'), { code: 'ESRCH' })
|
||||
}
|
||||
return {
|
||||
bootId: options.bootId ?? darwinBootId(),
|
||||
pid,
|
||||
startTimeTicks: BigInt(identity.starttime),
|
||||
processGroupId: identity.processGroupId,
|
||||
executablePath: identity.executablePath
|
||||
}
|
||||
}
|
||||
const procRoot = options.procRoot ?? '/proc'
|
||||
if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) {
|
||||
throw new Error('Linux proc root must be absolute')
|
||||
|
||||
@@ -1537,7 +1537,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
]
|
||||
} as const
|
||||
const agentPackageManager = {
|
||||
getSnapshot: vi.fn(async () => agentPackageInventory),
|
||||
getAllSnapshot: vi.fn(async () => agentPackageInventory),
|
||||
forPlatform: vi.fn(() => agentPackageManager),
|
||||
download: vi.fn(
|
||||
async (
|
||||
architecture: 'x64' | 'arm64',
|
||||
@@ -1675,7 +1676,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
)?.(event, { refresh: true })
|
||||
).resolves.toEqual(agentPackageInventory)
|
||||
expect(
|
||||
agentPackageManager.getSnapshot
|
||||
agentPackageManager.getAllSnapshot
|
||||
).toHaveBeenCalledWith({ refresh: true })
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
@@ -1690,6 +1691,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
ipcChannels.sshHostsAgentPackageProgress,
|
||||
{
|
||||
architecture: 'x64',
|
||||
platform: 'linux',
|
||||
phase: 'downloading',
|
||||
completedBytes: 1,
|
||||
totalBytes: 2
|
||||
@@ -1902,8 +1904,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
})
|
||||
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
expect(
|
||||
agentPackageManager.getSnapshot
|
||||
).toHaveBeenCalledOnce()
|
||||
agentPackageManager.getAllSnapshot
|
||||
).toHaveBeenCalledTimes(3)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
|
||||
+9
-11
@@ -5123,7 +5123,7 @@ export function registerIpcHandlers(
|
||||
const request =
|
||||
agentPackageInventoryRequestSchema.parse(input ?? {})
|
||||
return agentPackageInventorySchema.parse(
|
||||
await agentPackageManager.getSnapshot(request)
|
||||
await agentPackageManager.getAllSnapshot(request)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -5135,10 +5135,9 @@ export function registerIpcHandlers(
|
||||
if (!agentPackageManager) {
|
||||
throw new Error('Agent 包管理服务不可用')
|
||||
}
|
||||
const { architecture } =
|
||||
const { architecture, platform } =
|
||||
agentPackageArchitectureRequestSchema.parse(input)
|
||||
return agentPackageInventorySchema.parse(
|
||||
await agentPackageManager.download(
|
||||
await agentPackageManager.forPlatform(platform).download(
|
||||
architecture,
|
||||
(progress) =>
|
||||
sendValidatedProgress(
|
||||
@@ -5148,7 +5147,7 @@ export function registerIpcHandlers(
|
||||
progress
|
||||
)
|
||||
)
|
||||
)
|
||||
return agentPackageManager.getAllSnapshot()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5171,9 +5170,8 @@ export function registerIpcHandlers(
|
||||
if (result.canceled || !archivePath) {
|
||||
return undefined
|
||||
}
|
||||
return agentPackageInventorySchema.parse(
|
||||
await agentPackageManager.importArchive(archivePath)
|
||||
)
|
||||
await agentPackageManager.importArchive(archivePath)
|
||||
return agentPackageManager.getAllSnapshot()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5184,10 +5182,10 @@ export function registerIpcHandlers(
|
||||
if (!agentPackageManager) {
|
||||
throw new Error('Agent 包管理服务不可用')
|
||||
}
|
||||
const { architecture } =
|
||||
const { architecture, platform } =
|
||||
agentPackageArchitectureRequestSchema.parse(input)
|
||||
const defaultPath =
|
||||
await agentPackageManager.getExportArchiveName(
|
||||
await agentPackageManager.forPlatform(platform).getExportArchiveName(
|
||||
architecture
|
||||
)
|
||||
const result = await dialog.showSaveDialog(window, {
|
||||
@@ -5204,7 +5202,7 @@ export function registerIpcHandlers(
|
||||
const destination = result.filePath.endsWith('.gbagent')
|
||||
? result.filePath
|
||||
: `${result.filePath}.gbagent`
|
||||
await agentPackageManager.exportArchive(
|
||||
await agentPackageManager.forPlatform(platform).exportArchive(
|
||||
architecture,
|
||||
destination
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { binaryTargetArchitecture } from '../../shared/node/binary-target'
|
||||
import type { AgentPlatform } from '../../shared/agent-target'
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
@@ -206,9 +208,10 @@ export async function verifyAgentBundleDirectory(
|
||||
await assertElfArchitecture(
|
||||
join(bundleDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch,
|
||||
'Agent Node runtime'
|
||||
'Agent Node runtime',
|
||||
manifest.platform
|
||||
)
|
||||
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch)
|
||||
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch, manifest.platform)
|
||||
assertKoffiManifest(
|
||||
manifest,
|
||||
runtimeLock.koffi.version,
|
||||
@@ -218,7 +221,8 @@ export async function verifyAgentBundleDirectory(
|
||||
await assertElfArchitecture(
|
||||
join(bundleDirectory, ...nativePath.split('/')),
|
||||
manifest.arch,
|
||||
'Agent Koffi native binding'
|
||||
'Agent Koffi native binding',
|
||||
manifest.platform
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -349,15 +353,19 @@ export function assertAgentManifestMatchesRuntimeLock(
|
||||
}
|
||||
|
||||
function requiredKoffiPayloadPaths(
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
platform: AgentPlatform
|
||||
): {
|
||||
required: string[]
|
||||
native: string[]
|
||||
} {
|
||||
const packageRoot = 'lib/node_modules/koffi'
|
||||
const nativePackage =
|
||||
`@koromix/koffi-linux-${architecture}`
|
||||
`@koromix/koffi-${platform}-${architecture}`
|
||||
const nativeRoot = `lib/node_modules/${nativePackage}`
|
||||
const native = platform === 'darwin'
|
||||
? [`${nativeRoot}/darwin_${architecture}/koffi.node`]
|
||||
: [`${nativeRoot}/linux_${architecture}/koffi.node`, `${nativeRoot}/musl_${architecture}/koffi.node`]
|
||||
return {
|
||||
required: [
|
||||
`${packageRoot}/package.json`,
|
||||
@@ -366,13 +374,9 @@ function requiredKoffiPayloadPaths(
|
||||
`${packageRoot}/src/koffi/src/static.js`,
|
||||
`${nativeRoot}/package.json`,
|
||||
`${nativeRoot}/index.js`,
|
||||
`${nativeRoot}/linux_${architecture}/koffi.node`,
|
||||
`${nativeRoot}/musl_${architecture}/koffi.node`
|
||||
...native
|
||||
],
|
||||
native: [
|
||||
`${nativeRoot}/linux_${architecture}/koffi.node`,
|
||||
`${nativeRoot}/musl_${architecture}/koffi.node`
|
||||
]
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +394,7 @@ function assertKoffiManifest(
|
||||
}
|
||||
}
|
||||
const nativePackage =
|
||||
`@koromix/koffi-linux-${manifest.arch}`
|
||||
`@koromix/koffi-${manifest.platform}-${manifest.arch}`
|
||||
const koffiLicense = manifest.licenses.find(
|
||||
(license) => license.package === 'koffi'
|
||||
)
|
||||
@@ -614,14 +618,15 @@ function modeString(mode: number): string {
|
||||
async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expectedArchitecture: AgentArchitecture,
|
||||
description: string
|
||||
description: string,
|
||||
platform: AgentPlatform
|
||||
): 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 actualArchitecture = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
const actualArchitecture = binaryTargetArchitecture(
|
||||
header.subarray(0, bytesRead), platform
|
||||
)
|
||||
if (actualArchitecture !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
@@ -633,29 +638,6 @@ async function assertElfArchitecture(
|
||||
}
|
||||
}
|
||||
|
||||
function detectElfArchitecture(
|
||||
buffer: Buffer
|
||||
): AgentArchitecture | undefined {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x7f ||
|
||||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
(buffer[5] !== 1 && buffer[5] !== 2)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseContract<T>(
|
||||
schema: { safeParse(value: unknown): {
|
||||
|
||||
@@ -43,7 +43,7 @@ export type AgentInstallationIdentity = {
|
||||
major: number
|
||||
minor: number
|
||||
}
|
||||
platform: 'linux'
|
||||
platform: 'linux' | 'darwin'
|
||||
architecture: AgentArchitecture
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export class AgentInstallationManager {
|
||||
protocol: {
|
||||
...(registeredProtocol ?? runtimeLock.protocol)
|
||||
},
|
||||
platform: 'linux',
|
||||
platform: probe.platform,
|
||||
architecture: registered.arch,
|
||||
supervisor: 'detached-on-demand'
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../shared/agent-package-contracts'
|
||||
import {
|
||||
AgentPackageManager,
|
||||
selectLatestCompatibleEntry,
|
||||
type VerifiedRemoteAgentInstallCandidate
|
||||
} from './agent-package-manager'
|
||||
|
||||
@@ -256,6 +257,22 @@ function response(
|
||||
}
|
||||
|
||||
describe('AgentPackageManager remote install candidates', () => {
|
||||
it('selects Linux and Darwin arm64 independently from one catalog', async () => {
|
||||
await createFixture('mirror', (catalog) => {
|
||||
for (const platform of ['linux', 'darwin'] as const) {
|
||||
catalog.entries.push({
|
||||
...catalog.entries[0]!,
|
||||
platform,
|
||||
architecture: 'arm64',
|
||||
archive: `goodbuddy-agent-2.0.0-${platform}-arm64.gbagent`
|
||||
})
|
||||
}
|
||||
const parsed = agentPackageCatalogSchema.parse(catalog)
|
||||
expect(selectLatestCompatibleEntry(parsed, 'arm64', '2.0.0').platform).toBe('linux')
|
||||
expect(selectLatestCompatibleEntry(parsed, 'arm64', '2.0.0', 'darwin').platform).toBe('darwin')
|
||||
expect(() => selectLatestCompatibleEntry(parsed, 'x64', '2.0.0', 'darwin')).toThrow()
|
||||
})
|
||||
})
|
||||
it('removes interrupted manager-owned staging on startup', async () => {
|
||||
const fixture = await createFixture('mirror')
|
||||
const packageRoot = join(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { agentPlatformSchema, type AgentPlatform } from '../../shared/agent-target'
|
||||
import {
|
||||
agentPackageArchiveName,
|
||||
agentPackageCatalogSchema,
|
||||
@@ -94,6 +95,7 @@ type CatalogState =
|
||||
}
|
||||
|
||||
export type AgentPackageManagerOptions = {
|
||||
platform?: AgentPlatform
|
||||
userDataPath: string
|
||||
desktopVersion: string
|
||||
keyRegistryPath: string
|
||||
@@ -104,7 +106,7 @@ export type AgentPackageManagerOptions = {
|
||||
|
||||
export type VerifiedRemoteAgentInstallCandidate = {
|
||||
source: UpdateSource
|
||||
platform: 'linux'
|
||||
platform: AgentPlatform
|
||||
architecture: AgentArchitecture
|
||||
version: string
|
||||
minimumDesktopVersion: string
|
||||
@@ -161,6 +163,7 @@ export type AgentPackageArchiveLease = {
|
||||
}
|
||||
|
||||
export type AcquireInstallArchiveOptions = {
|
||||
platform?: AgentPlatform
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: AgentPackageDownloadProgress) => void
|
||||
}
|
||||
@@ -185,7 +188,7 @@ function candidateFromCatalogEntry(
|
||||
): VerifiedRemoteAgentInstallCandidate {
|
||||
return {
|
||||
source,
|
||||
platform: 'linux',
|
||||
platform: entry.platform,
|
||||
architecture: entry.architecture,
|
||||
version: entry.version,
|
||||
minimumDesktopVersion: entry.minimumDesktopVersion,
|
||||
@@ -209,7 +212,7 @@ function candidateFromVerifiedRecord(
|
||||
const descriptor = record.verified.descriptor
|
||||
return {
|
||||
source,
|
||||
platform: 'linux',
|
||||
platform: descriptor.platform,
|
||||
architecture: descriptor.architecture,
|
||||
version: descriptor.version,
|
||||
minimumDesktopVersion: descriptor.minimumDesktopVersion,
|
||||
@@ -220,7 +223,8 @@ function candidateFromVerifiedRecord(
|
||||
},
|
||||
archive: agentPackageArchiveName(
|
||||
descriptor.version,
|
||||
descriptor.architecture
|
||||
descriptor.architecture,
|
||||
descriptor.platform
|
||||
),
|
||||
size,
|
||||
sha256: record.archiveSha256,
|
||||
@@ -229,6 +233,9 @@ function candidateFromVerifiedRecord(
|
||||
}
|
||||
|
||||
export class AgentPackageManager {
|
||||
readonly #options: AgentPackageManagerOptions
|
||||
readonly #platform: AgentPlatform
|
||||
readonly #platformManagers = new Map<AgentPlatform, AgentPackageManager>()
|
||||
readonly #rootDirectory: string
|
||||
readonly #desktopVersion: string
|
||||
readonly #keyRegistryPath: string
|
||||
@@ -250,6 +257,8 @@ export class AgentPackageManager {
|
||||
#catalogState?: CatalogState
|
||||
|
||||
constructor(options: AgentPackageManagerOptions) {
|
||||
this.#options = options
|
||||
this.#platform = agentPlatformSchema.parse(options.platform ?? 'linux')
|
||||
this.#rootDirectory = resolve(
|
||||
options.userDataPath,
|
||||
'remote-components',
|
||||
@@ -263,6 +272,31 @@ export class AgentPackageManager {
|
||||
this.#startupCleanup = this.#cleanupInterruptedArtifacts()
|
||||
}
|
||||
|
||||
forPlatform(platform: AgentPlatform): AgentPackageManager {
|
||||
if (platform === this.#platform) return this
|
||||
let manager = this.#platformManagers.get(platform)
|
||||
if (!manager) {
|
||||
manager = new AgentPackageManager({ ...this.#options, platform })
|
||||
this.#platformManagers.set(platform, manager)
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
async getAllSnapshot(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
|
||||
const linux = await this.forPlatform('linux').getSnapshot(options)
|
||||
const darwin = this.forPlatform('darwin')
|
||||
darwin.#catalogState = this.forPlatform('linux').#catalogState
|
||||
const mac = await darwin.getInventory(options)
|
||||
return agentPackageInventorySchema.parse({
|
||||
...linux,
|
||||
entries: [...linux.entries, ...mac.entries]
|
||||
})
|
||||
}
|
||||
|
||||
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
|
||||
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
|
||||
}
|
||||
|
||||
async getInventory(
|
||||
options: { refresh?: boolean } = {}
|
||||
): Promise<AgentPackageInventory> {
|
||||
@@ -270,7 +304,9 @@ export class AgentPackageManager {
|
||||
this.#installed.clear()
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
agentArchitectureSchema.options.map(async (architecture) => {
|
||||
agentArchitectureSchema.options.filter(architecture =>
|
||||
this.#platform === 'linux' || architecture === 'arm64'
|
||||
).map(async (architecture) => {
|
||||
let record: InstalledRecord | undefined
|
||||
let state: 'not-downloaded' | 'verified' | 'invalid'
|
||||
try {
|
||||
@@ -283,7 +319,7 @@ export class AgentPackageManager {
|
||||
}
|
||||
const latestVersion = this.#latestVersion(architecture)
|
||||
return {
|
||||
platform: 'linux' as const,
|
||||
platform: this.#platform,
|
||||
architecture,
|
||||
state,
|
||||
version: record?.verified.descriptor.version ?? null,
|
||||
@@ -334,8 +370,11 @@ export class AgentPackageManager {
|
||||
|
||||
async getRemoteEnvironmentCatalog(
|
||||
architecture: AgentArchitecture,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
|
||||
): Promise<VerifiedRemoteAgentEnvironmentCatalog> {
|
||||
if (options.platform && options.platform !== this.#platform) {
|
||||
return this.forPlatform(options.platform).getRemoteEnvironmentCatalog(architecture, options)
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
let entry: AgentPackageCatalogEntry
|
||||
let source: UpdateSource | undefined
|
||||
@@ -346,7 +385,7 @@ export class AgentPackageManager {
|
||||
source,
|
||||
options.signal
|
||||
)
|
||||
entry = selectLatestCompatibleEntry(
|
||||
entry = this.#select(
|
||||
catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -406,8 +445,11 @@ export class AgentPackageManager {
|
||||
*/
|
||||
async getRemoteInstallCandidate(
|
||||
architecture: AgentArchitecture,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
|
||||
): Promise<VerifiedRemoteAgentInstallCandidate> {
|
||||
if (options.platform && options.platform !== this.#platform) {
|
||||
return this.forPlatform(options.platform).getRemoteInstallCandidate(architecture, options)
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
const source = await this.#getUpdateSource()
|
||||
options.signal?.throwIfAborted()
|
||||
@@ -415,7 +457,7 @@ export class AgentPackageManager {
|
||||
source,
|
||||
options.signal
|
||||
)
|
||||
const entry = selectLatestCompatibleEntry(
|
||||
const entry = this.#select(
|
||||
catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -463,7 +505,7 @@ export class AgentPackageManager {
|
||||
source,
|
||||
signal
|
||||
)
|
||||
const entry = selectLatestCompatibleEntry(
|
||||
const entry = this.#select(
|
||||
catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -484,6 +526,11 @@ export class AgentPackageManager {
|
||||
expectedCandidate: VerifiedRemoteAgentInstallCandidate,
|
||||
options: AcquireInstallArchiveOptions = {}
|
||||
): Promise<AgentPackageArchiveLease> {
|
||||
if (expectedCandidate.platform !== this.#platform) {
|
||||
return this.forPlatform(expectedCandidate.platform).acquireInstallArchive(
|
||||
architecture, expectedCandidate, options
|
||||
)
|
||||
}
|
||||
return this.#runExclusive(
|
||||
architecture,
|
||||
async () => {
|
||||
@@ -507,7 +554,7 @@ export class AgentPackageManager {
|
||||
source,
|
||||
options.signal
|
||||
)
|
||||
const entry = selectLatestCompatibleEntry(
|
||||
const entry = this.#select(
|
||||
catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -534,6 +581,9 @@ export class AgentPackageManager {
|
||||
architecture: AgentArchitecture,
|
||||
options: AcquireInstallArchiveOptions = {}
|
||||
): Promise<AgentPackageArchiveLease> {
|
||||
if (options.platform && options.platform !== this.#platform) {
|
||||
return this.forPlatform(options.platform).acquireGoodBuddyInstallArchive(architecture, options)
|
||||
}
|
||||
return this.#runExclusive(
|
||||
architecture,
|
||||
async () => {
|
||||
@@ -562,7 +612,7 @@ export class AgentPackageManager {
|
||||
}
|
||||
let entry: AgentPackageCatalogEntry
|
||||
if (this.#catalogState?.state === 'available') {
|
||||
entry = selectLatestCompatibleEntry(
|
||||
entry = this.#select(
|
||||
this.#catalogState.catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -584,7 +634,7 @@ export class AgentPackageManager {
|
||||
source,
|
||||
options.signal
|
||||
)
|
||||
entry = selectLatestCompatibleEntry(
|
||||
entry = this.#select(
|
||||
catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -651,6 +701,7 @@ export class AgentPackageManager {
|
||||
): Promise<boolean> {
|
||||
const descriptor = record.verified.descriptor
|
||||
if (
|
||||
descriptor.platform === candidate.platform &&
|
||||
descriptor.architecture === candidate.architecture &&
|
||||
descriptor.version === candidate.version &&
|
||||
descriptor.minimumDesktopVersion ===
|
||||
@@ -831,11 +882,12 @@ export class AgentPackageManager {
|
||||
)
|
||||
const architecture =
|
||||
installed.verified.descriptor.architecture
|
||||
return await this.#runExclusive(
|
||||
const manager = this.forPlatform(installed.verified.descriptor.platform)
|
||||
return await manager.#runExclusive(
|
||||
architecture,
|
||||
async () => {
|
||||
await this.#publishInstalled(installed, architecture)
|
||||
return this.getInventory()
|
||||
await manager.#publishInstalled(installed, architecture)
|
||||
return manager.getInventory()
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
@@ -918,7 +970,8 @@ export class AgentPackageManager {
|
||||
.verified.descriptor
|
||||
return agentPackageArchiveName(
|
||||
descriptor.version,
|
||||
descriptor.architecture
|
||||
descriptor.architecture,
|
||||
descriptor.platform
|
||||
)
|
||||
}
|
||||
|
||||
@@ -978,7 +1031,10 @@ export class AgentPackageManager {
|
||||
desktopVersion: this.#desktopVersion,
|
||||
trustedRegistry: await this.#loadTrustedRegistry()
|
||||
})
|
||||
if (packageContent.descriptor.version !== entry.name) {
|
||||
if (
|
||||
packageContent.descriptor.version !== entry.name ||
|
||||
packageContent.descriptor.platform !== this.#platform
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const archivePath = join(directory, 'package.gbagent')
|
||||
@@ -1051,6 +1107,10 @@ export class AgentPackageManager {
|
||||
architecture: AgentArchitecture,
|
||||
catalogEntry?: AgentPackageCatalogEntry
|
||||
): Promise<InstalledRecord> {
|
||||
if (
|
||||
installed.verified.descriptor.platform !== this.#platform ||
|
||||
installed.verified.descriptor.architecture !== architecture
|
||||
) throw new Error('Agent package does not match the selected platform and architecture')
|
||||
const destination = join(
|
||||
this.#architectureRoot(architecture),
|
||||
installed.verified.descriptor.version
|
||||
@@ -1318,7 +1378,7 @@ export class AgentPackageManager {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return selectLatestCompatibleEntry(
|
||||
return this.#select(
|
||||
this.#catalogState.catalog,
|
||||
architecture,
|
||||
this.#desktopVersion
|
||||
@@ -1701,7 +1761,7 @@ export class AgentPackageManager {
|
||||
await mkdir(this.#rootDirectory, { recursive: true })
|
||||
const path = join(
|
||||
this.#rootDirectory,
|
||||
`.stage-linux-${architecture}-${randomUUID()}`
|
||||
`.stage-${this.#platform}-${architecture}-${randomUUID()}`
|
||||
)
|
||||
await mkdir(path)
|
||||
return path
|
||||
@@ -1710,7 +1770,7 @@ export class AgentPackageManager {
|
||||
async #cleanupInterruptedArtifacts(): Promise<void> {
|
||||
await this.#removeOwnedTemporaryDirectories(
|
||||
this.#rootDirectory,
|
||||
/^\.stage-linux-(?:x64|arm64|import)-[0-9a-f-]{36}$/u
|
||||
new RegExp(`^\\.stage-${this.#platform}-(?:x64|arm64|import)-[0-9a-f-]{36}$`, 'u')
|
||||
)
|
||||
await Promise.all(
|
||||
agentArchitectureSchema.options.map((architecture) =>
|
||||
@@ -1756,7 +1816,7 @@ export class AgentPackageManager {
|
||||
#architectureRoot(
|
||||
architecture: AgentArchitecture
|
||||
): string {
|
||||
return join(this.#rootDirectory, `linux-${architecture}`)
|
||||
return join(this.#rootDirectory, `${this.#platform}-${architecture}`)
|
||||
}
|
||||
|
||||
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
|
||||
@@ -1825,10 +1885,10 @@ export class AgentPackageManager {
|
||||
observer:
|
||||
| ((progress: AgentPackageDownloadProgress) => void)
|
||||
| undefined,
|
||||
progress: AgentPackageDownloadProgress
|
||||
progress: Omit<AgentPackageDownloadProgress, 'platform'>
|
||||
): void {
|
||||
try {
|
||||
observer?.(agentPackageDownloadProgressSchema.parse(progress))
|
||||
observer?.(agentPackageDownloadProgressSchema.parse({ ...progress, platform: this.#platform }))
|
||||
} catch {
|
||||
// Progress observers cannot alter the package operation.
|
||||
}
|
||||
@@ -1838,11 +1898,13 @@ export class AgentPackageManager {
|
||||
export function selectLatestCompatibleEntry(
|
||||
catalog: AgentPackageCatalog,
|
||||
architecture: AgentArchitecture,
|
||||
desktopVersion: string
|
||||
desktopVersion: string,
|
||||
platform: AgentPlatform = 'linux'
|
||||
): AgentPackageCatalogEntry {
|
||||
const entry = catalog.entries
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.platform === platform &&
|
||||
candidate.architecture === architecture &&
|
||||
compareSemanticVersions(
|
||||
desktopVersion,
|
||||
|
||||
@@ -188,6 +188,7 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
}
|
||||
)
|
||||
if (
|
||||
agentBundle.manifest.platform !== descriptor.platform ||
|
||||
agentBundle.manifest.agentVersion !== descriptor.version ||
|
||||
agentBundle.manifest.protocol.major !==
|
||||
descriptor.agentProtocol.major ||
|
||||
@@ -231,6 +232,7 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
}
|
||||
)
|
||||
if (
|
||||
runtimeBundle.manifest.platform !== descriptor.platform ||
|
||||
runtimeBundle.manifest.runtimeVersion !==
|
||||
descriptor.remoteRuntime.version ||
|
||||
runtimeBundle.manifest.bundleDigest !==
|
||||
|
||||
@@ -63,6 +63,7 @@ const windowsReservedNamePattern =
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
type Architecture = 'x64' | 'arm64'
|
||||
type Platform = 'linux' | 'darwin'
|
||||
type Protocol = { major: number; minor: number }
|
||||
type FileRecord = {
|
||||
path: string
|
||||
@@ -94,7 +95,7 @@ export type PackageInstallerAgentIdentity = {
|
||||
agentVersion: string
|
||||
manifestSha256: string
|
||||
binaryDigest: string
|
||||
platform: 'linux'
|
||||
platform: Platform
|
||||
architecture: Architecture
|
||||
protocol: Protocol
|
||||
supervisor: 'detached-on-demand'
|
||||
@@ -107,7 +108,7 @@ export type PackageInstallerRuntimeIdentity = {
|
||||
manifestDigest: string
|
||||
runtimeAdapterDigest: string
|
||||
acpCapabilitiesDigest: string
|
||||
platform: 'linux'
|
||||
platform: Platform
|
||||
architecture: Architecture
|
||||
protocol: Protocol
|
||||
}
|
||||
@@ -818,7 +819,7 @@ function assertOuterDescriptor(
|
||||
descriptor.component !== 'agent' ||
|
||||
!versionPattern.test(String(descriptor.version)) ||
|
||||
!versionPattern.test(String(descriptor.minimumDesktopVersion)) ||
|
||||
descriptor.platform !== 'linux' ||
|
||||
!isSupportedTarget(descriptor.platform, architecture) ||
|
||||
!['x64', 'arm64'].includes(String(architecture)) ||
|
||||
typeof descriptor.signingKeyId !== 'string' ||
|
||||
!digestPattern.test(String(descriptor.contentDigest)) ||
|
||||
@@ -920,7 +921,7 @@ function assertAgentManifest(
|
||||
manifest.formatVersion !== 1 ||
|
||||
manifest.product !== 'GoodBuddy' ||
|
||||
!versionPattern.test(String(manifest.agentVersion)) ||
|
||||
manifest.platform !== 'linux' ||
|
||||
!isSupportedTarget(manifest.platform, manifest.arch) ||
|
||||
!['x64', 'arm64'].includes(String(manifest.arch)) ||
|
||||
!isRecord(manifest.entrypoint) ||
|
||||
!Array.isArray(manifest.licenses)
|
||||
@@ -944,6 +945,7 @@ function assertAgentManifest(
|
||||
lock.formatVersion !== 1 ||
|
||||
manifest.agentVersion !== lock.agentVersion ||
|
||||
manifest.arch !== descriptor.architecture ||
|
||||
manifest.platform !== descriptor.platform ||
|
||||
manifest.agentVersion !== descriptor.version ||
|
||||
canonicalJson(agentProtocol) !== canonicalJson(descriptor.agentProtocol) ||
|
||||
canonicalJson(agentProtocol) !== canonicalJson(lock.protocol)
|
||||
@@ -1002,7 +1004,7 @@ function assertAgentManifest(
|
||||
}
|
||||
const architecture = manifest.arch as Architecture
|
||||
const koffiPackageRoot = 'lib/node_modules/koffi'
|
||||
const koffiNativePackage = `@koromix/koffi-linux-${architecture}`
|
||||
const koffiNativePackage = `@koromix/koffi-${manifest.platform}-${architecture}`
|
||||
const koffiNativeRoot =
|
||||
`lib/node_modules/${koffiNativePackage}`
|
||||
for (const required of [
|
||||
@@ -1017,10 +1019,7 @@ function assertAgentManifest(
|
||||
throw new Error(`Agent Koffi payload is missing: ${required}`)
|
||||
}
|
||||
}
|
||||
for (const native of [
|
||||
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/linux_${manifest.arch}/koffi.node`,
|
||||
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/musl_${manifest.arch}/koffi.node`
|
||||
]) {
|
||||
for (const native of nativeKoffiPaths(manifest.platform as Platform, architecture)) {
|
||||
if (!declared.has(native)) {
|
||||
throw new Error(`Agent Koffi payload is missing: ${native}`)
|
||||
}
|
||||
@@ -1091,11 +1090,12 @@ function assertRuntimeManifest(
|
||||
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode)
|
||||
? lock.runtimes.opencode
|
||||
: undefined
|
||||
const targetKey = descriptor.platform === 'darwin' ? 'darwin-arm64' : String(descriptor.architecture)
|
||||
const target =
|
||||
runtime !== undefined &&
|
||||
isRecord(runtime.targets) &&
|
||||
isRecord(runtime.targets[String(descriptor.architecture)])
|
||||
? runtime.targets[String(descriptor.architecture)]
|
||||
isRecord(runtime.targets[targetKey])
|
||||
? runtime.targets[targetKey]
|
||||
: undefined
|
||||
const remote = descriptor.remoteRuntime as JsonRecord
|
||||
if (
|
||||
@@ -1103,7 +1103,7 @@ function assertRuntimeManifest(
|
||||
manifest.product !== 'GoodBuddy' ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
manifest.platform !== descriptor.platform ||
|
||||
manifest.architecture !== descriptor.architecture ||
|
||||
!versionPattern.test(String(manifest.runtimeVersion)) ||
|
||||
!digestPattern.test(String(manifest.bundleDigest)) ||
|
||||
@@ -1255,7 +1255,26 @@ function assertRuntimeManifest(
|
||||
}
|
||||
}
|
||||
|
||||
function elfArchitecture(bytes: Buffer): Architecture | undefined {
|
||||
function isSupportedTarget(platform: unknown, architecture: unknown): boolean {
|
||||
return (platform === 'linux' && (architecture === 'x64' || architecture === 'arm64')) ||
|
||||
(platform === 'darwin' && architecture === 'arm64')
|
||||
}
|
||||
|
||||
function nativeKoffiPaths(platform: Platform, architecture: Architecture): string[] {
|
||||
const root = `lib/node_modules/@koromix/koffi-${platform}-${architecture}`
|
||||
return platform === 'darwin'
|
||||
? [`${root}/darwin_${architecture}/koffi.node`]
|
||||
: [`${root}/linux_${architecture}/koffi.node`, `${root}/musl_${architecture}/koffi.node`]
|
||||
}
|
||||
|
||||
function elfArchitecture(bytes: Buffer, platform: Platform): Architecture | undefined {
|
||||
if (platform === 'darwin') {
|
||||
if (bytes.length < 8) return undefined
|
||||
const cpu = bytes.readUInt32LE(0) === 0xfeedfacf
|
||||
? bytes.readUInt32LE(4)
|
||||
: bytes.readUInt32BE(0) === 0xfeedfacf ? bytes.readUInt32BE(4) : 0
|
||||
return cpu === 0x0100000c ? 'arm64' : undefined
|
||||
}
|
||||
if (
|
||||
bytes.length < 20 ||
|
||||
!bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
@@ -1273,32 +1292,16 @@ function elfArchitecture(bytes: Buffer): Architecture | undefined {
|
||||
|
||||
function assertExtractedArchitectures(
|
||||
preparedRoot: string,
|
||||
architecture: Architecture
|
||||
architecture: Architecture,
|
||||
platform: Platform
|
||||
): void {
|
||||
for (const path of [
|
||||
join(preparedRoot, 'agent', 'node'),
|
||||
join(
|
||||
preparedRoot,
|
||||
'agent',
|
||||
'lib',
|
||||
'node_modules',
|
||||
`@koromix/koffi-linux-${architecture}`,
|
||||
`linux_${architecture}`,
|
||||
'koffi.node'
|
||||
),
|
||||
join(
|
||||
preparedRoot,
|
||||
'agent',
|
||||
'lib',
|
||||
'node_modules',
|
||||
`@koromix/koffi-linux-${architecture}`,
|
||||
`musl_${architecture}`,
|
||||
'koffi.node'
|
||||
),
|
||||
...nativeKoffiPaths(platform, architecture).map(path => join(preparedRoot, 'agent', path)),
|
||||
join(preparedRoot, 'runtime', 'bin', 'opencode')
|
||||
]) {
|
||||
if (
|
||||
elfArchitecture(readFileHeader(path, 64)) !== architecture
|
||||
elfArchitecture(readFileHeader(path, 64), platform) !== architecture
|
||||
) {
|
||||
throw new Error(`Installed executable architecture mismatch: ${basename(path)}`)
|
||||
}
|
||||
@@ -1518,7 +1521,7 @@ function verifyArchive(
|
||||
agentVersion: String(agentManifest.agentVersion),
|
||||
manifestSha256: agentManifestSha256,
|
||||
binaryDigest: `sha256:${agentManifestSha256}`,
|
||||
platform: 'linux',
|
||||
platform: descriptor.platform as Platform,
|
||||
architecture,
|
||||
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
|
||||
supervisor: 'detached-on-demand'
|
||||
@@ -1530,7 +1533,7 @@ function verifyArchive(
|
||||
manifestDigest: runtime.manifestDigest,
|
||||
runtimeAdapterDigest: String(runtime.manifest.adapterDigest),
|
||||
acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest),
|
||||
platform: 'linux',
|
||||
platform: descriptor.platform as Platform,
|
||||
architecture,
|
||||
protocol: protocol(runtime.manifest.protocol, 'Runtime protocol')
|
||||
}
|
||||
@@ -1766,7 +1769,7 @@ function extractPayload(
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
assertExtractedArchitectures(temporary, verified.agent.architecture)
|
||||
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform)
|
||||
chmodSync(temporary, 0o700)
|
||||
renameSync(temporary, destinationRoot)
|
||||
} catch (error) {
|
||||
@@ -2127,7 +2130,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
!sha256Pattern.test(String(agent.manifestSha256)) ||
|
||||
agent.installationId !== `agent-${String(agent.manifestSha256)}` ||
|
||||
agent.binaryDigest !== `sha256:${String(agent.manifestSha256)}` ||
|
||||
agent.platform !== 'linux' ||
|
||||
!isSupportedTarget(agent.platform, agent.architecture) ||
|
||||
!['x64', 'arm64'].includes(String(agent.architecture)) ||
|
||||
agent.supervisor !== 'detached-on-demand' ||
|
||||
runtime.runtimeId !== 'opencode' ||
|
||||
@@ -2136,7 +2139,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
!digestPattern.test(String(runtime.manifestDigest)) ||
|
||||
!digestPattern.test(String(runtime.runtimeAdapterDigest)) ||
|
||||
!digestPattern.test(String(runtime.acpCapabilitiesDigest)) ||
|
||||
runtime.platform !== 'linux' ||
|
||||
runtime.platform !== agent.platform ||
|
||||
runtime.architecture !== agent.architecture
|
||||
) {
|
||||
throw new Error('Prepared package state identity is invalid')
|
||||
@@ -2159,7 +2162,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
agentVersion: String(agent.agentVersion),
|
||||
manifestSha256: String(agent.manifestSha256),
|
||||
binaryDigest: String(agent.binaryDigest),
|
||||
platform: 'linux',
|
||||
platform: agent.platform as Platform,
|
||||
architecture: agent.architecture as Architecture,
|
||||
protocol: agentProtocol,
|
||||
supervisor: 'detached-on-demand'
|
||||
@@ -2171,7 +2174,7 @@ function readPreparedState(path: string): PreparedState {
|
||||
manifestDigest: String(runtime.manifestDigest),
|
||||
runtimeAdapterDigest: String(runtime.runtimeAdapterDigest),
|
||||
acpCapabilitiesDigest: String(runtime.acpCapabilitiesDigest),
|
||||
platform: 'linux',
|
||||
platform: agent.platform as Platform,
|
||||
architecture: runtime.architecture as Architecture,
|
||||
protocol: runtimeProtocol
|
||||
}
|
||||
@@ -2322,7 +2325,7 @@ function assertPreparedIdentity(
|
||||
agentVersion: String(agentManifest.agentVersion),
|
||||
manifestSha256: agentManifestSha256,
|
||||
binaryDigest: `sha256:${agentManifestSha256}`,
|
||||
platform: 'linux',
|
||||
platform: agentManifest.platform as Platform,
|
||||
architecture,
|
||||
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
|
||||
supervisor: 'detached-on-demand'
|
||||
@@ -2336,7 +2339,7 @@ function assertPreparedIdentity(
|
||||
acpCapabilitiesDigest: String(
|
||||
runtime.manifest.acpCapabilitiesDigest
|
||||
),
|
||||
platform: 'linux',
|
||||
platform: agentManifest.platform as Platform,
|
||||
architecture,
|
||||
protocol: protocol(
|
||||
runtime.manifest.protocol,
|
||||
|
||||
@@ -48,7 +48,7 @@ type ManagedRemoteRuntimeIdentity = {
|
||||
bundleDigest: string
|
||||
runtimeAdapterDigest: string
|
||||
acpCapabilitiesDigest: string
|
||||
platform: 'linux'
|
||||
platform: 'linux' | 'darwin'
|
||||
architecture: 'x64' | 'arm64'
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ function runtimeIdentityFromCapabilities(
|
||||
): ManagedRemoteRuntimeIdentity {
|
||||
if (
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
installation.platform !== 'linux'
|
||||
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64'))
|
||||
) {
|
||||
throw new Error(
|
||||
'Remote Agent does not advertise the current Runtime'
|
||||
@@ -660,7 +660,7 @@ function runtimeIdentityFromCapabilities(
|
||||
bundleDigest: installation.bundleDigest,
|
||||
runtimeAdapterDigest: installation.runtimeAdapterDigest,
|
||||
acpCapabilitiesDigest: installation.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
platform: installation.platform,
|
||||
architecture: installation.architecture
|
||||
}
|
||||
assertRuntimeAdvertised(runtime, capabilities)
|
||||
|
||||
@@ -155,6 +155,7 @@ function harness(options: {
|
||||
installationId: agentInstallationId,
|
||||
binaryDigest: status.binaryDigest,
|
||||
version: status.agentVersion,
|
||||
platform: 'linux',
|
||||
architecture,
|
||||
protocolMajor: 1
|
||||
},
|
||||
|
||||
@@ -165,7 +165,7 @@ function assertInstallationIdentity(
|
||||
): void {
|
||||
if (
|
||||
installation.runtimeId !== 'opencode' ||
|
||||
installation.platform !== 'linux' ||
|
||||
installation.platform !== input.agent.platform ||
|
||||
installation.architecture !== input.agent.architecture
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -64,7 +64,7 @@ export type RemoteAgentInstallationIdentity = {
|
||||
major: number
|
||||
minor: number
|
||||
}
|
||||
platform: 'linux'
|
||||
platform: 'linux' | 'darwin'
|
||||
architecture: 'x64' | 'arm64'
|
||||
supervisor: 'detached-on-demand'
|
||||
requiredCapabilities?: ReadonlyArray<{
|
||||
@@ -1145,7 +1145,7 @@ function validateInstallation(
|
||||
input.protocol ?? AGENT_PROTOCOL_VERSION
|
||||
)
|
||||
if (
|
||||
input.platform !== 'linux' ||
|
||||
(input.platform !== 'linux' && !(input.platform === 'darwin' && input.architecture === 'arm64')) ||
|
||||
!['x64', 'arm64'].includes(input.architecture) ||
|
||||
input.supervisor !== 'detached-on-demand'
|
||||
) {
|
||||
|
||||
@@ -164,7 +164,8 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
await this.#acquireGoodBuddyArchive(
|
||||
probe.architecture,
|
||||
emit,
|
||||
signal
|
||||
signal,
|
||||
probe.platform
|
||||
)
|
||||
candidate = archiveLease.candidate
|
||||
method = 'goodbuddy-transfer'
|
||||
@@ -173,7 +174,7 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
candidate =
|
||||
await this.#agentPackageManager.getRemoteInstallCandidate(
|
||||
probe.architecture,
|
||||
{ signal }
|
||||
{ signal, platform: probe.platform }
|
||||
)
|
||||
} catch (error) {
|
||||
if (requestedMethod === 'remote-download') {
|
||||
@@ -183,14 +184,15 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
await this.#acquireGoodBuddyArchive(
|
||||
probe.architecture,
|
||||
emit,
|
||||
signal
|
||||
signal,
|
||||
probe.platform
|
||||
)
|
||||
candidate = archiveLease.candidate
|
||||
method = 'goodbuddy-transfer'
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
assertCandidate(candidate, probe.architecture)
|
||||
assertCandidate(candidate, probe.architecture, probe.platform)
|
||||
await assertTargetCurrent(this.#resolver, target, signal)
|
||||
|
||||
operationId = randomUUID()
|
||||
@@ -503,12 +505,14 @@ export class RemoteEnvironmentPreparer implements Preparer {
|
||||
method: RemoteEnvironmentPreparationMethod,
|
||||
phase: RemoteEnvironmentUpdateProgress['phase']
|
||||
) => void,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<AgentPackageArchiveLease> {
|
||||
return this.#agentPackageManager.acquireGoodBuddyInstallArchive(
|
||||
architecture,
|
||||
{
|
||||
signal,
|
||||
platform,
|
||||
onProgress: (progress) => {
|
||||
emit(
|
||||
'goodbuddy-transfer',
|
||||
@@ -674,10 +678,11 @@ function assertSameLeaseIdentity(
|
||||
|
||||
function assertCandidate(
|
||||
candidate: VerifiedRemoteAgentInstallCandidate,
|
||||
architecture: 'x64' | 'arm64'
|
||||
architecture: 'x64' | 'arm64',
|
||||
platform: 'linux' | 'darwin'
|
||||
): void {
|
||||
if (
|
||||
candidate.platform !== 'linux' ||
|
||||
candidate.platform !== platform ||
|
||||
candidate.architecture !== architecture
|
||||
) {
|
||||
throw new Error('签名 Agent 安装候选与 Host 架构不匹配')
|
||||
|
||||
@@ -59,6 +59,7 @@ export type RemoteProjectRuntimeValidationInput = Readonly<{
|
||||
installationId: AgentInstallationIdentity['installationId']
|
||||
binaryDigest: string
|
||||
version: string
|
||||
platform: 'linux' | 'darwin'
|
||||
architecture: 'x64' | 'arm64'
|
||||
protocolMajor: number
|
||||
}>
|
||||
@@ -308,6 +309,7 @@ export class RemoteProjectSaveService {
|
||||
installationId: installation.installationId,
|
||||
binaryDigest: installation.binaryDigest,
|
||||
version: installation.agentVersion,
|
||||
platform: installation.platform,
|
||||
architecture: installation.architecture,
|
||||
protocolMajor: installation.protocol.major
|
||||
},
|
||||
@@ -484,7 +486,7 @@ function assertInstallation(
|
||||
installation.installationId
|
||||
) ||
|
||||
!sha256DigestSchema.safeParse(installation.binaryDigest).success ||
|
||||
installation.platform !== 'linux' ||
|
||||
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64')) ||
|
||||
!['x64', 'arm64'].includes(installation.architecture) ||
|
||||
installation.supervisor !== 'detached-on-demand'
|
||||
) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export type RemoteRuntimeInstallationIdentity = {
|
||||
manifestDigest: string
|
||||
runtimeAdapterDigest: string
|
||||
acpCapabilitiesDigest: string
|
||||
platform: 'linux'
|
||||
platform: 'linux' | 'darwin'
|
||||
architecture: AgentArchitecture
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
)
|
||||
}
|
||||
if (
|
||||
probe.platform !== 'linux' ||
|
||||
(probe.platform !== 'linux' && !(probe.platform === 'darwin' && probe.architecture === 'arm64')) ||
|
||||
(
|
||||
probe.architecture !== 'x64' &&
|
||||
probe.architecture !== 'arm64'
|
||||
@@ -279,7 +279,7 @@ export class RemoteRuntimeInstallationManager {
|
||||
runtimeAdapterDigest,
|
||||
acpCapabilitiesDigest:
|
||||
current.acpCapabilitiesDigest,
|
||||
platform: 'linux',
|
||||
platform: probe.platform,
|
||||
architecture: current.architecture
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -275,7 +275,7 @@ async function validateVerifiedBundle(
|
||||
verified.bundleDirectory !== bundleDirectory ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
(manifest.platform !== 'linux' && !(manifest.platform === 'darwin' && manifest.architecture === 'arm64')) ||
|
||||
manifest.architecture !== architecture
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -214,7 +214,8 @@ describe('fixed Agent SSH commands', () => {
|
||||
const cases: Array<
|
||||
[Partial<Record<string, string>>, string]
|
||||
> = [
|
||||
[{ os: 'Darwin' }, 'non-linux'],
|
||||
[{ os: 'Darwin' }, 'unsupported-architecture'],
|
||||
[{ os: 'FreeBSD' }, 'unsupported-platform'],
|
||||
[{ arch: 'riscv64' }, 'unsupported-architecture'],
|
||||
[{ home: 'unknown' }, 'home-directory-unavailable'],
|
||||
[{ uid: 'unknown' }, 'uid-unavailable'],
|
||||
|
||||
@@ -129,17 +129,17 @@ export function parseAgentBootstrapProbeOutput(
|
||||
}
|
||||
|
||||
const os = values.get('os')
|
||||
if (os !== 'Linux') {
|
||||
return incompatible('non-linux')
|
||||
if (os !== 'Linux' && os !== 'Darwin') {
|
||||
return incompatible('unsupported-platform')
|
||||
}
|
||||
const rawArchitecture = values.get('arch')
|
||||
const architecture =
|
||||
rawArchitecture === 'x86_64'
|
||||
? 'x64'
|
||||
: rawArchitecture === 'aarch64'
|
||||
: rawArchitecture === 'aarch64' || rawArchitecture === 'arm64'
|
||||
? 'arm64'
|
||||
: undefined
|
||||
if (!architecture) {
|
||||
if (!architecture || (os === 'Darwin' && architecture !== 'arm64')) {
|
||||
return incompatible('unsupported-architecture')
|
||||
}
|
||||
|
||||
@@ -169,17 +169,17 @@ export function parseAgentBootstrapProbeOutput(
|
||||
if (procfs !== 'ready' && procfs !== 'unavailable') {
|
||||
throw new Error('Agent 启动探针返回了无效结果')
|
||||
}
|
||||
if (procfs === 'unavailable') {
|
||||
if (procfs === 'unavailable' && os === 'Linux') {
|
||||
return incompatible('procfs-unavailable')
|
||||
}
|
||||
return agentBootstrapProbeResultSchema.parse({
|
||||
ready: true,
|
||||
platform: 'linux',
|
||||
platform: os === 'Darwin' ? 'darwin' : 'linux',
|
||||
architecture,
|
||||
canonicalHomeDirectory: home,
|
||||
uid,
|
||||
shell,
|
||||
procfs: 'ready'
|
||||
procfs: os === 'Darwin' ? 'not-applicable' : 'ready'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export type SshHostRemoteEnvironmentInspectorOptions = {
|
||||
remoteRuntimeLockPath: string
|
||||
loadRemoteEnvironmentCatalog?: (
|
||||
architecture: 'x64' | 'arm64',
|
||||
options: { signal?: AbortSignal }
|
||||
options: { signal?: AbortSignal; platform?: 'linux' | 'darwin' }
|
||||
) => Promise<RemoteEnvironmentCatalog>
|
||||
now?: () => Date
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export class SshHostRemoteEnvironmentInspector {
|
||||
await Promise.all([
|
||||
this.#loadRemoteEnvironmentCatalog(
|
||||
probe.architecture,
|
||||
{ signal }
|
||||
{ signal, platform: probe.platform }
|
||||
),
|
||||
readOptionalRegistry(
|
||||
sftp,
|
||||
|
||||
@@ -503,7 +503,7 @@ describe("fixed SSH remote package bootstrap", () => {
|
||||
'--archive-sha256-verified true',
|
||||
);
|
||||
expect(SSH_REMOTE_PACKAGE_BOOTSTRAP_SCRIPT).toContain(
|
||||
'actual_sha256=$(sha256sum "$archive"',
|
||||
'actual_sha256=$(hash_sha256 "$archive"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export type SshRemotePackageAgentIdentity = {
|
||||
agentVersion: string;
|
||||
manifestSha256: string;
|
||||
binaryDigest: string;
|
||||
platform: "linux";
|
||||
platform: "linux" | "darwin";
|
||||
architecture: "x64" | "arm64";
|
||||
protocol: SshRemotePackageProtocol;
|
||||
supervisor: "detached-on-demand";
|
||||
@@ -140,7 +140,7 @@ export type SshRemotePackageRuntimeIdentity = {
|
||||
manifestDigest: string;
|
||||
runtimeAdapterDigest: string;
|
||||
acpCapabilitiesDigest: string;
|
||||
platform: "linux";
|
||||
platform: "linux" | "darwin";
|
||||
architecture: "x64" | "arm64";
|
||||
protocol: SshRemotePackageProtocol;
|
||||
};
|
||||
@@ -614,9 +614,17 @@ if [ "$action" = prepare-uploaded ]; then
|
||||
run_prepare
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || stop unavailable missing-curl
|
||||
command -v sha256sum >/dev/null 2>&1 || stop unavailable missing-sha256sum
|
||||
hash_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$@"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$@"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
command -v unzip >/dev/null 2>&1 || stop unavailable missing-unzip
|
||||
printf test | sha256sum >/dev/null 2>&1 || stop unavailable missing-sha256sum
|
||||
printf test | hash_sha256 >/dev/null 2>&1 || stop unavailable missing-sha256sum
|
||||
unzip -v >/dev/null 2>&1 || stop unavailable missing-unzip
|
||||
if ! mkdir -p -- "$staging_root"; then stop unavailable managed-path-unavailable; fi
|
||||
if [ -L "$goodbuddy_root" ] || [ -L "$managed_root" ] ||
|
||||
@@ -708,7 +716,7 @@ set -- $actual_size
|
||||
actual_size=${SHELL_FIRST_POSITIONAL_OR_EMPTY}
|
||||
[ "$actual_size" = "$expected_size" ] || stop failed size-mismatch
|
||||
emit_progress verifying
|
||||
actual_sha256=$(sha256sum "$archive" 2>/dev/null) || stop failed sha256-mismatch
|
||||
actual_sha256=$(hash_sha256 "$archive" 2>/dev/null) || stop failed sha256-mismatch
|
||||
set -- $actual_sha256
|
||||
actual_sha256=${SHELL_FIRST_POSITIONAL_OR_EMPTY}
|
||||
[ "$actual_sha256" = "$expected_sha256" ] || stop failed sha256-mismatch
|
||||
@@ -973,7 +981,7 @@ function parsePackageIdentity(
|
||||
agent.installationId !== `agent-${agent.manifestSha256}` ||
|
||||
agent.binaryDigest !== `sha256:${agent.manifestSha256}` ||
|
||||
!isVersion(agent.agentVersion) ||
|
||||
agent.platform !== "linux" ||
|
||||
(agent.platform !== "linux" && !(agent.platform === "darwin" && architecture === "arm64")) ||
|
||||
(architecture !== "x64" && architecture !== "arm64") ||
|
||||
!agentProtocol ||
|
||||
agent.supervisor !== "detached-on-demand" ||
|
||||
@@ -987,7 +995,7 @@ function parsePackageIdentity(
|
||||
!DIGEST_PATTERN.test(runtime.runtimeAdapterDigest) ||
|
||||
typeof runtime.acpCapabilitiesDigest !== "string" ||
|
||||
!DIGEST_PATTERN.test(runtime.acpCapabilitiesDigest) ||
|
||||
runtime.platform !== "linux" ||
|
||||
runtime.platform !== agent.platform ||
|
||||
runtime.architecture !== architecture ||
|
||||
!runtimeProtocol
|
||||
) {
|
||||
@@ -1003,7 +1011,7 @@ function parsePackageIdentity(
|
||||
agentVersion: agent.agentVersion,
|
||||
manifestSha256: agent.manifestSha256,
|
||||
binaryDigest: agent.binaryDigest,
|
||||
platform: "linux",
|
||||
platform: agent.platform as "linux" | "darwin",
|
||||
architecture,
|
||||
protocol: agentProtocol,
|
||||
supervisor: "detached-on-demand",
|
||||
@@ -1015,7 +1023,7 @@ function parsePackageIdentity(
|
||||
manifestDigest: runtime.manifestDigest,
|
||||
runtimeAdapterDigest: runtime.runtimeAdapterDigest,
|
||||
acpCapabilitiesDigest: runtime.acpCapabilitiesDigest,
|
||||
platform: "linux",
|
||||
platform: agent.platform as "linux" | "darwin",
|
||||
architecture,
|
||||
protocol: runtimeProtocol,
|
||||
},
|
||||
|
||||
@@ -450,19 +450,19 @@ const desktopApi: DesktopApi = {
|
||||
ipcChannels.sshHostsAgentPackageInventory,
|
||||
{ refresh }
|
||||
) as Promise<AgentPackageInventory>,
|
||||
downloadAgentPackage: (architecture: AgentArchitecture) =>
|
||||
downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.sshHostsAgentPackageDownload,
|
||||
{ architecture }
|
||||
{ architecture, platform }
|
||||
) as Promise<AgentPackageInventory>,
|
||||
importAgentPackage: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.sshHostsAgentPackageImport
|
||||
) as Promise<AgentPackageInventory | undefined>,
|
||||
exportAgentPackage: async (architecture: AgentArchitecture) => {
|
||||
exportAgentPackage: async (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') => {
|
||||
await ipcRenderer.invoke(
|
||||
ipcChannels.sshHostsAgentPackageExport,
|
||||
{ architecture }
|
||||
{ architecture, platform }
|
||||
)
|
||||
},
|
||||
onAgentPackageProgress: (listener) => {
|
||||
|
||||
@@ -109,7 +109,7 @@ export function PlatformFeaturesSettingsSection({
|
||||
const [agentInventoryError, setAgentInventoryError] =
|
||||
useState<string>()
|
||||
const [agentPackageBusy, setAgentPackageBusy] =
|
||||
useState<AgentArchitecture | 'import'>()
|
||||
useState<string>()
|
||||
const [agentPackageProgress, setAgentPackageProgress] =
|
||||
useState<AgentPackageDownloadProgress>()
|
||||
const agentInventoryRequested = useRef(false)
|
||||
@@ -195,23 +195,24 @@ export function PlatformFeaturesSettingsSection({
|
||||
}, [])
|
||||
|
||||
const downloadAgentPackage = async (
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const download =
|
||||
window.goodbuddy.sshHosts?.downloadAgentPackage
|
||||
if (!download) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(architecture)
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentPackageProgress(undefined)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await download(architecture))
|
||||
setAgentInventory(await download(architecture, platform))
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-downloaded:${architecture}`,
|
||||
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
|
||||
{ architecture }
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
@@ -265,22 +266,23 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
|
||||
const exportAgentPackage = async (
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const exportPackage =
|
||||
window.goodbuddy.sshHosts?.exportAgentPackage
|
||||
if (!exportPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(architecture)
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
await exportPackage(architecture)
|
||||
await exportPackage(architecture, platform)
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-exported:${architecture}`,
|
||||
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
|
||||
{ architecture }
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
@@ -983,13 +985,13 @@ export function PlatformFeaturesSettingsSection({
|
||||
{agentInventory.entries.map((entry) => (
|
||||
<article
|
||||
className="bundled-agent-inventory__item"
|
||||
key={entry.architecture}
|
||||
key={`${entry.platform}-${entry.architecture}`}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="bundled-agent-inventory__item-header">
|
||||
<Cpu aria-hidden="true" size={16} />
|
||||
<strong>
|
||||
Linux {entry.architecture}
|
||||
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
|
||||
</strong>
|
||||
<span
|
||||
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
|
||||
@@ -1063,7 +1065,8 @@ export function PlatformFeaturesSettingsSection({
|
||||
</dl>
|
||||
{agentPackageProgress?.architecture ===
|
||||
entry.architecture &&
|
||||
agentPackageBusy === entry.architecture && (
|
||||
agentPackageProgress.platform === entry.platform &&
|
||||
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
|
||||
@@ -1084,7 +1087,8 @@ export function PlatformFeaturesSettingsSection({
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void downloadAgentPackage(
|
||||
entry.architecture
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
@@ -1107,7 +1111,8 @@ export function PlatformFeaturesSettingsSection({
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void exportAgentPackage(
|
||||
entry.architecture
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
|
||||
@@ -2149,7 +2149,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
await screen.findByText('GoodBuddy Agent 包')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('当前 1 / 2 个 Linux 架构可用。')
|
||||
screen.getByText('当前 1 / 2 个平台与架构目标可用。')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Linux x64')).toBeInTheDocument()
|
||||
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
|
||||
@@ -2192,7 +2192,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('x64')
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')
|
||||
)
|
||||
expect(
|
||||
await within(x64Card as HTMLElement).findByText('已是最新')
|
||||
@@ -2208,7 +2208,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(exportAgentPackage).toHaveBeenCalledWith('x64')
|
||||
expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')
|
||||
)
|
||||
fireEvent.click(
|
||||
within(arm64Card as HTMLElement).getByRole('button', {
|
||||
@@ -2216,7 +2216,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64')
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导入离线包' })
|
||||
@@ -2272,7 +2272,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('当前 1 / 2 个 Linux 架构可用。')
|
||||
await screen.findByText('当前 1 / 2 个平台与架构目标可用。')
|
||||
).toBeInTheDocument()
|
||||
|
||||
getAgentPackageInventory.mockRejectedValueOnce(
|
||||
|
||||
@@ -585,7 +585,7 @@ export const settingsSections = {
|
||||
downloadVersion: 'Download {{version}}',
|
||||
listLabel: 'Local Agent package inventory',
|
||||
summary:
|
||||
'{{available}} of {{total}} Linux architectures are available.',
|
||||
'{{available}} of {{total}} platform and architecture targets are available.',
|
||||
states: {
|
||||
verified: 'Downloaded and verified',
|
||||
'not-downloaded': 'Not downloaded',
|
||||
@@ -613,9 +613,9 @@ export const settingsSections = {
|
||||
installing: 'Writing the local cache…'
|
||||
},
|
||||
notifications: {
|
||||
downloaded: 'Linux {{architecture}} Agent package updated',
|
||||
downloaded: '{{platform}} {{architecture}} Agent package updated',
|
||||
imported: 'Agent offline package imported and verified',
|
||||
exported: 'Linux {{architecture}} Agent package exported'
|
||||
exported: '{{platform}} {{architecture}} Agent package exported'
|
||||
},
|
||||
errors: {
|
||||
unavailable:
|
||||
|
||||
@@ -554,7 +554,7 @@ export const settingsSections = {
|
||||
updateTo: '更新到 {{version}}',
|
||||
downloadVersion: '下载 {{version}}',
|
||||
listLabel: '本地 Agent 包清单',
|
||||
summary: '当前 {{available}} / {{total}} 个 Linux 架构可用。',
|
||||
summary: '当前 {{available}} / {{total}} 个平台与架构目标可用。',
|
||||
states: {
|
||||
verified: '已下载并验证',
|
||||
'not-downloaded': '未下载',
|
||||
@@ -582,9 +582,9 @@ export const settingsSections = {
|
||||
installing: '正在写入本地缓存…'
|
||||
},
|
||||
notifications: {
|
||||
downloaded: 'Linux {{architecture}} Agent 包已更新',
|
||||
downloaded: '{{platform}} {{architecture}} Agent 包已更新',
|
||||
imported: 'Agent 离线包已导入并验证',
|
||||
exported: 'Linux {{architecture}} Agent 包已导出'
|
||||
exported: '{{platform}} {{architecture}} Agent 包已导出'
|
||||
},
|
||||
errors: {
|
||||
unavailable: '当前版本未提供 Agent 包管理服务',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { agentPlatformSchema } from './agent-target'
|
||||
|
||||
const versionPattern =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u
|
||||
@@ -81,7 +82,7 @@ export const agentBundleManifestSchema = z
|
||||
formatVersion: z.literal(1),
|
||||
product: z.literal('GoodBuddy'),
|
||||
agentVersion: componentVersionSchema,
|
||||
platform: z.literal('linux'),
|
||||
platform: agentPlatformSchema,
|
||||
arch: agentArchitectureSchema,
|
||||
protocol: agentProtocolVersionSchema,
|
||||
signingKeyId: z.string().regex(keyIdPattern),
|
||||
@@ -248,7 +249,8 @@ export const agentRuntimeLockSchema = z
|
||||
targets: z
|
||||
.object({
|
||||
'linux-x64': lockedNodeTargetSchema,
|
||||
'linux-arm64': lockedNodeTargetSchema
|
||||
'linux-arm64': lockedNodeTargetSchema,
|
||||
'darwin-arm64': lockedNodeTargetSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentPackageArchiveName,
|
||||
agentPackageCatalogEntrySchema,
|
||||
agentPackageCatalogSchema,
|
||||
agentPackageInventoryRequestSchema,
|
||||
agentPackageInventorySchema,
|
||||
isSafeAgentPackagePath
|
||||
@@ -48,6 +49,27 @@ function catalogEntry() {
|
||||
}
|
||||
|
||||
describe('Agent package contracts', () => {
|
||||
it('accepts Linux and macOS arm64 independently in one signed catalog', () => {
|
||||
const linux = {
|
||||
...catalogEntry(),
|
||||
architecture: 'arm64',
|
||||
archive: agentPackageArchiveName('0.11.2', 'arm64', 'linux')
|
||||
}
|
||||
const mac = {
|
||||
...linux,
|
||||
platform: 'darwin',
|
||||
archive: agentPackageArchiveName('0.11.2', 'arm64', 'darwin')
|
||||
}
|
||||
const catalog = agentPackageCatalogSchema.parse({
|
||||
formatVersion: 1, product: 'GoodBuddy', component: 'agent',
|
||||
signingKeyId: 'test', generatedAt: '2026-09-06T00:00:00.000Z',
|
||||
entries: [catalogEntry(), linux, mac]
|
||||
})
|
||||
expect(catalog.entries).toHaveLength(3)
|
||||
expect(() => agentPackageCatalogSchema.parse({
|
||||
...catalog, entries: [...catalog.entries, mac]
|
||||
})).toThrow('duplicate')
|
||||
})
|
||||
it('binds each catalog archive name to its signed identity', () => {
|
||||
expect(agentPackageCatalogEntrySchema.parse(catalogEntry()))
|
||||
.toMatchObject({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { agentPlatformSchema, agentTargetKey, type AgentPlatform } from './agent-target'
|
||||
import {
|
||||
agentArchitectureSchema,
|
||||
agentProtocolVersionSchema
|
||||
@@ -9,9 +10,10 @@ export const AGENT_PACKAGE_FORMAT_VERSION = 1
|
||||
|
||||
export function agentPackageArchiveName(
|
||||
version: string,
|
||||
architecture: 'x64' | 'arm64'
|
||||
architecture: 'x64' | 'arm64',
|
||||
platform: AgentPlatform = 'linux'
|
||||
): string {
|
||||
return `goodbuddy-agent-${version}-linux-${architecture}.gbagent`
|
||||
return `goodbuddy-agent-${version}-${agentTargetKey({ platform, architecture })}.gbagent`
|
||||
}
|
||||
|
||||
const semanticVersionSchema = z
|
||||
@@ -31,7 +33,7 @@ const safeArchiveNameSchema = z
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(
|
||||
/^goodbuddy-agent-(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?-linux-(?:x64|arm64)\.gbagent$/u
|
||||
/^goodbuddy-agent-(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?-(?:linux-(?:x64|arm64)|darwin-arm64)\.gbagent$/u
|
||||
)
|
||||
|
||||
const windowsReservedNamePattern =
|
||||
@@ -86,7 +88,7 @@ const agentPackageDescriptorObjectSchema = z
|
||||
component: z.literal('agent'),
|
||||
version: semanticVersionSchema,
|
||||
minimumDesktopVersion: semanticVersionSchema,
|
||||
platform: z.literal('linux'),
|
||||
platform: agentPlatformSchema,
|
||||
architecture: agentArchitectureSchema,
|
||||
signingKeyId: signingKeyIdSchema,
|
||||
agentProtocol: agentProtocolVersionSchema,
|
||||
@@ -152,7 +154,7 @@ export const agentPackageCatalogEntrySchema =
|
||||
.superRefine((entry, context) => {
|
||||
if (
|
||||
entry.archive !==
|
||||
agentPackageArchiveName(entry.version, entry.architecture)
|
||||
agentPackageArchiveName(entry.version, entry.architecture, entry.platform)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
@@ -182,7 +184,7 @@ export const agentPackageCatalogSchema = z
|
||||
.strict()
|
||||
.superRefine((catalog, context) => {
|
||||
const identities = catalog.entries.map(
|
||||
(entry) => `${entry.version}:${entry.architecture}`
|
||||
(entry) => `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
)
|
||||
if (new Set(identities).size !== identities.length) {
|
||||
context.addIssue({
|
||||
@@ -205,7 +207,7 @@ export const agentPackageStateSchema = z.enum([
|
||||
|
||||
export const agentPackageInventoryEntrySchema = z
|
||||
.object({
|
||||
platform: z.literal('linux'),
|
||||
platform: agentPlatformSchema,
|
||||
architecture: agentArchitectureSchema,
|
||||
state: agentPackageStateSchema,
|
||||
version: semanticVersionSchema.nullable(),
|
||||
@@ -247,12 +249,12 @@ export const agentPackageInventorySchema = z
|
||||
catalog: agentPackageCatalogStatusSchema,
|
||||
entries: z
|
||||
.array(agentPackageInventoryEntrySchema)
|
||||
.length(agentArchitectureSchema.options.length)
|
||||
.min(1).max(3)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((inventory, context) => {
|
||||
const architectures = inventory.entries.map(
|
||||
(entry) => entry.architecture
|
||||
(entry) => `${entry.platform}-${entry.architecture}`
|
||||
)
|
||||
if (new Set(architectures).size !== architectures.length) {
|
||||
context.addIssue({
|
||||
@@ -276,7 +278,8 @@ export const agentPackageInventoryRequestSchema = z
|
||||
|
||||
export const agentPackageArchitectureRequestSchema = z
|
||||
.object({
|
||||
architecture: agentArchitectureSchema
|
||||
architecture: agentArchitectureSchema,
|
||||
platform: agentPlatformSchema.default('linux')
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -287,6 +290,7 @@ export type AgentPackageArchitectureRequest = z.infer<
|
||||
export const agentPackageDownloadProgressSchema = z
|
||||
.object({
|
||||
architecture: agentArchitectureSchema,
|
||||
platform: agentPlatformSchema.default('linux'),
|
||||
phase: z.enum([
|
||||
'catalog',
|
||||
'downloading',
|
||||
|
||||
@@ -190,7 +190,7 @@ export const daemonStatusSchema = z
|
||||
label: 'Agent version'
|
||||
}),
|
||||
protocol: protocolVersionSchema,
|
||||
platform: z.literal('linux'),
|
||||
platform: z.enum(['linux', 'darwin']),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
supervisor: z.literal('detached-on-demand'),
|
||||
remoteUserIdentity: agentIdentifierSchema,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const agentPlatformSchema = z.enum(['linux', 'darwin'])
|
||||
export type AgentPlatform = z.infer<typeof agentPlatformSchema>
|
||||
|
||||
export const AGENT_TARGETS = [
|
||||
{ platform: 'linux', architecture: 'x64' },
|
||||
{ platform: 'linux', architecture: 'arm64' },
|
||||
{ platform: 'darwin', architecture: 'arm64' }
|
||||
] as const
|
||||
|
||||
export type AgentTarget = (typeof AGENT_TARGETS)[number]
|
||||
export type AgentTargetKey = 'linux-x64' | 'linux-arm64' | 'darwin-arm64'
|
||||
|
||||
export function agentTargetKey(target: {
|
||||
platform: string
|
||||
architecture: string
|
||||
}): AgentTargetKey {
|
||||
if (!AGENT_TARGETS.some(entry =>
|
||||
entry.platform === target.platform && entry.architecture === target.architecture
|
||||
)) throw new Error(`Unsupported Agent target: ${target.platform}-${target.architecture}`)
|
||||
return `${target.platform}-${target.architecture}` as AgentTargetKey
|
||||
}
|
||||
|
||||
export function currentAgentPlatform(): AgentPlatform {
|
||||
const platform = agentPlatformSchema.parse(process.platform)
|
||||
agentTargetKey({ platform, architecture: process.arch })
|
||||
return platform
|
||||
}
|
||||
@@ -1660,11 +1660,13 @@ export type DesktopApi = {
|
||||
refresh?: boolean
|
||||
) => Promise<AgentPackageInventory>
|
||||
downloadAgentPackage: (
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
platform?: 'linux' | 'darwin'
|
||||
) => Promise<AgentPackageInventory>
|
||||
importAgentPackage: () => Promise<AgentPackageInventory | undefined>
|
||||
exportAgentPackage: (
|
||||
architecture: AgentArchitecture
|
||||
architecture: AgentArchitecture,
|
||||
platform?: 'linux' | 'darwin'
|
||||
) => Promise<void>
|
||||
onAgentPackageProgress: (
|
||||
listener: (progress: AgentPackageDownloadProgress) => void
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AgentArchitecture } from '../agent-installation-contracts'
|
||||
import type { AgentPlatform } from '../agent-target'
|
||||
|
||||
export function binaryTargetArchitecture(
|
||||
bytes: Buffer,
|
||||
platform: AgentPlatform
|
||||
): AgentArchitecture | undefined {
|
||||
if (platform === 'darwin') {
|
||||
if (bytes.length < 8) return undefined
|
||||
const cpu = bytes.readUInt32LE(0) === 0xfeedfacf
|
||||
? bytes.readUInt32LE(4)
|
||||
: bytes.readUInt32BE(0) === 0xfeedfacf ? bytes.readUInt32BE(4) : undefined
|
||||
return cpu === 0x0100000c ? 'arm64' : undefined
|
||||
}
|
||||
if (
|
||||
bytes.length < 20 || bytes[0] !== 0x7f ||
|
||||
bytes.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
![1, 2].includes(bytes[5]!)
|
||||
) return undefined
|
||||
const machine = bytes[5] === 2 ? bytes.readUInt16BE(18) : bytes.readUInt16LE(18)
|
||||
return machine === 62 ? 'x64' : machine === 183 ? 'arm64' : undefined
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { binaryTargetArchitecture } from './binary-target'
|
||||
import type { AgentPlatform } from '../agent-target'
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
@@ -219,7 +221,8 @@ export async function verifyPublishedRuntimeBundle(
|
||||
await assertElfArchitecture(
|
||||
executablePath,
|
||||
options.architecture,
|
||||
'Runtime executable'
|
||||
'Runtime executable',
|
||||
manifest.platform
|
||||
)
|
||||
return {
|
||||
bundleDirectory,
|
||||
@@ -412,8 +415,11 @@ export function assertRuntimeManifestMatchesLock(
|
||||
architecture: AgentArchitecture
|
||||
): void {
|
||||
const expected = lock.runtimes.opencode
|
||||
const target = expected.targets[architecture]
|
||||
const target = manifest.platform === 'darwin'
|
||||
? expected.targets['darwin-arm64']
|
||||
: expected.targets[architecture]
|
||||
if (
|
||||
target === undefined ||
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.provider !== expected.provider ||
|
||||
manifest.runtimeVersion !== expected.version ||
|
||||
@@ -696,7 +702,8 @@ async function sha256File(filePath: string): Promise<string> {
|
||||
async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expected: AgentArchitecture,
|
||||
label: string
|
||||
label: string,
|
||||
platform: AgentPlatform
|
||||
): Promise<void> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
@@ -707,8 +714,8 @@ async function assertElfArchitecture(
|
||||
header.length,
|
||||
0
|
||||
)
|
||||
const actual = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
const actual = binaryTargetArchitecture(
|
||||
header.subarray(0, bytesRead), platform
|
||||
)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
@@ -718,26 +725,4 @@ async function assertElfArchitecture(
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ const remoteRuntimeManifestFields = {
|
||||
runtimeId: agentIdentifierSchema,
|
||||
runtimeVersion: agentBundleManifestSchema.shape.agentVersion,
|
||||
provider: remoteRuntimeProviderKindSchema,
|
||||
platform: z.literal('linux'),
|
||||
platform: z.enum(['linux', 'darwin']),
|
||||
architecture: agentArchitectureSchema,
|
||||
signingKeyId: agentReleaseKeySchema.shape.keyId,
|
||||
bundleDigest: sha256DigestSchema,
|
||||
@@ -252,7 +252,10 @@ export const remoteRuntimeLockSchema = z
|
||||
}),
|
||||
arm64: remoteRuntimeLockedTargetSchema.extend({
|
||||
package: z.literal('opencode-linux-arm64')
|
||||
})
|
||||
}),
|
||||
'darwin-arm64': remoteRuntimeLockedTargetSchema.extend({
|
||||
package: z.literal('opencode-darwin-arm64')
|
||||
}).optional()
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
|
||||
@@ -404,6 +404,7 @@ export type RemoteEnvironmentUpdateProgress = z.infer<
|
||||
|
||||
export const agentBootstrapIncompatibleReasonSchema = z.enum([
|
||||
'non-linux',
|
||||
'unsupported-platform',
|
||||
'unsupported-architecture',
|
||||
'home-directory-unavailable',
|
||||
'uid-unavailable',
|
||||
@@ -562,12 +563,12 @@ export const agentBootstrapProbeResultSchema = z.discriminatedUnion(
|
||||
z
|
||||
.object({
|
||||
ready: z.literal(true),
|
||||
platform: z.literal('linux'),
|
||||
platform: z.enum(['linux', 'darwin']),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
canonicalHomeDirectory: canonicalRemotePathSchema,
|
||||
uid: z.number().int().min(0).max(4_294_967_294),
|
||||
shell: canonicalRemotePathSchema,
|
||||
procfs: z.literal('ready')
|
||||
procfs: z.enum(['ready', 'not-applicable'])
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { c as createTar } from 'tar'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ci = require('../build/agent-ci-bundle.cjs') as {
|
||||
readCiLocks(platform?: string, arch?: string): {
|
||||
lock: { agentVersion: string; node: { targets: Record<string, unknown> }; protocol: object }
|
||||
runtimeLock: { runtimes: { opencode: { version: string; targets: Record<string, { package: string; integrity: string }> } } }
|
||||
}
|
||||
}
|
||||
const agent = require('../build/agent-bundle.cjs') as {
|
||||
koffiPayloadPaths(arch: string, platform: string): { required: string[]; native: string[] }
|
||||
createManifest(directory: string, metadata: object): object
|
||||
canonicalManifestBytes(manifest: object): Buffer
|
||||
signaturePayload(bytes: Buffer): Buffer
|
||||
}
|
||||
const runtime = require('../build/remote-runtime-bundle.cjs') as {
|
||||
buildRuntimeBundle(options: object): { bundleDirectory: string; manifest: { platform: string } }
|
||||
}
|
||||
const packager = require('../build/agent-package.cjs') as {
|
||||
assembleAgentPackage(options: object): { sha256: string; descriptor: { platform: string } }
|
||||
}
|
||||
const target = require('../build/agent-build-target.cjs') as {
|
||||
assertTargetBinary(path: string, arch: string, platform: string, label: string): void
|
||||
}
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-darwin-build-test-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function machO(): Buffer {
|
||||
const bytes = Buffer.alloc(64)
|
||||
bytes.writeUInt32LE(0xfeedfacf)
|
||||
bytes.writeUInt32LE(0x0100000c, 4)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function write(root: string, path: string, contents: string | Buffer, mode = 0o644): void {
|
||||
const file = join(root, path)
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
writeFileSync(file, contents)
|
||||
chmodSync(file, mode)
|
||||
}
|
||||
|
||||
describe('Darwin arm64 Agent packages', () => {
|
||||
it('uses the same pinned native inputs for CI and production packaging', () => {
|
||||
const linux = ci.readCiLocks()
|
||||
const darwin = ci.readCiLocks('darwin', 'arm64')
|
||||
expect(linux).toEqual(darwin)
|
||||
expect(darwin.lock.node.targets['darwin-arm64']).toMatchObject({
|
||||
archive: 'node-v24.19.0-darwin-arm64.tar.gz',
|
||||
sha256: '8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d'
|
||||
})
|
||||
expect(darwin.runtimeLock.runtimes.opencode.targets['darwin-arm64']?.package)
|
||||
.toBe('opencode-darwin-arm64')
|
||||
expect(ci.readCiLocks().lock).toEqual(linux.lock)
|
||||
})
|
||||
|
||||
it('rejects a Linux ELF binary even when its architecture is arm64', () => {
|
||||
const root = scratch()
|
||||
write(root, 'native', machO())
|
||||
expect(() => target.assertTargetBinary(join(root, 'native'), 'arm64', 'darwin', 'test')).not.toThrow()
|
||||
expect(() => target.assertTargetBinary(join(root, 'native'), 'arm64', 'linux', 'test')).toThrow()
|
||||
const elf = Buffer.alloc(64)
|
||||
elf.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
elf[5] = 1
|
||||
elf.writeUInt16LE(183, 18)
|
||||
write(root, 'native', elf)
|
||||
expect(() => target.assertTargetBinary(join(root, 'native'), 'arm64', 'darwin', 'test')).toThrow()
|
||||
})
|
||||
|
||||
it('assembles deterministic Darwin packages and rejects cross-platform bundle selection', () => {
|
||||
const root = scratch()
|
||||
const bundle = join(root, 'agent')
|
||||
const { lock, runtimeLock } = ci.readCiLocks('darwin', 'arm64')
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
const identity = { keyId: 'darwin-build-test', privateKey }
|
||||
const registry = {
|
||||
formatVersion: 1,
|
||||
keys: [{
|
||||
keyId: identity.keyId,
|
||||
publicKeySpkiBase64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
||||
environment: 'test'
|
||||
}],
|
||||
revocations: []
|
||||
}
|
||||
write(bundle, 'node', machO(), 0o755)
|
||||
write(bundle, 'goodbuddy-agent', '#!/bin/sh\n', 0o755)
|
||||
write(bundle, 'lib/agent.cjs', '// fixture\n')
|
||||
const koffi = agent.koffiPayloadPaths('arm64', 'darwin')
|
||||
expect(koffi.native).toEqual([
|
||||
'lib/node_modules/@koromix/koffi-darwin-arm64/darwin_arm64/koffi.node'
|
||||
])
|
||||
for (const path of koffi.required) {
|
||||
write(bundle, path, koffi.native.includes(path) ? machO() : '{}')
|
||||
}
|
||||
for (const license of ['GoodBuddy-0BSD', 'Node.js-MIT', 'zod-MIT', 'koffi-MIT', 'koffi-native-MIT']) {
|
||||
write(bundle, `licenses/${license}.txt`, 'fixture license')
|
||||
}
|
||||
const manifest = agent.createManifest(bundle, {
|
||||
platform: 'darwin', arch: 'arm64', agentVersion: lock.agentVersion,
|
||||
nodeVersion: '24.19.0', zodVersion: '4.4.3', koffiVersion: '3.1.4',
|
||||
koffiNativePackage: '@koromix/koffi-darwin-arm64',
|
||||
protocol: lock.protocol, signingKeyId: identity.keyId
|
||||
})
|
||||
const bytes = agent.canonicalManifestBytes(manifest)
|
||||
write(bundle, 'manifest.json', bytes)
|
||||
write(bundle, 'manifest.sig', `${sign(null, agent.signaturePayload(bytes), privateKey).toString('base64')}\n`)
|
||||
const source = join(root, 'source')
|
||||
write(source, 'package/package.json', JSON.stringify({
|
||||
name: 'opencode-darwin-arm64', version: runtimeLock.runtimes.opencode.version
|
||||
}))
|
||||
write(source, 'package/bin/opencode', machO(), 0o755)
|
||||
const archive = join(root, `opencode-darwin-arm64-${runtimeLock.runtimes.opencode.version}.tgz`)
|
||||
createTar({ cwd: source, file: archive, sync: true, gzip: true }, ['package'])
|
||||
runtimeLock.runtimes.opencode.targets['darwin-arm64']!.integrity =
|
||||
`sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}`
|
||||
const built = runtime.buildRuntimeBundle({
|
||||
platform: 'darwin', architecture: 'arm64', runtimeArchive: archive,
|
||||
outputRoot: join(root, 'runtime'), lock: runtimeLock, registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
expect(built.manifest.platform).toBe('darwin')
|
||||
const output = join(root, `goodbuddy-agent-${lock.agentVersion}-darwin-arm64.gbagent`)
|
||||
const options = {
|
||||
platform: 'darwin', architecture: 'arm64', minimumDesktopVersion: '0.12.4',
|
||||
agentBundle: bundle, runtimeBundle: built.bundleDirectory,
|
||||
agentLock: lock, runtimeLock, registry, testSigningIdentity: identity, output
|
||||
}
|
||||
const first = packager.assembleAgentPackage(options)
|
||||
const second = packager.assembleAgentPackage(options)
|
||||
expect(first.sha256).toBe(second.sha256)
|
||||
expect(first.descriptor.platform).toBe('darwin')
|
||||
const files = unzipSync(readFileSync(output))
|
||||
expect(JSON.parse(Buffer.from(files['agent-package.json']!).toString()).platform).toBe('darwin')
|
||||
expect(() => packager.assembleAgentPackage({ ...options, platform: 'linux' }))
|
||||
.toThrow('platform')
|
||||
})
|
||||
})
|
||||
@@ -89,21 +89,26 @@ describe('GoodBuddy Agents workflow', () => {
|
||||
expect(workflow.match(/'src\/shared\/\*\*'/gu)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('builds both Agent architectures on native Linux runners', () => {
|
||||
it('builds Linux and Darwin arm64 targets on native runners', () => {
|
||||
expect(
|
||||
parsedWorkflow.jobs.build?.strategy?.matrix?.include
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
).toEqual([
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
runner: 'ubuntu-24.04'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'arm64',
|
||||
runner: 'ubuntu-24.04-arm'
|
||||
})
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
runner: 'macos-15'
|
||||
}
|
||||
])
|
||||
)
|
||||
expect(parsedWorkflow.jobs.build?.needs).toBe('validate')
|
||||
expect(serializedJob('build')).toContain(
|
||||
'"runs-on":"${{ matrix.runner }}"'
|
||||
@@ -125,7 +130,7 @@ describe('GoodBuddy Agents workflow', () => {
|
||||
it('resolves and verifies the locked official Node runtime', () => {
|
||||
const scripts = jobScripts('build')
|
||||
expect(scripts).toContain(
|
||||
"require('./agent-runtime-lock.json')"
|
||||
"require('./build/agent-ci-bundle.cjs').readCiLocks("
|
||||
)
|
||||
expect(scripts).toContain("url.protocol !== 'https:'")
|
||||
expect(scripts).toContain("url.hostname !== 'nodejs.org'")
|
||||
@@ -133,14 +138,15 @@ describe('GoodBuddy Agents workflow', () => {
|
||||
"curl --proto '=https' --tlsv1.2"
|
||||
)
|
||||
expect(scripts).toContain('--max-filesize 104857600')
|
||||
expect(scripts).toContain('sha256sum --check --strict -')
|
||||
expect(scripts).toContain('Node runtime archive integrity mismatch')
|
||||
expect(scripts).not.toContain('sha256sum')
|
||||
expect(serializedJob('build')).toContain('actions/cache@v6')
|
||||
})
|
||||
|
||||
it('resolves and verifies the locked official OpenCode Runtime', () => {
|
||||
const scripts = jobScripts('build')
|
||||
expect(scripts).toContain(
|
||||
"require('./remote-runtime-lock.json')"
|
||||
"require('./build/agent-ci-bundle.cjs').readCiLocks("
|
||||
)
|
||||
expect(scripts).toContain(
|
||||
'--registry https://registry.npmjs.org/'
|
||||
@@ -219,4 +225,26 @@ describe('GoodBuddy Agents workflow', () => {
|
||||
])
|
||||
).toThrow('Unknown Agent CI bundle argument')
|
||||
})
|
||||
|
||||
it('includes Darwin in production packaging while retaining native target checks', () => {
|
||||
expect(serializedJob('build')).toContain("matrix.platform == 'linux'")
|
||||
expect(jobScripts('build')).toContain('src/agent-daemon/darwin-process-identity.test.ts')
|
||||
const release = readFileSync(
|
||||
join(process.cwd(), '.github/workflows/agent-release.yml'), 'utf8'
|
||||
)
|
||||
expect(release).toContain('macos-15')
|
||||
expect(release).toContain('AGENT_PLATFORM')
|
||||
for (const target of ['darwin', 'win32']) {
|
||||
expect(() => agentCiBundle.parseArguments([
|
||||
'--platform', target, '--arch', 'x64'
|
||||
])).toThrow('Unsupported Agent build target')
|
||||
}
|
||||
expect(agentCiBundle.parseArguments([
|
||||
'--platform', 'darwin', '--arch', 'arm64',
|
||||
'--node-archive', 'node.tar.gz',
|
||||
'--opencode-archive', 'opencode.tgz',
|
||||
'--output-directory', 'bundle',
|
||||
'--archive', 'goodbuddy-agent-1.0.0-darwin-arm64.gbagent'
|
||||
])).toMatchObject({ platform: 'darwin', arch: 'arm64' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user