Compare commits

..
Author SHA1 Message Date
mesalogo 237b8a8cee chore: prepare GoodBuddy 0.12.6 and Agent 0.11.20 releases
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Record approved bilingual notes for complete subagent progress, workspace diffs, knowledge selection, OpenCode stream cleanup, external command directories, and restored GitHub Agent updates. Remote progress and diff improvements require the new Agent package. No LoongArch preview is included.
2026-09-06 22:39:41 +08:00
mesalogo d08ba33c3e fix: complete subagent output and file workspace interactions
OpenCode child cards now show ordered text, reasoning and tool progress,
with the final result displayed separately. Local SDK and remote ACP paths
share child tracking, including progress retained across recovery and cancellation.
Remote live progress requires an Agent package containing the event plugin;
older Agent packages still expose final results without inventing missing history.

Knowledge-library rows remain clickable when native label activation changes
focus. File workspace changes use colored Git status letters and per-file
staged/unstaged diffs, including deleted, renamed and untracked files.
Refreshing reloads the selected diff and expanded directories, and more than
50 changes remain accessible through incremental loading.

Validated with 3552 passing tests, typecheck, lint, desktop build, current-source
Linux Host scenarios and isolated Windows desktop UI interactions.

Release note: 展开 OpenCode 子代理卡片可查看执行过程、工具详情与独立最终结果;知识库支持整行勾选。工作区支持单文件暂存与未暂存差异,刷新会更新当前差异,更多变更可继续加载。远端实时过程需要更新 GoodBuddy Agent。
2026-09-06 22:13:45 +08:00
mesalogo b03c25685c fix: clean up OpenCode streams and allow external working directories
Completed OpenCode requests left their event streams open across repeated and
parallel conversations. Each request now closes its own subscription on exit
without cancelling peer sessions, and cancellation preserves its original cause.

Direct-model Execute commands no longer restrict cwd to the workspace. Absolute
paths, parent-relative paths, and symlinks to external directories are accepted.
The workspace remains the default and relative-path base; Ask stays read-only,
and local execution binding, directory validation, and process cleanup remain.

Add real OpenCode lifecycle and real Shell tool-loop regressions, plus matching
feature documentation. Focused checks: 182 passed, 1 skipped; typecheck and lint
passed. Full shared-worktree suite: 3542 passed, 57 skipped, 1 unrelated sidebar
heading failure, reproduced separately and documented.

Release note: 修复 OpenCode 连续及并行对话结束后事件连接未释放的问题;直连模型 Execute 命令现在可直接指定工作区外的绝对路径、相对路径或符号链接目录,Ask 只读边界保持不变。
2026-09-06 19:53:42 +08:00
mesalogo 7003141e56 fix: restore GitHub Agent update checks with paginated discovery
The GitHub release history exceeded the catalog response limit, preventing users from checking Agent updates. Read ten releases per page and stop at the first eligible catalog while preserving the existing 100-release search window and signature checks.

Release note: 修复选择 GitHub 来源时 Agent 更新检查提示发布目录超出大小限制的问题;镜像更新和已有本地包保持不变。
2026-09-06 19:25:40 +08:00
mesalogo e692362414 chore: move ShareServer development to a standalone repository
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Remove the extracted ShareServer prototype and its server-specific documents
from Desktop. Link the shared network architecture to goodbuddy-shareserver,
while retaining Desktop ownership of the cross-product architecture.

Preserve local generated artifacts and ignore the old server build output.
Desktop runtime behavior and released user data are unchanged. Desktop tests,
typecheck, and lint passed; the standalone service was validated separately.
2026-09-06 19:02:41 +08:00
mesalogo ee804edcae fix: isolate desktop lint from ShareServer configuration
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Exclude the independent prototype from root lint and explicitly anchor TypeScript parser configuration to the Desktop repository. This fixes clean CI runners detecting both root and ShareServer configuration directories. Product behavior and approved release notes are unchanged.
2026-09-06 17:49:44 +08:00
mesalogo 85a4a374fa fix: keep ShareServer prototype tests outside desktop releases
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Exclude the unfinished standalone ShareServer prototype from the Desktop root test suite and remove its CI dependency installation. Preserve its independent test configuration and document the release boundary. Approved Desktop and Agent release notes remain unchanged.
2026-09-06 17:37:37 +08:00
mesalogo 89a81dd07c fix: install ShareServer test dependencies in release CI
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The root validation suite includes ShareServer tests, but clean CI runners only installed Desktop dependencies and could not resolve react-router-dom. Install the independently locked ShareServer dependencies before running validators. Release content and approved bilingual notes are unchanged.
2026-09-06 17:01:50 +08:00
mesalogo 0eb664c0b6 chore: prepare GoodBuddy 0.12.5 and Agent 0.11.19 releases
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Record approved bilingual release notes and align Desktop and Agent version metadata. Publish Desktop first so users can upgrade before the shared Agent catalog gains macOS arm64 entries. No LoongArch preview is included.
2026-09-06 16:44:46 +08:00
mesalogo 9c6d1611e2 feat: support macOS arm64 remote Agent hosts
Extend GoodBuddy Agent packaging, installation, SSH attachment, Runtime lifecycle, and Desktop package management to macOS arm64. Linux x64, Linux arm64, and Darwin arm64 share the existing signed catalog. Preserve Ask read-only behavior and Execute account permissions, and stop detached tool descendants during Mac cancellation.

Upgrade Desktop before publishing the first mixed-platform catalog because older Linux-only readers reject Darwin entries. Intel Macs are not supported; no launchd service or administrator access is required. This commit does not publish a release.

Validation: 3520 tests passed, 57 skipped; typecheck, lint, and development build passed. Real Mac installation, Ask/Execute, cancellation and reconnect passed; final Linux installation, Ask and reconnect passed. Nine real model requests were recorded.

Release note: 支持将 Apple Silicon Mac 用作远程项目 Host,可安装 macOS arm64 Agent 并使用 Ask、Execute 和断线重连;与 Linux 共用一个包目录,发布混合目录前需升级 Desktop。
2026-09-06 16:14:02 +08:00
mesalogo 1f01c26e04 fix: keep replies streaming while switching windows
Switching away from the desktop window and back could mark a reply that was
still being generated as "上次运行意外中断", freeze its output partway through,
and push the next message into the pending queue even though nothing had
actually failed. The conversation area also shifted on each switch because the
OpenCode runtime controls were unmounted and re-detected on every refresh.

Reading the conversation store no longer treats an in-progress reply as
interrupted; only startup recovery marks a run that an application exit truly
cut off. Runtime feature detection is now keyed on the resolved runtime scope,
so the OpenCode toolbar and manual Compact control stay in place across
refreshes. No request timeout or recovery behavior changed.

Release note: 修复来回切换桌面窗口时,正在生成的回复被误判为"上次运行意外中断"、输出中途停住、之后发送的消息只能进入待发送队列的问题。使用 OpenCode Runtime 时,会话区也不再因重复检测专属功能而抖动。
2026-09-05 16:28:45 +08:00
mesalogo dd8289fdef fix: make local knowledge bases ready for daily use
Knowledge selection was shared globally, document readiness overstated usable content, and important import and source-management paths exposed technical details or lacked safe recovery actions. Knowledge scope now persists per conversation, readiness reflects real processing channels, and the workspace guides users from creation and import into cited chat with controlled source actions.

Release note: 知识库现按对话保存使用范围,准确区分可用、处理中和失败文档,并提供更简洁的创建、检索测试、来源打开、删除确认和失败重试流程。
2026-09-05 15:51:07 +08:00
mesalogo d5fa0ec67c fix: keep long conversation content scrollable
Large messages containing reasoning, tool lists, or long replies could use an estimated off-screen height that left later content outside the reachable scroll range. Conversation messages now participate in layout with their actual height while the existing 80-message render window keeps long histories bounded.

Release note: 修复偶发的长会话滚动条提前到底问题;包含较长推理、工具记录或正文时,现在可以直接滚动查看全部已加载内容,无需切换会话刷新。
2026-09-05 14:51:21 +08:00
mesalogo 6eb73657e0 chore: ignore Python bytecode caches 2026-09-05 14:02:53 +08:00
mesalogo 4041edefa8 feat: add ShareServer console prototype
ShareServer previously existed only as design documents. The repository now includes a standalone Hono service and a responsive React management-console prototype covering administrator and member workflows, with local build, test, and container entry points.

The sharing design now includes organization-scoped digital agents and explicit local partitioned-memory selection for @ calls. Memory documentation consistently uses partitioned memory and treats time or internal events only as optional recall triggers.

This remains an interactive prototype: fixture actions do not persist, and production authentication, PostgreSQL isolation, device connections, routing, relay, and federation still require implementation.

Release note: 新增可独立运行的 ShareServer 管理控制台交互原型,并完善组织数字人与分区记忆共享设计;当前原型数据和操作不会写入生产系统。
2026-09-05 14:01:20 +08:00
156 changed files with 4796 additions and 1695 deletions
+35 -14
View File
@@ -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"
+54 -14
View File
@@ -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"
+4
View File
@@ -1,6 +1,8 @@
node_modules/
out/
dist/
# Local build output left by the extracted ShareServer project.
shareserver/build-server/
coverage/
.vite/
.runtime-resources/
@@ -8,6 +10,8 @@ coverage/
.agent-resources/
*.log
*.tsbuildinfo
__pycache__/
*.py[cod]
.env
.env.*
!.env.example
+20 -4
View File
@@ -145,7 +145,7 @@ npm run dist:linux:arm64
组件,缺少远端包也不能阻塞普通桌面打包或发布。
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
用户也可导入或导出 `.gbagent` 离线包。
@@ -254,16 +254,32 @@ node build/agent-package.cjs build \
Agent 源码、共享协议、lock、bundle 工具和测试与桌面应用保持在同一仓库、同一 commit;
不维护长期分叉的 Agent 源码分支。`.github/workflows/agents.yml` 在相关 Pull Request、
`main` push 和手工触发时,分别使用 `ubuntu-24.04` 与 `ubuntu-24.04-arm` 原生构建
Linux x64/arm64 复合包。每个 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
Linux x64/arm64 复合包,并使用 `macos-15` 原生构建 Darwin arm64
复合包。所有 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
地址与 digest,并从 `remote-runtime-lock.json` 解析固定 OpenCode npm 包与 integrity;
下载后再次校验,随后使用仅存在于进程内的临时 Ed25519 测试 key 构建两次,完成内外层
测试签名验证、Agent 原生启动 smoke 和确定性 `.gbagent` 对比。
测试签名验证、Agent CLI 加载 smoke 和确定性 `.gbagent` 对比。所有目标还验证
Koffi 原生模块加载与 OpenCode `--version`;Linux job 继续运行 Agent 原生回归。
Darwin arm64 的 Node 和 OpenCode 固定输入分别由同一份
`agent-runtime-lock.json` 与 `remote-runtime-lock.json` 提供,不再维护 CI 专用副本。
组包器区分 ELF 与 Mach-O,Darwin CI 产物命名为
`goodbuddy-agent-<version>-darwin-arm64.gbagent`,不构建 Intel Mac 目标。
macOS Host 继续通过 SSH 按需启动 detached Agent,不注册 launchd 或开机服务。
进程身份使用 macOS 原生接口,Unix Socket 使用 `getpeereid`;Host 下载兼容系统自带
`shasum -a 256`,不要求安装 GNU coreutils。真实 Host 验证记录见
[远程主机技术设计](./docs/features/remote-host/technical-design.md)。
三个目标共用 `agent-catalog.json` 和现有签名格式,条目按平台与架构区分。旧版 Desktop
只接受 Linux catalog 条目;首次发布包含 Darwin 的累计 catalog 前必须先发布新版
Desktop,并在发布说明中要求用户升级 Desktop。旧版的在线 Agent 目录检查会失败,
本地已验证包不被删除。不得为此改写历史包或复用发布标签。
该验证 workflow 不读取 production signing secret,不修改公开 key registry,也不上传
可安装 Agent 工件。`.github/workflows/agent-release.yml` 是唯一 production Agent
发布路径:只接受指向受保护 `main` 历史的 annotated
`agent-v${agent-runtime-lock.agentVersion}` 标签,在 `agent-signing` Environment 中
原生构建两种架构,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
原生构建 Linux x64、Linux arm64、macOS arm64 三个目标,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
- `GOODBUDDY_SIGNING_KEY_ID` /
+32 -10
View File
@@ -32,6 +32,10 @@ otherwise.
Host heading and only the remote path on each project row.
- [x] **File, screenshot, window, and clipboard context**: Added to model
context only after explicit user selection.
- [x] **Per-file workspace diffs**: Changed files expose separate staged and
unstaged diffs, including deleted, renamed, and untracked files. Refresh
reloads the selected diff and expanded directories; more than 50 changes
remain accessible through incremental loading.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
constrained Mermaid diagrams, and static in-conversation HTML previews.
Complete HTML and HTML code blocks can be previewed in place after an Agent
@@ -60,8 +64,8 @@ otherwise.
supervision, unified Runtime monitoring, managed processes, safe static HTML
preview, target-pinnable workspace/browser/artifact instances, bottom
docking, and separate windows. Task Center remains the singleton Task index;
attachments and knowledge remain in the conversation composer, while future
memory and historical execution context belong to the associated Task. See
attachments and knowledge remain in the conversation composer, while memory,
when implemented, and historical execution context belong to the associated Task. See
the [Feature PRD](./docs/features/assistant-workbar/prd.md).
### Agent Runtimes and model connections
@@ -74,7 +78,9 @@ otherwise.
and capability scope. OpenCode, Continue, DeepSeek Harness, and managed SSH
do not receive duplicate copies of these tools. The Windows local command
path and a real-model edit, test, fix, and review loop have passed; native
macOS and Linux command validation remains.
macOS and Linux command validation remains. Execute commands can select
absolute, relative, or symbolic-link directories outside the workspace;
the workspace stays the default and relative-path base. Ask remains read-only.
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
variable allowlist, unified configuration, cancellation, total execution
limits, bounded streaming output, and activity records. Shared process
@@ -123,12 +129,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
@@ -148,9 +158,11 @@ otherwise.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
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
`0.11.20`, while the current Desktop release candidate is `0.12.6`; 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
@@ -207,6 +219,12 @@ otherwise.
up to three read-only experts running in parallel. Chat shows each
expandable full expert response first and the main Agent's synthesis below,
and persists both with the conversation.
- [x] **OpenCode child progress and final results**: Child cards show ordered
text, reasoning, and tool progress separately from the final result. Remote
live progress requires Agent `0.11.20`; older packages still expose final
results without reconstructing missing progress.
- [x] **OpenCode event-stream cleanup**: Each completed or cancelled request
closes its own subscription without cancelling parallel conversations.
- [x] **Role-bound model connections**: Each role can inherit the default model
or select an independent text-model connection. Invalid connections safely
fall back to the default; the synthesis role always inherits the default.
@@ -353,7 +371,11 @@ otherwise.
report an experimental Resource catalog; the current Continue version
explicitly does not support Resources.
- [x] **Local knowledge bases**: Supports file, directory, and web imports,
SQLite FTS5 retrieval, and source tracing.
SQLite FTS5 retrieval, and source tracing. Knowledge selection persists per
conversation and starts empty for new conversations. Advanced creation and
retrieval parameters are collapsed by default. Documents distinguish ready,
processing, and failed states, with source opening, retry, and confirmation
before source removal.
- [ ] **External knowledge-base connections** (planned): Manage Dify, FastGPT,
and RAGFlow instances from the existing Knowledge page, then discover and
bind remote knowledge bases with provider-specific retrieval settings and
@@ -432,8 +454,8 @@ otherwise.
created and edited for Global or one or more selected Projects. Legacy
single-project settings migrate without loss, and project-level memory and
action output must explicitly target a Project in scope. Task Center and
Settings no longer duplicate the form. Future partitioned memory remains an
independently designed long-term direction. See the
Settings no longer duplicate the form. Partition-aware review, candidate
generation, and recall triggers remain to be designed. See the
[Smart Heartbeat PRD](./docs/features/smart-heartbeat/prd.md).
- [ ] **General supervision** (planned): Uses a fixed supervision section to
observe user-selected conversations, Tasks, automations, or experiments and
+7 -3
View File
@@ -18,6 +18,7 @@
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异和已展开目录,超过 50 项变更可继续加载。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
@@ -29,13 +30,16 @@
- [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。
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
@@ -56,7 +60,7 @@
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
- [ ] **外部知识库接入**(规划中):在现有知识库页面管理 Dify、FastGPT 和 RAGFlow 实例;新建知识库时可读取并绑定远端知识库,保留 Provider 专属检索配置和引用。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
@@ -74,7 +78,7 @@
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
+5 -2
View File
@@ -609,6 +609,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
排除在可滚动范围之外。
### 13.2 最近对话
@@ -640,7 +643,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 状态卡片使用统一状态令牌,不只依赖颜色。
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
- “未来分区记忆”仅为长期方向,数据、状态和页面尚未设计,不得显示占位入口。
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
### 13.5 Task Center
@@ -700,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 不丢弃草稿,关闭设置中心前必须先保存或撤销。
+7 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.18",
"agentVersion": "0.11.20",
"protocol": {
"major": 2,
"minor": 0
@@ -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",
+33
View File
@@ -0,0 +1,33 @@
const { readFileSync } = require('node:fs')
const {
detectBinaryArchitecture,
detectElfArchitecture
} = require('./binary-architecture.cjs')
function targetName(architecture, platform = 'linux') {
if (
!(platform === 'linux' && ['x64', 'arm64'].includes(architecture)) &&
!(platform === 'darwin' && architecture === 'arm64')
) {
throw new Error(`Unsupported Agent build target: ${platform}-${architecture}`)
}
return `${platform}-${architecture}`
}
function assertTargetBinary(filePath, architecture, platform, label) {
targetName(architecture, platform)
const header = readFileSync(filePath).subarray(0, 64)
const isMachO = header.length >= 8 &&
(header.readUInt32LE(0) === 0xfeedfacf ||
header.readUInt32BE(0) === 0xfeedfacf)
const actual = platform === 'linux'
? detectElfArchitecture(header)
: isMachO ? detectBinaryArchitecture(header) : undefined
if (actual !== architecture) {
throw new Error(
`${label} architecture mismatch: expected ${platform}-${architecture}, received ${actual ?? 'unknown'}`
)
}
}
module.exports = { targetName, assertTargetBinary }
+55 -48
View File
@@ -29,9 +29,11 @@ const {
sep
} = require('node:path')
const tar = require('tar')
const { detectElfArchitecture } = require('./binary-architecture.cjs')
const {
detectElfArchitecture
} = require('./binary-architecture.cjs')
targetName,
assertTargetBinary
} = require('./agent-build-target.cjs')
const {
preflightRegisteredProductionKey
} = require('./signing-key-preflight.cjs')
@@ -132,15 +134,8 @@ function readTrustedKeyRegistry(projectRoot = root) {
return registry
}
function targetName(arch) {
if (!supportedArchitectures.includes(arch)) {
throw new Error(`Unsupported Agent architecture: ${arch}`)
}
return `linux-${arch}`
}
function lockedRuntimeInput(lock, arch, archivePath) {
const target = targetName(arch)
function lockedRuntimeInput(lock, arch, archivePath, platform = 'linux') {
const target = targetName(arch, platform)
const input = lock.node?.targets?.[target]
if (
!input ||
@@ -177,24 +172,23 @@ function lockedRuntimeInput(lock, arch, archivePath) {
function assertElfArchitecture(
filePath,
expectedArchitecture,
description = 'Agent Node runtime'
description = 'Agent Node runtime',
platform = 'linux'
) {
const actual = detectElfArchitecture(readFileSync(filePath).subarray(0, 64))
if (actual !== expectedArchitecture) {
throw new Error(
`${description} architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}`
)
}
assertTargetBinary(filePath, expectedArchitecture, platform, description)
}
function koffiNativePackageName(arch) {
return `@koromix/koffi-linux-${arch}`
function koffiNativePackageName(arch, platform = 'linux') {
return `@koromix/koffi-${targetName(arch, platform)}`
}
function koffiPayloadPaths(arch) {
function koffiPayloadPaths(arch, platform = 'linux') {
const packageRoot = `lib/node_modules/${koffiPackageName}`
const nativeRoot =
`lib/node_modules/${koffiNativePackageName(arch)}`
`lib/node_modules/${koffiNativePackageName(arch, platform)}`
const native = platform === 'linux'
? [`${nativeRoot}/linux_${arch}/koffi.node`, `${nativeRoot}/musl_${arch}/koffi.node`]
: [`${nativeRoot}/darwin_${arch}/koffi.node`]
return {
packageRoot,
nativeRoot,
@@ -205,13 +199,9 @@ function koffiPayloadPaths(arch) {
`${packageRoot}/src/koffi/src/static.js`,
`${nativeRoot}/package.json`,
`${nativeRoot}/index.js`,
`${nativeRoot}/linux_${arch}/koffi.node`,
`${nativeRoot}/musl_${arch}/koffi.node`
...native
],
native: [
`${nativeRoot}/linux_${arch}/koffi.node`,
`${nativeRoot}/musl_${arch}/koffi.node`
]
native
}
}
@@ -359,7 +349,7 @@ function createManifest(bundleDirectory, metadata) {
formatVersion: 1,
product: 'GoodBuddy',
agentVersion: metadata.agentVersion,
platform: 'linux',
platform: metadata.platform ?? 'linux',
arch: metadata.arch,
protocol: metadata.protocol,
signingKeyId: metadata.signingKeyId,
@@ -452,7 +442,10 @@ function validateManifestShape(manifest, expected) {
manifest.product !== 'GoodBuddy' ||
typeof manifest.agentVersion !== 'string' ||
!semanticVersionPattern.test(manifest.agentVersion) ||
manifest.platform !== 'linux' ||
!(
manifest.platform === 'linux' ||
(manifest.platform === 'darwin' && manifest.arch === 'arm64')
) ||
!supportedArchitectures.includes(manifest.arch) ||
!Number.isSafeInteger(manifest.protocol?.major) ||
!Number.isSafeInteger(manifest.protocol?.minor) ||
@@ -617,7 +610,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
throw new Error(`Agent entrypoint payload is missing: ${required}`)
}
}
const koffiPaths = koffiPayloadPaths(manifest.arch)
const koffiPaths = koffiPayloadPaths(manifest.arch, manifest.platform)
for (const required of koffiPaths.required) {
if (!declaredPaths.has(required)) {
throw new Error(`Agent Koffi payload is missing: ${required}`)
@@ -626,7 +619,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
const koffiLicense = manifest.licenses.find(
(license) => license.package === koffiPackageName
)
const nativePackage = koffiNativePackageName(manifest.arch)
const nativePackage = koffiNativePackageName(manifest.arch, manifest.platform)
const nativeLicense = manifest.licenses.find(
(license) => license.package === nativePackage
)
@@ -641,13 +634,16 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
}
assertElfArchitecture(
join(bundleDirectory, manifest.entrypoint.runtimePath),
manifest.arch
manifest.arch,
'Agent Node runtime',
manifest.platform
)
for (const nativePath of koffiPaths.native) {
assertElfArchitecture(
join(bundleDirectory, ...nativePath.split('/')),
manifest.arch,
'Agent Koffi native binding'
'Agent Koffi native binding',
manifest.platform
)
}
return {
@@ -668,7 +664,8 @@ function assertReplaceableBundle(directory) {
if (
manifest.formatVersion === 1 &&
manifest.product === 'GoodBuddy' &&
manifest.platform === 'linux'
(manifest.platform === 'linux' ||
(manifest.platform === 'darwin' && manifest.arch === 'arm64'))
) {
return
}
@@ -717,13 +714,13 @@ function copyRegularFile(source, destination) {
chmodSync(destination, 0o644)
}
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion, platform = 'linux') {
const sourceRoot = join(projectRoot, 'node_modules', koffiPackageName)
const packageMetadata = readJson(
join(sourceRoot, 'package.json'),
'Koffi package metadata'
)
const nativePackage = koffiNativePackageName(arch)
const nativePackage = koffiNativePackageName(arch, platform)
const nativeSourceRoot = join(
projectRoot,
'node_modules',
@@ -739,7 +736,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
nativeMetadata.name !== nativePackage ||
nativeMetadata.version !== lockedVersion ||
!Array.isArray(nativeMetadata.os) ||
!nativeMetadata.os.includes('linux') ||
!nativeMetadata.os.includes(platform) ||
!Array.isArray(nativeMetadata.cpu) ||
!nativeMetadata.cpu.includes(arch)
) {
@@ -748,10 +745,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
)
}
const paths = koffiPayloadPaths(arch)
const paths = koffiPayloadPaths(arch, platform)
const files = [
['package.json', `${paths.packageRoot}/package.json`],
['index.js', `${paths.packageRoot}/index.js`],
['index.cjs', `${paths.packageRoot}/index.cjs`],
['src/koffi/index.cjs', `${paths.packageRoot}/src/koffi/index.cjs`],
['src/koffi/src/static.cjs', `${paths.packageRoot}/src/koffi/src/static.cjs`],
[
'src/koffi/index.js',
`${paths.packageRoot}/src/koffi/index.js`
@@ -770,8 +770,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
for (const source of [
'package.json',
'index.js',
`linux_${arch}/koffi.node`,
`musl_${arch}/koffi.node`
...paths.native.map((path) => path.slice(paths.nativeRoot.length + 1))
]) {
const destination = `${paths.nativeRoot}/${source}`
copyRegularFile(
@@ -783,7 +782,8 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
assertElfArchitecture(
join(staging, ...nativePath.split('/')),
arch,
'Agent Koffi native binding'
'Agent Koffi native binding',
platform
)
}
return {
@@ -795,11 +795,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
function buildAgentBundle(options) {
const projectRoot = options.projectRoot ?? root
const arch = options.arch
const platform = options.platform ?? 'linux'
const lock = options.lock ?? readRuntimeLock(projectRoot)
const runtimeInput = lockedRuntimeInput(
lock,
arch,
options.runtimeArchive
options.runtimeArchive,
platform
)
const registry =
options.registry ?? readTrustedKeyRegistry(projectRoot)
@@ -843,13 +845,13 @@ function buildAgentBundle(options) {
const destination =
options.outputDirectory ??
join(projectRoot, '.agent-resources', targetName(arch))
join(projectRoot, '.agent-resources', targetName(arch, platform))
mkdirSync(dirname(destination), { recursive: true })
const staging = mkdtempSync(
join(dirname(destination), `.stage-${targetName(arch)}-`)
join(dirname(destination), `.stage-${targetName(arch, platform)}-`)
)
const runtimeStaging = mkdtempSync(
join(tmpdir(), `goodbuddy-node-${targetName(arch)}-`)
join(tmpdir(), `goodbuddy-node-${targetName(arch, platform)}-`)
)
try {
tar.x({
@@ -872,7 +874,7 @@ function buildAgentBundle(options) {
runtimeStaging,
...runtimeInput.licensePath.split('/')
)
assertElfArchitecture(runtimeBinary, arch)
assertElfArchitecture(runtimeBinary, arch, 'Agent Node runtime', platform)
copyFileSync(runtimeBinary, join(staging, 'node'))
chmodSync(join(staging, 'node'), 0o755)
@@ -912,7 +914,8 @@ function buildAgentBundle(options) {
projectRoot,
staging,
arch,
lock.koffi.version
lock.koffi.version,
platform
)
const launcher = [
'#!/bin/sh',
@@ -980,6 +983,7 @@ function buildAgentBundle(options) {
acpSdkVersion: acpSdkPackage.version,
koffiVersion: koffi.version,
koffiNativePackage: koffi.nativePackage,
platform,
arch,
protocol: lock.protocol,
signingKeyId: signingIdentity.keyId
@@ -1004,6 +1008,7 @@ function buildAgentBundle(options) {
? 'test'
: 'production',
expected: {
platform,
agentVersion: lock.agentVersion,
arch,
protocol: lock.protocol
@@ -1073,6 +1078,7 @@ function verifyLockedBundle(bundleDirectory, arch, options = {}) {
options.registry ?? readTrustedKeyRegistry(projectRoot),
verificationEnvironment: options.verificationEnvironment ?? 'production',
expected: {
platform: options.platform ?? 'linux',
agentVersion: lock.agentVersion,
arch,
protocol: lock.protocol
@@ -1203,6 +1209,7 @@ function main(argv = process.argv.slice(2)) {
}
module.exports = {
koffiPayloadPaths,
assertElfArchitecture,
assertSafeManifestPath,
buildAgentBundle,
+33 -9
View File
@@ -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(
+50 -3
View File
@@ -24,6 +24,7 @@ const {
assembleAgentPackage
} = require('./agent-package.cjs')
const { sha256File } = require('./file-hash.cjs')
const { targetName } = require('./agent-build-target.cjs')
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
@@ -34,6 +35,7 @@ function parseArguments(argv) {
if (
![
'--arch',
'--platform',
'--node-archive',
'--opencode-archive',
'--output-directory',
@@ -56,6 +58,8 @@ function parseArguments(argv) {
if (!supportedArchitectures.includes(options.arch)) {
throw new Error('--arch must be x64 or arm64')
}
const platform = options.platform ?? 'linux'
targetName(options.arch, platform)
for (const key of [
'nodearchive',
'opencodearchive',
@@ -70,6 +74,7 @@ function parseArguments(argv) {
}
return {
arch: options.arch,
platform,
nodeArchive: resolve(options.nodearchive),
opencodeArchive: resolve(options.opencodearchive),
outputDirectory: resolve(options.outputdirectory),
@@ -77,6 +82,14 @@ function parseArguments(argv) {
}
}
function readCiLocks(platform = 'linux', architecture = 'x64') {
const projectRoot = resolve(__dirname, '..')
targetName(architecture, platform)
const lock = readRuntimeLock(projectRoot)
const runtimeLock = readRemoteRuntimeLock(projectRoot)
return { lock, runtimeLock }
}
function ephemeralSigningIdentity() {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return {
@@ -97,8 +110,12 @@ function ephemeralSigningIdentity() {
async function buildCiAgentBundle(options) {
const projectRoot = resolve(__dirname, '..')
const lock = readRuntimeLock(projectRoot)
const runtimeLock = readRemoteRuntimeLock(projectRoot)
const platform = options.platform ?? 'linux'
const target = targetName(options.arch, platform)
if (process.platform !== platform || process.arch !== options.arch) {
throw new Error(`Agent CI build requires a native ${target} host`)
}
const { lock, runtimeLock } = readCiLocks(platform, options.arch)
const signing = ephemeralSigningIdentity()
const firstArchiveRoot = resolve(
join(
@@ -124,6 +141,8 @@ async function buildCiAgentBundle(options) {
force: true
})
buildAgentBundle({
platform,
lock,
projectRoot,
arch: options.arch,
runtimeArchive: options.nodeArchive,
@@ -132,6 +151,8 @@ async function buildCiAgentBundle(options) {
testSigningIdentity: identity
})
const runtime = buildRuntimeBundle({
platform,
lock: runtimeLock,
projectRoot,
architecture: options.arch,
runtimeArchive: options.opencodeArchive,
@@ -141,6 +162,7 @@ async function buildCiAgentBundle(options) {
})
const assemble = (archive) =>
assembleAgentPackage({
platform,
projectRoot,
architecture: options.arch,
minimumDesktopVersion:
@@ -181,7 +203,31 @@ async function buildCiAgentBundle(options) {
throw new Error('Built Agent native launch smoke failed')
}
const nativeChecks = [
[
resolve(options.outputDirectory, 'node'),
['-e', `const k = require(${JSON.stringify(resolve(options.outputDirectory, 'lib/node_modules/koffi'))}); if (k.sizeof('int') !== 4) process.exit(1)`]
],
[join(runtime.bundleDirectory, 'bin', 'opencode'), ['--version']]
]
for (const [executable, args] of nativeChecks) {
const result = spawnSync(executable, args, {
encoding: 'utf8',
timeout: 30_000,
windowsHide: true
})
if (
result.error ||
result.status !== 0 ||
(args[0] === '--version' &&
result.stdout.trim() !== runtimeLock.runtimes.opencode.version)
) {
throw new Error(`Built native dependency smoke failed: ${basename(executable)}`)
}
}
return {
platform,
agentVersion: lock.agentVersion,
remoteRuntimeVersion:
runtimeLock.runtimes.opencode.version,
@@ -214,5 +260,6 @@ if (require.main === module) {
module.exports = {
buildCiAgentBundle,
parseArguments
parseArguments,
readCiLocks
}
+13 -3
View File
@@ -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')
+39 -39
View File
@@ -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,
@@ -37,11 +37,11 @@ GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分
## 2. 核心产品判断
### 2.1 智能心跳保持独立,未来分区记忆另行设计
### 2.1 智能心跳保持独立,分区记忆协同另行设计
当前智能心跳继续承担周期回顾、报告和建议,并支持 Global 或指定 Project 范围。它不是
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。未来分区记忆的
数据、状态、唤起和页面需要独立设计,不能从当前方向直接推导。
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。按分区回顾、候选生成
以及时间/内部事件唤起需要独立设计,不能从当前心跳行为直接推导,也不创建平行记忆类型。
- Scheduled Task 解决“何时在一个 Task 中执行新的 Job”。
- Goal Task 解决“围绕结果在同一 Task 中持续规划和推进”。
@@ -507,7 +507,7 @@ experiment_runs
现有 `schedules`、`schedule_runs`、`heartbeat_configs`、`heartbeat_runs`、
`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与
Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得
静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。
静默转成 `AutomationPlan` 或顶层 Task。分区记忆协同和唤起条件完成设计前,不新增迁移目标。
## 16. 分阶段实施
@@ -518,7 +518,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
- 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center
索引边界,不复制内容。
- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。
- 明确当前心跳保持独立,未来分区记忆尚待设计。
- 明确当前心跳保持独立,分区记忆协同与唤起条件尚待设计。
- 补充触发来源、运行版本、预算和读写范围展示。
### 阶段 1:调度与运行基础
@@ -569,7 +569,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
## 18. 总体验收标准
- [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。
- [ ] 智能心跳保持独立,不作为 Task 类型;分区记忆协同与唤起条件尚未设计。
- [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以
关联多个 Task。
- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。
+24 -17
View File
@@ -9,7 +9,7 @@
| 日期 | 2026-09-03 |
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
| 服务端设计 | [ShareServer](../features/share-server/README.md) |
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
## 1. 文档职责
@@ -72,7 +72,8 @@ ShareServer 负责身份、目录、策略、任务路由、更新和审计。
| `Package` | 可下载到本机安装的签名能力工件 |
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
“网关”是 ShareServer 的网络角色;产品名称和代码目录统一使用 `ShareServer` / `shareserver`。
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
`goodbuddy-shareserver`。
## 4. 系统拓扑
@@ -406,21 +407,27 @@ Revocation
## 15. 仓库边界
目标目录结构:
当前仓库边界(跨仓库文档链接约定两个仓库克隆到同一级目录):
```text
docs/
architecture/share-network-architecture.md
features/device-sharing/
features/share-server/
packages/
share-protocol/ # 与 Electron、数据库和 Web 框架无关的 schema/类型
shareserver/ # 独立 Web 服务及管理前端
src/ # GoodBuddy Desktop
goodbuddy/
docs/architecture/share-network-architecture.md
docs/features/device-sharing/
src/ # GoodBuddy Desktop
goodbuddy-shareserver/
docs/features/share-server/
server/ # 独立 Hono Web 服务
shared/ # 当前原型 fixture,不是跨端协议
src/ # React 管理前端
```
当前文档先固定边界,不创建空服务脚手架。实现时 `shareserver` 不反向依赖桌面端 `src/shared`;
双方依赖独立 `share-protocol`。协议包不得包含数据库模型、Electron API、服务实现或凭据。
ShareServer 源码和服务端文档已迁入独立仓库:
`ssh://git@git.digiman.live:11022/root/goodbuddy-shareserver.git`。
当前服务是交互原型,尚未提供真实 Desktop 集成。共享网络总体设计仍由本文统一维护。
ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌面仓库存在。
真实跨端协议实现时双方依赖独立 `share-protocol` 包,具体仓库归属和发布方式届时确定,
不提前创建空包。协议包不得包含数据库模型、Electron API、服务实现或凭据。
## 16. 共同验收标准
@@ -442,9 +449,9 @@ src/ # GoodBuddy Desktop
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
- [ShareServer PRD](../features/share-server/prd.md)
- [ShareServer 功能逻辑](../features/share-server/logic-design.md)
- [ShareServer 管理界面](../features/share-server/ui-design.md)
- [ShareServer 技术设计](../features/share-server/technical-design.md)
- [ShareServer PRD](../../../goodbuddy-shareserver/docs/features/share-server/prd.md)
- [ShareServer 功能逻辑](../../../goodbuddy-shareserver/docs/features/share-server/logic-design.md)
- [ShareServer 管理界面](../../../goodbuddy-shareserver/docs/features/share-server/ui-design.md)
- [ShareServer 技术设计](../../../goodbuddy-shareserver/docs/features/share-server/technical-design.md)
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
+15 -6
View File
@@ -50,9 +50,9 @@ below.
optional: users explicitly download a compatible compound `.gbagent` package
from Settings or import one offline before GoodBuddy can install or update a
Host.
- Each independently published Linux x64 or arm64 `.gbagent` contains the
- Each independently published Linux x64, Linux arm64, or macOS arm64 `.gbagent` contains the
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
Runtime. Build both architectures on native GitHub Actions runners from one
Runtime. Build all three targets on native GitHub Actions runners from one
immutable `agent-v<agentVersion>` tag.
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
bundle tooling, and tests in this repository so a desktop commit identifies
@@ -66,7 +66,9 @@ below.
digest, use only an ephemeral in-memory test signing identity, and verify the
resulting compound package and deterministic archive. It must not read
production signing secrets, publish installable release artifacts, or modify
the checked-in public key registry.
the checked-in public key registry. The Darwin arm64 target uses `macos-15`
and the same production locks. Native CI checks do not replace real Host
installation, Attach, Ask/Execute, and lifecycle validation during development.
- `.github/workflows/agent-release.yml` is the only production compound Agent
publication path. It requires an annotated immutable Agent tag, the protected
`agent-signing` Environment, native x64/arm64 builds, one production
@@ -98,11 +100,18 @@ below.
and are separate from desktop `v${package.version}` releases. Confirm the
exact Agent release commit and tag with the user before creating or pushing
either.
- The tagged commit must be reachable from protected `main`. The two native
jobs build the compound x64/arm64 packages from the same source and locks;
- The tagged commit must be reachable from protected `main`. The three native
jobs build Linux x64, Linux arm64, and Darwin arm64 packages from the same source and locks;
the catalog job rejects missing architectures, changed bytes for an existing
version/architecture identity, invalid prior signatures, and incompatible
version/platform/architecture identity, invalid prior signatures, and incompatible
matrix metadata.
- All three targets share the existing signed `agent-catalog.json`. Before the
first mixed-platform catalog is published, release the Desktop reader that
accepts Darwin entries and document the required Desktop upgrade. Older
Desktop readers reject the entire mixed catalog and cannot check online Agent
updates; preserve their locally verified packages and immutable historical
releases. Do not introduce a second catalog or silently weaken package
signature and integrity verification.
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
must use `--latest=false`; they must never replace the desktop release marked
Latest. Publish immutable packages and the versioned catalog to
+2 -1
View File
@@ -8,6 +8,7 @@
| --- | --- |
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 |
当前尚未拆出独立 User Stories、功能逻辑、UI 设计、技术设计和进度文档;相关内容仍保留在
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
+16
View File
@@ -366,6 +366,22 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
- 显式打开、下载副本或在终端中打开。
- HTML 文件的源码与安全预览。
当前项目目录的文件浏览与 Git 交互遵循以下规则:
- 页签下不重复显示“项目工作区”标题,保留刷新按钮。
- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、
U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。
- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色;
删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态,
清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
无首次提交的仓库也应显示新增和暂存文件。
- 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除
文件失去 Diff 入口。
- 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。
目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。
本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和
符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。
@@ -0,0 +1,29 @@
# 工作栏实现与验证进度
## 2026-09-06 项目工作区
范围见 [PRD 工作区](./prd.md#76-工作区)。
当前源码已移除重复标题与底部完整 Diff 入口,使用彩色 Git 状态字母,点击更改项读取
单文件 Diff。已暂存与未暂存差异分开展示;本机 Git 测试覆盖无首次提交、未跟踪、
暂存后继续修改、重命名、删除及包含方括号的路径。Git 刷新驱动目录树重新读取,保留
展开状态;SSH 项目不提供本机系统打开按钮。审查后补齐了当前 Diff 随刷新重读、
旧请求结果失效,以及超过 50 项变更的继续加载入口。
验证记录:
- 最终 `npm run typecheck`、`npm run lint`、`npm run build` 均通过。
- 工作区专项 7 个测试文件复跑:317 项通过,3 项 Linux 专用测试在 Windows 跳过。
任务完成测试同时检查目录树出现新文件。
- 最终 `npm test`:326 个测试文件、3552 项测试通过,9 个文件、58 项测试按既有条件
跳过。复核首轮曾有一项浏览器地址草稿测试超时,单独复跑及最终全量复跑均通过;
更早的超时/失败记录不再作为最终验收结果。
- 后续复核已通过既有凭据和固定 Host Key 经 VPN 连接共享 Linux x64 Host,使用当前
源码构建的隔离 Agent 包安装并启动。真实 `RemoteWorkspaceAccess → Agent git/status`
与 `git/diff` 路径覆盖暂存后继续修改、删除、重命名、未跟踪和方括号路径;均通过。
Git 验证未调用模型。
- Windows 隔离桌面实例已通过真实点击验收:当前项目单文件暂存/工作树 Diff 分组与
增删颜色正确,修改测试文件后点击刷新可更新正在查看的 Diff,53 项变更经加载更多
全部可访问。截图留在本地验证目录,不纳入产品包。
目录分页、任意目录切换和 HTML 预览不在本轮改动范围内。
+2 -2
View File
@@ -11,8 +11,8 @@
## 1. 背景
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。其长期“未来分区记忆”
方向尚未设计,也不承担持续学习、模式挖掘或自动改进:
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
实施,也不承担持续学习、模式挖掘或自动改进:
- 候选是否改善未来行为没有评估。
- 一条反思是否会被检索和使用并不确定。
@@ -78,15 +78,14 @@ validating
| 输入 | 结果 |
| --- | --- |
| 未提供 | 当前工作区根目录 |
| `.` 或合法相对子目录 | 解析到工作区内目录 |
| 绝对路径 | 拒绝 |
| `..` 越界、符号链接越界 | 拒绝 |
| `.` 或相对路径 | 以当前工作区为基准解析,可指向工作区外目录 |
| 绝对路径 | 使用指定目录 |
| `..` 或指向工作区外的符号链接 | 解析到实际目标目录,按当前用户权限执行 |
| 不存在或不是目录 | 拒绝 |
| 当前工作区身份在验证期间变化 | 拒绝本次调用 |
首版进程工具比 Execute 的 OS 权限范围更窄地固定工作目录输入,只是为了保持请求与项目
一致;命令本身仍以当前用户权限运行,可以显式访问该账号有权访问的其他绝对路径。该规则
不是 Runtime OS 沙箱,也不能宣称文件系统 containment。
工作区提供默认目录和相对路径基准,不是 Execute 命令的文件系统边界。单次命令选择其他
工作目录不会修改父请求绑定的项目或工作区,也不会影响后续未指定 `cwd` 的命令。
## 6. Shell 选择
+5 -3
View File
@@ -65,7 +65,8 @@ Subagent 使用父请求的项目、执行空间、工作模式、直连模型
- Ask 父请求只能创建 Ask Subagent。
- Execute 父请求创建 Execute Subagent。
- 子级不能切换项目、目录、Runtime、模型连接或工作模式。
- 子级不能切换父请求绑定的项目、工作区、Runtime、模型连接或工作模式;Execute 命令的
单次工作目录选择遵循 FR-3。
- 子级不再看到 `subagent_delegate`,首版最大委派深度固定为 1。
- 子任务不是新的顶层 Task 或 Conversation,结果先返回父模型。
@@ -79,7 +80,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
### 4.1 包含
- 本机 Windows、macOS、Linux 的前台 Shell 命令执行。
- 当前工作区默认工作目录及合法子目录选择。
- 当前工作区默认工作目录,以及当前用户可访问的本机工作目录选择。
- Exit Code、标准输出、标准错误、Shell、目录、耗时和截断状态。
- 命令超时、用户取消、进程树停止和应用退出清理。
- 新进程使用当前本机工具环境提供的 Node、Python 和 PATH。
@@ -118,7 +119,8 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
### FR-3 工作目录和执行位置
- 默认目录是父请求当前工作区。
- 可选工作目录必须解析为当前工作区本身或其子目录。
- 可选工作目录支持绝对路径、相对工作区路径及指向工作区外目录的路径或符号链接,
不额外限制在工作区内;目标必须是实际存在且当前用户可访问的目录。
- 本机执行只能发生在当前桌面执行空间。
- 当前请求绑定 SSH 执行空间且没有远端进程后端时不注册工具,绝不在桌面目录回退执行。
@@ -84,6 +84,26 @@
GoodBuddy 托管 Node/Python 已可用。
- 远端直连模型进程执行不在首版,后续需要独立增加 Agent 进程协议和真机验证。
## 2026-09-06 命令工作目录限制修复
- FR-3 / US-A3:移除命令工作目录的工作区范围限制。绝对路径、`..` 和指向工作区外
的符号链接均按本机文件系统解析,工作区只作为默认目录和相对路径基准。
- 保留目录存在性、本机执行空间绑定、Ask 拒绝命令、取消、超时和进程树回收。
本次没有改变工作区文件读写工具或远端 Agent 路径。
- `direct-model-process-service.test.ts` 覆盖绝对路径、相对路径、外部目录、符号链接、
不存在的目录和普通文件;当前 Windows 环境通过。
- `model-runtime.test.ts` 新增两个生产工具链回归:模型协议响应提供绝对或相对外部
`cwd`,经 `ModelAgentRuntime → ModelToolProvider → LocalDirectModelProcessService`
启动真实 PowerShell,在专用工作区外测试目录复制文件并读回验证。模型响应为本机
测试替身,没有外部模型调用;不将此记录为真实模型或 macOS/Linux 真机验收。
- 最终聚焦验证:`opencode-runtime`、`opencode-runtime-lifecycle`、
`direct-model-process-service`、`model-tool-provider`、`model-runtime` 五个测试文件
`182 passed, 1 skipped`;`npm run typecheck` 和 `npm run lint` 通过。
- 本次共享工作区的全量 `npm test` 为 `3542 passed, 57 skipped, 1 failed`。唯一失败是
`App.test.tsx` 的 `opens the global assistant sidebar and switches work tabs`,
仍期望“项目工作区”标题,单独复测同样失败;该侧栏处于其他会话的修改范围,本次未改动。
因此本次全量验证不能记录为全部通过。
## 2026-09-01 至 2026-09-02 实施与验证证据
- 新增 `direct-model-process-service.ts`:PowerShell/Bash/Sh 探测、工作区目录校验、96 KiB
@@ -89,7 +89,8 @@ const processExecuteInputSchema = z.object({
}).strict()
```
- `cwd` 只接受相对工作区路径。
- `cwd` 接受绝对路径或相对工作区路径,省略时使用工作区根目录;按本机路径语义解析
`..` 和符号链接,不限制目标位于工作区内。工具 schema 向模型明确这一规则。
- 首版不接受 `env`、`stdin`、`background`、`pty`、`shell` 或任意 executable 参数。
- Shell 选择由执行后端决定,防止模型绕过平台契约启动另一套受管接口。
- 命令字符串仍可以调用当前账号本来有权运行的程序;Execute 不增加命令白名单。
@@ -114,8 +115,8 @@ type ProcessExecuteResult = {
}
```
`cwd` 返回工作区相对显示值,不把不必要的绝对用户路径送入模型上下文。Shell 在模型结果
中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
`cwd` 在工作区内返回相对显示值,根目录为 `.`;工作区外返回规范化绝对路径,准确说明
命令实际执行位置。Shell 在模型结果中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
### 4.3 输出边界
@@ -293,7 +294,7 @@ DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web
- `failed`:启动或契约失败。
- `cancelled`:父请求取消。
结果摘要包含 Shell、相对目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
结果摘要包含 Shell、工作目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
输出字段,不写应用诊断日志。
### 9.2 Subagent 事件
@@ -351,7 +352,7 @@ type SubagentActor =
- 工具可见性矩阵和 Runtime target 过滤。
- Ask 伪造进程调用拒绝。
- Windows PowerShell、POSIX Bash/Sh 选择和参数。
- cwd 默认、合法子目录、越界、符号链接越界和不存在。
- cwd 默认、相对子目录、绝对路径、工作区外目录、指向外部的符号链接和不存在的目录。
- exit 0、非零退出、spawn 失败、超时、取消和输出截断。
- 环境筛选、本机工具 PATH 优先且模型凭据不进入进程。
- 子级模式、模型、项目、执行空间和能力继承。
@@ -28,7 +28,7 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
```text
编程能力
进程执行
使用当前工作区和当前用户权限运行 PowerShell/Bash 命令。
使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。
编程 Subagent
将聚焦任务委派给同一模型连接;继承当前工作模式和工作区。
```
@@ -54,7 +54,7 @@ PowerShell · packages/app · npm test
```
- 命令只显示单行有界摘要,完整参数进入可展开详情。
- 目录显示工作区相对路径;工作区根显示 `.`。
- 工作区内目录显示相对路径,工作区根显示 `.`;工作区外目录显示规范化绝对路径。
- 不在摘要中显示完整环境或凭据。
### 3.2 状态
@@ -42,12 +42,13 @@ Shell、目录、退出码、输出和耗时。
Given 当前项目位于 macOS 或 Linux 本机,When 模型运行同一工具,Then GoodBuddy 使用
Bash,Bash 不可用时使用 Sh,工具契约保持不变且实际 Shell 明确可见。
### US-A3 使用工作区子目录
### US-A3 选择命令工作目录
关联:FR-3。
Given 项目包含多个包,When 模型传入工作区内相对目录,Then 命令在该目录执行;When
路径越出工作区、不是目录或不存在,Then 命令不启动并返回准确错误。
Given 当前请求为本机 Execute,When 模型传入绝对路径或相对工作区路径,Then 命令在
解析后的目录执行,包括工作区外目录和符号链接目标,And 后续省略 `cwd` 时仍使用原工作区。
When 目标不是目录或不存在,Then 命令不启动并返回准确错误。
### US-A4 根据非零退出继续修正
+7 -3
View File
@@ -1,7 +1,8 @@
# 知识库
知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答,也负责将
Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围仍处于规划阶段,不属于
当前已提供能力。
## 文档导航
@@ -9,6 +10,8 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
| --- | --- |
| [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 |
| [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 |
| [UI 设计](./ui-design.md) | 当前本地知识库的信息架构、首次使用、状态与高级功能分层 |
| [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 |
| [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 |
| [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 |
| [外部知识库接入 PRD](./external-knowledge-prd.md) | 外部实例、知识库绑定、检索范围和产品验收 |
@@ -27,4 +30,5 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
或 Agent,也不在外部实例中创建、修改、上传或删除知识库内容。远端资料不会批量同步
或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。
当前尚未拆出覆盖整个知识库功能家族的统一逻辑、UI 设计和功能进度文档。
本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约
共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。
@@ -162,7 +162,7 @@ GoodBuddy 当前已经具备:
引用和脱敏诊断。
- Agent Runtime 的选择或切换不改变当前向量连接;向量连接的选择或切换也不改变当前
Agent Runtime。
- 未来记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
- 分区记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
数据路径评审后显式接入,不能因存在全局向量连接而自动获得使用权。
---
+25 -6
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 实施中 |
| 版本 | 0.2 |
| 日期 | 2026-08-22 |
| 版本 | 0.3 |
| 日期 | 2026-09-05 |
| 适用产品 | GoodBuddy 桌面端 |
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
| 相关设计 | [本地文本向量模型与连接设计](./local-embedding-technical-design.md) |
@@ -149,7 +149,7 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
## 6. 信息架构
知识工作区继续使用主从布局和现有四个页签:
知识工作区使用主从布局和四个面向用户任务的页签:
```text
知识库
@@ -160,14 +160,21 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
│ └─ 分块查看与维护
├─ 知识图谱
├─ 任务中心
└─ 设置
└─ 高级设置
├─ 问答效果入口
├─ 向量索引
├─ 检索设置
├─ 分块设置
└─ 图谱设置
```
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
工作台,不新增第五个一级页签。
“检索测试”是当前知识库的高频验证操作,通过知识库标题区和“高级设置 →
问答效果”打开同一个独立工作台,不新增第五个一级页签。工作台首次打开只突出真实
问题输入和测试操作;候选倍数、权重、阈值、重排和上下文预算默认折叠。
首次使用流程固定为“创建知识库 → 添加资料 → 等待可检索 → 用于当前对话”。创建表单
默认只展示名称和描述;存储方式与知识图谱属于折叠的高级设置。没有知识库时,唯一主操作
位于首次使用空状态中。
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。GoodBuddy 内置连接和用户添加的
OpenAI 兼容连接使用同一连接列表;其界面、数据路径及切换语义以
@@ -195,6 +202,10 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不
2. 检索方式:模型按需检索、每次先检索。
3. 当前范围为空、索引降级或向量未配置时的短说明。
知识库 ID 选择按 Conversation 持久化。新对话和未保存过知识范围的旧对话默认不选择
任何知识库;切换对话恢复各自范围,不得在应用启动时自动勾选全部知识库。新建知识库只
加入当前 Conversation,不重新启用用户已取消的其他知识库。
## 7. 第一阶段:可用、可见、可诊断
### 7.1 检索方式
@@ -296,6 +307,14 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不
- 失败文档数。
- 当前向量模型与索引是否兼容。
`indexedDocumentCount` 只统计状态为 `ready` 的文档;`queued`、`parsing` 和
`indexing` 计入处理中,`failed` 单独计入需处理。文档行持续显示全文、向量和图谱通道
状态。已有可用分块的失败文档仍允许查看分块,并提供重新处理。
文档行可通过受控 `knowledgeBaseId + documentId` 打开数据库中保存的原始来源。
Renderer 不得提交任意路径。移除来源必须先说明将删除的文档、索引、图谱证据及是否包含
应用托管副本,用户确认后才执行。
降级事件包括:
- 未配置向量模型。
+41
View File
@@ -0,0 +1,41 @@
# 知识库实施进度
## 已验证实现
截至 2026-09-05:
- 知识库选择按 Conversation 持久化,新对话默认空范围。
- 创建流程默认只展示名称和描述,高级存储与图谱设置折叠。
- 知识库汇总区分可检索、处理中和需处理文档。
- 文档行展示全文、向量和图谱状态,并支持打开原始来源和重新处理失败文档。
- 移除来源需要确认并说明级联影响。
- 检索高级参数默认折叠,“高级设置”提供问答效果入口。
- 向量未配置状态可直接前往模型设置。
- 有效 DOCX 与多页 PDF 已通过磁盘文件导入、解析、分块和检索生产链路。
## 验证证据
2026-09-05 最终验证:
- `npm test`:325 个测试文件、3511 项测试通过;8 个文件、55 项手动或集成测试按既有
配置跳过。
- `npm run typecheck`:通过。
- `npm run lint`:通过。
- `npm run build`:Electron Main、Preload、Renderer 生产构建及控制面安装包检查通过。
- `npx vitest run src/main/knowledge/knowledge-service.test.ts -t "imports real DOCX and PDF files from disk into searchable chunks"`:
有效 Open XML DOCX 与两页原生文本 PDF 均通过生产 `KnowledgeService.importPaths`
导入、解析、分块和全文检索。
2026-09-06 对话知识库选择补充验证:
- `App.test.tsx` 覆盖点击名称、文档数量和行本身时切换选中状态,以及空目标失焦时
弹层不提前卸载。Windows 隔离桌面实际点击名称后选中、点击数量后取消,弹层保持打开。
- 本轮全量校验结果见[工作栏验证记录](../assistant-workbar/progress.md);本项验证未调用模型。
## 剩余工作
- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。
- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖
原生文本 PDF,不宣称覆盖扫描件 OCR。
- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受
影响。
+70
View File
@@ -0,0 +1,70 @@
# 知识库 UI 设计
## 1. 设计目标
知识库优先帮助普通用户完成“添加资料并可靠提问”,高级 RAG 调试和图谱维护不得阻塞
首次使用。全局视觉、页面壳层、通知、焦点和危险操作遵循
[UI-DESIGN.md](../../../UI-DESIGN.md)。
## 2. 信息架构
```text
知识库
├─ 知识库列表
└─ 当前知识库
├─ 文档与来源
├─ 知识图谱(启用后显示)
├─ 任务中心
└─ 高级设置
```
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
## 3. 首次使用
没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和
描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户
可以导入文件、文件夹或 URL。
文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前
Conversation 的检索范围,返回对话并聚焦输入框。
## 4. 状态
知识库列表显示“可用”和“需处理”文档数。详情标题持续显示可检索、处理中、需处理和
总文档数,即使用户填写了知识库描述也不隐藏状态。
文档行显示:
- 总体处理状态。
- 全文状态。
- 向量状态。
- 启用图谱时的图谱状态。
失败文档保留错误信息和“重新处理”。只要仍有可用分块,就允许打开分块管理,避免隐藏
上一版可用索引。
## 5. 问答效果与高级参数
“检索测试”默认只突出真实问题、测试按钮和结果。高级参数使用折叠区,包含候选倍数、
通道权重、最低相似度、重排、结果数和上下文预算。“高级设置 → 问答效果”打开同一
工作台。
向量模型未配置时显示“前往设置”,直接进入模型连接,不使用只有说明文字的死路状态。
## 6. 来源操作
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
取消,提交期间禁止重复操作,失败时保留对话框和错误。
## 7. 对话范围
知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库
只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。
输入区的知识库选择弹层使用整行原生 Checkbox 标签,点击名称、文档数量或行内空白
均切换一次选择;选中背景和整行焦点环保持可见。Tab 聚焦、Space 切换,Escape 关闭并
返回触发按钮。点击行内非焦点元素时产生的空目标失焦不关闭弹层;点击外部或焦点明确
移到外部时才关闭,避免原生标签完成勾选前被卸载。
@@ -33,6 +33,18 @@
## 2. Epic A:明确控制是否检索
### US-A0 首次建立可用知识库
作为首次使用者,我希望先命名知识库并添加资料,不需要先理解向量、分块或图谱。
验收:
- 没有知识库时只显示一个“新建知识库”主操作
- 创建表单默认只展示名称和描述
- 存储方式和知识图谱位于折叠的高级设置
- 文档可检索后提供“用于当前对话”
- 高级检索参数不阻塞首次导入和首次提问
### US-A1 模型按需检索
作为普通知识使用者,我希望保留由模型判断是否需要检索的模式,以便一般闲聊
@@ -73,6 +85,9 @@
- 同一请求最多启用 20 个知识库
- 对话输入区持续显示已选数量和检索方式
- 范围为空时检索方式不产生误导状态
- 知识库 ID 与检索方式一起按 Conversation 持久化
- 新对话和未保存过范围的旧对话默认不勾选知识库
- 切换 Conversation 恢复各自选择,不共享临时全局范围
## 3. Epic B:检索可见、可诊断
@@ -233,6 +248,18 @@
- 全库重建可取消
- 已成功替换的文档继续可用
### US-D4 查看和移除来源
作为知识库维护者,我希望打开原始文档并在移除来源前看到准确影响。
验收:
- 文档行可通过系统默认应用打开数据库保存的原始来源
- Renderer 只能提交知识库 ID 和文档 ID,不能提交任意路径或 URL
- 移除来源前显示来源名称、文档数量以及索引和图谱证据影响
- 托管模式额外说明应用托管副本会被删除
- 用户确认前不得删除来源
## 6. Epic E:高级分块
### US-E1 固定分块
+12 -11
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.3 |
| 日期 | 2026-08-19 |
| 版本 | 0.4 |
| 日期 | 2026-09-05 |
| 依赖 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md)、[智能心跳 PRD](../smart-heartbeat/prd.md)、[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
## 1. 背景
@@ -16,13 +16,13 @@ GoodBuddy 当前记忆已经支持:
- `global`、`project`、`conversation` 三种作用域。
- `preference`、`fact`、`summary`、`procedure` 四种类型。
- `proposed`、`confirmed`、`rejected` 三种状态。
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;其长期“未来分区记忆”方向尚未设计。
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;后续按本 PRD 的分区记忆模型增强。
但当前能力仍不足以支撑自动化和并行实验:
1. 交互请求会把已加载列表中的最多 20 条已确认记忆直接拼入提示,缺少查询相关度和明确的
会话级过滤契约。
2. 数据库有会话作用域,但旧版心跳候选与未来唤起需求混在同一产品概念中。
2. 数据库有会话作用域,但旧版心跳候选与分区记忆的唤起需求混在同一产品概念中。
3. 缺少 Automation、Experiment 和 Run 分区。
4. 来源字段存在于表结构,但普通创建和心跳候选尚未完整保存来源关系。
5. 缺少事实的有效时间、冲突、替代、访问记录和衰减。
@@ -64,10 +64,11 @@ SQLite 显式字段、FTS、来源关系和可选本地 Embedding 足以支持
- 同一实体跨大量会话的别名消歧。
- 可解释的关系证据链。
### 2.5 未来分区记忆尚待设计
### 2.5 分区记忆与唤起
已确认智能心跳的长期方向是“未来分区记忆”,但当前尚未定义其数据结构、唤起条件、状态、
生命周期、与长期记忆的关系或迁移方式。本 PRD 不新增 `FutureMemory` 类型、表或检索规则。
正式产品概念统一为“分区记忆”。时间和内部事件可以作为分区记忆条目的可选唤起条件,但
不能改变条目所属分区、绕过分区读取边界,也不建立平行的记忆类型或表。唤起字段、状态、
生命周期、页面和旧数据迁移必须在实施前补充设计。
## 3. 目标
@@ -430,8 +431,8 @@ Project 记忆与 Global 偏好冲突时:
每条记忆展示内容、类型、范围、来源、状态、时间、置信度、重要性和冲突。
智能心跳的完整配置仍在“智能心跳”菜单中管理;未来分区记忆完成独立设计前,不加入记忆
中心信息架构。
智能心跳的完整配置仍在“智能心跳”菜单中管理。分区记忆的唤起条件完成字段、状态和生命周期
设计后,可以在记忆中心增加“待唤起”视图;不得创建平行的记忆分区或内容页。
## 16. 数据模型建议
@@ -474,13 +475,13 @@ Project 记忆与 Global 偏好冲突时:
6. 增加冲突、时态、替代和归档。
7. 增加实验冻结快照与 Run 隔离。
8. 增加可选本地 Embedding 和混合排序。
9. 未来分区记忆和时间知识图谱都必须在明确需求与独立设计后再实施。
9. 增加分区记忆的时间/内部事件唤起;时间知识图谱仍须在明确需求与独立设计后再实施。
## 19. 验收标准
- [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。
- [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。
- [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。
- [ ] 唤起条件始终依附于一个已验证的记忆分区,不建立平行的记忆表或范围。
- [ ] Task 执行只读取内部 Run 快照绑定的分区。
- [ ] 实验 Run 不能读取其他 Run 的消息或记忆。
- [ ] 每条非手动记忆都有可追溯来源。
@@ -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。
+84 -3
View File
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
Agent 源码 lock 为 `0.11.18`,Desktop 候选为 `0.12.4`,等待各自独立发布审批。
Agent 源码 lock 为 `0.11.20`,Desktop 候选为 `0.12.6`,等待各自独立发布审批。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -104,6 +104,11 @@ Detached GoodBuddy Agent
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线
包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。
- GitHub 来源按每页 10 条读取 Release 元数据,最多检查原有的 100 条历史发布,
找到首个有效 Agent 目录及签名地址后立即停止。每次响应仍保留 1 MiB 上限,
避免一次读取全部历史正文和资产详情导致目录检查失败;签名、URL 与包校验不变。
2026-09-06 使用当前 Main 目录读取实现验证公开 GitHub 与北京镜像,两者均可读取
Agent `0.11.19` 的 Linux x64、Linux arm64 和 macOS arm64 条目,未下载或安装包。
- “自动”只在 operation/prepare 前用 Host capability probe 选择 acquisition:直连明确
可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或
adoption 失败后都不跨 acquisition 自动 fallback。
@@ -233,7 +238,25 @@ Execute 直接启动已签名 Runtime:
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
状态卡,并持久化任务说明、终态与输出。
状态卡,并持久化任务说明、终态与输出。卡片展开后分为“执行过程”和“最终结果”:
正文、折叠思考与可展开工具记录按收到的顺序显示,工具状态原位更新;完成后仅在最终
结果区显示与末尾正文完全相同的返回结果,避免重复。失败或取消保留已有过程,未终结
工具不再显示进行中。
- 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。
固定 OpenCode 1.18.9 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录
写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent`
转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理,
事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
远端实时过程需要包含该插件的 Agent;旧 Agent 的最终结果仍可查看,历史未采集过程
不会被补造。
- 本机 OpenCode SDK 路径允许不同会话并行,同一会话仍按请求顺序执行。每个请求独立拥有
SSE 事件订阅,并在正常完成、错误、取消或消费方结束迭代时主动关闭自己的响应流,
不关闭其他会话的订阅或共享 Server。取消导致事件迭代结束时保留原取消原因,不改报
“事件流意外结束”。这一清理规则属于本机 SDK 路径,不改变远端 Agent 持有 ACP Prompt
的断线继续执行语义。
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
@@ -351,10 +374,68 @@ 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-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP
插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常
完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共
8 次模型请求。
- 同日 Windows 隔离桌面实际发送 OpenCode 原生 Task,展开卡片可看到进行中的正文与
read 工具,取消后已有过程保留且工具显示已取消;正确工作区场景完成后显示测试文件
内容,最终结果不重复留在过程末尾。本机有界代理共记录 10 次模型请求(含错误路径、
取消和成功场景);另外 2 次使用原生测试配置的请求返回 401,改选显式测试模型后通过。
未改动正常用户设置,也未发布 Agent 包。全量静态与测试结果见
[本轮验证记录](../assistant-workbar/progress.md)。
- 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 发布并公开验证双架构工件;
当前源码 lock 固定为 `0.11.18`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
当前源码 lock 固定为 `0.11.20`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
-29
View File
@@ -1,29 +0,0 @@
# ShareServer
ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个或多个组织提供身份与设备
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦和审计管理。
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
文件、凭据或 SSH Host。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
设计。服务尚未实施,因此不创建 `progress.md`。
## 角色
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
- **组织管理员**:管理本组织成员、设备、组、策略、能力和联邦关系。
- **能力发布者**:在获准范围内发布设备能力或签名能力包。
- **审批人**:处理调用、发布、更新和联邦申请。
- **审计员**:只读查看和导出获准范围内的审计事件。
- **成员**:主要通过 GoodBuddy Desktop 使用能力,只在 Web 端处理个人设备和审批。
-209
View File
@@ -1,209 +0,0 @@
# ShareServer 功能逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 核心不变量
### INV-1 组织隔离先于角色
任何查询和变更先绑定 organization,再计算角色。系统管理员的跨组织操作使用独立系统管理
入口并形成审计,不能依靠省略 organization 条件获得全局数据。
### INV-2 注册不等于发布
设备注册只建立身份和连接资格。没有有效 Publication 时,目录中没有该设备能力。
### INV-3 目录不等于授权
看到能力不能推导可调用或可下载。创建任务和下载包时重新计算当前授权。
### INV-4 服务端授权不能扩大端点授权
ShareServer 的 `allow` 只是签发任务资格。Provider 和调用方仍执行各自本地策略;任一端拒绝
即终止动作。
### INV-5 策略拒绝优先
同一动作命中多个规则时使用 `deny > require_approval > allow`。没有匹配 allow 时默认 deny。
### INV-6 联邦为双边交集
单方创建或接受的关系不生效。双方 active 且两端发布、Grant 和数据策略均允许时才能跨组织
创建任务。
### INV-7 任务提供者不可改写
ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失败时替换节点。
### INV-8 不恢复短期权威
备份恢复不恢复 Web 会话、设备 socket、一次性挑战、审批 nonce 或任务数据通道。终端必须
重新连接并证明身份。
### INV-9 初始化入口一次性关闭
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
一次性引导材料也不能再次进入初始化流程。
## 2. 主要状态
### 2.1 成员
```text
invited -> active -> suspended -> active
invited -> expired | revoked
active/suspended -> removed
```
`suspended` 不能登录或创建新任务;历史归属保留。`removed` 不级联删除审计和已完成任务。
### 2.2 注册设备
```text
pending -> active <-> offline
pending -> expired | rejected
active/offline -> revoked
```
客户端心跳只决定 `active/offline`,不能清除 `revoked`。重新注册生成新设备 registration。
### 2.3 Publication
```text
draft -> pending_approval -> active
draft/pending_approval -> rejected
active <-> paused
active/paused -> revoked
```
provider 离线不会把 Publication 变为 paused;目录同时展示“已发布”和“当前离线”两个事实。
### 2.4 审批
```text
pending -> approved | rejected | cancelled | expired
```
审批对象内容摘要、申请主体或策略版本变化后,原审批不能复用。approved 只产生其声明范围内
的 Grant 或一次性决定。
### 2.5 Package Release
```text
draft -> verifying -> available -> paused -> available
verifying -> invalid
available/paused -> revoked
```
发布后的版本和字节不可变。修复内容必须使用新版本;revoked 不删除审计证据和对象摘要。
### 2.6 FederationTrust
```text
draft -> offered -> mutually_confirmed -> active
offered -> rejected | expired
active <-> paused
任意非终态 -> revoked
```
`mutually_confirmed` 只表示身份确认完成;双方策略同步成功后才进入 active。
## 3. 授权计算顺序
1. 验证请求身份、会话 audience 和 organization。
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
3. 校验能力版本、动作、输入 schema、数据类别、大小和委托链。
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
6. 校验次数、并发、预算和有效期。
7. 返回决定、稳定原因和策略引用;需要批准时建立内容绑定申请。
8. 创建任务时再次计算,并把决定快照写入任务。
目录查询可以预计算“可能可用”,但不能替代第 1 至 8 步。
## 4. 组织角色边界
| 动作 | 系统管理员 | 组织管理员 | 发布者 | 审批人 | 审计员 | 成员 |
| --- | --- | --- | --- | --- | --- | --- |
| 实例设置 | 是 | 否 | 否 | 否 | 只读有限项 | 否 |
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
| 审计导出 | 实例级 | 组织级 | 自己相关 | 自己相关 | 授权范围 | 自己相关 |
实际权限仍由策略计算,表格定义产品默认职责而非硬编码角色名称。
## 5. 任务路由决策
| 条件 | 结果 |
| --- | --- |
| provider 在线且双方可直连 | 签发短期直连协商信息 |
| 无法直连且双方策略允许中继 | 建立有界中继通道 |
| 中继关闭或任一方禁止 | 创建失败,说明无可用路由 |
| provider 离线 | 不排队到无限期,按能力配置有界等待或拒绝 |
| provider 接受后连接中断 | 保留 task,等待同一 provider 恢复/查询 |
| provider 终态未知且恢复期结束 | `outcome_unknown` |
| 调用方取消 | 转发取消;provider 确认后终态 cancelled |
中继容量不足只产生排队或资源错误,不切换 provider,不改变是否允许中继。
## 6. 更新策略优先级
```text
紧急撤销
> 组织强制固定版本/禁止版本
> 设备组发布通道
> 用户允许的版本范围
> 当前已安装版本
```
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
策略明确指定时才提供,不自动视为故障回滚。
## 7. 联邦可见性
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
不导出内部设备名、成员清单、组结构、节点地址、内部策略全文或审计记录。跨组织请求使用
双方关联 ID,双方各自解析本地主体。
## 8. 删除、撤销与保留
- 删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计;不提供无提示级联。
- 删除用户不删除其历史动作,审计显示不可变主体摘要。
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
## 9. 需求追踪
| 需求 | 主要逻辑 |
| --- | --- |
| `FR-1` | `INV-1`、`INV-9` 和系统管理员角色边界 |
| `FR-2` | 组织隔离不变量、成员状态和角色边界 |
| `FR-3` | 注册不等于发布、设备状态和短期权威恢复规则 |
| `FR-4` | 注册、Publication、目录与授权不变量 |
| `FR-5` | 拒绝优先不变量和授权计算顺序 |
| `FR-6` | 审批状态、内容绑定和授权重算 |
| `FR-7` | provider 冻结不变量和任务路由决策 |
| `FR-8` | Package Release 状态和更新策略优先级 |
| `FR-9` | 联邦双边交集、FederationTrust 状态和联邦可见性 |
| `FR-10` | 组织隔离、删除撤销与保留规则 |
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
## 10. 逻辑完整性
本设计覆盖实例、组织、成员、设备、发布、授权、审批、任务、更新、联邦、撤销和恢复。实现
前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
-149
View File
@@ -1,149 +0,0 @@
# ShareServer PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
## 1. 背景
局域网配对适合附近设备,但不能统一解决跨网络访问、成员离职、组织策略、集中更新、审计和
跨组织协作。ShareServer 提供一个可自建的组织控制面,让设备通过主动出站连接注册能力并
接收任务,不要求为每台桌面设备开放公网端口。
## 2. 产品目标
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
5. 两个组织可经双边确认建立有限范围的联邦协作。
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
## 3. 产品范围
### 3.1 包含
- 本地账号及可配置 OIDC 身份源。
- 组织、成员、角色、用户组、设备组和服务账号。
- 设备注册、状态、撤销和合规元数据。
- 能力目录、Publication、Grant、强制策略和审批。
- 设备出站控制通道、任务路由、取消和可选中继。
- 签名能力包仓库、发布通道、更新策略和撤销。
- ShareServer 间双边联邦。
- 审计、用量、保留和导出。
- Web 管理控制台和成员自助页面。
### 3.2 不包含
- 远程管理设备桌面、任意 Shell 或任意文件系统。
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 默认查看任务正文、附件和模型输出。
- 未经提供者发布自动盘点设备能力。
- 自动信任同域邮箱、同一网络或其他 ShareServer。
- 公共开放市场、计费结算或匿名能力调用。
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
## 4. 功能需求
### FR-1 实例初始化
- 首次启动通过一次性引导创建实例标识和首个系统管理员。
- 引导完成后永久关闭初始化入口,后续管理员通过正常身份流程创建。
- 实例公开描述包含名称、协议版本、登录方式和联邦端点,不包含内部拓扑。
### FR-2 身份与组织
- 支持一个实例承载多个隔离组织,也支持部署为单组织模式。
- 支持本地账号和 OIDC;组织可限制允许的身份源。
- 成员可以属于多个组并拥有有界角色,角色不隐式跨组织。
- 停用用户立即停止签发新设备和任务凭据。
### FR-3 设备管理
- 桌面端经用户登录、一次性挑战和设备签名完成注册。
- 设备条目显示所有者、平台、客户端版本、最近在线、连接和撤销状态。
- 设备主动建立出站控制连接;服务端不能借此调用未发布能力。
- 管理员可撤销设备并终止其新任务资格。
### FR-4 能力目录
- 只接收已注册设备或服务节点提交的显式 Publication。
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
- 支持同一能力多个版本和多个 provider,不掩盖来源。
- provider 离线时保留目录事实但显示不可用,不派发新任务。
### FR-5 授权策略
- Grant 可绑定用户、组、设备、设备组和联邦组织。
- 可约束能力、版本、动作、输入类型、数据分类、提供者、时间、次数、并发和委托。
- 支持组织强制策略和用户可收紧偏好;拒绝优先。
- 每个决定返回稳定结果、命中策略和面向用户的原因。
### FR-6 审批
- 调用、能力发布、权限扩大更新和联邦关系可以要求审批。
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
- 申请内容变化后旧审批失效。
### FR-7 任务路由
- 根据冻结 provider、在线连接和路由策略把任务声明发送给目标设备。
- 支持直连协商、网关中继和取消传播。
- 任务正文与控制元数据分离;默认审计不保存正文。
- 已接受任务断线后允许查询同一 task,不自动创建重复执行。
### FR-8 能力包与更新
- 保存不可变签名 Package 和发布目录。
- 支持组织、组、平台、架构和维护窗口的更新策略。
- 支持固定版本、允许版本范围、暂停发布和紧急撤销。
- 权限扩大必须触发新批准,不能被普通自动更新策略覆盖。
- 包来源、大小、摘要、签名和下载结果可审计。
### FR-9 联邦
- 两个 ShareServer 通过指纹交换和双方管理员确认建立关系。
- 双方分别配置公开能力、允许主体、数据范围、用量和期限。
- 最终权限取双方策略交集;任一端撤销后停止新任务。
- 不默认链式转发到第三个组织。
### FR-10 审计与用量
- 记录身份、设备、发布、授权、审批、任务、包和联邦管理事件。
- 默认仅记录共享架构定义的有界元数据。
- 支持按组织隔离查询、条件筛选、分页和流式导出。
- 审计员只读,不能因查看日志取得任务正文或管理权限。
### FR-11 管理控制台
- 提供概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、任务与
中继、审计、系统设置。
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
- 所有高影响操作说明对象、范围和后果。
### FR-12 可部署性
- 支持容器部署,配置与持久数据分离。
- 支持外部 PostgreSQL;对象存储和中继按功能启用。
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
## 5. 产品验收
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
- 两个不同组织不能通过 API、搜索、计数、错误或审计结果读取对方数据。
- 设备只有发布能力后才进入获准目录,撤销后不能接收新任务。
- 调用授权能同时处理用户、设备、组织、提供者、数据和联邦限制。
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
- 权限扩大的包更新不能自动部署。
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
@@ -1,242 +0,0 @@
# ShareServer 技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 仓库与运行边界
实现时在仓库顶层创建独立项目:
```text
shareserver/
README.md
package.json
src/
api/
auth/
organizations/
devices/
capabilities/
policies/
approvals/
tasks/
relay/
packages/
federation/
audit/
web/
migrations/
deploy/
tests/
```
当前只提交设计文档,不创建无法运行的目录占位。服务端和 Desktop 共同依赖未来的
`packages/share-protocol`,ShareServer 不导入 Electron 的 `src/shared`。
## 2. 技术选型约束
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库。
- Package 与可选中继暂存使用 S3 兼容对象存储;不开启对应功能时不要求对象存储。
- Web 控制台与 API 同源部署,采用严格 CSP,不在浏览器保存设备凭据。
- 单实例部署可完整工作;水平扩展时再引入共享事件/连接路由组件,不为未验证规模预设复杂
分布式协议。
## 3. 服务模块
| 模块 | 职责 |
| --- | --- |
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
| `organizations` | 组织、成员、组、角色和范围解析 |
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
| `capabilities` | manifest、provider、Publication 和目录投影 |
| `policies` | Grant、强制策略和确定性授权计算 |
| `approvals` | 内容绑定申请、决定、过期和通知 |
| `tasks` | task/attempt、provider 路由、状态和取消 |
| `relay` | 可选有界数据中继,不解释能力正文 |
| `packages` | 不可变对象、签名目录、通道和撤销 |
| `federation` | 网关身份、offer、双边策略和跨组织映射 |
| `audit` | 事务内审计写入、查询、保留和导出 |
模块通过服务接口调用,API handler 不直接拼接跨组织 SQL 或自行计算权限。
## 4. API 边界
建议分为四类 audience:
```text
/api/v1/web/* 管理控制台与成员自助 API
/api/v1/device/* 设备注册、目录和任务控制 API
/api/v1/federation/* ShareServer 间 API
/api/v1/public/* 服务描述、健康与登录入口
```
设备使用 WebSocket `/api/v1/device/connect` 建立出站控制通道。包下载使用短期、绑定主体和对象
摘要的 URL。联邦 API 使用网关身份,不接受普通用户 session。每类 token 使用不同 issuer/
audience/scope,不能互换。
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
最大页;错误返回 request ID、稳定 code 和有界参数。
## 5. 数据模型
核心表建议为:
```text
instances
identity_providers
users
organizations
memberships
groups
group_members
role_bindings
registered_devices
device_credentials
device_connections
capabilities
capability_versions
providers
publications
grants
policies
approval_requests
approval_decisions
tasks
task_attempts
task_events
package_objects
package_releases
release_channels
update_policies
federation_trusts
federation_capability_exports
audit_events
revocations
```
要求:
- 所有组织数据表包含不可为空的 `organization_id`,并由 repository 层强制作用域。
- 唯一约束同时包含 organization,避免跨组织名称冲突泄漏。
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 token。
- 在线连接和短期 nonce 主要在内存;表中只保存恢复所需元数据,不把 socket 当持久对象。
- task event 只存控制状态和有界统计;正文流不进入普通关系表。
## 6. 事务与一致性
- 业务变更与对应审计事件在同一 PostgreSQL 事务提交。
- Publication/Grant/Policy 使用 revision 做乐观并发,Web 草稿不能覆盖他人更新。
- 授权决定创建 task 时再次读取当前 revision,并将命中策略摘要冻结到 task。
- 设备在线状态允许短暂最终一致;身份、撤销、授权和任务接受必须走权威存储。
- 重复注册挑战、联邦 offer 和 task create 使用唯一幂等键。
- 无法确认 provider 副作用时只更新 `outcome_unknown`,不靠消息队列自动重放。
## 7. 设备连接与任务
连接建立后完成设备 credential、organization、客户端协议和当前 registration 校验。服务端为
每个连接设置心跳、空闲、帧、发送队列和并发上限。
任务流程:
```text
authorize -> create task -> resolve exact provider connection
-> send declaration -> provider accepts/rejects
-> establish direct or relay route -> stream progress
-> provider terminal result -> caller delivery -> audit terminal state
```
同一设备重复连接时使用 connection generation;新连接接管未来任务,旧连接仍只允许完成或
查询已归属 task,随后有界关闭。不能把新任务随机发到身份相同但 generation 过期的 socket。
## 8. 中继
中继是可选模块:
- 控制面与中继可部署在不同进程,但共享 task 授权验证。
- 每个 relay ticket 绑定 task、调用方、provider、方向、期限和最大字节。
- 实现背压和全局/组织/设备并发及带宽上限。
- 不解析能力正文,不写普通日志;需要临时落盘时使用加密 GoodBuddy-owned 暂存并按 task 清理。
- 中继关闭、过载或策略禁止时返回准确路由错误,不静默转到其他节点。
## 9. Package 存储
上传先进入不可公开暂存:流式计算大小和 SHA-256,验证签名、manifest、版本、平台和权限后,
以 digest 发布为不可变对象。相同 digest 可去重,但组织授权和 Release 分别保存。
Release catalog 使用独立签名域。服务器不能重打包已签名 payload;镜像目标必须保持相同字节、
大小和 SHA-256。撤销更新目录和授权,不覆盖或原地删除仍受审计保留的对象。
## 10. 联邦协议
- 每个实例生成网关身份和可轮换证书,私钥不进入数据库备份明文。
- offer 包含双方可人工核对的组织名、端点、指纹、协议和 nonce。
- 双方确认后交换有界 capability projection 和本地映射 ID。
- 跨组织 task 带双方关联 ID 和短期签名声明;每端独立授权并各写审计。
- 不接受调用方提供的任意回调 URL、第三方 hop 或内部主体 ID。
- 证书轮换需要旧身份签名或双方重新确认;身份突变直接暂停关系。
## 11. Web 安全
- 使用 HttpOnly、Secure、SameSite cookie 和 CSRF 防护管理 Web session。
- OIDC 使用 state、nonce、PKCE 和固定回调地址。
- 富文本字段按纯文本显示;manifest/schema 使用安全代码查看器,不执行 HTML。
- 权限隐藏仅改善界面,所有 API 在服务端重新校验。
- 登录、设备注册、目录、审批、导出和联邦接口分别限速。
- 密钥字段只能替换、轮换和测试,永不回显。
## 12. 部署与配置
最小生产部署:
```text
TLS reverse proxy/load balancer
ShareServer API + Web
PostgreSQL
可选:S3 compatible object storage
可选:Relay worker
```
部署配置通过环境变量或挂载文件提供数据库、主密钥、公开 URL、OIDC、邮件、对象存储和限制。
普通 Web 管理员不能修改基础设施秘密。提供数据库迁移命令、不可变镜像、非 root 容器、健康
与就绪端点、优雅停止和部署示例。
## 13. 可观测性与备份
- 结构化日志只包含 request/task/organization 关联 ID、稳定事件和错误码。
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package 和联邦,不包含能力正文。
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
- 备份 PostgreSQL、Package 对象和网关身份;不备份缓存、Web session、nonce 或中继暂存。
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
## 14. 测试策略
### 14.1 自动化
- schema、认证 audience、CSRF、OIDC 和设备挑战。
- 每个 repository/API 的跨组织隔离与枚举防护。
- 拒绝优先授权、审批内容绑定、预算和并发。
- device generation、离线、撤销、重连和 provider 冻结。
- task 幂等、取消、结果未知和不自动重放。
- Package 流式校验、不可变性、权限 diff 和撤销。
- 联邦双方确认、策略交集、暂停、证书变化和禁止第三 hop。
- 审计事务、过滤、分页、导出和正文缺失。
### 14.2 集成与部署
- Desktop 与真实 ShareServer 完成注册、目录、调用、文件和取消。
- 两个隔离组织验证搜索、计数、错误、对象和审计不泄漏。
- 两个 ShareServer 完成联邦建立、跨组织调用和单方撤销。
- 中继关闭与开启、容量耗尽、连接中断和大文件背压。
- 数据库备份恢复、版本升级、滚动停止和设备重连。
- 生产 TLS、OIDC 和对象存储组合的端到端验证。
设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。
-159
View File
@@ -1,159 +0,0 @@
# ShareServer Web 管理界面设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现,但沿用 GoodBuddy 的语义
词汇、状态含义和克制的运营工具风格。界面以列表、表格和主从详情为主,不使用营销首页或
装饰性卡片墙。
## 1. 使用对象
- 普通成员主要在 GoodBuddy Desktop 使用能力;Web 端只处理自己的设备、申请和授权。
- 组织管理员使用完整组织控制台。
- 系统管理员使用与组织控制台分开的实例管理范围。
- 审计员进入只读视图,不显示可变更控件。
登录后直接进入当前身份可用的实际控制台,不显示产品宣传页。
## 2. 全局框架
```text
顶部栏:ShareServer 实例名 | 当前组织 | 全局搜索 | 审批 | 账号
侧栏:
概览
组织与成员
设备
能力目录
权限策略
审批
发布与更新
网关联邦
任务与中继
审计
系统设置(仅系统管理员)
```
当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。
系统管理使用“实例范围”标识,不能伪装成某个组织。
## 3. 概览
概览只显示需要行动和运行事实:
- 待审批数量及最旧等待时间。
- 在线/离线/已撤销设备。
- 可用/离线/暂停能力。
- 当前任务、失败和结果未知。
- 包更新异常和紧急撤销。
- 联邦连接异常。
- 中继流量和容量,仅在中继启用时显示。
指标可点击进入已带筛选的对应页面。没有事件时不显示虚构趋势。
## 4. 组织与成员
使用“成员”“用户组”“角色”三个同级页签。成员表包含姓名、身份源、组、状态、设备数和
最近活动。邀请是唯一主操作;暂停和移除进入行末菜单。
成员详情采用抽屉,显示身份、组、角色、设备、有效 Grant、申请和审计。管理员不能查看成员
任务正文或桌面文件。
## 5. 设备
```text
设备 所有者 平台 状态 能力 最近在线
Jiang-PC Jiang Windows 在线 2 刚刚
GPU-Worker-02 AI Team Linux 离线 8 2 小时前
```
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
## 6. 能力目录
目录以能力为第一列,提供者为可展开信息:
```text
能力 版本 提供者 可见范围 可用性
图像生成 2.1.0 3 个节点 设计组 2/3 在线
```
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
看到的是组织元数据,不通过目录读取能力内部数据。
## 7. 权限策略
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
时间、预算、批准和委托。保存前提供自然语言摘要:
> 允许“设计组”从合规设备调用组织内“图像生成”2.x;只发送文本和图片;每人每日 20 次;
> 不允许下游委托。
策略冲突预览显示最终结果和命中规则,但不提供允许规则覆盖拒绝规则的顺序拖拽。
## 8. 审批
审批收件箱按调用、发布、更新和联邦分类。详情必须显示申请人、能力或对象、目标范围、数据
类别、权限变化、期限和策略依据。批准按钮使用具体文案,例如“允许本次调用”“批准此版本
发布”,不能只有“同意”。
批量批准仅用于同类型、同策略且不包含权限扩大的低影响申请;联邦和权限扩大更新不支持
批量批准。
## 9. 发布与更新
页面分“能力包”“发布通道”“更新策略”“撤销记录”。Package 表显示版本、发布者、签名、
大小、目标平台、权限变化和状态。上传后先进入验证,不直接发布。
版本详情提供 manifest、摘要、签名、兼容范围、权限 diff、依赖和下载事实。紧急撤销位于
独立 danger zone,需要输入版本标识确认,并说明受影响设备和新任务。
## 10. 网关联邦
```text
合作组织 对端地址 状态 开放能力 有效期
法务服务商 share.legal.example 已连接 2 项 12-31
```
建立关系采用向导:填写端点、核对组织和指纹、选择公开范围、设置调用与数据限制、生成 offer、
等待对方确认、复核双方交集。只有双方完成后显示“已连接”。
详情明确展示“我方开放”“对方开放”“最终可用”三个集合。暂停和撤销说明对活动任务的处理。
## 11. 任务与中继
任务表显示调用方、能力、provider、路由、数据类别、状态、耗时和时间。默认不显示正文。
活动任务提供取消;结果未知使用独立状态并显示 provider 最后确认点。
中继未启用时页面显示控制面正常和启用位置,不展示零值图表。启用后显示活动连接、吞吐、
排队、容量和拒绝原因,不提供查看正文的调试入口。
## 12. 审计
提供操作者、动作、对象、组织、结果、时间和关联 ID 的可筛选表格。导出前显示条件、估算行数、
时区和字段范围;大导出作为后台任务生成有时效下载。审计详情保持只读,不把原始内部错误、
认证信息或任务正文显示给管理员。
## 13. 系统设置
只包含实例名称、公开地址、身份源、邮件、数据库/对象存储状态、中继开关与上限、保留策略、
联邦总开关、证书和版本。秘密字段只支持替换或测试,不回显原值。
运行时基础设施配置优先来自部署环境;Web 界面不能修改数据库连接串、主密钥或容器挂载路径。
## 14. 状态、响应式与无障碍
- 加载、无数据、无匹配、无权限、服务错误和部分不可用分别展示。
- 管理表格支持键盘访问、可见焦点、表头关联和分页;行菜单有可访问名称。
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
+19 -19
View File
@@ -4,9 +4,9 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 入口与范围已实现;未来分区记忆待独立设计 |
| 版本 | 0.5 |
| 日期 | 2026-08-19 |
| 状态 | 入口与范围已实现;分区记忆协同待设计 |
| 版本 | 0.6 |
| 日期 | 2026-09-05 |
| 适用产品 | GoodBuddy 桌面端 |
| 文档角色 | 智能心跳当前能力、权威入口、范围和长期边界 |
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
@@ -14,8 +14,8 @@
| 相关界面 | [通用助手工作栏与执行空间 PRD](../assistant-workbar/prd.md) |
| Task 模型 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
> 本文区分近期实施与长期方向。未完成设计的“未来分区记忆”不得转写为数据结构、状态机、
> 页面、迁移或验收条件。
> 本文区分近期实施与长期方向。分区记忆由[分区记忆 PRD](../memory/prd.md)定义;智能心跳
> 不能另行创建平行的记忆类型、表或页面。
## 1. 已确认的产品边界
@@ -26,23 +26,23 @@
- 智能心跳不并入通用自动化中心,也不作为任务中心条目。
- 现有心跳报告、记忆建议、行动建议和审计历史在本轮继续保留。
### 1.2 长期方向:未来分区记忆
### 1.2 长期方向:分区记忆协同
已确认的方向只有:
> 智能心跳未来应成为按范围隔离的未来记忆。
> 智能心跳后续应按分区回顾本地信息,提出对应分区的记忆候选和可选唤起建议。
以下内容尚未设计:
- “未来记忆”的数据结构与存储方式。
- 与当前长期记忆、心跳报告和建议的关系。
- 唤起条件、状态和生命周期。
- 新增 Automation、Run 和 Agent 等分区后的逐分区回顾规则。
- 与当前记忆候选、心跳报告和建议的关系。
- 时间与内部事件唤起条件的字段、状态和生命周期。
- 到期后的页面和交互。
- 是否以及如何创建 Task。
- 旧版心跳数据如何迁移。
本轮不得实现 `FutureMemory`、新增未来记忆数据库表,或制作“未来记忆 / 即将唤起 /
已唤起”等页面。后续需要独立 PRD 和用户确认。
本轮不得扩展分区记忆类型或表。后续唤起能力必须作为分区记忆条目的可选条件,并继续遵守
该条目的分区读取边界。
## 2. 本轮实施范围
@@ -56,7 +56,7 @@
### 2.2 非目标
- 不设计或实现未来分区记忆。
- 不扩展分区记忆模型或实现唤起条件。
- 不修改 Task、Job 或 Task Center 模型。
- 不新增独立 Automation Center。
- 不移除定时任务现有入口。
@@ -181,7 +181,7 @@
## 6. 数据与安全要求
目标合同只扩展现有心跳范围,不引入 Future Memory:
目标合同只扩展现有心跳范围,不扩展分区记忆合同:
```ts
type HeartbeatScope =
@@ -217,11 +217,11 @@ type HeartbeatScope =
- 如需要,设置中心仅保留“打开智能心跳”导航。
- 验证应用内仍只有一个完整配置入口。
### 后续:独立设计
### 后续:分区记忆协同设计
- 未来分区记忆。
- 与长期记忆的关系。
- 唤起模型与生命周期。
- 按 Global、Project、Automation、Run 和 Agent 分区独立回顾并提出候选。
- 与已确认记忆、候选、心跳报告和建议的关系。
- 时间与内部事件唤起条件及生命周期。
- 与 Task 的关系。
这些内容需要新的 PRD 和明确确认,不属于本轮实现。
@@ -242,5 +242,5 @@ type HeartbeatScope =
- [x] 设置中心不再包含单条心跳 CRUD。
- [x] 任务中心本身以及定时任务现有行为没有被删除。
- [x] 智能心跳仍保持只读、禁用工具和有界输入输出。
- [x] 没有新增 Future Memory 表、状态机或未确认页面。
- [x] 没有新增平行的记忆类型、表或页面。
- [x] 加载失败、空状态、字段错误和危险操作满足统一设计与无障碍要求。
+3 -3
View File
@@ -169,8 +169,8 @@ GoodBuddy 应能够:
- 当前智能心跳继续提供周期回顾、报告、记忆建议、行动建议和运行历史。
- “智能心跳”菜单入口负责完整配置,并支持 Global 或指定一个、多个 Project。
- 任务中心和设置中心不复制智能心跳 CRUD。
- 智能心跳长期方向是“未来分区记忆”,但数据结构、唤起模型、生命周期、页面以及与任务和
长期记忆的关系尚未设计,不能提前实现。
- 智能心跳长期按分区回顾信息并提出记忆候选或唤起建议,但协同规则、唤起条件、生命周期、
页面以及与任务的关系尚未设计,不能提前实现平行的记忆类型或页面。
### 4.6 成果和预览
@@ -355,7 +355,7 @@ GoodBuddy 应能够:
- 用户删除后不再检索到。
- 每条记忆显示来源与作用域。
- 智能心跳配置明确属于 Global 或指定 Project,当前报告和建议继续沿用已有生命周期。
- 未来分区记忆完成独立设计前,不新增相关数据、页面或任务转换。
- 分区记忆协同和唤起条件完成设计前,不新增相关数据、页面或任务转换。
### 8.5 安全
+9 -1
View File
@@ -12,11 +12,19 @@ export default tseslint.config(
'coverage/**',
'dist/**',
'node_modules/**',
'out/**'
'out/**',
'shareserver/**'
]
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname
}
}
},
{
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'src/shared/**/*.ts'],
languageOptions: {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.6",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.6",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+4
View File
@@ -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=="
+78
View File
@@ -1,6 +1,84 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.12.6",
"releasedAt": "2026-09-06",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**子代理过程与结果显示更完整。** 展开 OpenCode 子代理卡片,可以按顺序查看文字、推理和工具详情,最终结果单独展示。远程项目需要更新 Agent 才能查看实时过程;旧 Agent 仍可显示最终结果,但不会补造缺失的过程记录。",
"**工作区文件变更更容易查看。** 点击变更文件,可分别查看已暂存和未暂存差异,包括删除、重命名和未跟踪文件。刷新会更新当前差异和已展开目录,超过 50 项的变更可以继续加载。",
"**知识库支持整行勾选。** 点击知识库名称或所在行即可切换选择,不再因焦点变化导致点击无效。选择范围仍按当前对话保存。",
"**OpenCode 连续与并行对话更稳定。** 每次请求结束后会释放自身的事件连接,避免连续使用后连接不断积累。取消一条请求不会关闭其他并行会话的连接。",
"**命令可以使用工作区外目录。** 直连模型在 Execute 模式下,可以使用绝对路径、相对路径或符号链接指定工作目录。未指定时仍使用项目工作区,Ask 保持只读。",
"**恢复 GitHub 来源的 Agent 更新检查。** 选择 GitHub 后,可以正常读取可用 Agent 版本,不再因历史发布信息过大提示“发布目录超出大小限制”。镜像入口和已有本地包不受影响。"
],
"notices": [
"远程子代理实时过程和本轮远程文件差异改进需要更新至 Agent `0.11.20`。提供 Linux x64、Linux arm64 和 macOS arm64 包,Desktop 不内置 Agent。",
"本次使用标准六平台桌面发布矩阵,不构建 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**More complete subagent progress and results.** Expand an OpenCode subagent card to view ordered text, reasoning, and tool details, with the final result shown separately. Remote projects require an updated Agent for live progress; older Agents still show final results without inventing missing progress history.",
"**Clearer workspace file changes.** Select a changed file to inspect staged and unstaged diffs separately, including deleted, renamed, and untracked files. Refresh updates the selected diff and expanded directories, and lists with more than 50 changes can load additional entries.",
"**Whole-row knowledge selection.** Click a knowledge library’s name or row to toggle its selection without focus changes losing the click. Selection remains scoped to the current conversation.",
"**More reliable consecutive and parallel OpenCode conversations.** Each request releases its own event connection when it ends, preventing connections from accumulating during continued use. Cancelling one request does not close other parallel conversations’ connections.",
"**Commands can use directories outside the workspace.** Direct models in Execute mode can select a working directory using an absolute path, a relative path, or a symbolic link. The project workspace remains the default, and Ask stays read-only.",
"**Restored Agent update checks through GitHub.** Selecting GitHub now retrieves available Agent versions without failing because the release history exceeds the response-size limit. Mirror access and existing local packages are unaffected."
],
"notices": [
"Update to Agent `0.11.20` for live remote subagent progress and the remote file-diff improvements in this release. Packages are available for Linux x64, Linux arm64, and macOS arm64; Desktop does not embed the Agent.",
"This release uses the standard six-target desktop matrix and does not include an experimental LoongArch preview."
]
}
}
},
{
"version": "0.12.5",
"releasedAt": "2026-09-06",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**Apple Silicon Mac 远程项目。** 可以把 macOS arm64 设备添加为 SSH Host,安装独立 Agent 后使用 Ask、Execute 和断线重连。无需管理员权限或 launchd 服务;Intel Mac 暂不支持,远程项目仍为默认关闭的技术预览。"
],
"fixes": [
"**知识库使用流程更完整。** 创建和检索测试默认收起高级参数,资料可用后可直接“用于当前对话”;知识范围按对话保存,新对话默认不选择知识库。文档明确区分可用、处理中和失败,支持打开来源、失败重试,并在移除来源前说明影响和要求确认。",
"**切换窗口不再误中断回复。** 来回切换桌面窗口时,仍在生成的回复不会被误判为“上次运行意外中断”,后续消息也不会因此滞留队列。OpenCode 专属操作栏在会话刷新时保持稳定,减少界面抖动。",
"**长会话可以完整滚动。** 较长推理、工具记录或正文不再因估算高度导致滚动条提前到底。全部已加载内容可直接滚动查看,无需切换会话刷新。"
],
"notices": [
"**先升级桌面端,再更新 Agent。** Agent `0.11.19` 提供 Linux x64、Linux arm64 和 macOS arm64 包,共用一个签名目录。旧桌面端无法解析包含 macOS 条目的新目录,需升级至 Desktop `0.12.5` 后检查在线 Agent 更新;已有本地包和历史发布仍保留。",
"Desktop 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 使用所选 SSH 账号的完整权限。",
"本次使用标准六平台桌面发布矩阵,不构建 LoongArch 实验预览。",
"仓库新增独立 ShareServer 管理控制台交互原型,不包含在桌面安装包中,也不是已上线的团队共享服务;原型数据操作不持久化。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Remote projects on Apple Silicon Macs.** Add a macOS arm64 device as an SSH Host and install the separate Agent to use Ask, Execute, and reconnection. No administrator access or launchd service is required; Intel Macs are not supported, and Remote Projects remain an off-by-default technical preview."
],
"fixes": [
"**A more complete knowledge workflow.** Creation and retrieval testing collapse advanced settings by default, and searchable libraries can be sent directly to the current conversation. Knowledge selection is saved per conversation and starts empty for new conversations; documents distinguish ready, processing, and failed states, with source opening, retry, and explicit removal confirmation.",
"**Window switching no longer interrupts active replies.** Returning to the desktop window no longer mislabels an active reply as interrupted or leaves subsequent messages queued because of that false state. OpenCode-specific controls remain stable during conversation refreshes, reducing layout shifts.",
"**Complete scrolling in long conversations.** Long reasoning, tool records, and replies no longer cause the scrollbar to end early because of estimated message heights. All loaded content remains reachable without switching conversations to refresh the layout."
],
"notices": [
"**Upgrade Desktop before updating the Agent.** Agent `0.11.19` provides Linux x64, Linux arm64, and macOS arm64 packages through one signed catalog. Older Desktop versions cannot parse the new catalog containing macOS entries; upgrade to Desktop `0.12.5` before checking online Agent updates. Existing local packages and historical releases remain available.",
"Desktop does not embed the Agent or Remote Runtime. Ask remains read-only; Execute uses the selected SSH account’s full permissions.",
"This release uses the standard six-target desktop matrix and does not include an experimental LoongArch preview.",
"The repository now includes a standalone interactive ShareServer console prototype. It is not bundled with Desktop or a deployed team-sharing service, and prototype data operations are not persisted."
]
}
}
},
{
"version": "0.12.4",
"releasedAt": "2026-09-05",
+6 -25
View File
@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { open } from 'node:fs/promises'
import { binaryTargetArchitecture } from '../shared/node/binary-target'
import type { AgentPlatform } from '../shared/agent-target'
import type {
AgentArchitecture
} from '../shared/agent-installation-contracts'
@@ -16,7 +18,8 @@ export async function sha256File(filePath: string): Promise<string> {
export async function assertElfArchitecture(
filePath: string,
expected: AgentArchitecture,
label: string
label: string,
platform: AgentPlatform = 'linux'
): Promise<void> {
const handle = await open(filePath, 'r')
try {
@@ -27,8 +30,8 @@ export async function assertElfArchitecture(
header.length,
0
)
const actual = detectElfArchitecture(
header.subarray(0, bytesRead)
const actual = binaryTargetArchitecture(
header.subarray(0, bytesRead), platform
)
if (actual !== expected) {
throw new Error(
@@ -39,25 +42,3 @@ export async function assertElfArchitecture(
await handle.close()
}
}
function detectElfArchitecture(
header: Buffer
): AgentArchitecture | undefined {
if (
header.length < 20 ||
header[0] !== 0x7f ||
header.toString('ascii', 1, 4) !== 'ELF' ||
(header[5] !== 1 && header[5] !== 2)
) {
return undefined
}
const machine =
header[5] === 2
? header.readUInt16BE(18)
: header.readUInt16LE(18)
return machine === 62
? 'x64'
: machine === 183
? 'arm64'
: undefined
}
+1 -1
View File
@@ -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 () => {
+3 -3
View File
@@ -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')
+7 -3
View File
@@ -100,6 +100,7 @@ export class AgentDaemon {
readonly #agentVersion: string
readonly #protocol: AttachPreface['protocol']
readonly #architecture: 'x64' | 'arm64'
readonly #platform = process.platform
readonly #remoteUserIdentity: string
readonly #stateDirectory: string
readonly #socketPath: string
@@ -171,7 +172,7 @@ export class AgentDaemon {
daemonBootId: this.#bootId,
agentVersion: this.#agentVersion,
protocol: this.#protocol,
platform: 'linux',
platform: this.#platform,
architecture: this.#architecture,
supervisor: 'detached-on-demand',
remoteUserIdentity: this.#remoteUserIdentity,
@@ -180,9 +181,12 @@ export class AgentDaemon {
}
async start(): Promise<void> {
if (process.platform !== 'linux') {
if (
this.#platform !== 'linux' &&
!(this.#platform === 'darwin' && process.arch === 'arm64')
) {
throw new AgentUnsupportedError(
'GoodBuddy Agent daemon is certified only on Linux',
'GoodBuddy Agent daemon requires Linux or macOS arm64',
'platform-incompatible'
)
}
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { realpathSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
darwinBootId,
inspectDarwinProcess,
listDarwinProcessGroup,
stopDarwinDescendants
} from './darwin-process-identity'
describe('Darwin process identity', () => {
const native = process.platform === 'darwin' && process.arch === 'arm64' ? it : it.skip
native('reads actual process identity and enumerates a dedicated child group', async () => {
const current = inspectDarwinProcess(process.pid)
expect(current?.pid).toBe(process.pid)
expect(current?.executablePath).toBe(realpathSync(process.execPath))
expect(BigInt(current!.starttime)).toBeGreaterThan(0n)
expect(darwinBootId()).toMatch(/^[a-f0-9-]{36}$/u)
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
detached: true, stdio: 'ignore'
})
await once(child, 'spawn')
try {
const identity = inspectDarwinProcess(child.pid!)
expect(identity?.processGroupId).toBe(child.pid)
expect(listDarwinProcessGroup(child.pid!)).toContain(child.pid)
expect(listDarwinProcessGroup(child.pid!)).not.toContain(process.pid)
} finally {
const exited = once(child, 'exit')
child.kill()
await exited
}
expect(inspectDarwinProcess(child.pid!)).toBeUndefined()
})
native('stops a tool child that starts its own process group', async () => {
const parent = spawn(process.execPath, ['-e', `
const {spawn}=require('node:child_process');
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'ignore'});
child.on('spawn',()=>console.log(child.pid));
setInterval(()=>{},1000);
`], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
const [output] = await once(parent.stdout!, 'data')
const childPid = Number(String(output).trim())
try {
expect(inspectDarwinProcess(childPid)?.processGroupId).toBe(childPid)
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
expect(inspectDarwinProcess(childPid)).toBeUndefined()
expect(inspectDarwinProcess(parent.pid!)).toBeDefined()
} finally {
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
const exited = once(parent, 'exit')
parent.kill()
await exited
}
})
})
+139
View File
@@ -0,0 +1,139 @@
import koffi from 'koffi'
import { realpathSync } from 'node:fs'
export type DarwinProcessIdentity = {
pid: number
starttime: string
executablePath: string
processGroupId: number
parentPid: number
}
let binding: ReturnType<typeof createBinding> | undefined
function createBinding() {
const library = koffi.load('/usr/lib/libSystem.B.dylib')
return {
info: library.func('proc_pidinfo', 'int', [
'int', 'int', 'uint64_t', 'void *', 'int'
]),
path: library.func('proc_pidpath', 'int', ['int', 'void *', 'uint32_t']),
list: library.func('proc_listpids', 'int', ['uint32_t', 'uint32_t', 'void *', 'int']),
sysctl: library.func('sysctlbyname', 'int', [
'str', 'void *', 'void *', 'void *', 'size_t'
])
}
}
function native() {
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
throw new Error('Darwin process inspection requires macOS arm64')
}
return binding ??= createBinding()
}
export function inspectDarwinProcess(pid: number): DarwinProcessIdentity | undefined {
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('Invalid process ID')
const api = native()
const info = Buffer.alloc(136)
const size = api.info(pid, 3, 0, info, info.length) as number
if (size === 0 && [3, 2].includes(koffi.errno())) return undefined
if (size !== info.length || info.readUInt32LE(12) !== pid) {
throw new Error(`proc_pidinfo failed (${koffi.errno()})`)
}
const path = Buffer.alloc(4096)
if ((api.path(pid, path, path.length) as number) <= 0) {
if ([3, 2].includes(koffi.errno())) return undefined
throw new Error(`proc_pidpath failed (${koffi.errno()})`)
}
const starttime = info.readBigUInt64LE(120) * 1_000_000n +
info.readBigUInt64LE(128)
return {
pid,
starttime: starttime.toString(),
executablePath: realpathSync(path.subarray(0, path.indexOf(0)).toString('utf8')),
processGroupId: info.readUInt32LE(100),
parentPid: info.readUInt32LE(16)
}
}
export function darwinBootId(): string {
const bytes = Buffer.alloc(128)
const size = Buffer.alloc(8)
size.writeBigUInt64LE(BigInt(bytes.length))
if (native().sysctl('kern.bootsessionuuid', bytes, size, null, 0) !== 0) {
throw new Error(`Boot session lookup failed (${koffi.errno()})`)
}
const value = bytes.subarray(0, bytes.indexOf(0)).toString('ascii').toLowerCase()
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) {
throw new Error('Invalid Darwin boot session identity')
}
return value
}
export function listDarwinProcessGroup(groupId: number): number[] {
return listDarwinPids(2, groupId)
}
function listDarwinPids(kind: number, parent: number): number[] {
const api = native()
if (!Number.isSafeInteger(parent) || parent <= 0) throw new Error('Invalid process group')
const required = api.list(kind, parent, null, 0) as number
if (required < 0) throw new Error('Process group enumeration failed')
const buffer = Buffer.alloc(required + 4096)
const size = api.list(kind, parent, buffer, buffer.length) as number
if (size < 0 || size >= buffer.length || size % 4 !== 0) {
throw new Error('Process group enumeration was incomplete')
}
const pids: number[] = []
for (let offset = 0; offset < size; offset += 4) {
const pid = buffer.readInt32LE(offset)
if (pid > 0) pids.push(pid)
}
return pids
}
export async function stopDarwinDescendants(
ownerPid: number,
deadline: number
): Promise<void> {
const descendants: DarwinProcessIdentity[] = []
const pending = [ownerPid]
const seen = new Set(pending)
while (pending.length > 0) {
const parentPid = pending.pop()!
for (const pid of listDarwinPids(6, parentPid)) {
if (seen.has(pid)) continue
seen.add(pid)
const identity = inspectDarwinProcess(pid)
if (!identity || identity.parentPid !== parentPid) continue
descendants.push(identity)
pending.push(pid)
}
}
const matches = (identity: DarwinProcessIdentity): boolean => {
const current = inspectDarwinProcess(identity.pid)
return current !== undefined &&
current.starttime === identity.starttime &&
current.executablePath === identity.executablePath
}
const signal = (identity: DarwinProcessIdentity, value: NodeJS.Signals): void => {
if (!matches(identity)) return
try { process.kill(identity.pid, value) }
catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error
}
}
for (const identity of [...descendants].reverse()) signal(identity, 'SIGTERM')
const grace = Math.min(deadline, Date.now() + 500)
while (Date.now() < grace && descendants.some(matches)) {
await new Promise(resolve => setTimeout(resolve, 25))
}
for (const identity of [...descendants].reverse()) signal(identity, 'SIGKILL')
while (Date.now() < deadline && descendants.some(matches)) {
await new Promise(resolve => setTimeout(resolve, 25))
}
if (descendants.some(matches)) {
throw new Error('Runtime child process cleanup could not be verified')
}
}
@@ -28,6 +28,7 @@ import {
sameLinuxRuntimeProcessIdentity,
type LinuxRuntimeProcessIdentity
} from './runtime-process-identity'
import { listDarwinProcessGroup, stopDarwinDescendants } from './darwin-process-identity'
const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
@@ -401,6 +402,9 @@ export class DirectLinuxStdioProcessOwner {
throw identityError('Runtime PID identity changed before signal')
}
this.#registry.markStopping(this.ownerId)
if (process.platform === 'darwin') {
await stopDarwinDescendants(this.processIdentity.pid, Date.parse(deadlineAt))
}
this.#stdinClosed = true
this.#child.stdin.end()
this.#sendSignal(-record.processIdentity.processGroupId, 'SIGTERM')
@@ -540,9 +544,9 @@ export class DirectLinuxStdioProcessOwner {
export async function launchDirectLinuxStdioProcessOwner(
options: DirectLinuxStdioProcessOwnerOptions
): Promise<DirectLinuxStdioProcessOwner> {
if ((options.platform ?? process.platform) !== 'linux') {
if (!['linux', 'darwin'].includes(options.platform ?? process.platform)) {
throw new DirectLinuxStdioProcessOwnerError(
'Direct Runtime ownership is Linux-only',
'Direct Runtime ownership requires Linux or macOS',
'platform'
)
}
@@ -786,6 +790,18 @@ export async function listLinuxPidNamespaceMembers(
>,
options: { procRoot?: string } = {}
): Promise<LinuxRuntimeProcessIdentity[]> {
if (process.platform === 'darwin') {
const identities: LinuxRuntimeProcessIdentity[] = []
for (const pid of listDarwinProcessGroup(owner.processGroupId)) {
try {
const identity = await readLinuxRuntimeProcessIdentity(pid, { bootId: owner.bootId })
if (identity.processGroupId === owner.processGroupId) identities.push(identity)
} catch (error) {
if (!isMissingProcess(error)) throw error
}
}
return identities.sort((left, right) => left.pid - right.pid)
}
const procRoot = options.procRoot ?? '/proc'
const entries = await readdir(procRoot, { withFileTypes: true })
const identities: LinuxRuntimeProcessIdentity[] = []
@@ -230,7 +230,8 @@ export async function verifyPublishedAgentBundle(
await assertElfArchitecture(
join(installationDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
'Agent Node runtime'
'Agent Node runtime',
manifest.platform
)
const manifestSha256 = sha256Bytes(manifestBytes)
return {
@@ -7,6 +7,7 @@ import {
import { isAbsolute, join, resolve } from 'node:path'
import { z } from 'zod'
import { assertAbsoluteManagedPath } from './managed-paths'
import { inspectDarwinProcess } from './darwin-process-identity'
const linuxProcessIdentitySchema = z
.object({
@@ -45,6 +46,14 @@ export class LinuxProcessInspector {
inspect(pidInput: number): LinuxProcessIdentity | undefined {
const pid = positivePid(pidInput)
if (process.platform === 'darwin') {
const identity = inspectDarwinProcess(pid)
return identity === undefined ? undefined : {
pid: identity.pid,
starttime: identity.starttime,
executablePath: identity.executablePath
}
}
try {
const starttime = parseStarttime(
this.#fileSystem.readText(
+3 -3
View File
@@ -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)
+16 -2
View File
@@ -1,5 +1,9 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { spawn as nodeSpawn } from 'node:child_process'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
import {
createServer,
type IncomingHttpHeaders,
@@ -8,7 +12,7 @@ import {
type ServerResponse
} from 'node:http'
import type { Socket } from 'node:net'
import { isAbsolute, resolve } from 'node:path'
import { isAbsolute, resolve, join } from 'node:path'
import type {
ModelBridgeModelProtocol
} from '../shared/model-bridge-contracts'
@@ -409,7 +413,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
const exchange = createUnixModelBridgeExchange({ socketPath })
const proxy = new ModelBridgeLoopbackProxy({ exchange })
const origin = await proxy.listen()
let pluginDirectory: string | undefined
try {
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
const pluginPath = join(pluginDirectory, 'subagent.mjs')
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
const config = createOpenCodeModelBridgeProviderConfig({
protocol: options.protocol,
model: options.model,
@@ -419,7 +427,10 @@ export async function runOpenCodeModelBridgeHelper(options: {
})
const environment = credentialFreeHelperEnvironment(
options.environment ?? process.env,
JSON.stringify(config)
JSON.stringify({
...config,
plugin: [pathToFileURL(pluginPath).href]
})
)
const child = (options.spawn ?? defaultHelperSpawn)(
opencodeEntrypoint,
@@ -442,6 +453,9 @@ export async function runOpenCodeModelBridgeHelper(options: {
})
} finally {
await proxy.close()
if (pluginDirectory) {
await rm(pluginDirectory, { recursive: true, force: true })
}
}
}
@@ -0,0 +1,42 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
describe('OpenCode ACP child event plugin', () => {
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
const write = vi.fn()
const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
{ process: { stdout: { write } } }
)
const hooks = await factory()
const event = {
type: 'message.part.updated',
properties: {
sessionID: 'parent',
part: {
type: 'tool', tool: 'task', callID: 'task',
state: { metadata: { sessionId: 'child' } }
}
}
}
await hooks.event({ event })
await hooks.event({ event: {
type: 'message.part.delta', properties: { sessionID: 'child', delta: 'Inspecting' }
} })
await hooks.event({ event: {
type: 'message.part.delta', properties: { sessionID: 'unrelated', delta: 'Private' }
} })
expect(write).toHaveBeenCalledTimes(2)
for (const [line] of write.mock.calls) {
expect(line.endsWith('\n')).toBe(true)
expect(JSON.parse(line)).toMatchObject({
method: 'session/update',
params: { sessionId: 'parent', update: {
sessionUpdate: 'tool_call_update', toolCallId: 'task',
_meta: { goodbuddySubagentEvent: { properties: expect.any(Object) } }
} }
})
}
})
})
@@ -0,0 +1,56 @@
/**
* OpenCode's ACP adapter omits native child sessions. This plugin forwards
* their SDK events as ACP metadata on the owning Task, using the same stdio
* stream and prompt lifetime as the built-in adapter.
*/
export function openCodeSubagentPluginSource(): string {
return `export default ${plugin.toString()}`
}
function plugin() {
const tasks = new Map<string, { sessionId: string; callId: string }>()
return Promise.resolve({
event: async ({ event }: { event: {
type: string
properties: {
sessionID?: string
part?: {
type: string
tool?: string
callID?: string
state?: {
metadata?: { sessionId?: string }
}
}
}
} }) => {
const properties = event.properties
const part = properties.part
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined
if (
!owner && event.type === 'message.part.updated' &&
part?.type === 'tool' && part.tool === 'task' &&
part.callID && properties.sessionID
) {
owner = { sessionId: properties.sessionID, callId: part.callID }
const child = part.state?.metadata?.sessionId
if (child) tasks.set(child, owner)
}
if (!owner || ![
'message.updated', 'message.part.updated', 'message.part.delta'
].includes(event.type)) return
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: owner.sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: owner.callId,
_meta: { goodbuddySubagentEvent: event }
}
}
}) + '\n')
}
})
}
+19 -3
View File
@@ -67,23 +67,39 @@ export async function createLinuxPeerIdentityProvider(
byteOrder?: 'BE' | 'LE'
loadKoffi?: () => Promise<Koffi>
} = {}
): Promise<LinuxPeerIdentityProvider> {
): Promise<UnixPeerIdentityProvider> {
const platform = options.platform ?? process.platform
const architecture = options.architecture ?? arch()
const byteOrder = options.byteOrder ?? endianness()
if (
platform !== 'linux' ||
(platform !== 'linux' && !(platform === 'darwin' && architecture === 'arm64')) ||
!SUPPORTED_ARCHITECTURES.has(architecture) ||
byteOrder !== 'LE'
) {
throw unavailable(
'SO_PEERCRED is certified only on little-endian Linux x64 and arm64'
'Unix peer credentials require little-endian Linux or macOS arm64'
)
}
try {
const koffi =
await (options.loadKoffi?.() ??
import('koffi').then((module) => module.default))
if (platform === 'darwin') {
const library = koffi.load('/usr/lib/libSystem.B.dylib')
const getpeereid = library.func('getpeereid', 'int', [
'int', 'void *', 'void *'
])
return {
async getPeerIdentity(socket) {
const uid = Buffer.alloc(4)
const gid = Buffer.alloc(4)
if (getpeereid(socketDescriptor(socket), uid, gid) !== 0) {
throw new Error(`getpeereid failed with errno ${koffi.errno()}`)
}
return { uid: uid.readUInt32LE() }
}
}
}
return new LinuxPeerIdentityProvider(createNativeBinding(koffi))
} catch (error) {
if (error instanceof AgentUnsupportedError) {
+2 -2
View File
@@ -225,7 +225,7 @@ export async function createProductionRuntimeProtocol(
try {
if (
verificationEnvironment === 'production' &&
process.platform === 'linux'
(process.platform === 'linux' || process.platform === 'darwin')
) {
await (
options.reconcileOrphanedProcesses ??
@@ -436,7 +436,7 @@ export function derivePrivateModelBridgeDirectory(
controllerGeneration?: number
}
): string {
if (process.platform === 'linux') {
if (process.platform === 'linux' || process.platform === 'darwin') {
const stateDirectory = resolve(stateDirectoryInput)
ensurePrivateDirectory(stateDirectory, { create: false })
const userRoot = ensurePrivateTemporaryRoot(
@@ -1,5 +1,6 @@
import { readFile, readlink } from 'node:fs/promises'
import { posix } from 'node:path'
import { darwinBootId, inspectDarwinProcess } from './darwin-process-identity'
export type LinuxRuntimeProcessIdentity = {
bootId: string
@@ -72,6 +73,19 @@ export async function readLinuxRuntimeProcessIdentity(
options: { procRoot?: string; bootId?: string } = {}
): Promise<LinuxRuntimeProcessIdentity> {
const pid = positiveInteger(pidInput, 'PID')
if (process.platform === 'darwin') {
const identity = inspectDarwinProcess(pid)
if (identity === undefined) {
throw Object.assign(new Error('Runtime process no longer exists'), { code: 'ESRCH' })
}
return {
bootId: options.bootId ?? darwinBootId(),
pid,
startTimeTicks: BigInt(identity.starttime),
processGroupId: identity.processGroupId,
executablePath: identity.executablePath
}
}
const procRoot = options.procRoot ?? '/proc'
if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) {
throw new Error('Linux proc root must be absolute')
@@ -127,6 +127,32 @@ describe('WorkspaceGitService', () => {
code: 'git-unsafe'
})
})
linuxIt('diffs unborn, untracked, staged, renamed and deleted files by literal path', async () => {
const root = temporaryDirectory()
const executable = await resolveVerifiedGitExecutable()
git(executable, root, 'init', '--quiet')
const service = new WorkspaceGitService({ gitExecutable: executable })
const access = accessFor(root)
const diff = (relativePath: string, staged = false) => service.diff(access, {
workspaceId: 'workspace-a', generation: 1, relativePath, staged, maximumBytes: 4096
})
writeFileSync(join(root, '[new].txt'), 'original\n')
expect((await diff('[new].txt')).patch).toContain('+original')
expect((await diff('[new].txt', true)).patch).toBe('')
git(executable, root, 'add', '.')
expect((await diff('[new].txt', true)).patch).toContain('+original')
writeFileSync(join(root, '[new].txt'), 'edited\n')
expect((await diff('[new].txt')).patch).toContain('+edited')
git(executable, root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
git(executable, root, 'mv', '[new].txt', 'renamed file.txt')
expect((await diff('renamed file.txt', true)).patch).toContain('rename from [new].txt')
expect((await diff('renamed file.txt')).patch).toContain('+edited')
rmSync(join(root, 'renamed file.txt'))
expect((await diff('renamed file.txt')).patch).toContain('-original')
git(executable, root, 'add', '-u')
expect((await diff('[new].txt', true)).patch).toContain('-original')
})
})
function git(executable: string, cwd: string, ...args: string[]): void {
+28 -11
View File
@@ -184,8 +184,23 @@ export class WorkspaceGitService {
options: WorkspaceIoOptions = {}
): Promise<RemoteGitDiffResult> {
const repository = await this.#repository(accessService, options)
if (request.relativePath !== undefined && request.relativePath !== '') {
await accessService.stat(request.relativePath, options)
const selectedPath = request.relativePath || undefined
const status = selectedPath === undefined ? undefined : await this.status(
accessService,
{ workspaceId: request.workspaceId, generation: request.generation,
includeIgnored: false, maximumEntries: 2000 },
options
)
const file = status?.entries.find((entry) => entry.relativePath === selectedPath)
if (selectedPath !== undefined && !file) {
throw new WorkspaceServiceError('File is no longer in the Git changes list', 'git-failed')
}
const untracked = file?.index === 'untracked'
if (untracked && request.staged) {
return { repositoryIdentity: repository.repositoryIdentity, patch: '', byteLength: 0, truncated: false }
}
if (untracked) {
await accessService.stat(selectedPath!, options)
}
const cursor = parseCursor(request.cursor)
if (cursor > MAXIMUM_GIT_PROCESS_BYTES) {
@@ -205,13 +220,14 @@ export class WorkspaceGitService {
'--no-ext-diff',
'--no-textconv',
'--no-color',
'--no-renames',
'--ignore-submodules=all',
...(request.staged ? ['--cached'] : []),
...(request.relativePath === undefined ||
request.relativePath === ''
? []
: ['--', request.relativePath])
...(untracked ? ['--no-index', '--', NULL_DEVICE, selectedPath!] : [
...(request.staged ? ['--cached'] : []),
...(selectedPath === undefined ? [] : ['--',
...[...new Set([file?.originalRelativePath, selectedPath])]
.filter((path): path is string => path !== undefined)
.map((path) => `:(literal)${path}`)])
])
]
if (cursor > 0) {
const cached = this.#diffSnapshots.get(snapshotKey)
@@ -228,7 +244,7 @@ export class WorkspaceGitService {
cached === undefined ||
cached.repositoryIdentity !== repository.repositoryIdentity ||
current === undefined ||
current.exitCode !== 0 ||
(current.exitCode !== 0 && !(untracked && current.exitCode === 1)) ||
current.truncated ||
!current.stdout.equals(cached.bytes) ||
cursor > cached.bytes.byteLength
@@ -254,7 +270,7 @@ export class WorkspaceGitService {
options,
MAXIMUM_GIT_PROCESS_BYTES
)
if (result.exitCode !== 0) {
if (result.exitCode !== 0 && !(untracked && result.exitCode === 1)) {
throw gitFailure(result)
}
const middle = await repositorySnapshot(repository, accessService)
@@ -266,7 +282,7 @@ export class WorkspaceGitService {
MAXIMUM_GIT_PROCESS_BYTES
)
if (
verification.exitCode !== 0 ||
(verification.exitCode !== 0 && !(untracked && verification.exitCode === 1)) ||
verification.truncated !== result.truncated ||
!verification.stdout.equals(result.stdout)
) {
@@ -876,6 +892,7 @@ function parseStatus(
continue
}
entries.push({
statusCode,
relativePath: remoteRelativePathSchema.parse(relativePath),
index: mapStatus(statusCode[0]!, statusCode),
worktree: mapStatus(statusCode[1]!, statusCode),
+52
View File
@@ -1544,6 +1544,58 @@ describe('AcpRemoteRuntime', () => {
})
})
it('keeps ACP content results and bridged child progress in the same card', async () => {
const client: { value?: AgentSideConnection } = {}
const input = { subagent_type: 'general', prompt: 'Read seed' }
const server = fakeServer({
prompt: async ({ sessionId }) => {
for (const event of [
{ type: 'message.part.updated', properties: {
sessionID: sessionId, part: {
id: 'task', callID: 'task', type: 'tool', tool: 'task',
state: { status: 'running', input, metadata: { sessionId: 'child' } }
}
} },
{ type: 'message.part.delta', properties: {
sessionID: 'child', partID: 'text', field: 'text', delta: 'Inspecting seed'
} },
{ type: 'message.part.updated', properties: {
sessionID: 'child', part: {
id: 'read', callID: 'read', type: 'tool', tool: 'read',
state: { status: 'completed', input: { path: 'seed.txt' }, output: 'seed' }
}
} }
]) {
await client.value!.sessionUpdate({
sessionId, update: {
sessionUpdate: 'tool_call_update', toolCallId: 'task',
_meta: { goodbuddySubagentEvent: event }
}
})
}
await client.value!.sessionUpdate({
sessionId, update: {
sessionUpdate: 'tool_call', toolCallId: 'task', title: 'Read seed', rawInput: input,
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: '**Final seed**' } }]
}
})
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const events = await collect(runtime(server).run(request, new AbortController().signal))
const children = events.filter(event => event.type === 'subagent')
expect(children.at(-1)).toMatchObject({
state: 'completed', output: '**Final seed**',
progress: [
{ type: 'text', content: 'Inspecting seed' },
{ type: 'tool', tool: { name: 'read', state: 'completed', output: 'seed' } }
]
})
expect(events.some(event => event.type === 'text')).toBe(false)
})
it('bounds native subagent output per event without a request-wide stop', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
+43 -5
View File
@@ -60,6 +60,7 @@ import {
} from './remote-runtime-channel'
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
import {
OpenCodeSubagentProgress,
parseOpenCodeSubagentInput,
toOpenCodeSubagentEvent
} from './opencode-subagent'
@@ -136,6 +137,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
}
type ActivePrompt = {
subagentProgress?: OpenCodeSubagentProgress
requestId: string
operationId: string
sessionId: string
@@ -153,6 +155,7 @@ type ActivePrompt = {
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
rawInput?: unknown
rawOutput?: unknown
contentOutput?: string
retainedInput?: string
retainedOutput?: string
retainedError?: string
@@ -2067,6 +2070,34 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
if (this.options.runtimeId === 'opencode') {
if (!prompt.subagentProgress) {
prompt.subagentProgress = new OpenCodeSubagentProgress(
prompt.requestId, prompt.sessionId
)
for (const tool of prompt.toolCalls.values()) {
if (tool.retainedSubagent) {
prompt.subagentProgress.retain({
...tool.retainedSubagent,
requestId: prompt.requestId, type: 'subagent'
})
}
}
}
const childEvent = update._meta?.goodbuddySubagentEvent
if (childEvent !== undefined) {
const event = prompt.subagentProgress.update(
childEvent, 'toolCallId' in update ? update.toolCallId : undefined
)
if (event?.runtimeCallId) {
const previous = prompt.toolCalls.get(event.runtimeCallId)
prompt.toolCalls.set(event.runtimeCallId, {
...previous, retainedSubagent: event
})
}
return event
}
}
if (
(update.sessionUpdate === 'agent_message_chunk' ||
update.sessionUpdate === 'agent_thought_chunk') &&
@@ -2118,6 +2149,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
update.rawOutput,
retainedMaximum
),
contentOutput:
update.content === undefined || update.content === null
? previous?.contentOutput
: update.content.flatMap((entry) =>
entry.type === 'content' && entry.content.type === 'text'
? [entry.content.text] : []
).join('\n').slice(0, retainedMaximum),
retainedInput:
update.rawInput === undefined
? previous?.retainedInput
@@ -2148,22 +2186,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
callId,
state: toolCall.status ?? 'pending',
input: toolCall.rawInput,
output: toolCall.rawOutput
output: toolCall.contentOutput || toolCall.rawOutput
})
: undefined
if (subagent) {
return subagent
return prompt.subagentProgress?.retain(subagent) ?? subagent
}
if (toolCall.retainedSubagent) {
const retained = toolCall.retainedSubagent
const state =
toolCall.status === 'pending'
? ('queued' as const)
? retained.state
: toolCall.status === 'in_progress'
? ('running' as const)
: toolCall.status ?? retained.state
const detail = safeStringify(
toolCall.rawOutput,
toolCall.contentOutput || toolCall.rawOutput,
retainedMaximum
)
return {
@@ -2191,7 +2229,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
retainedMaximum
) ?? toolCall.retainedInput
const output = safeStringify(
toolCall.rawOutput,
toolCall.contentOutput || toolCall.rawOutput,
retainedMaximum
) ?? toolCall.retainedOutput
return {
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import {
@@ -110,10 +110,10 @@ describe('LocalDirectModelProcessService', () => {
).rejects.toThrow()
await expect(
service.execute(
{ command: 'echo ok', cwd: join(process.cwd(), 'absolute') },
{ command: 'echo ok', cwd: 'invalid\0directory' },
context
)
).rejects.toThrow('相对于工作区')
).rejects.toThrow('无效字符')
expect(spawnProcess).not.toHaveBeenCalled()
await service.dispose()
})
@@ -198,68 +198,82 @@ describe('LocalDirectModelProcessService', () => {
await unavailable.dispose()
})
it('resolves only existing workspace directories and rejects symlink escapes', async () => {
const { root, access } = await workspace()
const child = new FakeChild()
const { service, spawnProcess } = fakeService(child)
await mkdir(join(root, 'nested'))
const execution = service.execute(
{ command: 'pwd', cwd: 'nested' },
{
conversationId: 'cwd',
workspace: access,
signal: new AbortController().signal
it.each(['relative', 'absolute', 'outside', 'parent', 'symlink'])(
'resolves an existing %s working directory without workspace containment',
async (kind) => {
const { root, access } = await workspace()
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
temporaryDirectories.push(outside)
const nested = join(root, 'nested')
await mkdir(nested)
const link = join(root, 'outside-link')
if (kind === 'symlink') {
await symlink(
outside,
link,
process.platform === 'win32' ? 'junction' : 'dir'
)
}
)
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(
await realpath(join(root, 'nested'))
)
child.close(0)
await expect(execution).resolves.toMatchObject({ cwd: 'nested' })
const cwd =
kind === 'relative'
? 'nested'
: kind === 'absolute'
? nested
: kind === 'outside'
? outside
: kind === 'parent'
? relative(root, outside)
: 'outside-link'
const expectedPath = await realpath(
kind === 'relative' || kind === 'absolute' ? nested : outside
)
const child = new FakeChild()
const { service, spawnProcess } = fakeService(child)
await expect(
service.execute(
{ command: 'pwd', cwd: '..' },
const execution = service.execute(
{ command: 'pwd', cwd },
{
conversationId: 'escape',
conversationId: 'cwd',
workspace: access,
signal: new AbortController().signal
}
)
).rejects.toThrow('不能超出')
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
temporaryDirectories.push(outside)
const link = join(root, 'outside-link')
try {
await symlink(
outside,
link,
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(
service.execute(
{ command: 'pwd', cwd: 'outside-link' },
{
conversationId: 'symlink',
workspace: access,
signal: new AbortController().signal
}
)
).rejects.toThrow('符号链接')
} catch (error) {
if (
!(
error instanceof Error &&
'code' in error &&
error.code === 'EPERM'
)
) {
throw error
}
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(expectedPath)
child.close(0)
await expect(execution).resolves.toMatchObject({
cwd:
kind === 'relative' || kind === 'absolute'
? 'nested'
: expectedPath
})
await service.dispose()
}
)
it('rejects missing working directories and files before starting a process', async () => {
const { root, access } = await workspace()
const file = join(root, 'file.txt')
await writeFile(file, 'not a directory')
const { service, spawnProcess } = fakeService(new FakeChild())
const context = {
conversationId: 'invalid-cwd',
workspace: access,
signal: new AbortController().signal
}
await expect(
service.execute(
{ command: 'pwd', cwd: join(root, 'missing') },
context
)
).rejects.toThrow()
await expect(
service.execute(
{ command: 'pwd', cwd: file },
context
)
).rejects.toThrow('不是目录')
expect(spawnProcess).not.toHaveBeenCalled()
await service.dispose()
})
+15 -21
View File
@@ -28,7 +28,15 @@ export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
export const processExecuteInputSchema = z
.object({
command: z.string().trim().min(1).max(100_000),
cwd: z.string().trim().min(1).max(4_096).optional(),
cwd: z
.string()
.trim()
.min(1)
.max(4_096)
.describe(
'工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。'
)
.optional(),
timeoutMs: z
.number()
.int()
@@ -350,10 +358,6 @@ async function resolveWorkingDirectory(
if (input.cwd?.includes('\0')) {
throw new Error('工作目录包含无效字符')
}
if (input.cwd && isAbsolute(input.cwd)) {
throw new Error('工作目录必须相对于工作区')
}
const initialIdentity = await workspace.getIdentity()
if (initialIdentity.kind !== 'local') {
throw new Error('本机进程不能在远程工作区中运行')
@@ -365,23 +369,8 @@ async function resolveWorkingDirectory(
throw new Error('项目工作区不是目录')
}
const candidatePath = resolve(canonicalRoot, input.cwd ?? '.')
const lexicalDifference = relative(canonicalRoot, candidatePath)
if (
lexicalDifference === '..' ||
lexicalDifference.startsWith(`..${sep}`) ||
isAbsolute(lexicalDifference)
) {
throw new Error('工作目录不能超出项目工作区')
}
const canonicalPath = await realpath(candidatePath)
const canonicalDifference = relative(canonicalRoot, canonicalPath)
if (
canonicalDifference === '..' ||
canonicalDifference.startsWith(`..${sep}`) ||
isAbsolute(canonicalDifference)
) {
throw new Error('工作目录不能通过符号链接超出项目工作区')
}
if (!(await stat(canonicalPath)).isDirectory()) {
throw new Error('工作目录不是目录')
}
@@ -397,7 +386,12 @@ async function resolveWorkingDirectory(
}
return {
canonicalPath,
displayPath: canonicalDifference || '.'
displayPath:
canonicalDifference === '..' ||
canonicalDifference.startsWith(`..${sep}`) ||
isAbsolute(canonicalDifference)
? canonicalPath
: canonicalDifference || '.'
}
}
+66
View File
@@ -1,4 +1,7 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
RecoverableModelToolError,
@@ -2496,6 +2499,69 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.dispose).toHaveBeenCalledOnce()
})
it.each(['absolute', 'relative'])(
'executes a real command outside the workspace using a %s cwd through the model tool loop',
async (pathKind) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cwd-'))
const workspace = join(root, 'workspace')
const target = join(root, 'target')
await mkdir(workspace)
await mkdir(target)
await writeFile(join(target, 'input.txt'), 'outside-workspace')
const command = process.platform === 'win32'
? "Copy-Item 'input.txt' 'output.txt'"
: 'cp input.txt output.txt'
const responses = [
{
choices: [{
message: {
role: 'assistant', content: null,
tool_calls: [{
id: 'call-process', type: 'function',
function: {
name: 'process_execute',
arguments: JSON.stringify({
command,
cwd: pathKind === 'absolute' ? target : relative(workspace, target)
})
}
}]
}
}]
},
{ choices: [{ message: { role: 'assistant', content: 'done' } }] }
]
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'test-model',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
defaultWorkspace: workspace
})
try {
const events: RuntimeEvent[] = []
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'Copy the file in the requested directory.',
workMode: 'execute'
}, new AbortController().signal, async () => 'once')) {
events.push(event)
}
expect(await readFile(join(target, 'output.txt'), 'utf8')).toBe('outside-workspace')
expect(events).toContainEqual(expect.objectContaining({
type: 'tool', callId: 'call-process', state: 'completed'
}))
expect(fetcher).toHaveBeenCalledTimes(2)
} finally {
await runtime.dispose()
await rm(root, { recursive: true, force: true })
}
}
)
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
const command =
process.platform === 'win32'
+1 -1
View File
@@ -1217,7 +1217,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
scopeKey: 'model:builtin:process_execute',
title: '允许运行项目命令?',
description:
'该命令会在当前工作区中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
'该命令会在指定目录(默认当前工作区)中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
toolName: tool.displayName,
argumentSummary,
allowPermanent: false
@@ -0,0 +1,136 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { expect, it } from 'vitest'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { OpenCodeRuntime } from './opencode-runtime'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath))(
'closes real event streams after parallel runs without cancelling a peer',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-lifecycle-'))
let cancelledController: AbortController | undefined
const model = createServer((request, response) => {
void (async () => {
let body = ''
for await (const chunk of request) body += chunk.toString()
if (body.includes('CANCEL_LIFECYCLE')) {
setTimeout(() => cancelledController?.abort(
new DOMException('Cancelled', 'AbortError')
), 30)
}
await new Promise((resolve) => setTimeout(resolve, 100))
response.writeHead(200, { 'content-type': 'text/event-stream' })
for (const [delta, finishReason] of [
[{ role: 'assistant', content: 'LIFECYCLE_OK' }, null],
[{}, 'stop']
]) {
response.write(`data: ${JSON.stringify({
id: 'lifecycle', object: 'chat.completion.chunk', created: 1,
model: 'lifecycle',
choices: [{ index: 0, delta, finish_reason: finishReason }]
})}\n\n`)
}
response.end('data: [DONE]\n\n')
})().catch(() => response.destroy())
})
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
let openEventBodies = 0
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
modelProfile: {
id: crypto.randomUUID(), name: 'Lifecycle fixture', modelName: 'lifecycle',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
createClient: (options) => createOpencodeClient({
...options,
fetch: async (request, init) => {
const response = await fetch(request, init)
const url = request instanceof Request ? request.url : request
if (new URL(url).pathname !== '/event' || !response.body) return response
openEventBodies++
const reader = response.body.getReader()
let closed = false
const close = (): void => {
if (!closed) openEventBodies--
closed = true
}
return new Response(new ReadableStream({
async pull(controller) {
try {
const item = await reader.read()
if (item.done) {
close()
controller.close()
} else controller.enqueue(item.value)
} catch (error) {
close()
controller.error(error)
}
},
async cancel(reason) {
close()
await reader.cancel(reason)
}
}), { status: response.status, headers: response.headers })
}
})
})
const run = async (controller = new AbortController(), cancel = false): Promise<string> => {
const requestId = crypto.randomUUID()
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
const deadline = setTimeout(() => controller.abort(), 30_000)
let text = ''
try {
for await (const event of runtime.run({
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
prompt: cancel ? 'CANCEL_LIFECYCLE' : 'Reply LIFECYCLE_OK without tools.',
knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
return text
} finally {
clearTimeout(deadline)
gateway.revoke(token)
}
}
try {
for (const concurrency of [1, 4, 8]) {
const results = await Promise.all(Array.from({ length: concurrency }, () => run()))
expect(results).toEqual(Array(concurrency).fill('LIFECYCLE_OK'))
await expect.poll(() => openEventBodies).toBe(0)
}
cancelledController = new AbortController()
const cancelled = run(cancelledController, true)
await Promise.all([
expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }),
expect(run()).resolves.toBe('LIFECYCLE_OK')
])
await expect.poll(() => openEventBodies).toBe(0)
} finally {
await runtime.dispose()
await gateway.dispose()
model.closeAllConnections()
await new Promise<void>((resolve) => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
120_000
)
+53
View File
@@ -1290,6 +1290,59 @@ describe('OpenCodeRuntime embedded launcher', () => {
expect(runtime.requiresToolApproval).toBe(false)
})
it.each(['completed', 'failed', 'cancelled', 'consumer-return'])(
'closes the event subscription when a run is %s',
async (outcome) => {
const setup = runClient([])
const controller = new AbortController()
let subscriptionSignal: AbortSignal | undefined
const subscribe = setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
subscribe.mockImplementation(async (_input, options: { signal: AbortSignal }) => {
subscriptionSignal = options.signal
return {
stream: (async function* () {
if (outcome === 'cancelled') {
controller.abort(new DOMException('Cancelled', 'AbortError'))
return
}
if (outcome === 'failed') return
yield {
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
})()
}
})
const runtime = new OpenCodeRuntime(
options({ embedded: false, baseUrl: 'http://127.0.0.1:4096' }),
{ createClient: (() => setup.client) as typeof createOpencodeClient }
)
const collect = async (): Promise<void> => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test',
workMode: 'execute'
}, controller.signal)) {
if (outcome === 'consumer-return' && event.type === 'done') break
}
}
try {
if (outcome === 'failed') {
await expect(collect()).rejects.toThrow('事件流意外结束')
} else if (outcome === 'cancelled') {
await expect(collect()).rejects.toMatchObject({ name: 'AbortError' })
} else {
await collect()
}
expect(subscriptionSignal?.aborted).toBe(true)
expect(controller.signal.aborted).toBe(outcome === 'cancelled')
} finally {
await runtime.dispose()
}
}
)
it('serializes external runs that share one conversation session', async () => {
const child = fakeChild()
let releaseFirst!: () => void
+21 -3
View File
@@ -63,7 +63,7 @@ import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
import { toOpenCodeSubagentEvent } from './opencode-subagent'
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
import { promptWithUntrustedConversationHistory } from './runtime-conversation-history'
const STARTUP_TIMEOUT_MS = 30_000
@@ -1980,6 +1980,7 @@ export class OpenCodeRuntime implements AgentRuntime {
let knowledgeToolIds: string[] = []
let customMcpName: string | undefined
let customMcpToken: string | undefined
const subscriptionController = new AbortController()
try {
if (
request.knowledgeCapabilityToken &&
@@ -2193,7 +2194,7 @@ export class OpenCodeRuntime implements AgentRuntime {
{ directory },
{ signal: controlSignal }
),
signal
AbortSignal.any([signal, subscriptionController.signal])
)
const abortSession = (): void => {
@@ -2216,6 +2217,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId)
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
let outputTruncated = false
@@ -2314,6 +2316,18 @@ export class OpenCodeRuntime implements AgentRuntime {
const repliedPermissionIds = new Set<string>()
const reportedMessageIds = new Set<string>()
for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event)
if (
childProgress &&
'sessionID' in event.properties &&
event.properties.sessionID !== sessionId
) {
const task = childProgress.runtimeCallId
? toolStates.get(childProgress.runtimeCallId)
: undefined
if (task) task.subagent = childProgress
yield childProgress
}
if (
event.type === 'message.updated' &&
event.properties.sessionID === sessionId &&
@@ -2423,7 +2437,9 @@ export class OpenCodeRuntime implements AgentRuntime {
...(subagent ? { subagent } : {})
})
if (subagent) {
yield subagent
const retained = subagentProgress.retain(subagent)
toolStates.get(callId)!.subagent = retained
yield retained
} else {
yield {
requestId: request.requestId,
@@ -2699,6 +2715,7 @@ export class OpenCodeRuntime implements AgentRuntime {
)
)
}
signal.throwIfAborted()
throw new Error('OpenCode 事件流意外结束')
} catch (error) {
abortSession()
@@ -2739,6 +2756,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
} finally {
subscriptionController.abort()
const cleanupSignal = AbortSignal.timeout(1_000)
if (knowledgeMcpName) {
await awaitWithAbort(
+87 -1
View File
@@ -1,9 +1,76 @@
import { describe, expect, it } from 'vitest'
import { toOpenCodeSubagentEvent } from './opencode-subagent'
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
describe('OpenCode native subagent events', () => {
it('extracts ACP output objects without displaying protocol metadata', () => {
const event = toOpenCodeSubagentEvent({
requestId, callId: 'task', state: 'completed',
input: { subagent_type: 'general', prompt: 'Inspect seed' },
output: {
output: '<task id="child" state="completed">\n<task_result>\n**Done**\n</task_result>\n</task>',
metadata: { sessionId: 'child' }
}
})
expect(event?.output).toBe('**Done**')
})
it('keeps ordered child text, reasoning and tools separate from the final result', () => {
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
const task = (status: string) => ({
type: 'message.part.updated',
properties: {
sessionID: 'parent',
part: {
id: 'task-part', callID: 'task', type: 'tool', tool: 'task',
state: {
status, input: { subagent_type: 'general', prompt: 'Inspect seed' },
metadata: { sessionId: 'child' },
...(status === 'completed' ? { output: 'Final result' } : {})
}
}
}
})
tracker.update(task('running'))
tracker.update({
type: 'message.updated',
properties: { sessionID: 'child', info: { id: 'user', role: 'user' } }
})
expect(tracker.update({
type: 'message.part.updated',
properties: { sessionID: 'child', part: {
id: 'prompt', messageID: 'user', type: 'text', text: 'Do not display prompt'
} }
})).toBeUndefined()
const delta = (sessionID: string, partID: string, text: string) => ({
type: 'message.part.delta',
properties: { sessionID, partID, field: 'text', delta: text }
})
expect(tracker.update(delta('unrelated', 'text', 'Not ours'))).toBeUndefined()
tracker.update(delta('child', 'text', 'Inspecting '))
const first = tracker.update(delta('child', 'text', 'seed'))
expect(first?.progress?.[0]).toMatchObject({ type: 'text', content: 'Inspecting seed' })
tracker.update({
type: 'message.part.updated', properties: { sessionID: 'child', part: {
id: 'thought', type: 'reasoning', text: ''
} }
})
tracker.update(delta('child', 'thought', 'Check contents'))
for (const status of ['running', 'completed']) {
tracker.update({
type: 'message.part.updated', properties: { sessionID: 'child', part: {
id: 'read', callID: 'read', type: 'tool', tool: 'read',
state: { status, input: { path: 'seed.txt' }, output: 'seed contents' }
} }
})
}
const done = tracker.update(task('completed'))
expect(done?.output).toBe('Final result')
expect(done?.progress?.map(b => b.type)).toEqual(['text', 'reasoning', 'tool'])
expect(done?.progress?.[2]).toMatchObject({ tool: { state: 'completed', output: 'seed contents' } })
expect(first?.progress).toHaveLength(1)
})
it('maps Task tool updates to stable bounded subagent identities', () => {
const running = toOpenCodeSubagentEvent({
requestId,
@@ -57,4 +124,23 @@ describe('OpenCode native subagent events', () => {
})
).toBeUndefined()
})
it('routes recovered live child events by the ACP owning Task without new metadata', () => {
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
tracker.retain({
...toOpenCodeSubagentEvent({
requestId, callId: 'task', state: 'running',
input: { subagent_type: 'general', prompt: 'Read seed' }
})!,
progress: []
})
const event = {
type: 'message.part.delta',
properties: { sessionID: 'child', partID: 'text', field: 'text', delta: 'Resumed progress' }
}
expect(tracker.update(event)).toBeUndefined()
expect(tracker.update(event, 'task')).toMatchObject({
state: 'running', progress: [{ type: 'text', content: 'Resumed progress' }]
})
})
})
+145 -2
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import type { SubagentEvent } from '../../shared/contracts'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import { boundedToolDetail } from './approval-summary'
type OpenCodeSubagentState =
@@ -113,11 +114,19 @@ export function toOpenCodeSubagentEvent(
? 'completed'
: 'failed'
const failed = state === 'failed'
const detail = isRecord(update.output)
? update.output.output ?? update.output.error
: update.output
const result = typeof detail === 'string'
? detail.match(
/^\s*<task\b[^>]*>\s*<task_result>\s*([\s\S]*?)\s*<\/task_result>\s*<\/task>\s*$/u
)?.[1] ?? detail
: detail
const output = failed
? undefined
: boundedDetail(update.output, 16_000)
: boundedDetail(result, 16_000)
const error = failed
? boundedDetail(update.error ?? update.output, 1_000)
? boundedDetail(update.error ?? detail, 1_000)
: undefined
return {
@@ -138,3 +147,137 @@ export function toOpenCodeSubagentEvent(
...(error ? { error } : {})
}
}
/**
* Tracks only sessions explicitly linked by a Task's metadata.sessionId.
* The same snapshots travel through the local SDK and the remote ACP bridge.
*/
export class OpenCodeSubagentProgress {
private readonly roles = new Map<string, string>()
private readonly reasoning = new Set<string>()
private readonly tasks = new Map<string, {
event: SubagentEvent
sessionId?: string
blocks: ConversationMessageBlock[]
}>()
constructor(
private readonly requestId: string,
private readonly parentSessionId?: string
) {}
update(value: unknown, ownerCallId?: string): SubagentEvent | undefined {
if (!isRecord(value) || !isRecord(value.properties)) return undefined
const properties = value.properties
const part = properties.part
if (
value.type === 'message.part.updated' &&
isRecord(part) && part.type === 'tool' &&
part.tool === 'task' && isRecord(part.state) &&
(!this.parentSessionId || properties.sessionID === this.parentSessionId) &&
![...this.tasks.values()].some(
(task) => task.sessionId === properties.sessionID
)
) {
const callId = boundedText(part.callID ?? part.id, 256)
if (!callId) return undefined
const event = toOpenCodeSubagentEvent({
requestId: this.requestId,
callId,
input: part.state.input,
state: part.state.status as OpenCodeSubagentState,
output: part.state.output,
error: part.state.error
})
if (!event) return undefined
const previous = this.tasks.get(callId)
const metadata = part.state.metadata
const task = {
event,
sessionId: (isRecord(metadata)
? boundedText(metadata.sessionId, 256)
: undefined) ?? previous?.sessionId,
blocks: previous?.blocks ?? []
}
this.tasks.set(callId, task)
return { ...event, progress: [...task.blocks] }
}
const task = [...this.tasks.values()].find(
(candidate) => candidate.sessionId === properties.sessionID
) ?? (ownerCallId ? this.tasks.get(ownerCallId) : undefined)
if (!task) return undefined
if (value.type === 'message.updated' && isRecord(properties.info)) {
this.roles.set(String(properties.info.id), String(properties.info.role))
return undefined
}
const messageId = isRecord(part) ? part.messageID : properties.messageID
if (this.roles.get(String(messageId)) === 'user') return undefined
const partId = isRecord(part) ? part.id : properties.partID
if (typeof partId !== 'string') return undefined
const id = deterministicUuid(`${task.event.childTaskId}\0${partId}`)
const index = task.blocks.findIndex((block) => block.id === id)
const previous = task.blocks[index]
let block: ConversationMessageBlock | undefined
if (value.type === 'message.part.updated' && isRecord(part)) {
if (part.type === 'reasoning') this.reasoning.add(id)
if (
(part.type === 'text' || part.type === 'reasoning') &&
typeof part.text === 'string' && part.text &&
part.ignored !== true && part.synthetic !== true
) {
block = { id, type: part.type, content: part.text.slice(0, 16_000) }
} else if (part.type === 'tool' && isRecord(part.state)) {
block = {
id,
type: 'tool',
tool: {
callId: String(part.callID ?? partId).slice(0, 256),
name: String(part.tool).slice(0, 200),
summary: boundedText(part.state.title, 240) ?? String(part.tool),
state: part.state.status === 'error'
? 'failed'
: part.state.status as 'pending' | 'running' | 'completed',
input: boundedDetail(part.state.input, 4_000),
output: boundedDetail(part.state.output, 16_000),
error: boundedDetail(part.state.error, 1_000)
}
}
}
} else if (
value.type === 'message.part.delta' &&
typeof properties.delta === 'string' && properties.delta &&
(properties.field === 'text' || properties.field === 'reasoning')
) {
block = {
id,
type: this.reasoning.has(id) || previous?.type === 'reasoning' || properties.field === 'reasoning'
? 'reasoning'
: 'text',
content: (
(previous?.type === 'text' || previous?.type === 'reasoning'
? previous.content : '') + properties.delta
).slice(0, 16_000)
}
}
if (!block) return undefined
if (index >= 0) task.blocks[index] = block
else task.blocks.push(block)
return { ...task.event, progress: [...task.blocks] }
}
retain(event: SubagentEvent): SubagentEvent {
const task = event.runtimeCallId
? this.tasks.get(event.runtimeCallId)
: undefined
if (!task) {
if (event.runtimeCallId && event.progress) {
this.tasks.set(event.runtimeCallId, {
event, blocks: [...event.progress]
})
}
return event
}
task.event = event
return { ...event, progress: [...task.blocks] }
}
}
+94 -3
View File
@@ -3954,6 +3954,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
title: '发布讨论',
updatedAt: 1_775_000_000_000,
@@ -4055,6 +4058,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
messages: [
expect.objectContaining({
@@ -4076,8 +4082,7 @@ describe('AssistantDatabase', () => {
}),
expect.objectContaining({
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断'),
state: 'streaming',
reasoning: '先分析发布范围',
contextCompression: {
state: 'completed',
@@ -4094,7 +4099,7 @@ describe('AssistantDatabase', () => {
type: 'tool',
tool: expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
state: 'running'
})
}),
expect.objectContaining({
@@ -4442,6 +4447,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
@@ -4550,6 +4558,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
branch: {
sourceConversationId,
@@ -4623,6 +4634,7 @@ describe('AssistantDatabase', () => {
id: branch.id,
projectId: branch.projectId,
runtimeSelection: branch.runtimeSelection,
knowledgeLibraryIds: branch.knowledgeLibraryIds,
knowledgeRetrievalMode: branch.knowledgeRetrievalMode,
branch: branch.branch,
title: '发布方案讨论 · 分支(独立更新)',
@@ -5424,6 +5436,85 @@ describe('AssistantDatabase', () => {
durable.close()
})
it('keeps a live streaming message streaming until startup recovery runs', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-live-streaming-read-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const conversationId =
'00000000-0000-4000-8000-000000000971'
const assistantMessageId =
'00000000-0000-4000-8000-000000000972'
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '流式进行中',
updatedAt: 1_775_000_000_000
},
messages: [
{
id: assistantMessageId,
role: 'assistant',
content: '正在回答',
createdAt: 1_775_000_000_001,
state: 'streaming',
status: '正在生成',
tools: [
{
callId: 'call-1',
name: 'read',
state: 'running',
summary: '正在调用'
}
]
}
]
}
])
const liveExpectation = {
id: assistantMessageId,
state: 'streaming',
status: '正在生成',
tools: [
expect.objectContaining({
callId: 'call-1',
state: 'running'
})
]
}
// Reading during a live run must not manufacture an interruption; the
// renderer keeps saving the streaming message while the run continues.
expect(
database.getConversation(conversationId).messages[0]
).toMatchObject(liveExpectation)
expect(
database.listConversations()[0]?.messages[0]
).toMatchObject(liveExpectation)
database.close()
const recovered = new AssistantDatabase(databasePath)
recovered.initialize('C:\\Workspace')
expect(
recovered.getConversation(conversationId).messages[0]
).toMatchObject({
id: assistantMessageId,
state: 'error',
status: '上次运行意外中断,可以重新发送问题',
tools: [
expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
})
]
})
recovered.close()
})
it('keeps duplicate chat replies out of artifact listings', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
+29 -43
View File
@@ -211,7 +211,6 @@ type MessageRow = {
state: ConversationSnapshot['messages'][number]['state']
metadata_json: string
created_at: string
remote_recoverable?: number
}
type MagicNoteRow = {
@@ -1033,6 +1032,7 @@ function interruptActiveToolBlocks(
}
const conversationContextStateSchema = conversationSnapshotSchema.pick({
knowledgeLibraryIds: true,
contextMetrics: true,
contextCompressionState: true
})
@@ -1041,7 +1041,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1059,12 +1059,14 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.contextMetrics ||
return conversation.knowledgeLibraryIds !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
@@ -1081,9 +1083,9 @@ function toConversationSnapshot(
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
...parseConversationContextState(conversation.context_state_json),
knowledgeRetrievalMode:
conversation.knowledge_retrieval_mode ?? undefined,
...parseConversationContextState(conversation.context_state_json),
...(conversation.channel &&
conversation.conversation_type &&
conversation.account_display
@@ -1107,38 +1109,31 @@ function toConversationSnapshot(
: {}),
title: conversation.title,
updatedAt: Date.parse(conversation.updated_at),
// Startup recovery is the only authority for interruption: it persists
// the terminal state once. Deriving it while reading would also hit
// messages that are streaming right now, because the renderer keeps
// saving them during a live run.
messages: messages.map((message) => {
const metadata = JSON.parse(
message.metadata_json
) as MessageMetadata
const interrupted =
message.state === 'streaming' &&
message.remote_recoverable !== 1
return {
id: message.id,
queueItemId: metadata.queueItemId,
role: message.role,
content: message.content,
reasoning: metadata.reasoning,
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
status: interrupted
? interruptedMessageStatus
: metadata.status,
state: message.state,
status: metadata.status,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
tools: interrupted
? interruptActiveTools(metadata.tools)
: metadata.tools,
subagents: interrupted
? interruptActiveSubagents(metadata.subagents)
: metadata.subagents,
tools: metadata.tools,
subagents: metadata.subagents,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -1399,6 +1394,7 @@ function reduceRecoveredAgentEvent(
workMode: event.workMode,
state: event.state,
reason: event.reason,
progress: event.progress,
output: event.output,
error: event.error
}
@@ -2601,18 +2597,10 @@ export class AssistantDatabase {
.all() as ConversationRow[]
const messageStatement = database.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at, remote_recoverable
created_at
FROM (
SELECT id, conversation_id, role, content, state, metadata_json,
created_at, sequence,
EXISTS(
SELECT 1 FROM tasks
WHERE tasks.id = messages.request_id
AND tasks.remote_recoverable = 1
AND tasks.status IN (
'running', 'waiting_approval', 'interrupted'
)
) AS remote_recoverable
created_at, sequence
FROM messages
WHERE conversation_id = ?
ORDER BY sequence DESC
@@ -2648,18 +2636,10 @@ export class AssistantDatabase {
const messages = database
.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at, remote_recoverable
created_at
FROM (
SELECT id, conversation_id, role, content, state,
metadata_json, created_at, sequence,
EXISTS(
SELECT 1 FROM tasks
WHERE tasks.id = messages.request_id
AND tasks.remote_recoverable = 1
AND tasks.status IN (
'running', 'waiting_approval', 'interrupted'
)
) AS remote_recoverable
metadata_json, created_at, sequence
FROM messages
WHERE conversation_id = ?
ORDER BY sequence DESC
@@ -2992,7 +2972,7 @@ export class AssistantDatabase {
const source = database
.prepare(
`SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title
knowledge_retrieval_mode, context_state_json, title
FROM conversations
WHERE id = ? AND status = 'active' AND channel IS NULL`
)
@@ -3002,6 +2982,7 @@ export class AssistantDatabase {
project_id: string | null
runtime_selection_json: string | null
knowledge_retrieval_mode: 'auto' | 'always' | null
context_state_json: string | null
title: string
}
| undefined
@@ -3052,13 +3033,18 @@ export class AssistantDatabase {
knowledge_retrieval_mode, context_state_json, work_mode,
title, branch_source_conversation_id, branch_source_title,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)`
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
)
.run(
destinationConversationId,
source.project_id,
source.runtime_selection_json,
source.knowledge_retrieval_mode,
serializeConversationContextState({
knowledgeLibraryIds:
parseConversationContextState(source.context_state_json)
.knowledgeLibraryIds
}),
input.title,
source.id,
source.title,
@@ -23,6 +23,47 @@ afterEach(async () => {
})
describe('getWorkspaceChanges', () => {
it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
await execute('git', ['init'], { cwd: directory })
expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' })
await mkdir(join(directory, 'new directory'))
const path = 'new directory/[file].txt'
await writeFile(join(directory, path), 'index\n')
expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' })
expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index')
await execute('git', ['add', '--', path], { cwd: directory })
await writeFile(join(directory, path), 'worktree\n')
const diff = await getWorkspaceChanges(directory, path)
expect(diff).toMatchObject({ available: true })
expect(diff.stagedPatch).toContain('+index')
expect(diff.patch).toContain('-index')
expect(diff.patch).toContain('+worktree')
})
it('reads deleted and renamed paths literally without including unrelated changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
const git = (...args: string[]) => execute('git', args, { cwd: directory })
await git('init')
await writeFile(join(directory, 'old name.txt'), 'original\n')
await writeFile(join(directory, 'other.txt'), 'other\n')
await git('add', '.')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
await git('mv', 'old name.txt', '[new].txt')
await writeFile(join(directory, '[new].txt'), 'edited\n')
await writeFile(join(directory, 'other.txt'), 'unrelated\n')
const renamed = await getWorkspaceChanges(directory, '[new].txt')
expect(renamed.stagedPatch).toContain('rename from old name.txt')
expect(renamed.stagedPatch).toContain('rename to [new].txt')
expect(renamed.patch).toContain('+edited')
expect(renamed.patch).not.toContain('unrelated')
await rm(join(directory, '[new].txt'))
expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original')
await git('add', '-u')
expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original')
})
it('returns tracked and untracked Git workspace changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
@@ -59,8 +59,8 @@ const previewFileNames = new Set([
export class WorkspaceChangesService {
constructor(private readonly workspace: WorkspaceAccess) {}
getChanges(signal?: AbortSignal): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal })
getChanges(signal?: AbortSignal, path?: string): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal, path })
}
async listDirectory(
@@ -158,13 +158,14 @@ export async function resolveWorkspaceEntryPath(
}
export async function getWorkspaceChanges(
workspace: string | WorkspaceAccess
workspace: string | WorkspaceAccess,
path?: string
): Promise<WorkspaceChanges> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).getChanges()
return new WorkspaceChangesService(workspace).getChanges(undefined, path)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).getChanges()
new WorkspaceChangesService(access).getChanges(undefined, path)
)
}
+7 -4
View File
@@ -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(
@@ -4155,6 +4157,7 @@ describe('registerIpcHandlers workspace files', () => {
for (const [channel, input] of [
[ipcChannels.workspaceChangesGet, projectId],
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
[
ipcChannels.workspaceDirectoryList,
{ projectId, path: '' }
+69 -12
View File
@@ -74,6 +74,7 @@ import {
knowledgeChunkPageSchema,
knowledgeChunksListInputSchema,
knowledgeChunkUpdateInputSchema,
knowledgeDocumentOpenInputSchema,
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
@@ -892,6 +893,8 @@ function getKnowledgeSnapshot(
sourceCount: library.sourceCount,
documentCount: library.documentCount,
indexedDocumentCount: library.indexedDocumentCount,
processingDocumentCount: library.processingDocumentCount,
failedDocumentCount: library.failedDocumentCount,
retrievalSettings: library.retrievalSettings,
chunkingSettings: library.chunkingSettings,
chunkingRebuildRequired: library.chunkingRebuildRequired,
@@ -926,6 +929,9 @@ function getKnowledgeSnapshot(
name: document.title,
path: document.sourceLocation,
status: document.status,
textIndexStatus: document.textIndexStatus,
vectorIndexStatus: document.vectorIndexStatus,
graphIndexStatus: document.graphIndexStatus,
indexProgress: document.status === 'ready' ? 100 : 0,
chunkCount: document.chunkCount,
size: document.size,
@@ -5117,7 +5123,7 @@ export function registerIpcHandlers(
const request =
agentPackageInventoryRequestSchema.parse(input ?? {})
return agentPackageInventorySchema.parse(
await agentPackageManager.getSnapshot(request)
await agentPackageManager.getAllSnapshot(request)
)
}
)
@@ -5129,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(
@@ -5142,7 +5147,7 @@ export function registerIpcHandlers(
progress
)
)
)
return agentPackageManager.getAllSnapshot()
}
)
@@ -5165,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()
}
)
@@ -5178,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, {
@@ -5198,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
)
@@ -6597,6 +6601,23 @@ export function registerIpcHandlers(
}
}
)
registerHandler(
ipcChannels.workspaceFileDiff,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = workspaceFileRequestSchema.omit({ offsetBytes: true }).parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
try {
return await getWorkspaceChanges(executionSpace.workspaceAccess, value.path)
} finally {
await executionSpace.workspaceAccess.dispose()
}
}
)
registerHandler(
ipcChannels.workspaceDirectoryList,
async (event, input: unknown) => {
@@ -6648,9 +6669,9 @@ export function registerIpcHandlers(
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
spaceResolver.assertLocal(executionSpace)
let targetPath: string
try {
spaceResolver.assertLocal(executionSpace)
targetPath = await resolveWorkspaceEntryPath(
executionSpace.rootPath,
value.path,
@@ -7969,6 +7990,42 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.knowledgeOpenDocumentSource,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeDocumentOpenInputSchema.parse(input)
const reference = knowledgeService.getDocumentSource(value)
if (!reference) {
throw new Error('文档来源不存在')
}
if (reference.source.type === 'url') {
const target = new URL(reference.source.location)
if (!['http:', 'https:'].includes(target.protocol)) {
throw new Error('文档来源 URL 协议不受支持')
}
await shell.openExternal(target.href)
return
}
const storedPath =
reference.document.sourceLocation ?? reference.source.location
if (!isAbsolute(storedPath)) {
throw new Error('文档来源路径无效')
}
if ((await lstat(storedPath)).isSymbolicLink()) {
throw new Error('文档来源不能是符号链接')
}
const targetPath = await realpath(storedPath)
if (!(await stat(targetPath)).isFile()) {
throw new Error('文档来源不是可打开的文件')
}
const openError = await shell.openPath(targetPath)
if (openError) {
throw new Error('无法打开文档来源')
}
}
)
registerHandler(
ipcChannels.knowledgeReferenceContext,
(event, input: unknown) => {
@@ -207,6 +207,51 @@ describe('KnowledgeDatabase', () => {
).toBe(true)
})
it('counts only ready documents as indexed and separates actionable states', async () => {
const { database } = await createDatabase()
const library = database.createKnowledgeBase({
name: 'Status counts',
storageMode: 'reference'
})
const source = database.upsertSource({
knowledgeBaseId: library.id,
type: 'file',
location: 'C:\\notes\\status.md',
displayName: 'status.md',
status: 'ready'
})
for (const [index, status] of [
'ready',
'queued',
'parsing',
'indexing',
'failed'
].entries()) {
database.upsertDocument(
{
knowledgeBaseId: library.id,
sourceId: source.id,
externalId: `status-${index}`,
title: `Status ${index}`,
metadata: { status }
},
[{
id: `status-${index}-chunk`,
ordinal: 0,
content: `status ${status}`
}]
)
}
expect(database.getKnowledgeBaseCounts().get(library.id)).toEqual({
sourceCount: 1,
documentCount: 5,
indexedDocumentCount: 1,
processingDocumentCount: 3,
failedDocumentCount: 1
})
})
it('normalizes legacy negative vector thresholds without losing settings', async () => {
const { database, path } = await createDatabase()
const library = database.createKnowledgeBase({
+18 -1
View File
@@ -846,6 +846,8 @@ export class KnowledgeDatabase {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
> {
const rows = this.requireDatabase()
@@ -855,8 +857,15 @@ export class KnowledgeDatabase {
COUNT(DISTINCT d.id) AS document_count,
COUNT(DISTINCT CASE
WHEN COALESCE(json_extract(d.metadata, '$.status'), 'ready')
<> 'failed'
= 'ready'
THEN d.id END) AS indexed_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status')
IN ('queued', 'parsing', 'indexing')
THEN d.id END) AS processing_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status') = 'failed'
THEN d.id END) AS failed_document_count
FROM knowledge_bases kb
LEFT JOIN knowledge_sources s ON s.knowledge_base_id = kb.id
LEFT JOIN documents d ON d.knowledge_base_id = kb.id
@@ -872,6 +881,14 @@ export class KnowledgeDatabase {
indexedDocumentCount: asNumber(
row,
'indexed_document_count'
),
processingDocumentCount: asNumber(
row,
'processing_document_count'
),
failedDocumentCount: asNumber(
row,
'failed_document_count'
)
}
])
@@ -7,6 +7,7 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExtractStructured } from './graph-extractor'
import { embeddingStorageProvider } from './embedding-provider-key'
@@ -17,6 +18,83 @@ import { UrlImporter } from './url-importer'
const temporaryDirectories: string[] = []
const services: KnowledgeService[] = []
function createRealDocxFixture(): Buffer {
return Buffer.from(
zipSync({
'[Content_Types].xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>'
),
'_rels/.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
'</Relationships>'
),
'word/document.xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
'<w:body>' +
'<w:p><w:r><w:t>蓝鲸发布流程</w:t></w:r></w:p>' +
'<w:p><w:r><w:t>发布前必须完成审批和回归测试。</w:t></w:r></w:p>' +
'<w:tbl><w:tr><w:tc><w:p><w:r><w:t>负责人</w:t></w:r></w:p></w:tc>' +
'<w:tc><w:p><w:r><w:t>产品团队</w:t></w:r></w:p></w:tc></w:tr></w:tbl>' +
'<w:sectPr/></w:body></w:document>'
),
'word/_rels/document.xml.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
)
})
)
}
function createRealPdfFixture(...pageTexts: string[]): Buffer {
const texts = pageTexts.length > 0 ? pageTexts : ['']
const fontObjectId = texts.length + 3
const firstContentObjectId = fontObjectId + 1
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
`<< /Type /Pages /Kids [${texts
.map((_, index) => `${index + 3} 0 R`)
.join(' ')}] /Count ${texts.length} >>`,
...texts.map(
(_, index) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' +
`/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` +
`/Contents ${firstContentObjectId + index} 0 R >>`
),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
...texts.map((text) => {
const stream = `BT /F1 14 Tf 72 720 Td (${text}) Tj ET`
return (
`<< /Length ${Buffer.byteLength(stream)} >>\n` +
`stream\n${stream}\nendstream`
)
})
]
let content = '%PDF-1.4\n'
const offsets = [0]
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(content))
content += `${index + 1} 0 obj\n${object}\nendobj\n`
}
const xrefOffset = Buffer.byteLength(content)
content += `xref\n0 ${objects.length + 1}\n`
content += '0000000000 65535 f \n'
content += offsets
.slice(1)
.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`)
.join('')
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`
content += `startxref\n${xrefOffset}\n%%EOF\n`
return Buffer.from(content)
}
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider,
@@ -48,6 +126,83 @@ afterEach(async () => {
})
describe('KnowledgeService', () => {
it('imports real DOCX and PDF files from disk into searchable chunks', async () => {
const { directory, service } = await createService()
const docxPath = join(directory, '蓝鲸发布手册.docx')
const pdfPath = join(directory, 'Blue-Harbor-operations.pdf')
await Promise.all([
writeFile(docxPath, createRealDocxFixture()),
writeFile(
pdfPath,
createRealPdfFixture(
'Blue Harbor deployment requires a signed approval.',
'Rollback begins by restoring the verified package.'
)
)
])
const library = service.createLibrary({
name: '真实办公文档',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [docxPath, pdfPath])
const snapshot = service.snapshot(library.id)
expect(snapshot.libraries[0]).toMatchObject({
documentCount: 2,
indexedDocumentCount: 2,
processingDocumentCount: 0,
failedDocumentCount: 0
})
expect(snapshot.documents).toHaveLength(2)
expect(
snapshot.documents.every(
(document) =>
document.status === 'ready' &&
document.textIndexStatus === 'ready' &&
document.vectorIndexStatus === 'disabled'
)
).toBe(true)
const docxDocument = snapshot.documents.find(
(document) => document.sourceLocation === docxPath
)
const pdfDocument = snapshot.documents.find(
(document) => document.sourceLocation === pdfPath
)
expect(docxDocument).toBeDefined()
expect(pdfDocument).toBeDefined()
expect(
service.getDocumentSource({
knowledgeBaseId: library.id,
documentId: docxDocument!.id
})
).toMatchObject({
document: { sourceLocation: docxPath },
source: { knowledgeBaseId: library.id }
})
const docxChunks = service.database.listChunks(docxDocument!.id)
const pdfChunks = service.database.listChunks(pdfDocument!.id)
expect(docxChunks.map((chunk) => chunk.content).join('\n')).toContain(
'发布前必须完成审批和回归测试'
)
expect(docxChunks.some((chunk) => chunk.location?.includes('表格'))).toBe(
true
)
expect(pdfChunks.map((chunk) => chunk.location)).toEqual([
'第 1 页',
'第 2 页'
])
expect(service.search(library.id, '发布审批')[0]?.document.id).toBe(
docxDocument!.id
)
expect(
service.search(library.id, 'verified package')[0]?.document.id
).toBe(pdfDocument!.id)
})
it('uses document and query embedding roles without exposing providers to runtimes', async () => {
const embed = vi.fn(async (input: readonly string[]) =>
input.map(() => [9, 9])
+88 -2
View File
@@ -30,6 +30,8 @@ import {
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
knowledgeDocumentOpenInputSchema,
type KnowledgeDocumentOpenInput,
knowledgeRetrieveInputSchema,
knowledgeSettingsUpdateInputSchema,
type KnowledgeChunkDeleteInput,
@@ -109,6 +111,8 @@ export type KnowledgeLibrarySnapshot = KnowledgeBase & {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
export type KnowledgeSourceSnapshot = KnowledgeSource & {
@@ -120,6 +124,9 @@ export type KnowledgeSourceSnapshot = KnowledgeSource & {
export type KnowledgeDocumentSnapshot = Document & {
chunkCount: number
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
textIndexStatus: 'waiting' | 'ready' | 'failed'
vectorIndexStatus: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing'
graphIndexStatus: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed'
size?: number
error?: string
}
@@ -1067,7 +1074,9 @@ export class KnowledgeService {
const counts = libraryCounts.get(library.id) ?? {
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
indexedDocumentCount: 0,
processingDocumentCount: 0,
failedDocumentCount: 0
}
return {
...library,
@@ -1120,6 +1129,21 @@ export class KnowledgeService {
: undefined
}))
const chunkCounts = this.database.getDocumentChunkCounts(libraryId)
const tasks = this.database.listKnowledgeTasks(libraryId)
const library = libraries.find((item) => item.id === libraryId)
const graphTaskByDocument = new Map<
string,
KnowledgeTaskItem
>()
for (const task of tasks) {
if (
task.documentId &&
task.kind === 'graph' &&
!graphTaskByDocument.has(task.documentId)
) {
graphTaskByDocument.set(task.documentId, task)
}
}
const documents = libraryDocuments.map((document) => {
const status =
typeof document.metadata.status === 'string' &&
@@ -1132,6 +1156,49 @@ export class KnowledgeService {
...document,
chunkCount: chunkCounts.get(document.id) ?? 0,
status,
textIndexStatus:
status === 'ready'
? ('ready' as const)
: status === 'failed'
? ('failed' as const)
: ('waiting' as const),
vectorIndexStatus: (() => {
if (!this.embeddingProvider) {
return 'disabled' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const state = this.database.getEmbeddingIndexState(
document.id,
embeddingStorageProvider(this.embeddingProvider),
this.embeddingProvider.model
)
return state?.status === 'ready'
? ('ready' as const)
: state?.status === 'error'
? ('failed' as const)
: ('missing' as const)
})(),
graphIndexStatus: (() => {
if (!library?.graphEnabled) {
return 'disabled' as const
}
if (library.graphStrategy === 'ask') {
return 'on-demand' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const task = graphTaskByDocument.get(document.id)
if (task?.status === 'failed' || task?.status === 'interrupted') {
return 'failed' as const
}
if (task?.status === 'succeeded') {
return 'ready' as const
}
return 'waiting' as const
})(),
size:
typeof document.metadata.size === 'number'
? document.metadata.size
@@ -1150,7 +1217,7 @@ export class KnowledgeService {
entities: graph.entities,
relations: graph.relations,
evidence: graph.evidence,
tasks: this.database.listKnowledgeTasks(libraryId)
tasks
}
}
@@ -1698,6 +1765,25 @@ export class KnowledgeService {
}
}
getDocumentSource(rawInput: KnowledgeDocumentOpenInput) {
const input = knowledgeDocumentOpenInputSchema.parse(rawInput)
const document = this.database.getDocument(input.documentId)
if (
!document ||
document.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
const source = this.database.getSource(document.sourceId)
if (
!source ||
source.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
return { document, source }
}
async searchHybrid(
knowledgeBaseId: string,
query: string,
+20 -38
View File
@@ -1,3 +1,5 @@
import { binaryTargetArchitecture } from '../../shared/node/binary-target'
import type { AgentPlatform } from '../../shared/agent-target'
import {
createHash,
createPublicKey,
@@ -206,9 +208,10 @@ export async function verifyAgentBundleDirectory(
await assertElfArchitecture(
join(bundleDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
'Agent Node runtime'
'Agent Node runtime',
manifest.platform
)
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch)
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch, manifest.platform)
assertKoffiManifest(
manifest,
runtimeLock.koffi.version,
@@ -218,7 +221,8 @@ export async function verifyAgentBundleDirectory(
await assertElfArchitecture(
join(bundleDirectory, ...nativePath.split('/')),
manifest.arch,
'Agent Koffi native binding'
'Agent Koffi native binding',
manifest.platform
)
}
return {
@@ -349,15 +353,19 @@ export function assertAgentManifestMatchesRuntimeLock(
}
function requiredKoffiPayloadPaths(
architecture: AgentArchitecture
architecture: AgentArchitecture,
platform: AgentPlatform
): {
required: string[]
native: string[]
} {
const packageRoot = 'lib/node_modules/koffi'
const nativePackage =
`@koromix/koffi-linux-${architecture}`
`@koromix/koffi-${platform}-${architecture}`
const nativeRoot = `lib/node_modules/${nativePackage}`
const native = platform === 'darwin'
? [`${nativeRoot}/darwin_${architecture}/koffi.node`]
: [`${nativeRoot}/linux_${architecture}/koffi.node`, `${nativeRoot}/musl_${architecture}/koffi.node`]
return {
required: [
`${packageRoot}/package.json`,
@@ -366,13 +374,9 @@ function requiredKoffiPayloadPaths(
`${packageRoot}/src/koffi/src/static.js`,
`${nativeRoot}/package.json`,
`${nativeRoot}/index.js`,
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
...native
],
native: [
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
]
native
}
}
@@ -390,7 +394,7 @@ function assertKoffiManifest(
}
}
const nativePackage =
`@koromix/koffi-linux-${manifest.arch}`
`@koromix/koffi-${manifest.platform}-${manifest.arch}`
const koffiLicense = manifest.licenses.find(
(license) => license.package === 'koffi'
)
@@ -614,14 +618,15 @@ function modeString(mode: number): string {
async function assertElfArchitecture(
filePath: string,
expectedArchitecture: AgentArchitecture,
description: string
description: string,
platform: AgentPlatform
): Promise<void> {
const handle = await open(filePath, 'r')
try {
const header = Buffer.alloc(64)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
const actualArchitecture = detectElfArchitecture(
header.subarray(0, bytesRead)
const actualArchitecture = binaryTargetArchitecture(
header.subarray(0, bytesRead), platform
)
if (actualArchitecture !== expectedArchitecture) {
throw new Error(
@@ -633,29 +638,6 @@ async function assertElfArchitecture(
}
}
function detectElfArchitecture(
buffer: Buffer
): AgentArchitecture | undefined {
if (
buffer.length < 20 ||
buffer[0] !== 0x7f ||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
(buffer[5] !== 1 && buffer[5] !== 2)
) {
return undefined
}
const machine =
buffer[5] === 2
? buffer.readUInt16BE(18)
: buffer.readUInt16LE(18)
if (machine === 62) {
return 'x64'
}
if (machine === 183) {
return 'arm64'
}
return undefined
}
function parseContract<T>(
schema: { safeParse(value: unknown): {
@@ -43,7 +43,7 @@ export type AgentInstallationIdentity = {
major: number
minor: number
}
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: AgentArchitecture
supervisor: 'detached-on-demand'
}
@@ -239,7 +239,7 @@ export class AgentInstallationManager {
protocol: {
...(registeredProtocol ?? runtimeLock.protocol)
},
platform: 'linux',
platform: probe.platform,
architecture: registered.arch,
supervisor: 'detached-on-demand'
}
@@ -20,6 +20,7 @@ import {
} from '../../shared/agent-package-contracts'
import {
AgentPackageManager,
selectLatestCompatibleEntry,
type VerifiedRemoteAgentInstallCandidate
} from './agent-package-manager'
@@ -256,6 +257,64 @@ function response(
}
describe('AgentPackageManager remote install candidates', () => {
it('pages GitHub release metadata and stops at the first signed catalog', async () => {
const fixture = await createFixture('github')
const pages: number[] = []
const desktopReleases = Array.from({ length: 10 }, () => ({
tag_name: 'v9.0.0',
body: 'release notes'.repeat(1500),
assets: []
}))
expect(JSON.stringify(Array(10).fill(desktopReleases)).length)
.toBeGreaterThan(1024 * 1024)
const transport = vi.fn<typeof fetch>(async (input) => {
const url = new URL(input.toString())
if (url.hostname === 'api.github.com') {
expect(url.searchParams.get('per_page')).toBe('10')
const page = Number(url.searchParams.get('page'))
pages.push(page)
return response(JSON.stringify(
page === 1 ? desktopReleases : fixture.releases
))
}
return response(url.pathname.endsWith('.sig')
? fixture.signature : fixture.catalogBytes)
})
const snapshot = await fixture.manager(transport).getSnapshot()
expect(snapshot.catalog.state).toBe('available')
expect(snapshot.entries[0]?.latestVersion).toBe('2.0.0')
expect(pages).toEqual([1, 2])
})
it.each([0, 10])('bounds GitHub discovery when each page contains %i releases', async (count) => {
const fixture = await createFixture('github')
const transport = vi.fn<typeof fetch>(async () =>
response(JSON.stringify(Array.from({ length: count }, () => ({
tag_name: 'v9.0.0'
}))))
)
const snapshot = await fixture.manager(transport).getSnapshot()
expect(snapshot.catalog.state).toBe('unavailable')
expect(transport).toHaveBeenCalledTimes(count === 0 ? 1 : 10)
})
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(
+109 -35
View File
@@ -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,
@@ -60,7 +61,7 @@ const CATALOG_SIGNATURE_DOMAIN = Buffer.from(
const MIRROR_ROOT =
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/'
const GITHUB_RELEASES_API =
'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=100'
'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=10'
const GITHUB_RELEASE_ROOT =
'https://github.com/mesalogo/goodbuddy/releases/download/'
const MAXIMUM_CATALOG_BYTES = 1024 * 1024
@@ -94,6 +95,7 @@ type CatalogState =
}
export type AgentPackageManagerOptions = {
platform?: AgentPlatform
userDataPath: string
desktopVersion: string
keyRegistryPath: string
@@ -104,7 +106,7 @@ export type AgentPackageManagerOptions = {
export type VerifiedRemoteAgentInstallCandidate = {
source: UpdateSource
platform: 'linux'
platform: AgentPlatform
architecture: AgentArchitecture
version: string
minimumDesktopVersion: string
@@ -161,6 +163,7 @@ export type AgentPackageArchiveLease = {
}
export type AcquireInstallArchiveOptions = {
platform?: AgentPlatform
signal?: AbortSignal
onProgress?: (progress: AgentPackageDownloadProgress) => void
}
@@ -185,7 +188,7 @@ function candidateFromCatalogEntry(
): VerifiedRemoteAgentInstallCandidate {
return {
source,
platform: 'linux',
platform: entry.platform,
architecture: entry.architecture,
version: entry.version,
minimumDesktopVersion: entry.minimumDesktopVersion,
@@ -209,7 +212,7 @@ function candidateFromVerifiedRecord(
const descriptor = record.verified.descriptor
return {
source,
platform: 'linux',
platform: descriptor.platform,
architecture: descriptor.architecture,
version: descriptor.version,
minimumDesktopVersion: descriptor.minimumDesktopVersion,
@@ -220,7 +223,8 @@ function candidateFromVerifiedRecord(
},
archive: agentPackageArchiveName(
descriptor.version,
descriptor.architecture
descriptor.architecture,
descriptor.platform
),
size,
sha256: record.archiveSha256,
@@ -229,6 +233,9 @@ function candidateFromVerifiedRecord(
}
export class AgentPackageManager {
readonly #options: AgentPackageManagerOptions
readonly #platform: AgentPlatform
readonly #platformManagers = new Map<AgentPlatform, AgentPackageManager>()
readonly #rootDirectory: string
readonly #desktopVersion: string
readonly #keyRegistryPath: string
@@ -250,6 +257,8 @@ export class AgentPackageManager {
#catalogState?: CatalogState
constructor(options: AgentPackageManagerOptions) {
this.#options = options
this.#platform = agentPlatformSchema.parse(options.platform ?? 'linux')
this.#rootDirectory = resolve(
options.userDataPath,
'remote-components',
@@ -263,6 +272,31 @@ export class AgentPackageManager {
this.#startupCleanup = this.#cleanupInterruptedArtifacts()
}
forPlatform(platform: AgentPlatform): AgentPackageManager {
if (platform === this.#platform) return this
let manager = this.#platformManagers.get(platform)
if (!manager) {
manager = new AgentPackageManager({ ...this.#options, platform })
this.#platformManagers.set(platform, manager)
}
return manager
}
async getAllSnapshot(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
const linux = await this.forPlatform('linux').getSnapshot(options)
const darwin = this.forPlatform('darwin')
darwin.#catalogState = this.forPlatform('linux').#catalogState
const mac = await darwin.getInventory(options)
return agentPackageInventorySchema.parse({
...linux,
entries: [...linux.entries, ...mac.entries]
})
}
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
}
async getInventory(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
@@ -270,7 +304,9 @@ export class AgentPackageManager {
this.#installed.clear()
}
const entries = await Promise.all(
agentArchitectureSchema.options.map(async (architecture) => {
agentArchitectureSchema.options.filter(architecture =>
this.#platform === 'linux' || architecture === 'arm64'
).map(async (architecture) => {
let record: InstalledRecord | undefined
let state: 'not-downloaded' | 'verified' | 'invalid'
try {
@@ -283,7 +319,7 @@ export class AgentPackageManager {
}
const latestVersion = this.#latestVersion(architecture)
return {
platform: 'linux' as const,
platform: this.#platform,
architecture,
state,
version: record?.verified.descriptor.version ?? null,
@@ -334,8 +370,11 @@ export class AgentPackageManager {
async getRemoteEnvironmentCatalog(
architecture: AgentArchitecture,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentEnvironmentCatalog> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteEnvironmentCatalog(architecture, options)
}
options.signal?.throwIfAborted()
let entry: AgentPackageCatalogEntry
let source: UpdateSource | undefined
@@ -346,7 +385,7 @@ export class AgentPackageManager {
source,
options.signal
)
entry = selectLatestCompatibleEntry(
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -406,8 +445,11 @@ export class AgentPackageManager {
*/
async getRemoteInstallCandidate(
architecture: AgentArchitecture,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentInstallCandidate> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteInstallCandidate(architecture, options)
}
options.signal?.throwIfAborted()
const source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
@@ -415,7 +457,7 @@ export class AgentPackageManager {
source,
options.signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -463,7 +505,7 @@ export class AgentPackageManager {
source,
signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -484,6 +526,11 @@ export class AgentPackageManager {
expectedCandidate: VerifiedRemoteAgentInstallCandidate,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (expectedCandidate.platform !== this.#platform) {
return this.forPlatform(expectedCandidate.platform).acquireInstallArchive(
architecture, expectedCandidate, options
)
}
return this.#runExclusive(
architecture,
async () => {
@@ -507,7 +554,7 @@ export class AgentPackageManager {
source,
options.signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -534,6 +581,9 @@ export class AgentPackageManager {
architecture: AgentArchitecture,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).acquireGoodBuddyInstallArchive(architecture, options)
}
return this.#runExclusive(
architecture,
async () => {
@@ -562,7 +612,7 @@ export class AgentPackageManager {
}
let entry: AgentPackageCatalogEntry
if (this.#catalogState?.state === 'available') {
entry = selectLatestCompatibleEntry(
entry = this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
@@ -584,7 +634,7 @@ export class AgentPackageManager {
source,
options.signal
)
entry = selectLatestCompatibleEntry(
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -651,6 +701,7 @@ export class AgentPackageManager {
): Promise<boolean> {
const descriptor = record.verified.descriptor
if (
descriptor.platform === candidate.platform &&
descriptor.architecture === candidate.architecture &&
descriptor.version === candidate.version &&
descriptor.minimumDesktopVersion ===
@@ -831,11 +882,12 @@ export class AgentPackageManager {
)
const architecture =
installed.verified.descriptor.architecture
return await this.#runExclusive(
const manager = this.forPlatform(installed.verified.descriptor.platform)
return await manager.#runExclusive(
architecture,
async () => {
await this.#publishInstalled(installed, architecture)
return this.getInventory()
await manager.#publishInstalled(installed, architecture)
return manager.getInventory()
}
)
} finally {
@@ -918,7 +970,8 @@ export class AgentPackageManager {
.verified.descriptor
return agentPackageArchiveName(
descriptor.version,
descriptor.architecture
descriptor.architecture,
descriptor.platform
)
}
@@ -978,7 +1031,10 @@ export class AgentPackageManager {
desktopVersion: this.#desktopVersion,
trustedRegistry: await this.#loadTrustedRegistry()
})
if (packageContent.descriptor.version !== entry.name) {
if (
packageContent.descriptor.version !== entry.name ||
packageContent.descriptor.platform !== this.#platform
) {
continue
}
const archivePath = join(directory, 'package.gbagent')
@@ -1051,6 +1107,10 @@ export class AgentPackageManager {
architecture: AgentArchitecture,
catalogEntry?: AgentPackageCatalogEntry
): Promise<InstalledRecord> {
if (
installed.verified.descriptor.platform !== this.#platform ||
installed.verified.descriptor.architecture !== architecture
) throw new Error('Agent package does not match the selected platform and architecture')
const destination = join(
this.#architectureRoot(architecture),
installed.verified.descriptor.version
@@ -1318,7 +1378,7 @@ export class AgentPackageManager {
return null
}
try {
return selectLatestCompatibleEntry(
return this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
@@ -1389,17 +1449,29 @@ export class AgentPackageManager {
])
}
async *#githubReleases(signal?: AbortSignal): AsyncGenerator<unknown> {
// Keep the existing 100-release search window without downloading all
// historical release bodies and asset metadata in one response.
for (let page = 1; page <= 10; page += 1) {
signal?.throwIfAborted()
const releases = await this.#fetchJson(
`${GITHUB_RELEASES_API}&page=${page}`,
signal
)
if (!Array.isArray(releases)) {
throw new Error('GitHub Agent 发布目录无效')
}
yield* releases.slice(0, 10)
if (releases.length < 10) {
return
}
}
}
async #fetchGithubCatalog(
signal?: AbortSignal
): Promise<[Buffer, Buffer]> {
const releases = await this.#fetchJson(
GITHUB_RELEASES_API,
signal
)
if (!Array.isArray(releases)) {
throw new Error('GitHub Agent 发布目录无效')
}
for (const untrusted of releases.slice(0, 100)) {
for await (const untrusted of this.#githubReleases(signal)) {
if (
typeof untrusted !== 'object' ||
untrusted === null ||
@@ -1701,7 +1773,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 +1782,7 @@ export class AgentPackageManager {
async #cleanupInterruptedArtifacts(): Promise<void> {
await this.#removeOwnedTemporaryDirectories(
this.#rootDirectory,
/^\.stage-linux-(?:x64|arm64|import)-[0-9a-f-]{36}$/u
new RegExp(`^\\.stage-${this.#platform}-(?:x64|arm64|import)-[0-9a-f-]{36}$`, 'u')
)
await Promise.all(
agentArchitectureSchema.options.map((architecture) =>
@@ -1756,7 +1828,7 @@ export class AgentPackageManager {
#architectureRoot(
architecture: AgentArchitecture
): string {
return join(this.#rootDirectory, `linux-${architecture}`)
return join(this.#rootDirectory, `${this.#platform}-${architecture}`)
}
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
@@ -1825,10 +1897,10 @@ export class AgentPackageManager {
observer:
| ((progress: AgentPackageDownloadProgress) => void)
| undefined,
progress: AgentPackageDownloadProgress
progress: Omit<AgentPackageDownloadProgress, 'platform'>
): void {
try {
observer?.(agentPackageDownloadProgressSchema.parse(progress))
observer?.(agentPackageDownloadProgressSchema.parse({ ...progress, platform: this.#platform }))
} catch {
// Progress observers cannot alter the package operation.
}
@@ -1838,11 +1910,13 @@ export class AgentPackageManager {
export function selectLatestCompatibleEntry(
catalog: AgentPackageCatalog,
architecture: AgentArchitecture,
desktopVersion: string
desktopVersion: string,
platform: AgentPlatform = 'linux'
): AgentPackageCatalogEntry {
const entry = catalog.entries
.filter(
(candidate) =>
candidate.platform === platform &&
candidate.architecture === architecture &&
compareSemanticVersions(
desktopVersion,
@@ -188,6 +188,7 @@ export async function verifyExtractedAgentPackage(options: {
}
)
if (
agentBundle.manifest.platform !== descriptor.platform ||
agentBundle.manifest.agentVersion !== descriptor.version ||
agentBundle.manifest.protocol.major !==
descriptor.agentProtocol.major ||
@@ -231,6 +232,7 @@ export async function verifyExtractedAgentPackage(options: {
}
)
if (
runtimeBundle.manifest.platform !== descriptor.platform ||
runtimeBundle.manifest.runtimeVersion !==
descriptor.remoteRuntime.version ||
runtimeBundle.manifest.bundleDigest !==
@@ -63,6 +63,7 @@ const windowsReservedNamePattern =
type JsonRecord = Record<string, unknown>
type Architecture = 'x64' | 'arm64'
type Platform = 'linux' | 'darwin'
type Protocol = { major: number; minor: number }
type FileRecord = {
path: string
@@ -94,7 +95,7 @@ export type PackageInstallerAgentIdentity = {
agentVersion: string
manifestSha256: string
binaryDigest: string
platform: 'linux'
platform: Platform
architecture: Architecture
protocol: Protocol
supervisor: 'detached-on-demand'
@@ -107,7 +108,7 @@ export type PackageInstallerRuntimeIdentity = {
manifestDigest: string
runtimeAdapterDigest: string
acpCapabilitiesDigest: string
platform: 'linux'
platform: Platform
architecture: Architecture
protocol: Protocol
}
@@ -818,7 +819,7 @@ function assertOuterDescriptor(
descriptor.component !== 'agent' ||
!versionPattern.test(String(descriptor.version)) ||
!versionPattern.test(String(descriptor.minimumDesktopVersion)) ||
descriptor.platform !== 'linux' ||
!isSupportedTarget(descriptor.platform, architecture) ||
!['x64', 'arm64'].includes(String(architecture)) ||
typeof descriptor.signingKeyId !== 'string' ||
!digestPattern.test(String(descriptor.contentDigest)) ||
@@ -920,7 +921,7 @@ function assertAgentManifest(
manifest.formatVersion !== 1 ||
manifest.product !== 'GoodBuddy' ||
!versionPattern.test(String(manifest.agentVersion)) ||
manifest.platform !== 'linux' ||
!isSupportedTarget(manifest.platform, manifest.arch) ||
!['x64', 'arm64'].includes(String(manifest.arch)) ||
!isRecord(manifest.entrypoint) ||
!Array.isArray(manifest.licenses)
@@ -944,6 +945,7 @@ function assertAgentManifest(
lock.formatVersion !== 1 ||
manifest.agentVersion !== lock.agentVersion ||
manifest.arch !== descriptor.architecture ||
manifest.platform !== descriptor.platform ||
manifest.agentVersion !== descriptor.version ||
canonicalJson(agentProtocol) !== canonicalJson(descriptor.agentProtocol) ||
canonicalJson(agentProtocol) !== canonicalJson(lock.protocol)
@@ -1002,7 +1004,7 @@ function assertAgentManifest(
}
const architecture = manifest.arch as Architecture
const koffiPackageRoot = 'lib/node_modules/koffi'
const koffiNativePackage = `@koromix/koffi-linux-${architecture}`
const koffiNativePackage = `@koromix/koffi-${manifest.platform}-${architecture}`
const koffiNativeRoot =
`lib/node_modules/${koffiNativePackage}`
for (const required of [
@@ -1017,10 +1019,7 @@ function assertAgentManifest(
throw new Error(`Agent Koffi payload is missing: ${required}`)
}
}
for (const native of [
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/linux_${manifest.arch}/koffi.node`,
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/musl_${manifest.arch}/koffi.node`
]) {
for (const native of nativeKoffiPaths(manifest.platform as Platform, architecture)) {
if (!declared.has(native)) {
throw new Error(`Agent Koffi payload is missing: ${native}`)
}
@@ -1091,11 +1090,12 @@ function assertRuntimeManifest(
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode)
? lock.runtimes.opencode
: undefined
const targetKey = descriptor.platform === 'darwin' ? 'darwin-arm64' : String(descriptor.architecture)
const target =
runtime !== undefined &&
isRecord(runtime.targets) &&
isRecord(runtime.targets[String(descriptor.architecture)])
? runtime.targets[String(descriptor.architecture)]
isRecord(runtime.targets[targetKey])
? runtime.targets[targetKey]
: undefined
const remote = descriptor.remoteRuntime as JsonRecord
if (
@@ -1103,7 +1103,7 @@ function assertRuntimeManifest(
manifest.product !== 'GoodBuddy' ||
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.platform !== 'linux' ||
manifest.platform !== descriptor.platform ||
manifest.architecture !== descriptor.architecture ||
!versionPattern.test(String(manifest.runtimeVersion)) ||
!digestPattern.test(String(manifest.bundleDigest)) ||
@@ -1255,7 +1255,26 @@ function assertRuntimeManifest(
}
}
function elfArchitecture(bytes: Buffer): Architecture | undefined {
function isSupportedTarget(platform: unknown, architecture: unknown): boolean {
return (platform === 'linux' && (architecture === 'x64' || architecture === 'arm64')) ||
(platform === 'darwin' && architecture === 'arm64')
}
function nativeKoffiPaths(platform: Platform, architecture: Architecture): string[] {
const root = `lib/node_modules/@koromix/koffi-${platform}-${architecture}`
return platform === 'darwin'
? [`${root}/darwin_${architecture}/koffi.node`]
: [`${root}/linux_${architecture}/koffi.node`, `${root}/musl_${architecture}/koffi.node`]
}
function elfArchitecture(bytes: Buffer, platform: Platform): Architecture | undefined {
if (platform === 'darwin') {
if (bytes.length < 8) return undefined
const cpu = bytes.readUInt32LE(0) === 0xfeedfacf
? bytes.readUInt32LE(4)
: bytes.readUInt32BE(0) === 0xfeedfacf ? bytes.readUInt32BE(4) : 0
return cpu === 0x0100000c ? 'arm64' : undefined
}
if (
bytes.length < 20 ||
!bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
@@ -1273,32 +1292,16 @@ function elfArchitecture(bytes: Buffer): Architecture | undefined {
function assertExtractedArchitectures(
preparedRoot: string,
architecture: Architecture
architecture: Architecture,
platform: Platform
): void {
for (const path of [
join(preparedRoot, 'agent', 'node'),
join(
preparedRoot,
'agent',
'lib',
'node_modules',
`@koromix/koffi-linux-${architecture}`,
`linux_${architecture}`,
'koffi.node'
),
join(
preparedRoot,
'agent',
'lib',
'node_modules',
`@koromix/koffi-linux-${architecture}`,
`musl_${architecture}`,
'koffi.node'
),
...nativeKoffiPaths(platform, architecture).map(path => join(preparedRoot, 'agent', path)),
join(preparedRoot, 'runtime', 'bin', 'opencode')
]) {
if (
elfArchitecture(readFileHeader(path, 64)) !== architecture
elfArchitecture(readFileHeader(path, 64), platform) !== architecture
) {
throw new Error(`Installed executable architecture mismatch: ${basename(path)}`)
}
@@ -1518,7 +1521,7 @@ function verifyArchive(
agentVersion: String(agentManifest.agentVersion),
manifestSha256: agentManifestSha256,
binaryDigest: `sha256:${agentManifestSha256}`,
platform: 'linux',
platform: descriptor.platform as Platform,
architecture,
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
supervisor: 'detached-on-demand'
@@ -1530,7 +1533,7 @@ function verifyArchive(
manifestDigest: runtime.manifestDigest,
runtimeAdapterDigest: String(runtime.manifest.adapterDigest),
acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest),
platform: 'linux',
platform: descriptor.platform as Platform,
architecture,
protocol: protocol(runtime.manifest.protocol, 'Runtime protocol')
}
@@ -1766,7 +1769,7 @@ function extractPayload(
} finally {
closeSync(handle)
}
assertExtractedArchitectures(temporary, verified.agent.architecture)
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform)
chmodSync(temporary, 0o700)
renameSync(temporary, destinationRoot)
} catch (error) {
@@ -2127,7 +2130,7 @@ function readPreparedState(path: string): PreparedState {
!sha256Pattern.test(String(agent.manifestSha256)) ||
agent.installationId !== `agent-${String(agent.manifestSha256)}` ||
agent.binaryDigest !== `sha256:${String(agent.manifestSha256)}` ||
agent.platform !== 'linux' ||
!isSupportedTarget(agent.platform, agent.architecture) ||
!['x64', 'arm64'].includes(String(agent.architecture)) ||
agent.supervisor !== 'detached-on-demand' ||
runtime.runtimeId !== 'opencode' ||
@@ -2136,7 +2139,7 @@ function readPreparedState(path: string): PreparedState {
!digestPattern.test(String(runtime.manifestDigest)) ||
!digestPattern.test(String(runtime.runtimeAdapterDigest)) ||
!digestPattern.test(String(runtime.acpCapabilitiesDigest)) ||
runtime.platform !== 'linux' ||
runtime.platform !== agent.platform ||
runtime.architecture !== agent.architecture
) {
throw new Error('Prepared package state identity is invalid')
@@ -2159,7 +2162,7 @@ function readPreparedState(path: string): PreparedState {
agentVersion: String(agent.agentVersion),
manifestSha256: String(agent.manifestSha256),
binaryDigest: String(agent.binaryDigest),
platform: 'linux',
platform: agent.platform as Platform,
architecture: agent.architecture as Architecture,
protocol: agentProtocol,
supervisor: 'detached-on-demand'
@@ -2171,7 +2174,7 @@ function readPreparedState(path: string): PreparedState {
manifestDigest: String(runtime.manifestDigest),
runtimeAdapterDigest: String(runtime.runtimeAdapterDigest),
acpCapabilitiesDigest: String(runtime.acpCapabilitiesDigest),
platform: 'linux',
platform: agent.platform as Platform,
architecture: runtime.architecture as Architecture,
protocol: runtimeProtocol
}
@@ -2322,7 +2325,7 @@ function assertPreparedIdentity(
agentVersion: String(agentManifest.agentVersion),
manifestSha256: agentManifestSha256,
binaryDigest: `sha256:${agentManifestSha256}`,
platform: 'linux',
platform: agentManifest.platform as Platform,
architecture,
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
supervisor: 'detached-on-demand'
@@ -2336,7 +2339,7 @@ function assertPreparedIdentity(
acpCapabilitiesDigest: String(
runtime.manifest.acpCapabilitiesDigest
),
platform: 'linux',
platform: agentManifest.platform as Platform,
architecture,
protocol: protocol(
runtime.manifest.protocol,
@@ -48,7 +48,7 @@ type ManagedRemoteRuntimeIdentity = {
bundleDigest: string
runtimeAdapterDigest: string
acpCapabilitiesDigest: string
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: 'x64' | 'arm64'
}
@@ -648,7 +648,7 @@ function runtimeIdentityFromCapabilities(
): ManagedRemoteRuntimeIdentity {
if (
installation.runtimeId !== 'opencode' ||
installation.platform !== 'linux'
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64'))
) {
throw new Error(
'Remote Agent does not advertise the current Runtime'
@@ -660,7 +660,7 @@ function runtimeIdentityFromCapabilities(
bundleDigest: installation.bundleDigest,
runtimeAdapterDigest: installation.runtimeAdapterDigest,
acpCapabilitiesDigest: installation.acpCapabilitiesDigest,
platform: 'linux',
platform: installation.platform,
architecture: installation.architecture
}
assertRuntimeAdvertised(runtime, capabilities)
@@ -155,6 +155,7 @@ function harness(options: {
installationId: agentInstallationId,
binaryDigest: status.binaryDigest,
version: status.agentVersion,
platform: 'linux',
architecture,
protocolMajor: 1
},
@@ -165,7 +165,7 @@ function assertInstallationIdentity(
): void {
if (
installation.runtimeId !== 'opencode' ||
installation.platform !== 'linux' ||
installation.platform !== input.agent.platform ||
installation.architecture !== input.agent.architecture
) {
throw new Error(
@@ -64,7 +64,7 @@ export type RemoteAgentInstallationIdentity = {
major: number
minor: number
}
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: 'x64' | 'arm64'
supervisor: 'detached-on-demand'
requiredCapabilities?: ReadonlyArray<{
@@ -1145,7 +1145,7 @@ function validateInstallation(
input.protocol ?? AGENT_PROTOCOL_VERSION
)
if (
input.platform !== 'linux' ||
(input.platform !== 'linux' && !(input.platform === 'darwin' && input.architecture === 'arm64')) ||
!['x64', 'arm64'].includes(input.architecture) ||
input.supervisor !== 'detached-on-demand'
) {
@@ -164,7 +164,8 @@ export class RemoteEnvironmentPreparer implements Preparer {
await this.#acquireGoodBuddyArchive(
probe.architecture,
emit,
signal
signal,
probe.platform
)
candidate = archiveLease.candidate
method = 'goodbuddy-transfer'
@@ -173,7 +174,7 @@ export class RemoteEnvironmentPreparer implements Preparer {
candidate =
await this.#agentPackageManager.getRemoteInstallCandidate(
probe.architecture,
{ signal }
{ signal, platform: probe.platform }
)
} catch (error) {
if (requestedMethod === 'remote-download') {
@@ -183,14 +184,15 @@ export class RemoteEnvironmentPreparer implements Preparer {
await this.#acquireGoodBuddyArchive(
probe.architecture,
emit,
signal
signal,
probe.platform
)
candidate = archiveLease.candidate
method = 'goodbuddy-transfer'
}
}
signal.throwIfAborted()
assertCandidate(candidate, probe.architecture)
assertCandidate(candidate, probe.architecture, probe.platform)
await assertTargetCurrent(this.#resolver, target, signal)
operationId = randomUUID()
@@ -503,12 +505,14 @@ export class RemoteEnvironmentPreparer implements Preparer {
method: RemoteEnvironmentPreparationMethod,
phase: RemoteEnvironmentUpdateProgress['phase']
) => void,
signal: AbortSignal
signal: AbortSignal,
platform: 'linux' | 'darwin'
): Promise<AgentPackageArchiveLease> {
return this.#agentPackageManager.acquireGoodBuddyInstallArchive(
architecture,
{
signal,
platform,
onProgress: (progress) => {
emit(
'goodbuddy-transfer',
@@ -674,10 +678,11 @@ function assertSameLeaseIdentity(
function assertCandidate(
candidate: VerifiedRemoteAgentInstallCandidate,
architecture: 'x64' | 'arm64'
architecture: 'x64' | 'arm64',
platform: 'linux' | 'darwin'
): void {
if (
candidate.platform !== 'linux' ||
candidate.platform !== platform ||
candidate.architecture !== architecture
) {
throw new Error('签名 Agent 安装候选与 Host 架构不匹配')
@@ -59,6 +59,7 @@ export type RemoteProjectRuntimeValidationInput = Readonly<{
installationId: AgentInstallationIdentity['installationId']
binaryDigest: string
version: string
platform: 'linux' | 'darwin'
architecture: 'x64' | 'arm64'
protocolMajor: number
}>
@@ -308,6 +309,7 @@ export class RemoteProjectSaveService {
installationId: installation.installationId,
binaryDigest: installation.binaryDigest,
version: installation.agentVersion,
platform: installation.platform,
architecture: installation.architecture,
protocolMajor: installation.protocol.major
},
@@ -484,7 +486,7 @@ function assertInstallation(
installation.installationId
) ||
!sha256DigestSchema.safeParse(installation.binaryDigest).success ||
installation.platform !== 'linux' ||
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64')) ||
!['x64', 'arm64'].includes(installation.architecture) ||
installation.supervisor !== 'detached-on-demand'
) {
@@ -36,7 +36,7 @@ export type RemoteRuntimeInstallationIdentity = {
manifestDigest: string
runtimeAdapterDigest: string
acpCapabilitiesDigest: string
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: AgentArchitecture
}
@@ -188,7 +188,7 @@ export class RemoteRuntimeInstallationManager {
)
}
if (
probe.platform !== 'linux' ||
(probe.platform !== 'linux' && !(probe.platform === 'darwin' && probe.architecture === 'arm64')) ||
(
probe.architecture !== 'x64' &&
probe.architecture !== 'arm64'
@@ -279,7 +279,7 @@ export class RemoteRuntimeInstallationManager {
runtimeAdapterDigest,
acpCapabilitiesDigest:
current.acpCapabilitiesDigest,
platform: 'linux',
platform: probe.platform,
architecture: current.architecture
}
} catch (error) {

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