From 9c6d1611e2da141f9e7b686ffdcd38471764dbfc Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 6 Sep 2026 16:14:02 +0800 Subject: [PATCH] feat: support macOS arm64 remote Agent hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .github/workflows/agent-release.yml | 49 ++++-- .github/workflows/agents.yml | 68 ++++++-- BUILD.md | 24 ++- FEATURES.md | 12 +- FEATURES.zh-CN.md | 2 +- UI-DESIGN.md | 2 +- agent-runtime-lock.json | 6 + build/agent-build-target.cjs | 33 ++++ build/agent-bundle.cjs | 103 ++++++------ build/agent-catalog.cjs | 42 +++-- build/agent-ci-bundle.cjs | 53 +++++- build/agent-package.cjs | 16 +- build/remote-runtime-bundle.cjs | 78 ++++----- docs/development/release-runbook.md | 21 ++- ...vironment-provisioning-technical-design.md | 2 +- docs/features/remote-host/technical-design.md | 47 ++++++ remote-runtime-lock.json | 4 + src/agent-daemon/bundle-file-verification.ts | 31 +--- src/agent-daemon/cli.test.ts | 2 +- src/agent-daemon/cli.ts | 6 +- src/agent-daemon/daemon.ts | 10 +- .../darwin-process-identity.test.ts | 57 +++++++ src/agent-daemon/darwin-process-identity.ts | 139 ++++++++++++++++ .../direct-linux-stdio-process-owner.ts | 20 ++- src/agent-daemon/installed-bundle-verifier.ts | 3 +- src/agent-daemon/linux-process-identity.ts | 9 ++ src/agent-daemon/managed-paths.ts | 6 +- src/agent-daemon/peer-identity-provider.ts | 22 ++- src/agent-daemon/runtime-composition.ts | 4 +- src/agent-daemon/runtime-process-identity.ts | 14 ++ src/main/ipc.test.ts | 10 +- src/main/ipc.ts | 20 ++- .../remote-agent/agent-bundle-verifier.ts | 58 +++---- .../agent-installation-manager.ts | 4 +- .../agent-package-manager.test.ts | 17 ++ .../remote-agent/agent-package-manager.ts | 114 ++++++++++--- .../remote-agent/agent-package-verifier.ts | 2 + .../control-plane-package-installer.ts | 87 +++++----- .../managed-remote-acp-runtime.ts | 6 +- ...d-remote-project-runtime-validator.test.ts | 1 + ...anaged-remote-project-runtime-validator.ts | 2 +- .../remote-agent-connection-manager.ts | 4 +- .../remote-environment-preparer.ts | 19 ++- .../remote-project-save-service.ts | 4 +- .../remote-runtime-installation-manager.ts | 6 +- .../remote-runtime-resource-loader.ts | 2 +- src/main/ssh/ssh-agent-command.test.ts | 3 +- src/main/ssh/ssh-agent-command.ts | 14 +- src/main/ssh/ssh-host-remote-environment.ts | 4 +- .../ssh/ssh-remote-package-bootstrap.test.ts | 2 +- src/main/ssh/ssh-remote-package-bootstrap.ts | 26 +-- src/preload/index.ts | 8 +- .../src/PlatformFeaturesSettingsSection.tsx | 37 +++-- src/renderer/src/SettingsPanel.test.tsx | 10 +- .../i18n/locales/en-US/settingsSections.ts | 6 +- .../i18n/locales/zh-CN/settingsSections.ts | 6 +- src/shared/agent-installation-contracts.ts | 6 +- src/shared/agent-package-contracts.test.ts | 22 +++ src/shared/agent-package-contracts.ts | 24 +-- .../agent-protocol/control-contracts.ts | 2 +- src/shared/agent-target.ts | 29 ++++ src/shared/contracts.ts | 6 +- src/shared/node/binary-target.ts | 22 +++ src/shared/node/runtime-bundle-verifier.ts | 41 ++--- src/shared/remote-runtime-launch-contracts.ts | 7 +- src/shared/ssh-host-contracts.ts | 5 +- tests/agent-darwin-build.test.ts | 151 ++++++++++++++++++ tests/agents-workflow.test.ts | 50 ++++-- 68 files changed, 1283 insertions(+), 439 deletions(-) create mode 100644 build/agent-build-target.cjs create mode 100644 src/agent-daemon/darwin-process-identity.test.ts create mode 100644 src/agent-daemon/darwin-process-identity.ts create mode 100644 src/shared/agent-target.ts create mode 100644 src/shared/node/binary-target.ts create mode 100644 tests/agent-darwin-build.test.ts diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index c0b0a357..9753d8f9 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -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" diff --git a/.github/workflows/agents.yml b/.github/workflows/agents.yml index 6c947169..5058eb9e 100644 --- a/.github/workflows/agents.yml +++ b/.github/workflows/agents.yml @@ -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" diff --git a/BUILD.md b/BUILD.md index 181c28a2..c54fcbc6 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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--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` / diff --git a/FEATURES.md b/FEATURES.md index e93ee3df..ce2e0258 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index c32a8f7a..a59b9622 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -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。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index f218a8cb..561b4c9c 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -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 不丢弃草稿,关闭设置中心前必须先保存或撤销。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index d34ec0d3..16ce5c84 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -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", diff --git a/build/agent-build-target.cjs b/build/agent-build-target.cjs new file mode 100644 index 00000000..5156841f --- /dev/null +++ b/build/agent-build-target.cjs @@ -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 } diff --git a/build/agent-bundle.cjs b/build/agent-bundle.cjs index dec95611..cdcde5b7 100644 --- a/build/agent-bundle.cjs +++ b/build/agent-bundle.cjs @@ -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, diff --git a/build/agent-catalog.cjs b/build/agent-catalog.cjs index 1db28b52..ca0399c3 100644 --- a/build/agent-catalog.cjs +++ b/build/agent-catalog.cjs @@ -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( diff --git a/build/agent-ci-bundle.cjs b/build/agent-ci-bundle.cjs index 9194dad2..17d765ac 100644 --- a/build/agent-ci-bundle.cjs +++ b/build/agent-ci-bundle.cjs @@ -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 } diff --git a/build/agent-package.cjs b/build/agent-package.cjs index 71bfa04e..22cff1bb 100644 --- a/build/agent-package.cjs +++ b/build/agent-package.cjs @@ -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') diff --git a/build/remote-runtime-bundle.cjs b/build/remote-runtime-bundle.cjs index 94073d96..8d0d3f07 100644 --- a/build/remote-runtime-bundle.cjs +++ b/build/remote-runtime-bundle.cjs @@ -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, diff --git a/docs/development/release-runbook.md b/docs/development/release-runbook.md index 00e3616a..2e261db1 100644 --- a/docs/development/release-runbook.md +++ b/docs/development/release-runbook.md @@ -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` 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 diff --git a/docs/features/remote-host/environment-provisioning-technical-design.md b/docs/features/remote-host/environment-provisioning-technical-design.md index 7fe80827..4794e912 100644 --- a/docs/features/remote-host/environment-provisioning-technical-design.md +++ b/docs/features/remote-host/environment-provisioning-technical-design.md @@ -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。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 12092231..fe2099b1 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -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 发布并公开验证双架构工件; diff --git a/remote-runtime-lock.json b/remote-runtime-lock.json index 6e6dcce3..0e34a8a4 100644 --- a/remote-runtime-lock.json +++ b/remote-runtime-lock.json @@ -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==" diff --git a/src/agent-daemon/bundle-file-verification.ts b/src/agent-daemon/bundle-file-verification.ts index 5c71dc14..7fb37420 100644 --- a/src/agent-daemon/bundle-file-verification.ts +++ b/src/agent-daemon/bundle-file-verification.ts @@ -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 { export async function assertElfArchitecture( filePath: string, expected: AgentArchitecture, - label: string + label: string, + platform: AgentPlatform = 'linux' ): Promise { 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 -} diff --git a/src/agent-daemon/cli.test.ts b/src/agent-daemon/cli.test.ts index 78492038..16ba9a1b 100644 --- a/src/agent-daemon/cli.test.ts +++ b/src/agent-daemon/cli.test.ts @@ -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 () => { diff --git a/src/agent-daemon/cli.ts b/src/agent-daemon/cli.ts index c9443cbc..04b229bc 100644 --- a/src/agent-daemon/cli.ts +++ b/src/agent-daemon/cli.ts @@ -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') diff --git a/src/agent-daemon/daemon.ts b/src/agent-daemon/daemon.ts index 7a10edaa..f129f9ca 100644 --- a/src/agent-daemon/daemon.ts +++ b/src/agent-daemon/daemon.ts @@ -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 { - 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' ) } diff --git a/src/agent-daemon/darwin-process-identity.test.ts b/src/agent-daemon/darwin-process-identity.test.ts new file mode 100644 index 00000000..9d409e97 --- /dev/null +++ b/src/agent-daemon/darwin-process-identity.test.ts @@ -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 + } + }) +}) diff --git a/src/agent-daemon/darwin-process-identity.ts b/src/agent-daemon/darwin-process-identity.ts new file mode 100644 index 00000000..bdee36b7 --- /dev/null +++ b/src/agent-daemon/darwin-process-identity.ts @@ -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 | 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 { + 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') + } +} diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.ts b/src/agent-daemon/direct-linux-stdio-process-owner.ts index f98177f2..6723ebd9 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.ts @@ -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 { - 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 { + 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[] = [] diff --git a/src/agent-daemon/installed-bundle-verifier.ts b/src/agent-daemon/installed-bundle-verifier.ts index 45e5c718..913bd74b 100644 --- a/src/agent-daemon/installed-bundle-verifier.ts +++ b/src/agent-daemon/installed-bundle-verifier.ts @@ -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 { diff --git a/src/agent-daemon/linux-process-identity.ts b/src/agent-daemon/linux-process-identity.ts index 08371c89..ebafcd59 100644 --- a/src/agent-daemon/linux-process-identity.ts +++ b/src/agent-daemon/linux-process-identity.ts @@ -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( diff --git a/src/agent-daemon/managed-paths.ts b/src/agent-daemon/managed-paths.ts index dff40643..d2011369 100644 --- a/src/agent-daemon/managed-paths.ts +++ b/src/agent-daemon/managed-paths.ts @@ -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) diff --git a/src/agent-daemon/peer-identity-provider.ts b/src/agent-daemon/peer-identity-provider.ts index a52337fc..355b7fff 100644 --- a/src/agent-daemon/peer-identity-provider.ts +++ b/src/agent-daemon/peer-identity-provider.ts @@ -67,23 +67,39 @@ export async function createLinuxPeerIdentityProvider( byteOrder?: 'BE' | 'LE' loadKoffi?: () => Promise } = {} -): Promise { +): Promise { 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) { diff --git a/src/agent-daemon/runtime-composition.ts b/src/agent-daemon/runtime-composition.ts index 795a350e..b2d7f2a8 100644 --- a/src/agent-daemon/runtime-composition.ts +++ b/src/agent-daemon/runtime-composition.ts @@ -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( diff --git a/src/agent-daemon/runtime-process-identity.ts b/src/agent-daemon/runtime-process-identity.ts index 67a62852..7abfa5ad 100644 --- a/src/agent-daemon/runtime-process-identity.ts +++ b/src/agent-daemon/runtime-process-identity.ts @@ -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 { 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') diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 2787f78e..f03db089 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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( diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ad387da3..c15fb4d5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 ) diff --git a/src/main/remote-agent/agent-bundle-verifier.ts b/src/main/remote-agent/agent-bundle-verifier.ts index c2f9958b..b654a584 100644 --- a/src/main/remote-agent/agent-bundle-verifier.ts +++ b/src/main/remote-agent/agent-bundle-verifier.ts @@ -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 { 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( schema: { safeParse(value: unknown): { diff --git a/src/main/remote-agent/agent-installation-manager.ts b/src/main/remote-agent/agent-installation-manager.ts index 9f8a65b0..ad279a40 100644 --- a/src/main/remote-agent/agent-installation-manager.ts +++ b/src/main/remote-agent/agent-installation-manager.ts @@ -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' } diff --git a/src/main/remote-agent/agent-package-manager.test.ts b/src/main/remote-agent/agent-package-manager.test.ts index 0880b887..6671c845 100644 --- a/src/main/remote-agent/agent-package-manager.test.ts +++ b/src/main/remote-agent/agent-package-manager.test.ts @@ -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( diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts index 7b4fd891..d9933062 100644 --- a/src/main/remote-agent/agent-package-manager.ts +++ b/src/main/remote-agent/agent-package-manager.ts @@ -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() 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 { + 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 { @@ -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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { 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 { @@ -1825,10 +1885,10 @@ export class AgentPackageManager { observer: | ((progress: AgentPackageDownloadProgress) => void) | undefined, - progress: AgentPackageDownloadProgress + progress: Omit ): 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, diff --git a/src/main/remote-agent/agent-package-verifier.ts b/src/main/remote-agent/agent-package-verifier.ts index c427b378..d848ad52 100644 --- a/src/main/remote-agent/agent-package-verifier.ts +++ b/src/main/remote-agent/agent-package-verifier.ts @@ -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 !== diff --git a/src/main/remote-agent/control-plane-package-installer.ts b/src/main/remote-agent/control-plane-package-installer.ts index dabaae02..4cea5918 100644 --- a/src/main/remote-agent/control-plane-package-installer.ts +++ b/src/main/remote-agent/control-plane-package-installer.ts @@ -63,6 +63,7 @@ const windowsReservedNamePattern = type JsonRecord = Record 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, diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts index 02baac5a..84a6e561 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -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) diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts index 01a54fdf..c84c646e 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.test.ts @@ -155,6 +155,7 @@ function harness(options: { installationId: agentInstallationId, binaryDigest: status.binaryDigest, version: status.agentVersion, + platform: 'linux', architecture, protocolMajor: 1 }, diff --git a/src/main/remote-agent/managed-remote-project-runtime-validator.ts b/src/main/remote-agent/managed-remote-project-runtime-validator.ts index d683f8dd..85a4e709 100644 --- a/src/main/remote-agent/managed-remote-project-runtime-validator.ts +++ b/src/main/remote-agent/managed-remote-project-runtime-validator.ts @@ -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( diff --git a/src/main/remote-agent/remote-agent-connection-manager.ts b/src/main/remote-agent/remote-agent-connection-manager.ts index 0fd3562c..482d920f 100644 --- a/src/main/remote-agent/remote-agent-connection-manager.ts +++ b/src/main/remote-agent/remote-agent-connection-manager.ts @@ -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' ) { diff --git a/src/main/remote-agent/remote-environment-preparer.ts b/src/main/remote-agent/remote-environment-preparer.ts index c666569d..cbd637df 100644 --- a/src/main/remote-agent/remote-environment-preparer.ts +++ b/src/main/remote-agent/remote-environment-preparer.ts @@ -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 { 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 架构不匹配') diff --git a/src/main/remote-agent/remote-project-save-service.ts b/src/main/remote-agent/remote-project-save-service.ts index f1dd142e..d763fb83 100644 --- a/src/main/remote-agent/remote-project-save-service.ts +++ b/src/main/remote-agent/remote-project-save-service.ts @@ -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' ) { diff --git a/src/main/remote-agent/remote-runtime-installation-manager.ts b/src/main/remote-agent/remote-runtime-installation-manager.ts index ba7cecdd..d4ebe23a 100644 --- a/src/main/remote-agent/remote-runtime-installation-manager.ts +++ b/src/main/remote-agent/remote-runtime-installation-manager.ts @@ -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) { diff --git a/src/main/remote-agent/remote-runtime-resource-loader.ts b/src/main/remote-agent/remote-runtime-resource-loader.ts index 0004605b..06b8bde8 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.ts @@ -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( diff --git a/src/main/ssh/ssh-agent-command.test.ts b/src/main/ssh/ssh-agent-command.test.ts index 0d8e8609..029a7046 100644 --- a/src/main/ssh/ssh-agent-command.test.ts +++ b/src/main/ssh/ssh-agent-command.test.ts @@ -214,7 +214,8 @@ describe('fixed Agent SSH commands', () => { const cases: Array< [Partial>, 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'], diff --git a/src/main/ssh/ssh-agent-command.ts b/src/main/ssh/ssh-agent-command.ts index 53374b26..f39f8ec7 100644 --- a/src/main/ssh/ssh-agent-command.ts +++ b/src/main/ssh/ssh-agent-command.ts @@ -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' }) } diff --git a/src/main/ssh/ssh-host-remote-environment.ts b/src/main/ssh/ssh-host-remote-environment.ts index 242f4300..3d582c77 100644 --- a/src/main/ssh/ssh-host-remote-environment.ts +++ b/src/main/ssh/ssh-host-remote-environment.ts @@ -83,7 +83,7 @@ export type SshHostRemoteEnvironmentInspectorOptions = { remoteRuntimeLockPath: string loadRemoteEnvironmentCatalog?: ( architecture: 'x64' | 'arm64', - options: { signal?: AbortSignal } + options: { signal?: AbortSignal; platform?: 'linux' | 'darwin' } ) => Promise now?: () => Date } @@ -155,7 +155,7 @@ export class SshHostRemoteEnvironmentInspector { await Promise.all([ this.#loadRemoteEnvironmentCatalog( probe.architecture, - { signal } + { signal, platform: probe.platform } ), readOptionalRegistry( sftp, diff --git a/src/main/ssh/ssh-remote-package-bootstrap.test.ts b/src/main/ssh/ssh-remote-package-bootstrap.test.ts index 8e085539..d1afa7c4 100644 --- a/src/main/ssh/ssh-remote-package-bootstrap.test.ts +++ b/src/main/ssh/ssh-remote-package-bootstrap.test.ts @@ -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"', ); }); diff --git a/src/main/ssh/ssh-remote-package-bootstrap.ts b/src/main/ssh/ssh-remote-package-bootstrap.ts index ca1fe792..6b53ca56 100644 --- a/src/main/ssh/ssh-remote-package-bootstrap.ts +++ b/src/main/ssh/ssh-remote-package-bootstrap.ts @@ -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, }, diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a85fc0f..8b33db6b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -450,19 +450,19 @@ const desktopApi: DesktopApi = { ipcChannels.sshHostsAgentPackageInventory, { refresh } ) as Promise, - downloadAgentPackage: (architecture: AgentArchitecture) => + downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') => ipcRenderer.invoke( ipcChannels.sshHostsAgentPackageDownload, - { architecture } + { architecture, platform } ) as Promise, importAgentPackage: () => ipcRenderer.invoke( ipcChannels.sshHostsAgentPackageImport ) as Promise, - exportAgentPackage: async (architecture: AgentArchitecture) => { + exportAgentPackage: async (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') => { await ipcRenderer.invoke( ipcChannels.sshHostsAgentPackageExport, - { architecture } + { architecture, platform } ) }, onAgentPackageProgress: (listener) => { diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index cafe0008..f41ba141 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -109,7 +109,7 @@ export function PlatformFeaturesSettingsSection({ const [agentInventoryError, setAgentInventoryError] = useState() const [agentPackageBusy, setAgentPackageBusy] = - useState() + useState() const [agentPackageProgress, setAgentPackageProgress] = useState() const agentInventoryRequested = useRef(false) @@ -195,23 +195,24 @@ export function PlatformFeaturesSettingsSection({ }, []) const downloadAgentPackage = async ( - architecture: AgentArchitecture + architecture: AgentArchitecture, + platform: 'linux' | 'darwin' ): Promise => { 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 => { 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) => (